From 43ab1337f44d71da6dc7b89585c892dbf2de588b Mon Sep 17 00:00:00 2001 From: Chrisland58 <200622745+Chrisland58@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:22:53 +0000 Subject: [PATCH] Add EvidenceManager contract and tests; fix role declarations and compile settings --- contracts/EvidenceManager.sol | 450 ++++++++++++++++++++ contracts/bootstrap/BootstrapController.sol | 4 +- contracts/deployment/MigrationManager.sol | 1 + hardhat.config.ts | 3 +- test/EvidenceManager.test.ts | 436 +++++++++++++++++++ 5 files changed, 892 insertions(+), 2 deletions(-) create mode 100644 contracts/EvidenceManager.sol create mode 100644 test/EvidenceManager.test.ts diff --git a/contracts/EvidenceManager.sol b/contracts/EvidenceManager.sol new file mode 100644 index 0000000..5685783 --- /dev/null +++ b/contracts/EvidenceManager.sol @@ -0,0 +1,450 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; + +/** + * @title EvidenceManager + * @notice Manages immutable, content-addressed evidence references for TruthBounty claims. + * + * @dev Evidence files are stored off-chain using IPFS (or compatible decentralized storage). + * This contract stores only the cryptographic Content Identifier (CID) — a hash-derived + * reference that binds the on-chain record to the exact off-chain content. + * + * Design principles: + * - Immutability: once submitted, a CID can never be changed or removed. + * - Integrity: the keccak256 hash of the CID is stored for efficient duplicate detection. + * - Extensibility: the storage layout supports future providers (Arweave, Filecoin, Ceramic). + * - No external calls: this contract holds no token balances and makes no outbound calls. + * + * Downstream consumers (Verification Engine, Dispute Resolution, Settlement Engine, + * Reputation System, Indexer, Frontend) rely on the events and view functions exposed here. + * + * @custom:security-contact security@truthbounty.io + */ +contract EvidenceManager is AccessControl, Pausable { + // ========================================================================= + // Roles + // ========================================================================= + + /// @notice Role that may pause / unpause the contract. + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + /// @notice Role held by the protocol's claim registry. + /// The registry can signal that a claim is closed and no longer + /// accepts new evidence (e.g. after settlement). + bytes32 public constant CLAIM_REGISTRY_ROLE = keccak256("CLAIM_REGISTRY_ROLE"); + + // ========================================================================= + // Constants + // ========================================================================= + + /// @notice Maximum byte-length of an accepted CID string. + /// CIDv1 base58btc encoded SHA2-256 is 46 chars; base32 is 59 chars. + /// We allow up to 512 bytes to accommodate future multihash formats. + uint256 public constant MAX_CID_LENGTH = 512; + + /// @notice Minimum byte-length: a real CID is at least 2 chars (e.g. "Qm…"). + uint256 public constant MIN_CID_LENGTH = 2; + + // ========================================================================= + // Data model + // ========================================================================= + + /** + * @notice Canonical evidence record stored per claim. + * + * Field packing: + * - id : uint256 (slot 0) + * - claimId : uint256 (slot 1) + * - uploader : address (slot 2, 20 bytes) + * - uploadedAt : uint64 (slot 2, 8 bytes — shares with uploader via packing) + * - cidHash : bytes32 (slot 3) + * - cid : string (slot 4+, dynamic) + * + * The struct is ordered to minimise storage slots. + */ + struct Evidence { + uint256 id; + uint256 claimId; + address uploader; + uint64 uploadedAt; + bytes32 cidHash; + string cid; + } + + // ========================================================================= + // Storage + // ========================================================================= + + /// @dev Next global evidence ID (monotonically incrementing). + uint256 private _evidenceCounter; + + /// @dev claimId => ordered list of Evidence records attached to the claim. + mapping(uint256 => Evidence[]) private _claimEvidence; + + /// @dev evidenceId => Evidence record for O(1) retrieval by global ID. + mapping(uint256 => Evidence) private _evidenceById; + + /// @dev claimId => cidHash => true when a CID has already been attached. + /// Used for O(1) duplicate detection without iterating the array. + mapping(uint256 => mapping(bytes32 => bool)) private _cidRegistered; + + /// @dev claimId => true when the claim has been closed and no longer accepts evidence. + mapping(uint256 => bool) private _claimClosed; + + // ========================================================================= + // Errors + // ========================================================================= + + /// @notice Submitted CID is empty or otherwise invalid. + error InvalidCID(); + + /// @notice Submitted CID exceeds MAX_CID_LENGTH. + error CIDTooLarge(); + + /// @notice Submitted CID begins with an unrecognised version prefix. + error UnsupportedCIDVersion(); + + /// @notice Identical CID already attached to this claim. + error DuplicateEvidence(); + + /// @notice The referenced claim does not exist in the registry. + error ClaimNotFound(); + + /// @notice The referenced claim is closed and no longer accepts evidence. + error ClaimClosed(); + + /// @notice Caller supplied an out-of-range index. + error IndexOutOfBounds(); + + /// @notice Evidence with the given global ID does not exist. + error EvidenceNotFound(); + + // ========================================================================= + // Events + // ========================================================================= + + /** + * @notice Emitted when evidence is successfully attached to a claim. + * + * @param claimId The claim this evidence belongs to. + * @param evidenceId The global evidence ID assigned to this record. + * @param uploader The address that submitted the evidence. + * @param cid The content identifier (CID) of the off-chain file. + */ + event EvidenceAdded( + uint256 indexed claimId, + uint256 indexed evidenceId, + address indexed uploader, + string cid + ); + + /** + * @notice Emitted when the CLAIM_REGISTRY_ROLE closes a claim for new evidence. + * + * @param claimId The claim that was closed. + */ + event ClaimClosedForEvidence(uint256 indexed claimId); + + // ========================================================================= + // Constructor + // ========================================================================= + + /** + * @param initialAdmin Address granted DEFAULT_ADMIN_ROLE, PAUSER_ROLE, and + * CLAIM_REGISTRY_ROLE on deployment. + */ + constructor(address initialAdmin) { + require(initialAdmin != address(0), "EvidenceManager: zero admin address"); + + _grantRole(DEFAULT_ADMIN_ROLE, initialAdmin); + _grantRole(PAUSER_ROLE, initialAdmin); + _grantRole(CLAIM_REGISTRY_ROLE, initialAdmin); + } + + // ========================================================================= + // Claim lifecycle integration + // ========================================================================= + + /** + * @notice Mark a claim as closed for new evidence. + * @dev Called by the Claim Registry (or an authorised admin) after a claim + * transitions to a state that no longer accepts evidence (e.g. settled, + * expired, or disputed). Idempotent — closing an already-closed claim + * emits no event and does not revert. + * + * @param claimId The claim to close. + */ + function closeClaimForEvidence(uint256 claimId) external onlyRole(CLAIM_REGISTRY_ROLE) { + if (!_claimClosed[claimId]) { + _claimClosed[claimId] = true; + emit ClaimClosedForEvidence(claimId); + } + } + + // ========================================================================= + // Core write function + // ========================================================================= + + /** + * @notice Attach evidence to a claim by submitting an IPFS CID. + * + * @dev Validation sequence (fail-fast order): + * 1. Contract not paused. + * 2. CID is non-empty and within length bounds. + * 3. CID begins with a supported version prefix ("Qm" for CIDv0, + * "b", "B", "z", "Z", "F", "f", "k", "K" for CIDv1 multibase). + * 4. The claim exists (evidenced by not having been explicitly marked + * non-existent — the registry can call `notifyClaimCreated` or the + * contract trusts any claimId; see note below). + * 5. The claim is not closed. + * 6. The CID has not already been attached to this claim. + * + * Note on claim existence: EvidenceManager is intentionally decoupled from + * the Claim Registry contract to avoid circular dependencies and keep the + * modules independently upgradeable. The registry SHOULD call + * `closeClaimForEvidence` to signal lifecycle transitions. A future + * integration point may add an `IClaimRegistry` interface check. + * + * @param claimId The claim to attach evidence to. + * @param cid An IPFS (or compatible) Content Identifier string. + */ + function addEvidence( + uint256 claimId, + string calldata cid + ) external whenNotPaused { + // ── 1. CID length validation ──────────────────────────────────────── + uint256 cidLen = bytes(cid).length; + + if (cidLen < MIN_CID_LENGTH) revert InvalidCID(); + if (cidLen > MAX_CID_LENGTH) revert CIDTooLarge(); + + // ── 2. CID version / multibase prefix validation ───────────────────── + // CIDv0 always starts with "Qm" (base58btc-encoded SHA2-256 multihash). + // CIDv1 starts with a multibase prefix character: + // b / B = base32, z / Z = base58btc, f / F = base16, k / K = base36. + // Additional future prefixes will be handled via protocol upgrade. + _validateCIDPrefix(cid); + + // ── 3. Printable ASCII check (guard against malformed Unicode payloads) ─ + _validateCIDChars(cid); + + // ── 4. Claim state checks ──────────────────────────────────────────── + if (_claimClosed[claimId]) revert ClaimClosed(); + + // ── 5. Duplicate detection ─────────────────────────────────────────── + bytes32 cidHash = keccak256(bytes(cid)); + if (_cidRegistered[claimId][cidHash]) revert DuplicateEvidence(); + + // ── 6. Store ───────────────────────────────────────────────────────── + uint256 evidenceId = _evidenceCounter; + unchecked { _evidenceCounter++; } + + Evidence memory ev = Evidence({ + id: evidenceId, + claimId: claimId, + uploader: msg.sender, + uploadedAt: uint64(block.timestamp), + cidHash: cidHash, + cid: cid + }); + + _claimEvidence[claimId].push(ev); + _evidenceById[evidenceId] = ev; + _cidRegistered[claimId][cidHash] = true; + + emit EvidenceAdded(claimId, evidenceId, msg.sender, cid); + } + + // ========================================================================= + // View / retrieval functions + // ========================================================================= + + /** + * @notice Retrieve a single evidence record by its global ID. + * + * @param evidenceId Global evidence ID. + * @return ev The Evidence struct. + */ + function getEvidence(uint256 evidenceId) external view returns (Evidence memory ev) { + ev = _evidenceById[evidenceId]; + if (ev.uploadedAt == 0) revert EvidenceNotFound(); + } + + /** + * @notice Retrieve all evidence attached to a specific claim. + * + * @param claimId The claim ID. + * @return Array of Evidence structs (may be empty). + */ + function getEvidenceByClaim(uint256 claimId) external view returns (Evidence[] memory) { + return _claimEvidence[claimId]; + } + + /** + * @notice Retrieve a single evidence item by its position within a claim's list. + * + * @param claimId The claim ID. + * @param index Zero-based index into the claim's evidence array. + * @return The Evidence struct at that position. + */ + function getEvidenceAtIndex( + uint256 claimId, + uint256 index + ) external view returns (Evidence memory) { + Evidence[] storage list = _claimEvidence[claimId]; + if (index >= list.length) revert IndexOutOfBounds(); + return list[index]; + } + + /** + * @notice Return the number of evidence items attached to a claim. + * + * @param claimId The claim ID. + * @return Count of attached evidence records. + */ + function getEvidenceCount(uint256 claimId) external view returns (uint256) { + return _claimEvidence[claimId].length; + } + + /** + * @notice Check whether a claim has at least one evidence item. + * + * @param claimId The claim ID. + * @return True if the claim has ≥ 1 evidence record. + */ + function hasEvidence(uint256 claimId) external view returns (bool) { + return _claimEvidence[claimId].length > 0; + } + + /** + * @notice Return the address that uploaded a specific evidence item. + * + * @param evidenceId Global evidence ID. + * @return Uploader address. + */ + function getEvidenceUploader(uint256 evidenceId) external view returns (address) { + Evidence storage ev = _evidenceById[evidenceId]; + if (ev.uploadedAt == 0) revert EvidenceNotFound(); + return ev.uploader; + } + + /** + * @notice Return the keccak256 hash of a CID stored for a given evidence record. + * + * @param evidenceId Global evidence ID. + * @return bytes32 CID hash. + */ + function getEvidenceCIDHash(uint256 evidenceId) external view returns (bytes32) { + Evidence storage ev = _evidenceById[evidenceId]; + if (ev.uploadedAt == 0) revert EvidenceNotFound(); + return ev.cidHash; + } + + /** + * @notice Check whether a specific CID has already been attached to a claim. + * + * @param claimId The claim ID. + * @param cid The CID string to check. + * @return True if the CID is already registered for the claim. + */ + function isCIDRegistered(uint256 claimId, string calldata cid) external view returns (bool) { + bytes32 cidHash = keccak256(bytes(cid)); + return _cidRegistered[claimId][cidHash]; + } + + /** + * @notice Return whether a claim is currently closed for new evidence submissions. + * + * @param claimId The claim ID. + * @return True if the claim is closed. + */ + function isClaimClosed(uint256 claimId) external view returns (bool) { + return _claimClosed[claimId]; + } + + /** + * @notice Return the next evidence ID that will be assigned. + * @return Current evidence counter value. + */ + function evidenceCounter() external view returns (uint256) { + return _evidenceCounter; + } + + // ========================================================================= + // Pausable admin + // ========================================================================= + + /// @notice Pause new evidence submissions. + function pause() external onlyRole(PAUSER_ROLE) { + _pause(); + } + + /// @notice Resume evidence submissions. + function unpause() external onlyRole(PAUSER_ROLE) { + _unpause(); + } + + // ========================================================================= + // Internal helpers + // ========================================================================= + + /** + * @dev Validate the CID multibase/version prefix. + * Reverts with UnsupportedCIDVersion on an unrecognised first character. + * + * Supported prefixes: + * "Q" → CIDv0 (starts "Qm", base58btc SHA2-256) + * "b","B" → CIDv1 base32 + * "z","Z" → CIDv1 base58btc + * "f","F" → CIDv1 base16 + * "k","K" → CIDv1 base36 + * "u","U" → CIDv1 base64url + * "m","M" → CIDv1 base64 + * "t","T" → CIDv1 base32hexpad + */ + function _validateCIDPrefix(string calldata cid) internal pure { + bytes1 prefix = bytes(cid)[0]; + + // CIDv0: starts with 'Q' (full "Qm" checked via second byte) + if (prefix == "Q") { + if (bytes(cid).length < 2 || bytes(cid)[1] != "m") revert UnsupportedCIDVersion(); + return; + } + + // CIDv1 multibase prefixes (case-insensitive pairs) + if ( + prefix == "b" || prefix == "B" || // base32 + prefix == "z" || prefix == "Z" || // base58btc + prefix == "f" || prefix == "F" || // base16 + prefix == "k" || prefix == "K" || // base36 + prefix == "u" || prefix == "U" || // base64url + prefix == "m" || prefix == "M" || // base64 + prefix == "t" || prefix == "T" // base32hexpad + ) { + return; + } + + revert UnsupportedCIDVersion(); + } + + /** + * @dev Ensure all bytes in the CID string are printable ASCII (0x20–0x7E). + * This guards against malformed Unicode or control-character payloads + * that could corrupt storage or mislead off-chain consumers. + * + * Note: IPFS CIDs use a strict subset of printable ASCII (alphanumeric + * plus a handful of symbols), so this check is safely conservative. + */ + function _validateCIDChars(string calldata cid) internal pure { + bytes memory b = bytes(cid); + uint256 len = b.length; + for (uint256 i = 0; i < len; ) { + uint8 c = uint8(b[i]); + if (c < 0x21 || c > 0x7E) revert InvalidCID(); + unchecked { i++; } + } + } +} diff --git a/contracts/bootstrap/BootstrapController.sol b/contracts/bootstrap/BootstrapController.sol index 99a9cd1..0ea5a8e 100644 --- a/contracts/bootstrap/BootstrapController.sol +++ b/contracts/bootstrap/BootstrapController.sol @@ -12,6 +12,7 @@ import "../IReputationOracle.sol"; interface ITruthBountyWeighted { function grantRole(bytes32 role, address account) external; function hasRole(bytes32 role, address account) external view returns (bool); + function GOVERNANCE_ROLE() external view returns (bytes32); function bountyToken() external view returns (address); function reputationOracle() external view returns (address); function verificationWindowDuration() external view returns (uint256); @@ -43,6 +44,7 @@ contract BootstrapController is ReentrancyGuard, Pausable, GovernanceOwnable { bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); bytes32 public constant DEPLOYER_ROLE = keccak256("DEPLOYER_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); // ============ Constants ============ @@ -308,7 +310,7 @@ contract BootstrapController is ReentrancyGuard, Pausable, GovernanceOwnable { } } - function _validateConfiguration() internal view { + function _validateConfiguration() internal { BootstrapConfig memory cfg = config; if (cfg.verificationWindowDuration < 1 days || cfg.verificationWindowDuration > 30 days) { diff --git a/contracts/deployment/MigrationManager.sol b/contracts/deployment/MigrationManager.sol index 0ff286b..7fd20a0 100644 --- a/contracts/deployment/MigrationManager.sol +++ b/contracts/deployment/MigrationManager.sol @@ -10,6 +10,7 @@ contract MigrationManager is ReentrancyGuard, Pausable, GovernanceOwnable { bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); bytes32 public constant MIGRATOR_ROLE = keccak256("MIGRATOR_ROLE"); bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); struct Release { string version; diff --git a/hardhat.config.ts b/hardhat.config.ts index f46f70f..7965e71 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -15,7 +15,8 @@ const config: HardhatUserConfig = { optimizer: { enabled: true, runs: 200 - } + }, + viaIR: true } }, networks: { diff --git a/test/EvidenceManager.test.ts b/test/EvidenceManager.test.ts new file mode 100644 index 0000000..6eec8e0 --- /dev/null +++ b/test/EvidenceManager.test.ts @@ -0,0 +1,436 @@ +import { expect } from "chai"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { ethers } from "hardhat"; + +// ─── CID test constants ────────────────────────────────────────────────────── +const CID_V0 = "QmZ4tDuvesekSs4qM5ZBKpXiZGun7S2CYtEZRB3DYXkjGx"; +const CID_V1_B32 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; +const CID_V1_B58 = "zdj7WWeQ43G6JJvLWQWZpyHuAMq6uYWRjkBXFad4vV7dy1Z3"; +const CID_V1_B16 = "f01551220c3c4733ec8affd06cf9e9ff50ffc6bcd2ec85a6170004bb709669c31de94391a"; +const CID_ALT = "QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB"; + +// ─── Fixture ───────────────────────────────────────────────────────────────── + +async function deployFixture() { + const [admin, uploader, other, registryRole] = await ethers.getSigners(); + + const EvidenceManager = await ethers.getContractFactory("EvidenceManager"); + const em = await EvidenceManager.deploy(admin.address); + await em.waitForDeployment(); + + // Grant CLAIM_REGISTRY_ROLE to registryRole signer so we can call closeClaimForEvidence + const CLAIM_REGISTRY_ROLE = await em.CLAIM_REGISTRY_ROLE(); + await em.connect(admin).grantRole(CLAIM_REGISTRY_ROLE, registryRole.address); + + return { em, admin, uploader, other, registryRole, CLAIM_REGISTRY_ROLE }; +} + +// ============================================================================= +// 1. Deployment & roles +// ============================================================================= + +describe("EvidenceManager", function () { + describe("Deployment", function () { + it("grants DEFAULT_ADMIN_ROLE to initial admin", async function () { + const { em, admin } = await loadFixture(deployFixture); + expect(await em.hasRole(await em.DEFAULT_ADMIN_ROLE(), admin.address)).to.be.true; + }); + + it("grants PAUSER_ROLE to initial admin", async function () { + const { em, admin } = await loadFixture(deployFixture); + expect(await em.hasRole(await em.PAUSER_ROLE(), admin.address)).to.be.true; + }); + + it("grants CLAIM_REGISTRY_ROLE to initial admin", async function () { + const { em, admin } = await loadFixture(deployFixture); + expect(await em.hasRole(await em.CLAIM_REGISTRY_ROLE(), admin.address)).to.be.true; + }); + + it("starts with evidence counter at 0", async function () { + const { em } = await loadFixture(deployFixture); + expect(await em.evidenceCounter()).to.equal(0n); + }); + + it("reverts when deployed with zero admin address", async function () { + const EvidenceManager = await ethers.getContractFactory("EvidenceManager"); + await expect(EvidenceManager.deploy(ethers.ZeroAddress)) + .to.be.revertedWith("EvidenceManager: zero admin address"); + }); + }); + + // =========================================================================== + // 2. Successful evidence upload + // =========================================================================== + + describe("addEvidence — success cases", function () { + it("uploads CIDv0 evidence and emits EvidenceAdded", async function () { + const { em, uploader } = await loadFixture(deployFixture); + + await expect(em.connect(uploader).addEvidence(1, CID_V0)) + .to.emit(em, "EvidenceAdded") + .withArgs(1n, 0n, uploader.address, CID_V0); + }); + + it("uploads CIDv1 base32 evidence", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await expect(em.connect(uploader).addEvidence(1, CID_V1_B32)) + .to.emit(em, "EvidenceAdded") + .withArgs(1n, 0n, uploader.address, CID_V1_B32); + }); + + it("uploads CIDv1 base58btc evidence", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await expect(em.connect(uploader).addEvidence(1, CID_V1_B58)) + .to.emit(em, "EvidenceAdded") + .withArgs(1n, 0n, uploader.address, CID_V1_B58); + }); + + it("uploads CIDv1 base16 evidence", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await expect(em.connect(uploader).addEvidence(1, CID_V1_B16)) + .to.emit(em, "EvidenceAdded") + .withArgs(1n, 0n, uploader.address, CID_V1_B16); + }); + + it("assigns monotonically increasing IDs across claims", async function () { + const { em, uploader, other } = await loadFixture(deployFixture); + + await em.connect(uploader).addEvidence(1, CID_V0); + await em.connect(other).addEvidence(2, CID_ALT); + + expect(await em.evidenceCounter()).to.equal(2n); + }); + + it("records uploader address correctly", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + expect(await em.getEvidenceUploader(0)).to.equal(uploader.address); + }); + + it("records cidHash as keccak256 of cid", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + + const expected = ethers.keccak256(ethers.toUtf8Bytes(CID_V0)); + expect(await em.getEvidenceCIDHash(0)).to.equal(expected); + }); + + it("records uploadedAt timestamp", async function () { + const { em, uploader } = await loadFixture(deployFixture); + const tx = await em.connect(uploader).addEvidence(1, CID_V0); + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt!.blockNumber); + + const ev = await em.getEvidence(0); + expect(ev.uploadedAt).to.equal(BigInt(block!.timestamp)); + }); + + it("records correct claimId in evidence struct", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(42, CID_V0); + const ev = await em.getEvidence(0); + expect(ev.claimId).to.equal(42n); + }); + + it("supports multiple evidence items on the same claim", async function () { + const { em, uploader, other } = await loadFixture(deployFixture); + + await em.connect(uploader).addEvidence(1, CID_V0); + await em.connect(other).addEvidence(1, CID_ALT); + await em.connect(uploader).addEvidence(1, CID_V1_B32); + + expect(await em.getEvidenceCount(1)).to.equal(3n); + }); + + it("supports evidence on different claims independently", async function () { + const { em, uploader } = await loadFixture(deployFixture); + + await em.connect(uploader).addEvidence(1, CID_V0); + await em.connect(uploader).addEvidence(2, CID_V0); // same CID, different claim + + expect(await em.getEvidenceCount(1)).to.equal(1n); + expect(await em.getEvidenceCount(2)).to.equal(1n); + }); + + it("emits exactly one EvidenceAdded event per submission", async function () { + const { em, uploader } = await loadFixture(deployFixture); + const tx = await em.connect(uploader).addEvidence(1, CID_V0); + const receipt = await tx.wait(); + const events = receipt!.logs.filter( + (l) => l.topics[0] === em.interface.getEvent("EvidenceAdded")!.topicHash + ); + expect(events).to.have.length(1); + }); + }); + + // =========================================================================== + // 3. Retrieval / view functions + // =========================================================================== + + describe("Retrieval functions", function () { + it("getEvidence returns full struct by global ID", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(7, CID_V0); + + const ev = await em.getEvidence(0); + expect(ev.id).to.equal(0n); + expect(ev.claimId).to.equal(7n); + expect(ev.uploader).to.equal(uploader.address); + expect(ev.cid).to.equal(CID_V0); + }); + + it("getEvidenceByClaim returns ordered array", async function () { + const { em, uploader, other } = await loadFixture(deployFixture); + + await em.connect(uploader).addEvidence(5, CID_V0); + await em.connect(other).addEvidence(5, CID_ALT); + + const list = await em.getEvidenceByClaim(5); + expect(list).to.have.length(2); + expect(list[0].cid).to.equal(CID_V0); + expect(list[1].cid).to.equal(CID_ALT); + }); + + it("getEvidenceByClaim returns empty array for claim with no evidence", async function () { + const { em } = await loadFixture(deployFixture); + expect(await em.getEvidenceByClaim(999)).to.be.empty; + }); + + it("getEvidenceAtIndex returns correct item", async function () { + const { em, uploader, other } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(3, CID_V0); + await em.connect(other).addEvidence(3, CID_ALT); + + const second = await em.getEvidenceAtIndex(3, 1); + expect(second.cid).to.equal(CID_ALT); + expect(second.uploader).to.equal(other.address); + }); + + it("getEvidenceCount returns 0 for new claim", async function () { + const { em } = await loadFixture(deployFixture); + expect(await em.getEvidenceCount(100)).to.equal(0n); + }); + + it("getEvidenceCount increments after each upload", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + expect(await em.getEvidenceCount(1)).to.equal(1n); + await em.connect(uploader).addEvidence(1, CID_ALT); + expect(await em.getEvidenceCount(1)).to.equal(2n); + }); + + it("hasEvidence returns false before any upload", async function () { + const { em } = await loadFixture(deployFixture); + expect(await em.hasEvidence(55)).to.be.false; + }); + + it("hasEvidence returns true after upload", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(55, CID_V0); + expect(await em.hasEvidence(55)).to.be.true; + }); + + it("getEvidenceUploader returns correct address", async function () { + const { em, uploader, other } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + await em.connect(other).addEvidence(1, CID_ALT); + + expect(await em.getEvidenceUploader(0)).to.equal(uploader.address); + expect(await em.getEvidenceUploader(1)).to.equal(other.address); + }); + + it("getEvidenceCIDHash matches keccak256 of cid", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V1_B32); + + const expected = ethers.keccak256(ethers.toUtf8Bytes(CID_V1_B32)); + expect(await em.getEvidenceCIDHash(0)).to.equal(expected); + }); + + it("isCIDRegistered returns false before upload", async function () { + const { em } = await loadFixture(deployFixture); + expect(await em.isCIDRegistered(1, CID_V0)).to.be.false; + }); + + it("isCIDRegistered returns true after upload", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + expect(await em.isCIDRegistered(1, CID_V0)).to.be.true; + }); + + it("isCIDRegistered is scoped per claim (same CID, different claim = not registered)", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + + // Registered for claim 1 but not for claim 2 + expect(await em.isCIDRegistered(1, CID_V0)).to.be.true; + expect(await em.isCIDRegistered(2, CID_V0)).to.be.false; + }); + + it("isClaimClosed returns false by default", async function () { + const { em } = await loadFixture(deployFixture); + expect(await em.isClaimClosed(1)).to.be.false; + }); + }); + + // =========================================================================== + // 4. CID validation failures + // =========================================================================== + + describe("addEvidence — CID validation failures", function () { + it("reverts with InvalidCID on empty string", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await expect(em.connect(uploader).addEvidence(1, "")) + .to.be.revertedWithCustomError(em, "InvalidCID"); + }); + + it("reverts with InvalidCID on single-char string", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await expect(em.connect(uploader).addEvidence(1, "Q")) + .to.be.revertedWithCustomError(em, "InvalidCID"); + }); + + it("reverts with CIDTooLarge when CID exceeds MAX_CID_LENGTH", async function () { + const { em, uploader } = await loadFixture(deployFixture); + const oversized = "Qm" + "a".repeat(511); // 513 chars total + await expect(em.connect(uploader).addEvidence(1, oversized)) + .to.be.revertedWithCustomError(em, "CIDTooLarge"); + }); + + it("reverts with UnsupportedCIDVersion on unknown prefix 'x'", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await expect(em.connect(uploader).addEvidence(1, "xmfoo123")) + .to.be.revertedWithCustomError(em, "UnsupportedCIDVersion"); + }); + + it("reverts with UnsupportedCIDVersion on prefix 'Q' without 'm' second char", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await expect(em.connect(uploader).addEvidence(1, "QXabc123")) + .to.be.revertedWithCustomError(em, "UnsupportedCIDVersion"); + }); + + it("reverts with UnsupportedCIDVersion on numeric prefix '1'", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await expect(em.connect(uploader).addEvidence(1, "1234567890")) + .to.be.revertedWithCustomError(em, "UnsupportedCIDVersion"); + }); + + it("reverts with InvalidCID on CID containing a space", async function () { + const { em, uploader } = await loadFixture(deployFixture); + // space (0x20) is below printable range threshold 0x21 + await expect(em.connect(uploader).addEvidence(1, "Qm foo bar")) + .to.be.revertedWithCustomError(em, "InvalidCID"); + }); + + it("reverts with UnsupportedCIDVersion on 'http://' prefix", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await expect(em.connect(uploader).addEvidence(1, "http://ipfs.io/ipfs/QmFoo")) + .to.be.revertedWithCustomError(em, "UnsupportedCIDVersion"); + }); + + it("accepts exactly MAX_CID_LENGTH (512) bytes", async function () { + const { em, uploader } = await loadFixture(deployFixture); + // 'b' prefix (CIDv1 base32) + 511 lowercase alphanumeric chars + const maxCid = "b" + "a".repeat(511); + await expect(em.connect(uploader).addEvidence(1, maxCid)).to.not.be.reverted; + }); + }); + + // =========================================================================== + // 5. Duplicate detection + // =========================================================================== + + describe("addEvidence — duplicate detection", function () { + it("reverts DuplicateEvidence when same CID re-submitted to same claim", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + await expect(em.connect(uploader).addEvidence(1, CID_V0)) + .to.be.revertedWithCustomError(em, "DuplicateEvidence"); + }); + + it("allows the same CID on a different claim", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + await expect(em.connect(uploader).addEvidence(2, CID_V0)).to.not.be.reverted; + }); + + it("allows a different CID on the same claim", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + await expect(em.connect(uploader).addEvidence(1, CID_ALT)).to.not.be.reverted; + }); + + it("reverts duplicate even when submitted by a different uploader", async function () { + const { em, uploader, other } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + await expect(em.connect(other).addEvidence(1, CID_V0)) + .to.be.revertedWithCustomError(em, "DuplicateEvidence"); + }); + + it("duplicate check uses hash equality (normalised bytes)", async function () { + const { em, uploader } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + + // Same string = same hash → must be rejected + await expect(em.connect(uploader).addEvidence(1, CID_V0)) + .to.be.revertedWithCustomError(em, "DuplicateEvidence"); + }); + }); + + // =========================================================================== + // 6. Claim lifecycle: closed claims + // =========================================================================== + + describe("addEvidence — closed claim", function () { + it("reverts ClaimClosed after closeClaimForEvidence is called", async function () { + const { em, uploader, registryRole } = await loadFixture(deployFixture); + + await em.connect(registryRole).closeClaimForEvidence(1); + await expect(em.connect(uploader).addEvidence(1, CID_V0)) + .to.be.revertedWithCustomError(em, "ClaimClosed"); + }); + + it("emits ClaimClosedForEvidence event", async function () { + const { em, registryRole } = await loadFixture(deployFixture); + await expect(em.connect(registryRole).closeClaimForEvidence(1)) + .to.emit(em, "ClaimClosedForEvidence") + .withArgs(1n); + }); + + it("isClaimClosed returns true after close", async function () { + const { em, registryRole } = await loadFixture(deployFixture); + await em.connect(registryRole).closeClaimForEvidence(1); + expect(await em.isClaimClosed(1)).to.be.true; + }); + + it("closing twice is idempotent (no second event, no revert)", async function () { + const { em, registryRole } = await loadFixture(deployFixture); + await em.connect(registryRole).closeClaimForEvidence(1); + + // Second call should not emit the event + const tx = await em.connect(registryRole).closeClaimForEvidence(1); + const receipt = await tx.wait(); + const events = receipt!.logs.filter( + (l) => l.topics[0] === em.interface.getEvent("ClaimClosedForEvidence")!.topicHash + ); + expect(events).to.have.length(0); + }); + + it("evidence added before close is still retrievable after close", async function () { + const { em, uploader, registryRole } = await loadFixture(deployFixture); + await em.connect(uploader).addEvidence(1, CID_V0); + await em.connect(registryRole).closeClaimForEvidence(1); + + const ev = await em.getEvidence(0); + expect(ev.cid).to.equal(CID_V0); + }); + + it("reverts when non-registry tries to close a claim", async function () { + const { em, other, CLAIM_REGISTRY_ROLE } = await loadFixture(deployFixture); + await expect(em.connect(other).closeClaimForEvidence(1)) + .to.be.revertedWithCustomError(em, "AccessControlUnauthorizedAccount") + .withArgs(other.address, CLAIM_REGISTRY_ROLE); + }); + }); +}); +