// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /** * @title AereInferNet — EU AI Act Article 12 inference-log commitments * @notice EU AI Act Article 12 (in force from August 2 2026) requires * providers and deployers of HIGH-RISK AI systems to maintain * automatic event logs covering the system's operation. The * logs must allow ex-post traceability and incident analysis * by the relevant national competent authority. * * Today, "logs" lives in vendor S3 buckets. A subpoena later * can show whatever is convenient. AereInferNet turns the * commitment into a public-good primitive: * * 1. Each inference provider registers their model + the TEE * attestation that bounds it (e.g. Intel SGX / AMD SEV-SNP * measurement). The registration is immutable per (provider, * modelId) — the model commitment cannot be retroactively * "amended" without a new modelId. * * 2. Each epoch (~24h), the provider commits a Merkle root of * (requestHash, responseHash, timestampMs, deployerId) * leaves for every inference served in that epoch. The * commitment is one transaction per provider per epoch — * cheap enough that even a multi-million-request/day * provider pays cents in gas. * * 3. Anyone (regulator, customer, journalist, the deployer * themselves) can later prove inclusion of a specific * inference by submitting a Merkle proof against the * committed root. If the provider denies serving a request * and the proof shows it WAS served, the public record * speaks. * * 4. Slashing: providers MAY post bond via AereAgentBond, * keyed on agentId = keccak256(modelId, providerAddress). * Regulators / customers can dispute → trigger the slash * path. AereInferNet does NOT enforce this layer — it's * composable. Consumers that need stronger guarantees can * require `bond.isBonded(provider, agentId, minBond)` * before accepting the provider's commitments. * * DEPLOYER COMMITMENT: * Article 12 obligates BOTH providers and deployers. A * deployer who wires an inference into their own application * records the same leaves on their side and can publish their * own Merkle root — making provider/deployer collusion * detectable (the two roots' leaves must reconcile). * * NO PII: * Both `requestHash` and `responseHash` are content hashes * computed off-chain — the chain stores only commitments, * never raw prompts or responses. Aligned with GDPR Art 5(1)(c). * * IMMUTABILITY: * - Model registry is append-only; per (provider, modelId) * we set once and never overwrite. Bug-fix releases bump * modelId. * - Commitments append to per-provider epoch history. A * commitment for an epoch already committed reverts. */ contract AereInferNet { /* ================================ types ================================ */ /// @notice Severity tier the model is offered for per EU AI Act Annex III. /// Used by indexers to filter; not enforced on-chain. enum RiskTier { Minimal, // 0 Limited, // 1 High, // 2 — Article 12 logging strictly applies Unacceptable // 3 — should never register; left for completeness } struct ModelRegistration { bytes32 modelDigest; // SHA256 of the model weights blob bytes32 attestationDigest; // hash of the TEE attestation that pins the // running binary to `modelDigest` RiskTier risk; string modelName; // human-readable, e.g. "claude-4.7-sonnet-20251022" string attestationUri; // ipfs:// or https:// — full TEE quote + signing chain uint64 registeredAt; bool exists; } struct EpochCommitment { bytes32 root; // Merkle root of (req, resp, ts, deployer) leaves uint64 committedAt; uint64 inferenceCount; // number of leaves under this root (informational) string metadataUri; // optional pointer to provider-published leaves } /* ================================ storage ================================ */ /// @notice (provider, modelId) → ModelRegistration. modelId is provider-chosen. mapping(address => mapping(bytes32 => ModelRegistration)) internal _models; /// @notice (provider, modelId, epochId) → EpochCommitment. /// AUDIT FIX (HIGH #25): previously the key was (provider, epochId) which /// meant multi-model providers could only commit one root per epoch across /// all models, and verifyInference had no way to bind a proof to a specific /// model. Now keyed by all three. mapping(address => mapping(bytes32 => mapping(uint64 => EpochCommitment))) internal _commits; /// @notice (provider, modelId) → count of registered epochs (informational). mapping(address => mapping(bytes32 => uint256)) public modelEpochCount; /* ================================= events ================================= */ event ModelRegistered( address indexed provider, bytes32 indexed modelId, bytes32 modelDigest, bytes32 attestationDigest, RiskTier risk, string modelName, string attestationUri ); event EpochCommitted( address indexed provider, bytes32 indexed modelId, uint64 indexed epochId, bytes32 root, uint64 inferenceCount, string metadataUri ); /* ================================= errors ================================= */ error ModelExists(); error ModelUnknown(); error EpochExists(); error EpochOutOfRange(); error ZeroDigest(); error EmptyName(); /* ============================== model registry ============================ */ /// @notice Provider registers a new model. msg.sender is the provider. /// Reverts if the (provider, modelId) is already set; cannot be /// amended — register a new modelId for the next version. function registerModel( bytes32 modelId, bytes32 modelDigest, bytes32 attestationDigest, RiskTier risk, string calldata modelName, string calldata attestationUri ) external { if (modelDigest == bytes32(0) || attestationDigest == bytes32(0)) revert ZeroDigest(); if (bytes(modelName).length == 0) revert EmptyName(); ModelRegistration storage m = _models[msg.sender][modelId]; if (m.exists) revert ModelExists(); m.modelDigest = modelDigest; m.attestationDigest = attestationDigest; m.risk = risk; m.modelName = modelName; m.attestationUri = attestationUri; m.registeredAt = uint64(block.timestamp); m.exists = true; emit ModelRegistered(msg.sender, modelId, modelDigest, attestationDigest, risk, modelName, attestationUri); } /* ============================ epoch commitments ========================== */ /// @notice Provider commits the Merkle root for an epoch. The model /// MUST already be registered (reverts otherwise — prevents /// committing inferences for a phantom model). /// epochId is provider-chosen but convention is unix-day: /// epochId = uint64(block.timestamp / 86400) function commitEpoch( bytes32 modelId, uint64 epochId, bytes32 root, uint64 inferenceCount, string calldata metadataUri ) external { if (root == bytes32(0)) revert ZeroDigest(); ModelRegistration storage m = _models[msg.sender][modelId]; if (!m.exists) revert ModelUnknown(); // AUDIT FIX (HIGH #26): epochId must be the current unix-day (with 1-day // grace for the just-ended day). Prevents back-dating to rewrite history // and forward-dating to commit to future inferences. uint64 currentDay = uint64(block.timestamp / 86400); if (epochId > currentDay) revert EpochOutOfRange(); if (currentDay > epochId && currentDay - epochId > 1) revert EpochOutOfRange(); EpochCommitment storage e = _commits[msg.sender][modelId][epochId]; if (e.committedAt != 0) revert EpochExists(); e.root = root; e.committedAt = uint64(block.timestamp); e.inferenceCount = inferenceCount; e.metadataUri = metadataUri; modelEpochCount[msg.sender][modelId] += 1; emit EpochCommitted(msg.sender, modelId, epochId, root, inferenceCount, metadataUri); } /* ================================= views ================================= */ function modelOf(address provider, bytes32 modelId) external view returns ( bytes32 modelDigest, bytes32 attestationDigest, RiskTier risk, string memory modelName, string memory attestationUri, uint64 registeredAt, bool exists ) { ModelRegistration storage m = _models[provider][modelId]; return (m.modelDigest, m.attestationDigest, m.risk, m.modelName, m.attestationUri, m.registeredAt, m.exists); } function epochOf(address provider, bytes32 modelId, uint64 epochId) external view returns ( bytes32 root, uint64 committedAt, uint64 inferenceCount, string memory metadataUri ) { EpochCommitment storage e = _commits[provider][modelId][epochId]; return (e.root, e.committedAt, e.inferenceCount, e.metadataUri); } /// @notice Verify that a specific (requestHash, responseHash, timestampMs, /// deployerId) tuple is committed under the provider's root for /// (modelId, epochId). Returns false if the proof is invalid or the /// (modelId, epoch) was never committed. /// AUDIT FIX (HIGH #25): modelId is now part of the lookup key. function verifyInference( address provider, bytes32 modelId, uint64 epochId, bytes32 requestHash, bytes32 responseHash, uint64 timestampMs, bytes32 deployerId, bytes32[] calldata proof ) external view returns (bool) { EpochCommitment storage e = _commits[provider][modelId][epochId]; if (e.committedAt == 0) return false; bytes32 leaf = keccak256(abi.encode(requestHash, responseHash, timestampMs, deployerId)); return _verifyProof(proof, e.root, leaf); } /* =============================== internal =============================== */ /// @dev OpenZeppelin-style Merkle proof verification (sorted-pairs hashing). function _verifyProof(bytes32[] calldata proof, bytes32 root, bytes32 leaf) internal pure returns (bool) { bytes32 h = leaf; for (uint256 i = 0; i < proof.length; i++) { bytes32 p = proof[i]; h = h <= p ? keccak256(abi.encodePacked(h, p)) : keccak256(abi.encodePacked(p, h)); } return h == root; } }