// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /** * @title AereShutterMempool — threshold-encrypted mempool ingress (Shutter playbook) * @notice MEV at its worst lives in the gap between transaction broadcast and * block inclusion. AereShutterMempool closes that gap by accepting * ENCRYPTED tx envelopes that a Shutter-style threshold keypers * committee decrypts only at block N+1 (configurable delay), AFTER * the block ordering is finalised. * * Phase 1 (this contract): the on-chain commitment + reveal logic. * An off-chain Shutter keyper committee handles the actual * threshold encryption + key release. The on-chain side just * tracks: * * 1. Active keyper committee (Foundation-rotated per epoch). * 2. Per-epoch committed ciphertext envelopes (anyone can * submit). * 3. Per-epoch decryption-key release (keypers post the * aggregated decryption key once the epoch has finalised). * 4. Reveal-and-execute path: anyone can present a plaintext * tx whose ciphertext was committed in epoch E, prove the * decryption key was released, and have it MARKED as * decrypt-eligible. The actual EVM execution happens via * the receiver contract's own logic (this contract is * orchestration, not an executor). * * INVARIANT — sequencer cannot front-run: * - At block N, ciphertexts are visible but undecipherable. * - Block N's ordering is finalised by consensus. * - At block N+REVEAL_DELAY, keypers release the decryption * key. Now the ciphertexts can be decrypted in the order * they were committed. * - Reordering after the fact is impossible because the * ordering was already in block N. * * IMMUTABILITY: * - REVEAL_DELAY_BLOCKS constant. * - FOUNDATION immutable. * - Keyper threshold parameters set at deploy; rotation is * one-shot per epoch (no in-epoch swaps). * * WHAT'S NOT IN V0: * - Slashing of keypers that fail to release on time (Phase 2). * - Actual on-chain decryption (Phase 2 precompile at 0x102). * - Per-tx fee accounting / priority (Phase 2; we ship plumbing * first). * - MEV redistribution (separate contract). */ contract AereShutterMempool { /* ================================ config ================================ */ address public immutable FOUNDATION; uint64 public immutable REVEAL_DELAY_BLOCKS; // e.g. 2 for ~1s on 0.5s blocks uint16 public immutable KEYPER_THRESHOLD; // e.g. 9 of 13 uint16 public immutable KEYPER_SIZE; /* ================================= types ================================ */ struct KeyperEpoch { bytes32 committeeRoot; // Merkle root of keyper pubkeys (BLS12-381) bytes32 pubkeyAggregateHash; // hash of the aggregated public key uint64 startBlock; uint64 endBlock; bool exists; } struct CipherEnvelope { address sender; // submitter (pays gas; not necessarily the tx originator) bytes32 ciphertextHash; // SHA256 of the raw ciphertext blob string ciphertextUri; // ipfs:// or https:// (encrypted blob lives off-chain) uint64 committedAtBlock; uint64 epochId; bool revealed; } /* ================================ storage ================================ */ /// @notice epochId → KeyperEpoch. mapping(uint64 => KeyperEpoch) public epochs; uint64 public currentEpochId; /// @notice epochId → decryption key commitment posted by keypers. mapping(uint64 => bytes32) public decryptionKeyHash; mapping(uint64 => uint64) public decryptionKeyReleasedAt; mapping(uint64 => string) public decryptionKeyUri; /// @notice envelopeId → CipherEnvelope. mapping(bytes32 => CipherEnvelope) internal _envelopes; /// @notice per-epoch monotonic ingestion counter. mapping(uint64 => uint256) public envelopeCount; /* ================================ events ================================ */ event EpochOpened(uint64 indexed epochId, bytes32 committeeRoot, bytes32 pubkeyAggregateHash, uint64 startBlock); event EpochClosed(uint64 indexed epochId, uint64 endBlock); event Committed(bytes32 indexed envelopeId, uint64 indexed epochId, address indexed sender, bytes32 ciphertextHash, string ciphertextUri); event DecryptionKeyReleased(uint64 indexed epochId, bytes32 keyHash, string keyUri); event Revealed(bytes32 indexed envelopeId, bytes32 plaintextHash); /* ================================ errors ================================ */ error NotFoundation(); error EpochClosedAlready(); error EpochNotOpen(); error EmptyCommitment(); error EnvelopeUnknown(); error EnvelopeAlreadyCommitted(); error KeyNotReleased(); error AlreadyRevealed(); error PlaintextMismatch(); error KeyAlreadyReleased(); error EpochTransitionRace(uint64 currentEpoch, uint64 expectedEpoch); error RevealTooEarly(uint64 nowBlock, uint64 earliest); /* =============================== modifiers ============================== */ modifier onlyFoundation() { if (msg.sender != FOUNDATION) revert NotFoundation(); _; } /* ============================== constructor ============================= */ constructor(address foundation, uint64 revealDelayBlocks, uint16 keyperSize, uint16 keyperThreshold) { require(foundation != address(0), "zero-foundation"); require(keyperThreshold > 0 && keyperThreshold <= keyperSize, "bad-threshold"); // AUDIT FIX (MED #31): MEV protection requires at least 1 block of delay. require(revealDelayBlocks >= 1, "reveal-delay-zero"); FOUNDATION = foundation; REVEAL_DELAY_BLOCKS = revealDelayBlocks; KEYPER_SIZE = keyperSize; KEYPER_THRESHOLD = keyperThreshold; } /* ============================== epoch ops ============================== */ /// @notice Foundation opens a new keyper epoch. Implicitly closes the /// previous one. Open at most one epoch at a time. function openEpoch(uint64 epochId, bytes32 committeeRoot, bytes32 pubkeyAggregateHash) external onlyFoundation { if (epochs[epochId].exists) revert EpochClosedAlready(); // close prior KeyperEpoch storage prev = epochs[currentEpochId]; if (prev.exists && prev.endBlock == 0) { prev.endBlock = uint64(block.number); emit EpochClosed(currentEpochId, prev.endBlock); } epochs[epochId] = KeyperEpoch({ committeeRoot: committeeRoot, pubkeyAggregateHash: pubkeyAggregateHash, startBlock: uint64(block.number), endBlock: 0, exists: true }); currentEpochId = epochId; emit EpochOpened(epochId, committeeRoot, pubkeyAggregateHash, uint64(block.number)); } /* ============================ commit ciphertext ========================== */ /// @notice Submit an encrypted tx envelope for the current epoch. /// envelopeId = keccak256(epochId, ciphertextHash, sender). function commit(bytes32 ciphertextHash, string calldata ciphertextUri) external returns (bytes32 envelopeId) { return _commit(ciphertextHash, ciphertextUri, currentEpochId); } /// AUDIT FIX (HIGH #6): caller can pin the expected epoch to avoid an /// epoch transition race where a sequencer reorders openEpoch before /// this commit, attaching the ciphertext to the wrong keyper pubkey /// (permanently undecryptable). function commitForEpoch(bytes32 ciphertextHash, string calldata ciphertextUri, uint64 expectedEpochId) external returns (bytes32 envelopeId) { return _commit(ciphertextHash, ciphertextUri, expectedEpochId); } function _commit(bytes32 ciphertextHash, string calldata ciphertextUri, uint64 expectedEpochId) internal returns (bytes32 envelopeId) { if (ciphertextHash == bytes32(0)) revert EmptyCommitment(); if (expectedEpochId != currentEpochId) revert EpochTransitionRace(currentEpochId, expectedEpochId); KeyperEpoch storage e = epochs[currentEpochId]; if (!e.exists || e.endBlock != 0) revert EpochNotOpen(); envelopeId = keccak256(abi.encode(currentEpochId, ciphertextHash, msg.sender)); // AUDIT FIX (HIGH #4): refuse to overwrite an existing envelope slot; // double-reveal via re-commit is now impossible. if (_envelopes[envelopeId].sender != address(0)) revert EnvelopeAlreadyCommitted(); _envelopes[envelopeId] = CipherEnvelope({ sender: msg.sender, ciphertextHash: ciphertextHash, ciphertextUri: ciphertextUri, committedAtBlock: uint64(block.number), epochId: currentEpochId, revealed: false }); envelopeCount[currentEpochId]++; emit Committed(envelopeId, currentEpochId, msg.sender, ciphertextHash, ciphertextUri); } /* ============================= release key =============================== */ /// @notice Foundation (proxying for keypers post quorum) releases the /// decryption key for an epoch. Releasable from /// epoch.startBlock + REVEAL_DELAY_BLOCKS onward. function releaseDecryptionKey(uint64 epochId, bytes32 keyHash, string calldata keyUri) external onlyFoundation { KeyperEpoch storage e = epochs[epochId]; if (!e.exists) revert EpochNotOpen(); uint64 earliest = e.startBlock + REVEAL_DELAY_BLOCKS; if (block.number < earliest) revert RevealTooEarly(uint64(block.number), earliest); // AUDIT FIX (HIGH #5): one-shot per epoch. Previously Foundation could // overwrite a released key arbitrarily, defeating the commitment trail. if (decryptionKeyHash[epochId] != bytes32(0)) revert KeyAlreadyReleased(); decryptionKeyHash[epochId] = keyHash; decryptionKeyReleasedAt[epochId] = uint64(block.number); decryptionKeyUri[epochId] = keyUri; emit DecryptionKeyReleased(epochId, keyHash, keyUri); } /* ============================ reveal + execute ========================== */ /// @notice Mark an envelope as decrypt-eligible (the key was released). /// The consumer contract (e.g. AereCoreBookV0, AERE402, the /// settlement layer) reads `revealed` to gate execution of the /// decoded plaintext tx. /// AUDIT FIX (HIGH #3): gated to Foundation in Phase 1 (Phase 2 will use /// keyper-signed reveals against the BLS aggregate pubkey). Previously /// anyone could forge a Revealed event with any plaintextHash, defeating /// the entire anti-MEV invariant. Also: Foundation must submit the actual /// plaintext bytes so the contract can check H(plaintext, releasedKey) /// matches the ciphertextHash — proving the reveal is consistent with /// the released key + the committed ciphertext. function markRevealed(bytes32 envelopeId, bytes calldata plaintext) external onlyFoundation returns (bool) { CipherEnvelope storage env = _envelopes[envelopeId]; if (env.sender == address(0)) revert EnvelopeUnknown(); if (env.revealed) revert AlreadyRevealed(); bytes32 releasedKey = decryptionKeyHash[env.epochId]; if (releasedKey == bytes32(0)) revert KeyNotReleased(); // Verify the plaintext binds to (ciphertextHash, releasedKey). // The off-chain protocol: ciphertext = encrypt(key, plaintext); // so H(plaintext, releasedKey) must equal the committed ciphertextHash. bytes32 expected = keccak256(abi.encodePacked(plaintext, releasedKey)); if (expected != env.ciphertextHash) revert PlaintextMismatch(); env.revealed = true; emit Revealed(envelopeId, keccak256(plaintext)); return true; } /* ================================ views ================================ */ function envelopeOf(bytes32 id) external view returns (CipherEnvelope memory) { return _envelopes[id]; } function isRevealed(bytes32 id) external view returns (bool) { return _envelopes[id].revealed; } function isKeyReleased(uint64 epochId) external view returns (bool) { return decryptionKeyHash[epochId] != bytes32(0); } }