// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /// @title AerePQCEncryptedIntent - quantum-safe encrypted-intent settlement rail /// @notice A commit / reveal escrow rail that lets a user hand a designated solver an order or /// intent whose CONTENTS are hidden from front-runners until settlement. Confidentiality is /// post-quantum: the symmetric key that protects the intent is transported with ML-KEM-768 /// (FIPS 203, a NIST Module-Lattice KEM), so a "harvest-now, decrypt-later" quantum /// adversary that records the chain cannot recover the plaintext, unlike an ECIES / X25519 /// envelope that a future quantum computer would open. /// /// FLOW (heavy KEM and AEAD math run OFF-CHAIN; this contract stores only hashes, /// commitments, escrow and timeout logic): /// 1. A solver publishes its ML-KEM-768 encapsulation key `ek` via publishEncapKey. The /// contract stores keccak256(ek) and emits the full key, so any user can fetch it from /// logs and verify it against the on-chain hash. /// 2. A user, off-chain, runs ML-KEM.Encaps(ek) to get (kemCiphertext c, shared secret K), /// derives an AES-256-GCM key from K, encrypts the intent to `encPayload`, and forms a /// commitment = keccak256(abi.encode(intentPlaintext, salt)). It calls submitIntent with /// the full c and encPayload (emitted for the solver), their hashes, the commitment, and /// a native-value escrow. /// 3. The designated solver claimIntent's the job, posting an optional bond, which starts a /// settlement window. /// 4. The solver, holding the ML-KEM decapsulation (secret) key, off-chain runs /// ML-KEM.Decaps to recover K, decrypts encPayload to learn the intent and salt, executes /// the fill, then calls revealAndSettle with (intentPlaintext, salt). The contract checks /// keccak256(abi.encode(intentPlaintext, salt)) == commitment and only then releases the /// escrow. A solver therefore can settle ONLY the exact intent the user committed to. /// 5. If the solver never claims (or claims but never settles) inside the windows, anyone /// can call timeoutRefund and the user is made whole: the escrow is returned, and a bond /// posted by a claiming-then-idle solver is forfeited to the user. /// /// WHY REVEALING AT SETTLEMENT IS SAFE. Front-running protection only needs the intent hidden /// BEFORE it executes. Like an encrypted-mempool / threshold-decryption scheme, the plaintext /// becomes public exactly at the moment the solver executes it, inside the settling /// transaction, so there is no window in which a searcher can observe the order and jump /// ahead of it. Until then the chain holds only ciphertext and a hash commitment. /// /// HONEST SCOPE AND TRUST MODEL. /// - AERE consensus is unchanged: classical ECDSA QBFT (Foundation validators). What is /// post-quantum here is the CONFIDENTIALITY of the intent (ML-KEM key transport) and, /// separately, any PQC signatures a client layers on top. This contract adds no consensus /// claim. /// - The designated solver is TRUSTED to keep the intent private: it holds the decapsulation /// key and can read the plaintext, so it could in principle exploit that information off /// this rail. The rail cannot cryptographically prevent that. What it DOES guarantee on /// chain is (a) the escrow is released only against a valid reveal of the user's committed /// intent, (b) the user is refunded if the solver fails to act in time, and (c) a solver /// that claims and then stalls forfeits its bond to the user. Choose reputable solvers and /// set a bond sized to the leaked-alpha risk. /// - This rail does NOT itself perform the swap or verify token delivery. The `escrow` is /// the solver's settlement payment, released on proof (reveal) that the solver knows the /// exact committed intent. Output delivery is handled by the solver's own settlement, or /// by a settlement hook in a future version. See the design note. /// /// The ML-KEM-768 precompile at 0x0AE6 (deterministic Encaps, a sibling effort) is NOT called /// on the confidential hot path on purpose: verifying the KEM transcript on chain would force /// the user to reveal the encapsulation coins, which would let anyone recompute K and decrypt /// the payload, destroying the very confidentiality this rail exists to provide. The precompile /// constants below are recorded for documentation and off-chain / audit use only. contract AerePQCEncryptedIntent { // ---- ML-KEM-768 (FIPS 203) sibling precompile: documentation constants ----- /// @notice Address of AERE's ML-KEM-768 deterministic-Encaps precompile (input ek(1184)||m(32), /// output c(1088)||K(32)). Recorded for off-chain and audit reference; not called here. address public constant ML_KEM_768_PRECOMPILE = 0x0000000000000000000000000000000000000AE6; /// @notice ML-KEM-768 encapsulation-key (public key) length in bytes, FIPS 203. uint256 public constant ML_KEM_768_EK_LEN = 1184; /// @notice ML-KEM-768 KEM-ciphertext length in bytes, FIPS 203. uint256 public constant ML_KEM_768_CT_LEN = 1088; // ---- Timeout windows ------------------------------------------------------- uint64 public constant MIN_CLAIM_WINDOW = 1 minutes; uint64 public constant MAX_CLAIM_WINDOW = 7 days; uint64 public constant MIN_SETTLE_WINDOW = 1 minutes; uint64 public constant MAX_SETTLE_WINDOW = 7 days; enum Status { None, // never created Open, // submitted, awaiting the designated solver's claim Claimed, // solver claimed and posted a bond; settlement window running Settled, // solver revealed the committed intent; escrow paid Refunded // user refunded after a solver timeout (terminal) } /// @notice A solver's currently advertised ML-KEM encapsulation key. struct SolverKey { bytes32 keyHash; // keccak256 of the advertised ek uint64 epoch; // increments on each publish so clients can detect rotation bool active; // false once retired; no new intents may target it } /// @notice One encrypted intent's on-chain state. The ciphertext bytes themselves live in the /// submit calldata and event logs, NOT in storage; storage keeps only their hashes. struct Intent { address user; // submitter and refund recipient address solver; // designated solver: the only address that may claim / settle uint64 claimBy; // Open is refundable once block.timestamp >= claimBy uint64 settleBy; // set at claim; Claimed is refundable once block.timestamp > settleBy uint64 settleWindow; // seconds the solver gets after claiming, fixed at submit Status status; uint256 escrow; // native value locked by the user, paid to the solver on settle uint256 bond; // native value posted by the solver at claim, returned on settle uint256 minBond; // minimum bond the solver must post to claim (set by the user) bytes32 ekHash; // the solver encapsulation key the user encrypted to bytes32 kemCtHash; // keccak256 of the ML-KEM ciphertext c bytes32 payloadHash; // keccak256 of the AES-GCM encrypted intent blob bytes32 commitment; // keccak256(abi.encode(intentPlaintext, salt)) } /// @notice solver address => its advertised encapsulation key. mapping(address => SolverKey) public solverKeyOf; /// @notice intentId => intent record. mapping(uint256 => Intent) internal _intents; /// @notice next intent id to assign (ids start at 1; 0 is reserved for "none"). uint256 public nextIntentId = 1; // ---- Reentrancy guard (self-contained, no external dependency) ------------- uint256 private _locked = 1; modifier nonReentrant() { if (_locked == 2) revert Reentrancy(); _locked = 2; _; _locked = 1; } // ---- Events ---------------------------------------------------------------- event EncapKeyPublished(address indexed solver, bytes32 indexed keyHash, uint64 epoch, bytes ek); event EncapKeyRetired(address indexed solver, bytes32 indexed keyHash); event IntentSubmitted( uint256 indexed intentId, address indexed user, address indexed solver, bytes32 ekHash, bytes32 commitment, uint256 escrow, uint256 minBond, uint64 claimBy, bytes kemCiphertext, bytes encPayload ); event IntentClaimed(uint256 indexed intentId, address indexed solver, uint256 bond, uint64 settleBy); event IntentSettled(uint256 indexed intentId, address indexed solver, uint256 escrowPaid, bytes intentPlaintext); event IntentRefunded(uint256 indexed intentId, address indexed user, uint256 escrowRefunded, uint256 bondForfeited); // ---- Errors ---------------------------------------------------------------- error BadEncapKeyLength(uint256 got, uint256 want); error SolverKeyInactive(address solver); error StaleEncapKey(bytes32 got, bytes32 current); error BadCiphertextLength(uint256 got, uint256 want); error EmptyPayload(); error ZeroCommitment(); error ClaimWindowOutOfRange(uint64 got); error SettleWindowOutOfRange(uint64 got); // AUD-CONTRACT-5: the former `EscrowTooLarge()` error was dead code (no revert site). The escrow // is the user's own native value locked against their own refund address, with value // conservation enforced across the lifecycle, so no upper bound is required. Removed. error UnknownIntent(uint256 intentId); error WrongStatus(Status got, Status want); error NotDesignatedSolver(address caller, address solver); error ClaimWindowClosed(uint64 claimBy, uint256 nowTs); error InsufficientBond(uint256 got, uint256 need); error SettleWindowClosed(uint64 settleBy, uint256 nowTs); error CommitmentMismatch(bytes32 got, bytes32 want); error NotYetRefundable(uint256 intentId); error TransferFailed(address to, uint256 amount); error Reentrancy(); // ============================================================================ // Solver key registry // ============================================================================ /// @notice Publish (or rotate) the caller's ML-KEM-768 encapsulation key. Stores keccak256(ek) /// and emits the full key so users can fetch and verify it trustlessly from logs. Rotating /// changes the hash intents must bind to; a solver MUST keep the matching decapsulation /// key for every ekHash referenced by an open intent, or those intents will simply time /// out and refund the user. /// @param ek the FIPS 203 ML-KEM-768 encapsulation key (exactly 1184 bytes). function publishEncapKey(bytes calldata ek) external returns (bytes32 keyHash) { if (ek.length != ML_KEM_768_EK_LEN) revert BadEncapKeyLength(ek.length, ML_KEM_768_EK_LEN); keyHash = keccak256(ek); SolverKey storage sk = solverKeyOf[msg.sender]; uint64 epoch = sk.epoch + 1; sk.keyHash = keyHash; sk.epoch = epoch; sk.active = true; emit EncapKeyPublished(msg.sender, keyHash, epoch, ek); } /// @notice Retire the caller's advertised key so no NEW intent can target it. Existing intents are /// unaffected (they carry their own ekHash) and can still be settled or refunded. function retireEncapKey() external { SolverKey storage sk = solverKeyOf[msg.sender]; if (!sk.active) revert SolverKeyInactive(msg.sender); sk.active = false; emit EncapKeyRetired(msg.sender, sk.keyHash); } // ============================================================================ // Intent lifecycle // ============================================================================ /// @notice Submit an encrypted intent to a designated solver, locking `msg.value` as escrow. /// The full `kemCiphertext` and `encPayload` are passed in calldata and emitted for the /// solver to fetch; only their hashes plus the commitment are stored. /// @param solver the designated solver (must have an active advertised key). /// @param ekHash keccak256 of the solver key the user encrypted to; must equal the solver's /// current advertised keyHash (guards against encrypting to a stale key). /// @param kemCiphertext the ML-KEM-768 ciphertext c (exactly 1088 bytes). /// @param encPayload the AES-256-GCM encrypted intent blob (nonce || ciphertext || tag), nonempty. /// @param commitment keccak256(abi.encode(intentPlaintext, salt)); nonzero. Use a fresh random /// 32-byte salt per intent so a commitment is never reusable across intents. /// @param claimWindow seconds the solver has to claim, in [MIN_CLAIM_WINDOW, MAX_CLAIM_WINDOW]. /// @param settleWindow seconds the solver has to settle after claiming, in /// [MIN_SETTLE_WINDOW, MAX_SETTLE_WINDOW]. /// @param minBond minimum bond the solver must post at claim (may be 0). function submitIntent( address solver, bytes32 ekHash, bytes calldata kemCiphertext, bytes calldata encPayload, bytes32 commitment, uint64 claimWindow, uint64 settleWindow, uint256 minBond ) external payable returns (uint256 intentId) { SolverKey memory sk = solverKeyOf[solver]; if (!sk.active) revert SolverKeyInactive(solver); if (ekHash != sk.keyHash) revert StaleEncapKey(ekHash, sk.keyHash); if (kemCiphertext.length != ML_KEM_768_CT_LEN) { revert BadCiphertextLength(kemCiphertext.length, ML_KEM_768_CT_LEN); } if (encPayload.length == 0) revert EmptyPayload(); if (commitment == bytes32(0)) revert ZeroCommitment(); if (claimWindow < MIN_CLAIM_WINDOW || claimWindow > MAX_CLAIM_WINDOW) { revert ClaimWindowOutOfRange(claimWindow); } if (settleWindow < MIN_SETTLE_WINDOW || settleWindow > MAX_SETTLE_WINDOW) { revert SettleWindowOutOfRange(settleWindow); } uint64 claimBy = uint64(block.timestamp) + claimWindow; intentId = nextIntentId++; _intents[intentId] = Intent({ user: msg.sender, solver: solver, claimBy: claimBy, settleBy: 0, settleWindow: settleWindow, status: Status.Open, escrow: msg.value, bond: 0, minBond: minBond, ekHash: ekHash, kemCtHash: keccak256(kemCiphertext), payloadHash: keccak256(encPayload), commitment: commitment }); emit IntentSubmitted( intentId, msg.sender, solver, ekHash, commitment, msg.value, minBond, claimBy, kemCiphertext, encPayload ); } /// @notice Claim an open intent. Only the designated solver may call, and only before the claim /// window closes. The solver posts a bond of at least `minBond` (as msg.value); the bond /// is returned on settle and forfeited to the user if the solver then stalls. Claiming /// starts the settlement window. function claimIntent(uint256 intentId) external payable { Intent storage it = _intents[intentId]; if (it.status == Status.None) revert UnknownIntent(intentId); if (it.status != Status.Open) revert WrongStatus(it.status, Status.Open); if (msg.sender != it.solver) revert NotDesignatedSolver(msg.sender, it.solver); if (block.timestamp >= it.claimBy) revert ClaimWindowClosed(it.claimBy, block.timestamp); if (msg.value < it.minBond) revert InsufficientBond(msg.value, it.minBond); uint64 settleBy = uint64(block.timestamp) + it.settleWindow; it.status = Status.Claimed; it.bond = msg.value; it.settleBy = settleBy; emit IntentClaimed(intentId, msg.sender, msg.value, settleBy); } /// @notice Settle a claimed intent by revealing the committed plaintext. Only the designated /// solver may call, and only within the settlement window. The reveal must reproduce the /// commitment, so the solver can settle ONLY the exact intent the user committed to; a /// mismatched reveal reverts and the state is untouched (the solver may retry with the /// correct preimage until the window closes). On success the escrow is paid to the solver /// and the bond is returned, and the plaintext is emitted (public only now, atomically at /// settlement). /// @param intentId the intent to settle. /// @param intentPlaintext the decrypted, canonical intent bytes. /// @param salt the 32-byte commitment salt the user used. function revealAndSettle(uint256 intentId, bytes calldata intentPlaintext, bytes32 salt) external nonReentrant { Intent storage it = _intents[intentId]; if (it.status == Status.None) revert UnknownIntent(intentId); if (it.status != Status.Claimed) revert WrongStatus(it.status, Status.Claimed); if (msg.sender != it.solver) revert NotDesignatedSolver(msg.sender, it.solver); if (block.timestamp > it.settleBy) revert SettleWindowClosed(it.settleBy, block.timestamp); bytes32 got = keccak256(abi.encode(intentPlaintext, salt)); if (got != it.commitment) revert CommitmentMismatch(got, it.commitment); // effects before interactions it.status = Status.Settled; uint256 escrow = it.escrow; uint256 bond = it.bond; it.escrow = 0; it.bond = 0; address solver = it.solver; emit IntentSettled(intentId, solver, escrow, intentPlaintext); // interactions: pay the solver its escrow plus its returned bond in one transfer _pay(solver, escrow + bond); } /// @notice Refund the user after a solver timeout. Permissionless (funds always go to the user). /// Two cases: /// - Open and block.timestamp >= claimBy: the solver never claimed; the escrow is /// returned. No bond was posted. /// - Claimed and block.timestamp > settleBy: the solver claimed then stalled; the escrow /// is returned AND the solver's bond is forfeited to the user. function timeoutRefund(uint256 intentId) external nonReentrant { Intent storage it = _intents[intentId]; if (it.status == Status.None) revert UnknownIntent(intentId); uint256 escrow = it.escrow; uint256 bondForfeited; if (it.status == Status.Open) { if (block.timestamp < it.claimBy) revert NotYetRefundable(intentId); } else if (it.status == Status.Claimed) { if (block.timestamp <= it.settleBy) revert NotYetRefundable(intentId); bondForfeited = it.bond; } else { revert WrongStatus(it.status, Status.Open); } // effects before interactions it.status = Status.Refunded; it.escrow = 0; it.bond = 0; address user = it.user; emit IntentRefunded(intentId, user, escrow, bondForfeited); _pay(user, escrow + bondForfeited); } // ---- internal -------------------------------------------------------------- /// @dev Native-value transfer with an explicit success check. A recipient contract that rejects /// the transfer only bricks its OWN payout, so a push transfer is acceptable here. function _pay(address to, uint256 amount) internal { if (amount == 0) return; (bool ok, ) = payable(to).call{value: amount}(""); if (!ok) revert TransferFailed(to, amount); } // ============================================================================ // Views // ============================================================================ /// @notice The full intent record (reverts on an unknown id). function getIntent(uint256 intentId) external view returns (Intent memory) { Intent memory it = _intents[intentId]; if (it.status == Status.None) revert UnknownIntent(intentId); return it; } /// @notice The status of an intent (Status.None for an unknown id). function statusOf(uint256 intentId) external view returns (Status) { return _intents[intentId].status; } /// @notice Whether timeoutRefund would currently succeed for this intent. function isRefundable(uint256 intentId) external view returns (bool) { Intent memory it = _intents[intentId]; if (it.status == Status.Open) return block.timestamp >= it.claimBy; if (it.status == Status.Claimed) return block.timestamp > it.settleBy; return false; } /// @notice The commitment a client must reproduce, for parity checks against off-chain code. function commitmentFor(bytes calldata intentPlaintext, bytes32 salt) external pure returns (bytes32) { return keccak256(abi.encode(intentPlaintext, salt)); } }