aere-contracts/contracts/mempool/AereShutterMempoolV3.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

442 lines
25 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
/**
* @title AereShutterMempoolV3 - threshold-BLS anti-MEV encrypted mempool (PoC)
* @notice APPLICATION-LEVEL, OPT-IN Shutter-style encrypted mempool. This is NOT
* an L1 base-layer change: a Hyperledger Besu QBFT chain cannot gain a
* protocol-level encrypted mempool without a client fork. Instead this
* contract implements the commit -> order -> threshold-decrypt pipeline
* at the application layer, so a proposer/sequencer cannot see plaintext
* transactions BEFORE the ordering for an epoch is fixed.
*
* V3 is byte-for-byte the V2 threshold-BLS design (same 3-of-5 committee,
* same commitment binding, same out-of-order reject, same no-early-decrypt
* guarantee) PLUS one liveness fix. See "V3 LIVENESS FIX" below.
*
* ------------------------------------------------------------------------
* V3 LIVENESS FIX (audit finding: reveal-timeout DoS)
* ------------------------------------------------------------------------
* PROBLEM in V2: revealAndExecute requires index == nextExecIndex and the
* cursor only advances on a SUCCESSFUL reveal. A single committed position
* whose plaintext/opening is never revealed (griefed, lost, or malformed)
* therefore stalls EVERY later position in that epoch forever. There is no
* skip and no timeout, so one stuck position denies liveness to the rest of
* the ordering.
*
* FIX in V3:
* 1. finalizeEpoch stamps a per-epoch reveal DEADLINE:
* revealDeadline = block.timestamp + REVEAL_WINDOW.
* The whole ordering has until this deadline to be revealed in order.
* 2. skipUnrevealed(epochId, index) advances nextExecIndex PAST the current
* head position, but ONLY when
* (a) the epoch is Finalized,
* (b) index == nextExecIndex (skip strictly at the head),
* (c) index < envelopeCount (there is a position to skip),
* (d) block.timestamp > revealDeadline (the window has closed).
* The skip is recorded (skipped[epochId][index] = true) and emits
* PositionSkipped, so later positions can execute.
*
* Because skip only ever acts on the head (index == nextExecIndex) and a
* successful revealAndExecute advances the head atomically, the head is by
* construction the still-unrevealed position: a position that WAS revealed is
* already behind the cursor, so skipping it reverts OutOfOrder. Revealing is
* never blocked by the deadline: revealAndExecute keeps working after the
* window closes, so a late-but-honest opener can still land its position; the
* skip is only an escape hatch for a position that nobody reveals.
*
* PERMISSIONLESS REVEAL REQUIREMENT (protocol invariant):
* The ciphertext MUST embed its own opening, i.e. the encrypted blob decrypts
* (under the epoch decryption key DK) to (plaintext || opening) such that
* keccak256(abi.encode(plaintext, opening)) == the committed commitment.
* This makes reveal PERMISSIONLESS: once DK is reconstructed, ANY holder of
* the decryption key can open ANY committed position and call
* revealAndExecute. A well-formed position is therefore always revealable by
* anyone, so skipUnrevealed only ever fires on a position whose opening was
* genuinely withheld/lost/malformed, never to censor a revealable tx.
* ------------------------------------------------------------------------
*
* Unlike the V0 orchestration contract (which used a keccak placeholder
* for the "decryption key"), V2/V3 verify a REAL (t,N) threshold-BLS
* scheme on-chain using the EIP-2537 BLS12-381 pairing precompile at
* address 0x0f (PROBED LIVE on AERE chain 2800: a valid pairing identity
* returns 1, garbage input reverts).
*
* Cryptography (Boldyreva threshold BLS over BLS12-381):
* - Committee master secret s is Shamir (t,N)-shared off-chain by a dealer.
* - Group public key PK = s * g2 (G2) : the epoch encryption pubkey.
* - Keyper pubkeys P_i = s_i * g2 (G2) : published VSS commitments.
* - Epoch identity H1 = hashToCurve(epoch tag) in G1.
* - Epoch decrypt key DK = s * H1 (G1) : the threshold signature.
* - Keyper share sig_i = s_i * H1 (G1).
* - DK is reconstructed off-chain from t shares via Lagrange interpolation.
*
* On-chain pairing checks (all via 0x0f):
* share valid : e(sig_i, g2) == e(H1, P_i) <=> e(sig_i,g2)*e(-H1,P_i)==1
* DK valid : e(DK, g2) == e(H1, PK) <=> e(DK, g2)*e(-H1,PK )==1
* A share or a reconstructed DK that does not satisfy its pairing REVERTS,
* so a keyper cannot post an inconsistent share and no forged DK is accepted.
*
* Anti-MEV invariant:
* 1. Users submit ciphertexts + a hiding/binding commitment while the epoch
* is Open. The sequencer sees only ciphertexts.
* 2. The sequencer commits an ordering (Merkle root over positions) BEFORE
* any decryption share exists. submitDecryptionShare REVERTS until then.
* 3. Keypers post t valid shares; anyone reconstructs DK off-chain; the
* reconstruction is pairing-verified on-chain at finalizeEpoch.
* 4. Reveal-and-execute walks the committed ordering strictly in sequence;
* a plaintext that does not open its committed value REVERTS, and an
* out-of-order reveal REVERTS. After the reveal deadline, an unrevealed
* head position may be skipped (V3) so liveness never stalls.
*
* HONEST SCOPE / ASSUMPTIONS:
* - The symmetric unmasking step (hashing the GT pairing element to a byte
* mask for XOR) is performed OFF-CHAIN by the executor: the 0x0f precompile
* returns only a pairing-equality boolean, it cannot output a GT element to
* hash. On-chain, plaintext<->commitment binding is a keccak commitment.
* DK correctness itself is fully pairing-verified on-chain, so decryption is
* deterministic and cannot be produced before t keypers act.
* - PK, P_i, H1 and its negation H1neg are published by the committee dealer
* at registration/epoch start (the "VSS commitments"). A malformed published
* point only breaks LIVENESS (an honest DK/share would fail its pairing); it
* cannot let a non-committee actor forge DK, which still requires the master
* secret s that only t-of-N shares reconstruct.
* - Trusted-dealer Shamir setup is used for the PoC; a production system would
* run a DKG so no single party ever holds s.
* - The reveal window trades a little worst-case latency (a stuck position is
* only skippable after REVEAL_WINDOW) for guaranteed liveness. Since every
* well-formed position is permissionlessly revealable (see the invariant
* above), the window only ever elapses for genuinely un-openable positions.
*/
contract AereShutterMempoolV3 {
/* ================================ roles ================================ */
address public coordinator; // committee dealer / admin
address public sequencer; // commits the epoch ordering
address public executor; // optional consumer that executes revealed txs
/* ============================== precompile ============================= */
address internal constant BLS_PAIRING = address(0x0f); // EIP-2537 pairing check
/* =============================== timeout ============================== */
/// @notice Seconds after finalizeEpoch during which the committed ordering
/// must be revealed strictly in order. Once elapsed, an unrevealed
/// head position becomes skippable via skipUnrevealed. Immutable.
uint64 public immutable REVEAL_WINDOW;
/* =============================== committee ============================= */
bool public committeeSet;
uint16 public threshold; // t
uint16 public keyperCount; // N
mapping(uint16 => address) public keyperAddr; // 0-based
mapping(uint16 => bytes) public keyperPub; // P_i = s_i*g2, 256-byte G2
bytes public G2_GEN; // canonical BLS12-381 G2 generator (256-byte EIP-2537)
/* ================================ epochs ============================== */
enum Phase { None, Open, Ordered, Finalized }
struct Epoch {
Phase phase;
uint64 startBlock;
bytes pk; // 256-byte G2 group/encryption pubkey
bytes h1; // 128-byte G1 epoch identity
bytes h1neg; // 128-byte G1 -h1
bytes32 orderingRoot; // Merkle root over committed execution positions
bytes dk; // 128-byte G1 reconstructed decryption key
uint256 envelopeCount;
uint256 shareCount;
uint256 nextExecIndex; // strict sequential execution cursor
uint64 revealDeadline; // V3: block.timestamp deadline set at finalize
}
mapping(uint64 => Epoch) internal _epochs;
mapping(uint64 => mapping(uint16 => bytes)) internal _shareOf;
mapping(uint64 => mapping(uint16 => bool)) public shareSubmitted;
mapping(uint64 => mapping(bytes32 => bool)) public committedCommitment;
mapping(uint64 => mapping(bytes32 => bool)) public executedCommitment;
/// @notice V3: epochId => position index => was skipped past (unrevealed).
mapping(uint64 => mapping(uint256 => bool)) public skippedPosition;
/* ================================ events ============================== */
event CommitteeRegistered(uint16 threshold, uint16 keyperCount);
event SequencerSet(address indexed sequencer);
event ExecutorSet(address indexed executor);
event EpochStarted(uint64 indexed epochId, bytes pk, bytes h1);
event Encrypted(uint64 indexed epochId, uint256 indexed index, address indexed sender, bytes32 commitment, bytes ciphertext);
event OrderingCommitted(uint64 indexed epochId, bytes32 orderingRoot, uint256 envelopeCount);
event ShareSubmitted(uint64 indexed epochId, uint16 indexed keyperIndex, bytes32 shareHash);
event EpochFinalized(uint64 indexed epochId, bytes32 dkHash, uint256 shareCount, uint64 revealDeadline);
event Executed(uint64 indexed epochId, uint256 indexed index, bytes32 plaintextHash, bytes32 commitment);
event PositionSkipped(uint64 indexed epochId, uint256 indexed index, uint64 deadline, address caller);
event KeyperFlagged(uint64 indexed epochId, uint16 indexed keyperIndex, address indexed keyper);
/* ================================ errors ============================== */
error NotCoordinator();
error NotSequencer();
error CommitteeAlreadySet();
error CommitteeNotSet();
error BadArgs();
error BadPointLength();
error EpochExists();
error EpochNotOpen();
error OrderingNotCommitted();
error OrderingAlreadyCommitted();
error NoEnvelopes();
error NotFinalized();
error AlreadyFinalized();
error DuplicateShare();
error BadShare(); // share fails on-chain pairing check
error NotEnoughShares();
error BadReconstruction(); // DK fails on-chain pairing check
error KeyperIndexRange();
error DuplicateCommitment();
error UnknownCommitment();
error AlreadyExecuted();
error OutOfOrder();
error BadMerkleProof();
error PlaintextMismatch();
error ShareIsConsistent();
error SkipTooEarly(); // V3: reveal window has not elapsed yet
error NothingToSkip(); // V3: nextExecIndex is already past the last position
/* ============================== modifiers ============================= */
modifier onlyCoordinator() { if (msg.sender != coordinator) revert NotCoordinator(); _; }
modifier onlySequencer() { if (msg.sender != sequencer) revert NotSequencer(); _; }
/* ============================= constructor ============================ */
/// @param coordinator_ committee dealer / admin (also the initial sequencer).
/// @param g2Gen canonical BLS12-381 G2 generator (256-byte EIP-2537).
/// @param revealWindow_ seconds after finalize before an unrevealed head
/// position may be skipped. MUST be > 0.
constructor(address coordinator_, bytes memory g2Gen, uint64 revealWindow_) {
if (coordinator_ == address(0) || g2Gen.length != 256 || revealWindow_ == 0) revert BadArgs();
coordinator = coordinator_;
sequencer = coordinator_;
G2_GEN = g2Gen;
REVEAL_WINDOW = revealWindow_;
}
/* ============================== admin ops ============================= */
function setSequencer(address s) external onlyCoordinator { sequencer = s; emit SequencerSet(s); }
function setExecutor(address e) external onlyCoordinator { executor = e; emit ExecutorSet(e); }
/// @notice Register the (t,N) keyper committee with their BLS12-381 G2
/// public keys P_i = s_i*g2 (the on-chain VSS commitments). One-shot.
function registerKeypers(address[] calldata addrs, bytes[] calldata pubkeys, uint16 t)
external onlyCoordinator
{
if (committeeSet) revert CommitteeAlreadySet();
uint256 n = addrs.length;
if (n == 0 || pubkeys.length != n || t == 0 || t > n || n > type(uint16).max) revert BadArgs();
for (uint256 i = 0; i < n; i++) {
if (pubkeys[i].length != 256) revert BadPointLength();
keyperAddr[uint16(i)] = addrs[i];
keyperPub[uint16(i)] = pubkeys[i];
}
threshold = t;
keyperCount = uint16(n);
committeeSet = true;
emit CommitteeRegistered(t, uint16(n));
}
/* ============================== epoch ops ============================= */
/// @notice Open an epoch by publishing the epoch encryption pubkey `pk`
/// (= the committee group key PK), the epoch identity point `h1`
/// (= hashToCurve(epoch tag) in G1) and its negation `h1neg`.
function startEpoch(uint64 epochId, bytes calldata pk, bytes calldata h1, bytes calldata h1neg)
external onlyCoordinator
{
if (!committeeSet) revert CommitteeNotSet();
if (_epochs[epochId].phase != Phase.None) revert EpochExists();
if (pk.length != 256 || h1.length != 128 || h1neg.length != 128) revert BadPointLength();
Epoch storage e = _epochs[epochId];
e.phase = Phase.Open;
e.startBlock = uint64(block.number);
e.pk = pk;
e.h1 = h1;
e.h1neg = h1neg;
emit EpochStarted(epochId, pk, h1);
}
/// @notice Submit an encrypted tx envelope while the epoch is Open.
/// `ciphertext` = U(256-byte G2) || C(payload XOR mask); the full
/// blob is emitted for off-chain availability, only its binding
/// `commitment` = keccak256(abi.encode(plaintext, opening)) is stored.
/// @dev PERMISSIONLESS-REVEAL INVARIANT: the plaintext payload MUST itself
/// carry the `opening`, i.e. the ciphertext decrypts under DK to
/// (plaintext || opening) that reproduces `commitment`. Then any DK
/// holder can open this position and call revealAndExecute, so a
/// well-formed position is always revealable and never wrongly skipped.
function submitEncrypted(uint64 epochId, bytes calldata ciphertext, bytes32 commitment)
external returns (uint256 index)
{
Epoch storage e = _epochs[epochId];
if (e.phase != Phase.Open) revert EpochNotOpen();
if (commitment == bytes32(0) || ciphertext.length < 256) revert BadArgs();
if (committedCommitment[epochId][commitment]) revert DuplicateCommitment();
committedCommitment[epochId][commitment] = true;
index = e.envelopeCount++;
emit Encrypted(epochId, index, msg.sender, commitment, ciphertext);
}
/// @notice Sequencer commits the epoch ordering as a Merkle root over
/// execution positions. MUST happen before any decryption share.
function commitOrdering(uint64 epochId, bytes32 orderingRoot) external onlySequencer {
Epoch storage e = _epochs[epochId];
if (e.phase != Phase.Open) {
if (e.phase == Phase.None) revert EpochNotOpen();
revert OrderingAlreadyCommitted();
}
if (e.envelopeCount == 0) revert NoEnvelopes();
if (orderingRoot == bytes32(0)) revert BadArgs();
e.orderingRoot = orderingRoot;
e.phase = Phase.Ordered;
emit OrderingCommitted(epochId, orderingRoot, e.envelopeCount);
}
/// @notice A keyper (or a relayer on its behalf) posts sig_i = s_i*H1. The
/// share is REJECTED unless e(sig_i,g2)==e(H1,P_i) verifies on-chain.
/// Reverts before the ordering is committed (prevents early decrypt).
function submitDecryptionShare(uint64 epochId, uint16 keyperIndex, bytes calldata share) external {
Epoch storage e = _epochs[epochId];
if (e.phase == Phase.Open || e.phase == Phase.None) revert OrderingNotCommitted();
if (keyperIndex >= keyperCount) revert KeyperIndexRange();
if (shareSubmitted[epochId][keyperIndex]) revert DuplicateShare();
if (share.length != 128) revert BadPointLength();
// e(sig_i, g2) * e(-H1, P_i) == 1
if (!_pairingCheck2(share, G2_GEN, e.h1neg, keyperPub[keyperIndex])) revert BadShare();
_shareOf[epochId][keyperIndex] = share;
shareSubmitted[epochId][keyperIndex] = true;
e.shareCount++;
emit ShareSubmitted(epochId, keyperIndex, keccak256(share));
}
/// @notice Once >= t valid shares exist, submit the off-chain Lagrange
/// reconstruction DK. Accepted only if e(DK,g2)==e(H1,PK) verifies.
/// V3: stamps the reveal deadline = block.timestamp + REVEAL_WINDOW.
function finalizeEpoch(uint64 epochId, bytes calldata dk) external {
Epoch storage e = _epochs[epochId];
if (e.phase == Phase.Finalized) revert AlreadyFinalized();
if (e.phase != Phase.Ordered) revert OrderingNotCommitted();
if (e.shareCount < threshold) revert NotEnoughShares();
if (dk.length != 128) revert BadPointLength();
// e(DK, g2) * e(-H1, PK) == 1
if (!_pairingCheck2(dk, G2_GEN, e.h1neg, e.pk)) revert BadReconstruction();
e.dk = dk;
e.phase = Phase.Finalized;
uint64 deadline = uint64(block.timestamp) + REVEAL_WINDOW;
e.revealDeadline = deadline;
emit EpochFinalized(epochId, keccak256(dk), e.shareCount, deadline);
}
/// @notice Reveal a plaintext at its committed execution position and mark it
/// executed. Execution positions MUST be revealed strictly in order.
/// Verifies: (a) sequential position, (b) the commitment was actually
/// submitted, (c) Merkle membership of (epochId,index,commitment) in
/// the committed ordering, (d) the plaintext opens the commitment.
/// NOTE: reveal is never gated by the deadline; a late-but-honest
/// opener can still land its position after the window closes.
function revealAndExecute(
uint64 epochId,
uint256 index,
bytes calldata plaintext,
bytes32 opening,
bytes32[] calldata proof
) external {
Epoch storage e = _epochs[epochId];
if (e.phase != Phase.Finalized) revert NotFinalized();
if (index != e.nextExecIndex) revert OutOfOrder();
bytes32 commitment = keccak256(abi.encode(plaintext, opening));
if (!committedCommitment[epochId][commitment]) revert UnknownCommitment();
if (executedCommitment[epochId][commitment]) revert AlreadyExecuted();
// Merkle leaf = keccak(bytes.concat(keccak(abi.encode(epochId,index,commitment)))) (OZ sorted-pair)
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(epochId, index, commitment))));
if (!MerkleProof.verify(proof, e.orderingRoot, leaf)) revert BadMerkleProof();
executedCommitment[epochId][commitment] = true;
e.nextExecIndex = index + 1;
emit Executed(epochId, index, keccak256(plaintext), commitment);
// Optional: hand the decoded plaintext to a consumer executor.
address ex = executor;
if (ex != address(0)) {
(bool ok, ) = ex.call(abi.encodeWithSignature("execute(uint64,uint256,bytes)", epochId, index, plaintext));
ok; // best-effort; failure of the inner consumer does not roll back the reveal record
}
}
/// @notice V3 LIVENESS FIX. Skip the current head position when it was never
/// revealed and its reveal window has closed, so the epoch's later
/// positions can execute. Permissionless: anyone may call it once the
/// conditions hold.
/// @param epochId the finalized epoch.
/// @param index MUST equal nextExecIndex (skip strictly at the head). This
/// guarantees the position is still unrevealed: a revealed position is
/// already behind the cursor, so skipping it reverts OutOfOrder.
/// @dev Reverts unless: epoch Finalized (NotFinalized); index == nextExecIndex
/// (OutOfOrder); index < envelopeCount (NothingToSkip); and the reveal
/// deadline has passed (SkipTooEarly). Records skippedPosition and emits
/// PositionSkipped, then advances nextExecIndex by one.
function skipUnrevealed(uint64 epochId, uint256 index) external {
Epoch storage e = _epochs[epochId];
if (e.phase != Phase.Finalized) revert NotFinalized();
if (index != e.nextExecIndex) revert OutOfOrder();
if (index >= e.envelopeCount) revert NothingToSkip();
if (block.timestamp <= e.revealDeadline) revert SkipTooEarly();
skippedPosition[epochId][index] = true;
e.nextExecIndex = index + 1;
emit PositionSkipped(epochId, index, e.revealDeadline, msg.sender);
}
/* ============================ slashing flag =========================== */
/// @notice Flag a keyper who published (off-chain) a share that is
/// INCONSISTENT with its on-chain VSS commitment P_i. Reverts if the
/// alleged share is actually valid (no false accusations).
function reportInconsistentShare(uint64 epochId, uint16 keyperIndex, bytes calldata allegedShare) external {
Epoch storage e = _epochs[epochId];
if (e.phase == Phase.None) revert EpochNotOpen();
if (keyperIndex >= keyperCount) revert KeyperIndexRange();
if (allegedShare.length != 128) revert BadPointLength();
if (_pairingCheck2(allegedShare, G2_GEN, e.h1neg, keyperPub[keyperIndex])) revert ShareIsConsistent();
emit KeyperFlagged(epochId, keyperIndex, keyperAddr[keyperIndex]);
}
/* ============================ pairing helper ========================== */
/// @dev Returns true iff e(a1,b1) * e(a2,b2) == 1 via the EIP-2537 pairing
/// precompile at 0x0f. a* are 128-byte G1, b* are 256-byte G2. A revert
/// inside the precompile (e.g. a non-subgroup point) yields false.
function _pairingCheck2(bytes memory a1, bytes memory b1, bytes memory a2, bytes memory b2)
internal view returns (bool)
{
if (a1.length != 128 || a2.length != 128 || b1.length != 256 || b2.length != 256) revert BadPointLength();
bytes memory input = bytes.concat(a1, b1, a2, b2); // 2 pairs * 384 = 768 bytes
(bool ok, bytes memory out) = BLS_PAIRING.staticcall(input);
if (!ok || out.length != 32) return false;
return abi.decode(out, (uint256)) == 1;
}
/* =============================== views =============================== */
function getEpoch(uint64 epochId)
external view
returns (Phase phase, uint64 startBlock, bytes32 orderingRoot, uint256 envelopeCount, uint256 shareCount, uint256 nextExecIndex, uint64 revealDeadline)
{
Epoch storage e = _epochs[epochId];
return (e.phase, e.startBlock, e.orderingRoot, e.envelopeCount, e.shareCount, e.nextExecIndex, e.revealDeadline);
}
function epochPk(uint64 epochId) external view returns (bytes memory) { return _epochs[epochId].pk; }
function epochH1(uint64 epochId) external view returns (bytes memory) { return _epochs[epochId].h1; }
function epochDk(uint64 epochId) external view returns (bytes memory) { return _epochs[epochId].dk; }
function revealDeadlineOf(uint64 epochId) external view returns (uint64) { return _epochs[epochId].revealDeadline; }
function shareOf(uint64 epochId, uint16 i) external view returns (bytes memory) { return _shareOf[epochId][i]; }
function isFinalized(uint64 epochId) external view returns (bool) { return _epochs[epochId].phase == Phase.Finalized; }
function verifyShareView(uint64 epochId, uint16 keyperIndex, bytes calldata share) external view returns (bool) {
if (keyperIndex >= keyperCount || share.length != 128) return false;
return _pairingCheck2(share, G2_GEN, _epochs[epochId].h1neg, keyperPub[keyperIndex]);
}
}