// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /** * @title AereAgentActionReceipt — PQC-anchored, publicly verifiable AI provenance * (AERE chain 2800) * * @notice An on-chain provenance record for an autonomous/AI agent's output. An * agent commits the HASH of an output (or action) it produced, signed by a * short-lived secp256k1 SESSION key that was itself issued under a * quantum-durable Falcon ROOT via AereAgentDID (proof-of-possession verified * on-chain by AERE's live native PQC precompile). The result is a permanent, * append-only, publicly re-verifiable statement: "agent A produced an output * whose hash is H, authorized by session S under Falcon root A." * * WHY THIS EXISTS. A wallet claiming an AI output is a claim, not a property. * Off-chain "the model said X" is unverifiable and forgeable. A receipt here * binds the output hash to a PQC-rooted identity with a signature ANYONE can * re-check in a single read, and cross-wires it to the agent's live economic * accountability: its AereAgentBond stake and its AereAIReputation score / * slashing history. A verifier learns, from one contract, both "is this * provenance genuine?" and "how much is this agent economically on the hook, * and has it been slashed?". * * QUANTUM DURABILITY. The receipt's authorship chain terminates at a Falcon * root key. The hot session signature is classical secp256k1 (cheap ecrecover), * but a session only exists because a Falcon PoP authorized it in AereAgentDID, * and a session (hence any new receipt under it) stops validating the instant * its Falcon root is rotated or revoked. The commitment hash itself is content * you choose (e.g. a SHA3/Keccak digest of the output), durable by construction. * * HONEST SCOPE. Application-layer provenance + authorization. This does NOT * change AERE consensus (blocks are still Besu QBFT, classical ECDSA). Falcon * verification lives in AereAgentDID / AerePQCKeyRegistry via the live native * precompile, not here. This contract holds no funds and never mutates the DID, * bond, or reputation contracts — it only reads them. Emitting is permissionless * (any relayer may submit): the SESSION SIGNATURE is the sole authority, so the * relayer paying gas is irrelevant to authorship. * * CROSS-WIRE CONVENTION. AereAgentDID keys an agent by its Falcon rootKeyId * (a uint256). AereAgentBond / AereAIReputation key by (operator, bytes32 * agentId). This contract reads the economic layer at the canonical key * (operator = the DID controller of the root key, agentId = bytes32(rootKeyId)). * An operator that wants its DID-rooted agent's stake / reputation surfaced * alongside its receipts posts its bond under agentId == bytes32(rootKeyId) * (see {bondKeyOf}). If it does not, the cross-reads simply return zero — the * provenance signature is unaffected and remains fully verifiable. */ /// @dev Read-only surface of AereAgentDID used by this contract. interface IAereAgentDID { function getSession(uint256 sessionId) external view returns ( uint256 rootKeyId, address sessionAddr, bytes32 scopeHash, uint256 spendCap, uint256 spent, uint64 issuedBlock, uint64 expiry, uint64 actionNonce, bool revoked ); function isSessionValid(uint256 sessionId) external view returns (bool); function getAgent(uint256 rootKeyId) external view returns (address controller, uint64 createdBlock, uint64 sessionNonce, uint256 sessionCountForAgent); function agentExists(uint256 rootKeyId) external view returns (bool); } /// @dev Read-only surface of AereAgentBond used for economic cross-reads. interface IAereAgentBondView { function bondOf(address operator, bytes32 agentId) external view returns (uint256 amount, uint64 requestedAt, uint256 lifetimeSlashed, bool exists); function operatorLifetimeSlashed(address operator) external view returns (uint256); } /// @dev Read-only surface of AereAIReputation used for reputation cross-reads. interface IAereAIReputationView { function scoreOf(address operator, bytes32 agentId) external view returns (uint256); function statsOf(address operator, bytes32 agentId) external view returns (uint256 positiveCount, uint256 disputeCount, uint256 lifetimeSlashed); } contract AereAgentActionReceipt { // ----- domain separator (distinct from AereAgentDID's ACTION_DOMAIN so a DID // action signature can never be replayed as a provenance receipt) ----- bytes32 public constant RECEIPT_DOMAIN = keccak256("AereAgentActionReceipt.v1.receipt"); // ----- immutable wiring ----- IAereAgentDID public immutable did; // Falcon-rooted DID: session verification source of truth address public immutable agentBond; // AereAgentBond (slashable stake) or 0 address public immutable aiReputation; // AereAIReputation (composable score) or 0 // ----- provenance records (global incrementing id; append-only) ----- struct Receipt { uint256 agentId; // the Falcon rootKeyId (AereAgentDID agent) that authored this uint256 sessionId; // the DID session that signed it address signer; // the session secp256k1 address recovered at emit time bytes32 outputHash; // the committed hash of the agent's output/action bytes32 scope; // the session scope tag this output was produced under uint64 nonce; // per-session receipt nonce used in the signed digest uint64 emittedBlock; uint64 emittedAt; // unix timestamp bytes sig; // the 65-byte session signature (stored so anyone can re-verify) } Receipt[] private _receipts; // receiptId is the index (0-based) mapping(uint256 => uint256[]) private _agentReceipts; // rootKeyId => receiptIds mapping(uint256 => uint256[]) private _sessionReceipts; // sessionId => receiptIds mapping(uint256 => uint64) private _receiptNonce; // sessionId => next receipt nonce // ----- events ----- event ReceiptEmitted( uint256 indexed receiptId, uint256 indexed agentId, uint256 indexed sessionId, address signer, bytes32 outputHash, bytes32 scope, uint64 nonce ); // ----- errors ----- error ZeroDID(); error EmptyOutputHash(); error RevokedOrInvalidSession(uint256 sessionId); error ScopeMismatch(uint256 sessionId); error BadSessionSignature(uint256 sessionId); error UnknownReceipt(uint256 receiptId); constructor(address did_, address agentBond_, address aiReputation_) { if (did_ == address(0)) revert ZeroDID(); did = IAereAgentDID(did_); agentBond = agentBond_; aiReputation = aiReputation_; } // ========================================================================== // Emit // ========================================================================== /** * @notice Record a provenance receipt for an agent output. Permissionless relay: * the SESSION SIGNATURE is the authority, not msg.sender. * * Reverts (recording nothing) if the output hash is zero, the session is * not currently valid (revoked / expired / its Falcon root no longer ACTIVE * / unknown session), the scope does not match the session's scope, or the * signature is not by the session key over the exact commitment (which binds * the output hash — a tampered output hash recovers a different address and * is rejected). * * @param sessionId the AereAgentDID session that signs this output. * @param scope the scope tag for this output (must equal the session's scopeHash). * @param outputHash a 32-byte commitment to the agent's output/action (non-zero). * @param sig 65-byte secp256k1 signature (r||s||v) by the session address over * receiptDigest(agentId, sessionId, outputHash, scope, nonce). * @return receiptId the id of the newly recorded receipt. */ function emitReceipt(uint256 sessionId, bytes32 scope, bytes32 outputHash, bytes calldata sig) external returns (uint256 receiptId) { if (outputHash == bytes32(0)) revert EmptyOutputHash(); // A live session gates everything: revoked / expired / root-not-active / unknown // all collapse to "invalid" here (isSessionValid returns false, never reverts). if (!did.isSessionValid(sessionId)) revert RevokedOrInvalidSession(sessionId); (uint256 rootKeyId, address sessionAddr, bytes32 scopeHash,,,,,,) = did.getSession(sessionId); if (scope != scopeHash) revert ScopeMismatch(sessionId); uint64 nonce = _receiptNonce[sessionId]; bytes32 digest = _receiptDigest(rootKeyId, sessionId, outputHash, scope, nonce); address recovered = _recover(digest, sig); // A tampered outputHash (or wrong signer) recovers a different / null address. if (recovered == address(0) || recovered != sessionAddr) revert BadSessionSignature(sessionId); // Effects. _receiptNonce[sessionId] = nonce + 1; receiptId = _receipts.length; _receipts.push( Receipt({ agentId: rootKeyId, sessionId: sessionId, signer: sessionAddr, outputHash: outputHash, scope: scope, nonce: nonce, emittedBlock: uint64(block.number), emittedAt: uint64(block.timestamp), sig: sig }) ); _agentReceipts[rootKeyId].push(receiptId); _sessionReceipts[sessionId].push(receiptId); emit ReceiptEmitted(receiptId, rootKeyId, sessionId, sessionAddr, outputHash, scope, nonce); } // ========================================================================== // Verification // ========================================================================== /** * @notice Independently re-verify a stored receipt in one read. Re-derives the * signed digest from the stored fields, recovers the signer from the stored * signature, and reports whether it matches the recorded session key, plus * whether that session is STILL live now (a receipt stays genuine forever; * `sessionLiveNow` tells a consumer whether the agent is currently active or * has since been revoked / expired / rotated away). * * @return sigValid the stored signature genuinely authorizes the stored output. * @return sessionLiveNow the authoring session is valid at the current block. * @return agentId the Falcon rootKeyId that authored the receipt. * @return signer the session address the signature recovers to. * @return outputHash the committed output hash. */ function verifyReceipt(uint256 receiptId) external view returns (bool sigValid, bool sessionLiveNow, uint256 agentId, address signer, bytes32 outputHash) { if (receiptId >= _receipts.length) revert UnknownReceipt(receiptId); Receipt storage r = _receipts[receiptId]; bytes32 digest = _receiptDigest(r.agentId, r.sessionId, r.outputHash, r.scope, r.nonce); address recovered = _recover(digest, r.sig); sigValid = (recovered != address(0) && recovered == r.signer); sessionLiveNow = did.isSessionValid(r.sessionId); return (sigValid, sessionLiveNow, r.agentId, r.signer, r.outputHash); } /** * @notice Verify that a receipt attests EXACTLY `outputHash` and that its stored * signature is genuine. This is the tamper check at read time: pass the hash * of the output you hold; a byte-different output yields a different hash and * returns false. Returns false (never reverts) for an out-of-range receiptId. */ function verifyReceiptOutput(uint256 receiptId, bytes32 outputHash) external view returns (bool) { if (receiptId >= _receipts.length) return false; Receipt storage r = _receipts[receiptId]; if (r.outputHash != outputHash) return false; bytes32 digest = _receiptDigest(r.agentId, r.sessionId, r.outputHash, r.scope, r.nonce); address recovered = _recover(digest, r.sig); return (recovered != address(0) && recovered == r.signer); } /** * @notice The full provenance picture in one call: the receipt's identity + validity * AND the agent's live economic accountability, read from AereAgentBond and * AereAIReputation at the canonical cross-wire key (operator = DID controller, * agentId = bytes32(rootKeyId)). All economic reads are defensive: if a * cross-link is unset or reverts, its fields come back zero and the * provenance fields (sigValid / signer / outputHash) are still returned. * * @return agentId the Falcon rootKeyId author. * @return operator the DID controller (bond/reputation key operator). * @return signer the authoring session key. * @return outputHash the committed output hash. * @return scope the scope the output was produced under. * @return emittedAt unix timestamp the receipt was recorded. * @return sigValid the stored signature genuinely authorizes the output. * @return sessionLiveNow the authoring session is valid now. * @return reputationScore AereAIReputation.scoreOf(operator, bytes32(agentId)). * @return bondAmount current AereAgentBond stake at the canonical key. * @return bondLifetimeSlashed cumulative slash on that (operator, agentId) bond. * @return operatorTotalSlashed operator-level cumulative slash across all its agents. */ function provenanceOf(uint256 receiptId) external view returns ( uint256 agentId, address operator, address signer, bytes32 outputHash, bytes32 scope, uint64 emittedAt, bool sigValid, bool sessionLiveNow, uint256 reputationScore, uint256 bondAmount, uint256 bondLifetimeSlashed, uint256 operatorTotalSlashed ) { if (receiptId >= _receipts.length) revert UnknownReceipt(receiptId); Receipt storage r = _receipts[receiptId]; agentId = r.agentId; signer = r.signer; outputHash = r.outputHash; scope = r.scope; emittedAt = r.emittedAt; bytes32 digest = _receiptDigest(r.agentId, r.sessionId, r.outputHash, r.scope, r.nonce); address recovered = _recover(digest, r.sig); sigValid = (recovered != address(0) && recovered == r.signer); sessionLiveNow = did.isSessionValid(r.sessionId); operator = _operatorOf(r.agentId); bytes32 bondKey = bytes32(r.agentId); (reputationScore) = _scoreOf(operator, bondKey); (bondAmount, bondLifetimeSlashed) = _bondOf(operator, bondKey); operatorTotalSlashed = _operatorLifetimeSlashed(operator); } // ========================================================================== // Digest / cross-wire views // ========================================================================== /// @notice The digest a session key must sign to record a receipt at `nonce`. function receiptDigest(uint256 agentId, uint256 sessionId, bytes32 outputHash, bytes32 scope, uint64 nonce) external view returns (bytes32) { return _receiptDigest(agentId, sessionId, outputHash, scope, nonce); } function _receiptDigest(uint256 agentId, uint256 sessionId, bytes32 outputHash, bytes32 scope, uint64 nonce) internal view returns (bytes32) { return keccak256( abi.encode(RECEIPT_DOMAIN, block.chainid, address(this), agentId, sessionId, outputHash, scope, nonce) ); } /// @notice The next receipt nonce for a session (the value the next emit will sign). function receiptNonceOf(uint256 sessionId) external view returns (uint64) { return _receiptNonce[sessionId]; } /// @notice The canonical AereAgentBond / AereAIReputation agentId for a DID rootKeyId. /// Operators post their bond under this key to have stake surfaced by /// {provenanceOf}. Pure — no state, just the encoding convention. function bondKeyOf(uint256 rootKeyId) external pure returns (bytes32) { return bytes32(rootKeyId); } /// @notice The operator (bond/reputation key) this contract reads for an agent: /// the DID controller of the root key. Returns address(0) if unknown. function operatorOf(uint256 rootKeyId) external view returns (address) { return _operatorOf(rootKeyId); } // ========================================================================== // Views // ========================================================================== /// @notice Total receipts ever recorded (receiptIds run 0..receiptCount-1). function receiptCount() external view returns (uint256) { return _receipts.length; } /// @notice Read a receipt. Reverts on an out-of-range receiptId. function getReceipt(uint256 receiptId) external view returns ( uint256 agentId, uint256 sessionId, address signer, bytes32 outputHash, bytes32 scope, uint64 nonce, uint64 emittedBlock, uint64 emittedAt, bytes memory sig ) { if (receiptId >= _receipts.length) revert UnknownReceipt(receiptId); Receipt storage r = _receipts[receiptId]; return (r.agentId, r.sessionId, r.signer, r.outputHash, r.scope, r.nonce, r.emittedBlock, r.emittedAt, r.sig); } /// @notice All receiptIds authored by an agent (append-only, chronological). function receiptsOf(uint256 rootKeyId) external view returns (uint256[] memory) { return _agentReceipts[rootKeyId]; } /// @notice All receiptIds recorded under a session (append-only, chronological). function receiptsOfSession(uint256 sessionId) external view returns (uint256[] memory) { return _sessionReceipts[sessionId]; } // ========================================================================== // Defensive economic cross-reads // ========================================================================== function _operatorOf(uint256 rootKeyId) internal view returns (address) { try did.getAgent(rootKeyId) returns (address controller, uint64, uint64, uint256) { return controller; } catch { return address(0); } } function _scoreOf(address operator, bytes32 bondKey) internal view returns (uint256) { if (aiReputation == address(0) || operator == address(0)) return 0; try IAereAIReputationView(aiReputation).scoreOf(operator, bondKey) returns (uint256 s) { return s; } catch { return 0; } } function _bondOf(address operator, bytes32 bondKey) internal view returns (uint256 amount, uint256 slashed) { if (agentBond == address(0) || operator == address(0)) return (0, 0); try IAereAgentBondView(agentBond).bondOf(operator, bondKey) returns ( uint256 a, uint64, uint256 sl, bool ) { return (a, sl); } catch { return (0, 0); } } function _operatorLifetimeSlashed(address operator) internal view returns (uint256) { if (agentBond == address(0) || operator == address(0)) return 0; try IAereAgentBondView(agentBond).operatorLifetimeSlashed(operator) returns (uint256 s) { return s; } catch { return 0; } } // ========================================================================== // ECDSA recovery // ========================================================================== /// @dev Minimal ecrecover with malleability guard (low-s, v in {27,28}). Returns /// address(0) on any malformed input so callers treat it as an invalid signature. /// Mirrors AereAgentDID._recover so a session key signs both surfaces identically. function _recover(bytes32 digest, bytes memory sig) internal pure returns (address) { if (sig.length != 65) return address(0); bytes32 r; bytes32 s; uint8 v; assembly { r := mload(add(sig, 0x20)) s := mload(add(sig, 0x40)) v := byte(0, mload(add(sig, 0x60))) } // Reject high-s (EIP-2) to prevent signature malleability. if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { return address(0); } if (v != 27 && v != 28) return address(0); return ecrecover(digest, v, r, s); } }