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.
391 lines
21 KiB
Solidity
391 lines
21 KiB
Solidity
// 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));
|
|
}
|
|
}
|