aere-contracts/contracts/mempool/AereShutterMempool.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

266 lines
12 KiB
Solidity

// 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);
}
}