// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; /** * @title AereAIProof — anchors signed (model, input, output) triples for * AI inference / generation provenance * @notice AI providers register a `modelId` with a signing key. For each * inference / generation request they expose, the provider commits * an EIP-712 signed statement: * {modelId, inputHash, outputHash, timestamp, requesterHash} * where requesterHash is keccak256(abi.encode(requester, nonce)). * * Use cases: * - Agentic transactions (AERE402 caller proves the LLM output * it acted on came from a specific signed model) * - Tamper-evident audit logs for regulated AI deployments * - Cross-tenant accountability without revealing raw I/O * * IMMUTABILITY: * - Provider's signer key is rotatable (via setSigner) but the * modelId itself is permanent once registered * - Anchored attestations are append-only — never editable * * PRIVACY: * - inputHash + outputHash + requesterHash are all hashes * - The on-chain record never sees raw prompts or completions * * @dev EIP-712 domain pinned at deploy. Signatures are verified by * ECDSA.recover against the provider's currently-registered signer. */ contract AereAIProof { using ECDSA for bytes32; /* --------------------------------- types --------------------------------- */ struct Model { bool registered; address signer; address operator; // can rotate signer string description; // human-readable identifier } struct Attestation { bytes32 modelId; bytes32 inputHash; bytes32 outputHash; bytes32 requesterHash; uint256 timestamp; uint256 nonce; // per-modelId monotonic } /* ------------------------------- immutable ------------------------------- */ bytes32 public immutable DOMAIN_SEPARATOR; bytes32 public constant ATTESTATION_TYPEHASH = keccak256( "Attestation(bytes32 modelId,bytes32 inputHash,bytes32 outputHash,bytes32 requesterHash,uint256 timestamp,uint256 nonce)" ); /* --------------------------------- state -------------------------------- */ mapping(bytes32 => Model) public models; mapping(bytes32 => mapping(uint256 => bool)) public usedNonce; // modelId → nonce → consumed mapping(bytes32 => uint256) public attestationCount; // modelId → total anchored /// @notice M3 fix: nonces below this watermark are inadmissible — set /// to the highest used nonce + 1 on every signer rotation so /// the new signer cannot re-use nonces issued under the old /// signer's context, polluting provenance history. mapping(bytes32 => uint256) public minValidNonce; mapping(bytes32 => uint256) public highestNonceSeen; /* --------------------------------- events ------------------------------- */ event ModelRegistered(bytes32 indexed modelId, address indexed operator, address indexed signer, string description); event SignerRotated(bytes32 indexed modelId, address indexed newSigner); event Anchored( bytes32 indexed modelId, bytes32 indexed inputHash, bytes32 indexed outputHash, bytes32 requesterHash, uint256 timestamp, uint256 nonce ); /* --------------------------------- errors ------------------------------- */ error ModelExists(); error UnknownModel(); error NotOperator(); error BadSignature(); error NonceConsumed(); error NonceBelowWatermark(uint256 got, uint256 minValid); error AttestationInFuture(); error ZeroAddress(); /* ----------------------------- constructor ------------------------------ */ constructor() { DOMAIN_SEPARATOR = keccak256(abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256("AereAIProof"), keccak256("1"), block.chainid, address(this) )); } /* ------------------------------ registration ---------------------------- */ function registerModel(bytes32 modelId, address signer, string calldata description) external { if (modelId == bytes32(0)) revert ZeroAddress(); if (signer == address(0)) revert ZeroAddress(); if (models[modelId].registered) revert ModelExists(); models[modelId] = Model({ registered: true, signer: signer, operator: msg.sender, description: description }); emit ModelRegistered(modelId, msg.sender, signer, description); } function setSigner(bytes32 modelId, address newSigner) external { Model storage m = models[modelId]; if (!m.registered) revert UnknownModel(); if (msg.sender != m.operator) revert NotOperator(); if (newSigner == address(0)) revert ZeroAddress(); m.signer = newSigner; // M3 fix: invalidate any nonces below this point so new signer // cannot reuse the old signer's nonce space. minValidNonce[modelId] = highestNonceSeen[modelId] + 1; emit SignerRotated(modelId, newSigner); } /* ------------------------------- anchor --------------------------------- */ /// @notice Submit a provider-signed attestation. Anyone can submit /// (the provider, the requester, an indexer). The signature /// must come from the model's CURRENT signer. function anchor( bytes32 modelId, bytes32 inputHash, bytes32 outputHash, bytes32 requesterHash, uint256 timestamp, uint256 nonce, bytes calldata signature ) external { Model memory m = models[modelId]; if (!m.registered) revert UnknownModel(); if (timestamp > block.timestamp) revert AttestationInFuture(); if (usedNonce[modelId][nonce]) revert NonceConsumed(); // M3 fix: post-rotation, old nonce space is gone. if (nonce < minValidNonce[modelId]) revert NonceBelowWatermark(nonce, minValidNonce[modelId]); bytes32 structHash = keccak256(abi.encode( ATTESTATION_TYPEHASH, modelId, inputHash, outputHash, requesterHash, timestamp, nonce )); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); address recovered = digest.recover(signature); if (recovered == address(0) || recovered != m.signer) revert BadSignature(); usedNonce[modelId][nonce] = true; if (nonce > highestNonceSeen[modelId]) highestNonceSeen[modelId] = nonce; attestationCount[modelId] += 1; emit Anchored(modelId, inputHash, outputHash, requesterHash, timestamp, nonce); } }