aere-contracts/contracts/zkverify/AereStorageProofVerifierV2.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

399 lines
20 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
/// @dev Canonical SP1 verifier / SP1VerifierGateway ABI: verifyProof RETURNS
/// NOTHING and REVERTS on an invalid proof.
interface ISp1Verifier {
function verifyProof(
bytes32 programVKey,
bytes calldata publicValues,
bytes calldata proof
) external view;
}
/**
* @title AereStorageProofVerifierV2 — zk storage-proof coprocessor primitive.
* CORRECTED fork of AereStorageProofVerifier 0xF9a1A183…9362E (see THE FIX below).
*
* @notice On-chain anchor for SP1 zkVM proofs that, at a GIVEN state root R, an account A's
* storage slot S held value V. The proof is generated by the `aere-storage-proof-program`
* guest (UNCHANGED — same ELF, same vkey), which RLP-decodes and hash-chains the
* `eth_getProof` account-proof and storage-proof nodes of AERE's REAL Merkle-Patricia
* state trie (the same trie committed in each Besu block header's stateRoot). No
* Foundation-seeded side tree is involved: the guest walks account trie
* stateRoot -> storageRoot, then storage trie storageRoot -> value, panicking on any
* node whose keccak256 does not match its parent reference.
*
* PUBLIC-VALUES CONVENTION (exactly 192 bytes) — IDENTICAL to V1, guest UNCHANGED:
* abi.encode(
* uint256 chainId, // must equal this chain
* uint256 blockNumber, // METADATA only, see canonicity note
* bytes32 stateRoot, // the root the value was proven against
* address account, // A
* bytes32 slot, // S
* bytes32 value // V (left-padded 32-byte slot value)
* )
*
* @dev ------------------------------------------------------------------
* THE FIX (vs V1 0xF9a1A183…9362E) — RECORD POISONING ACROSS TRUST DOMAINS.
*
* KNOWN AND ALREADY DOCUMENTED (not the bug): this contract proves V against a GIVEN,
* prover-supplied stateRoot and CANNOT itself show that root is canonical. That is the
* honest, intended scope of a storage-proof primitive.
*
* THE ACTUAL BUG: V1 wrote EVERY submission — canonically-anchored or not — into ONE
* shared mapping keyed only by (blockNumber, account, slot):
* proven[keccak256(blockNumber, account, slot)] = ProvenSlot({...});
* So a record established through the trust-minimized path (AereStateRootAnchor
* .proveAgainstAnchor, which forces the proof's root to equal a root recovered from the
* BLOCKHASH opcode) could be SILENTLY OVERWRITTEN by anyone calling the unbound
* `submitProof` with a proof against a state root of their OWN making for the SAME key.
*
* THIS IS NOT THEORETICAL. The live V1 holds a genuinely anchored record —
* AereTreasury 0x687933…6119 slot 0 (owner) == Foundation 0x0243A4…f3C3 at block
* 8,915,939, against the canonical stateRoot 0x515fce04…4a92f3 (== the real
* eth_getBlockByNumber(8915939).stateRoot). An attacker builds a trie of their own
* where that slot holds THEIR address, proves it honestly against their own root (the
* guest cannot know which root is canonical — that is precisely its documented scope),
* and calls submitProof for the same (8915939, AereTreasury, slot 0). V1 overwrites
* the anchored record in place. Every downstream reader of getProven() then sees the
* attacker as the Treasury owner, with `exists == true` and no way to tell.
*
* THE FIX — SEPARATE THE TRUST DOMAINS. Anchored and unbound records now live in
* DIFFERENT mappings and can never collide:
* * ANCHORED records: key = (blockNumber, account, slot). WRITABLE ONLY by the
* IMMUTABLE CANONICAL_ANCHOR, whose own logic forces the proof's committed root to
* equal a root it recovered from consensus (BLOCKHASH). This is the canonical domain.
* * UNBOUND records: key = (SUBMITTER, blockNumber, account, slot). Every submitter
* gets their OWN namespace, so an unbound submission cannot overwrite the anchored
* record NOR another submitter's record. Records are tagged Provenance.Unbound.
* `getProven(blockNumber, account, slot)` — the V1-shaped read — now returns the
* ANCHORED record ONLY. Any consumer that read V1's getProven and could be poisoned
* reads canonical-or-nothing on V2: FAIL-CLOSED, not fail-quiet.
*
* THE UNBOUND API IS NOT DROPPED — it has legitimate uses (a caller who obtained a root
* from a source it trusts, off-chain indexers, and the anchor's own two-call pattern).
* It is simply made clearly NON-CANONICAL and NON-OVERWRITING.
*
* WHY PROVENANCE IS DECIDED BY THE CALLER, NOT THE FUNCTION. Note that
* `submitProofWithExpectedRoot` is NOT self-securing: the CALLER supplies
* `expectedStateRoot`, so an attacker simply passes their own fabricated root and the
* check passes trivially. Canonicity comes from WHO is calling — the anchor derives its
* expected root from the BLOCKHASH opcode. Hence V2 grants Provenance.Anchored strictly
* to CANONICAL_ANCHOR and to nobody else.
* ------------------------------------------------------------------
*
* @dev CANONICITY SOURCE ON CHAIN 2800. CANONICAL_ANCHOR is an AereStateRootAnchor
* (BLOCKHASH-derived, 256-block reach). EIP-2935 history storage
* (0x0000F90827F1C53a10cb7A02335B175320002935) is NOT deployed on chain 2800 today, so
* the ~8191-block AereHistoryStateRootAnchor path is inert here. CANONICAL_ANCHOR is a
* SINGLE immutable address: adding the EIP-2935 anchor as a second canonical writer,
* once that contract is live on 2800, requires a fresh deployment of this verifier.
* Stated plainly — same expiring-credential discipline as the light-client anchors, not
* a silent gap.
*
* @dev HONEST SCOPE / RESIDUAL TRUST — read before quoting.
* * ANCHORED records are trust-minimized: the root came from the BLOCKHASH opcode, never
* from a caller argument. Their reach is the anchor's 256-block window at anchoring
* time; once anchored the record is permanent.
* * UNBOUND records are NOT canonical and must never be read as if they were. They say
* only: "submitter X proved value V against a root X chose". That is a real statement
* and sometimes a useful one — it is not a statement about chain 2800's history.
* * This contract does not make the unbound path trustless; it makes it HARMLESS to the
* canonical path, which is what was broken.
* * NOT QUANTUM-SAFE, TRUST-MINIMIZED NOT TRUSTLESS: SP1 Groth16 over BN254 is classical.
* APPLICATION layer only; does not touch AERE consensus (still classical ECDSA QBFT).
*
* IMMUTABILITY: SP1_VERIFIER, PROGRAM_VKEY and CANONICAL_ANCHOR are fixed at deploy.
* There is no owner and no admin: anyone may submit a valid proof.
*/
contract AereStorageProofVerifierV2 {
/// @notice SP1VerifierGateway (routes by proof selector to the versioned verifier).
address public immutable SP1_VERIFIER;
/// @notice Verification key binding the exact storage-proof guest ELF.
bytes32 public immutable PROGRAM_VKEY;
/// @notice The ONLY contract whose submissions earn Provenance.Anchored. An
/// AereStateRootAnchor: it recovers the canonical block hash from the BLOCKHASH
/// opcode, checks the RLP header against it, and forces the proof's committed
/// stateRoot to equal the header's. IMMUTABLE.
address public immutable CANONICAL_ANCHOR;
/// @notice Trust domain of a record. None = absent.
enum Provenance {
None,
Unbound,
Anchored
}
struct ProvenSlot {
bool exists;
bytes32 stateRoot;
bytes32 value;
uint64 blockNumber;
uint64 at; // block timestamp when recorded
Provenance provenance;
address submitter;
}
/// @notice CANONICAL domain. key = keccak256(blockNumber, account, slot).
/// Writable ONLY via CANONICAL_ANCHOR. Cannot be touched by the unbound path.
mapping(bytes32 => ProvenSlot) private _anchoredRecords;
/// @notice NON-CANONICAL domain. key = keccak256(submitter, blockNumber, account, slot).
/// Each submitter has their own namespace: no cross-writes, ever.
mapping(bytes32 => ProvenSlot) private _unboundRecords;
/// @notice Emitted for a trust-minimized record written through CANONICAL_ANCHOR.
event SlotProvenAnchored(
address indexed account,
bytes32 indexed slot,
uint256 indexed blockNumber,
bytes32 stateRoot,
bytes32 value
);
/// @notice Emitted for a NON-CANONICAL record. `submitter` is part of the identity of the
/// record: it is namespaced under them and overwrites nothing else.
event SlotProvenUnbound(
address indexed account,
bytes32 indexed slot,
uint256 indexed blockNumber,
address submitter,
bytes32 stateRoot,
bytes32 value
);
error InvalidProof();
error WrongChain(uint256 inProof, uint256 onChain);
error BadPublicValuesLength(uint256 got);
error ZeroAddress();
error ZeroVKey();
error StateRootMismatch(bytes32 inProof, bytes32 expected);
error VerifierHasNoCode();
error NotAnchored(uint256 blockNumber, address account, bytes32 slot);
/// @notice An anchored record already exists for this key with a DIFFERENT canonical root.
/// Impossible for a correct anchor (a block has exactly one canonical state root);
/// asserted so a broken anchor fails loudly instead of silently rewriting history.
error AnchoredRootConflict(bytes32 existing, bytes32 incoming);
constructor(address sp1Verifier, bytes32 programVKey, address canonicalAnchor) {
if (sp1Verifier == address(0) || canonicalAnchor == address(0)) revert ZeroAddress();
// A code-less verifier would make verifyProof (which returns no data) succeed silently,
// accepting every "proof". Removes a deploy-time footgun.
if (sp1Verifier.code.length == 0) revert VerifierHasNoCode();
if (programVKey == bytes32(0)) revert ZeroVKey();
SP1_VERIFIER = sp1Verifier;
PROGRAM_VKEY = programVKey;
// NOTE: CANONICAL_ANCHOR is NOT code-checked here. The anchor's constructor takes this
// verifier's address, so the two are mutually referential and the anchor cannot exist
// yet at this point. The deploy script predicts the anchor's CREATE address, deploys it
// immediately after, and then VERIFIES BOTH DIRECTIONS on chain
// (anchor.VERIFIER() == this && this.CANONICAL_ANCHOR() == anchor) before the pair is
// published. See scripts/deploy-storage-proof-verifier-v2.js.
CANONICAL_ANCHOR = canonicalAnchor;
}
/* ------------------------------------ keys ------------------------------------- */
/// @dev Canonical-domain key: identity is the chain position, not the submitter.
function _key(uint256 blockNumber, address account, bytes32 slot) internal pure returns (bytes32) {
return keccak256(abi.encode(blockNumber, account, slot));
}
/// @dev Unbound-domain key: the SUBMITTER is part of the identity, which is exactly what
/// stops an unbound submission from landing on top of anyone else's record.
function _unboundKey(address submitter, uint256 blockNumber, address account, bytes32 slot)
internal
pure
returns (bytes32)
{
return keccak256(abi.encode(submitter, blockNumber, account, slot));
}
/* ---------------------------------- verify ------------------------------------- */
/// @dev Verify the proof through the gateway (reverts on failure) and decode.
function _verifyAndDecode(bytes calldata publicValues, bytes calldata proof)
internal
view
returns (
uint256 chainId,
uint256 blockNumber,
bytes32 stateRoot,
address account,
bytes32 slot,
bytes32 value
)
{
// 6 x 32 = 192 bytes for (uint256, uint256, bytes32, address, bytes32, bytes32).
if (publicValues.length != 192) revert BadPublicValuesLength(publicValues.length);
try ISp1Verifier(SP1_VERIFIER).verifyProof(PROGRAM_VKEY, publicValues, proof) {
// verified - did not revert
} catch {
revert InvalidProof();
}
(chainId, blockNumber, stateRoot, account, slot, value) =
abi.decode(publicValues, (uint256, uint256, bytes32, address, bytes32, bytes32));
if (chainId != block.chainid) revert WrongChain(chainId, block.chainid);
}
/* ---------------------------------- writes ------------------------------------- */
/// @notice Verify an SP1 storage proof and record it in the CALLER'S OWN non-canonical
/// namespace. Reverts if the proof is invalid or the committed chainId is wrong.
/// @dev Does NOT check stateRoot canonicity — see the contract-level note. The resulting
/// record is tagged Provenance.Unbound, is keyed under msg.sender, and CANNOT
/// overwrite an anchored record or another submitter's record (the V2 fix).
/// Read it with {getUnboundProven}; it is NOT visible through {getProven}.
function submitProof(bytes calldata publicValues, bytes calldata proof) external returns (bytes32 value) {
(, uint256 blockNumber, bytes32 stateRoot, address account, bytes32 slot, bytes32 v) =
_verifyAndDecode(publicValues, proof);
_writeUnbound(blockNumber, stateRoot, account, slot, v);
return v;
}
/// @notice Same as {submitProof} but reverts unless the proof's committed stateRoot equals
/// `expectedStateRoot`, moving the canonicity check to the call site.
/// @dev PROVENANCE: if msg.sender is CANONICAL_ANCHOR, the record is written to the
/// canonical domain (the anchor derived `expectedStateRoot` from the BLOCKHASH
/// opcode). For ANY OTHER caller the expected root is just a value they chose, so
/// the record goes to their own unbound namespace. This asymmetry is the point:
/// the function cannot tell a canonical root from a fabricated one — the CALLER can.
function submitProofWithExpectedRoot(
bytes calldata publicValues,
bytes calldata proof,
bytes32 expectedStateRoot
) external returns (bytes32 value) {
(, uint256 blockNumber, bytes32 stateRoot, address account, bytes32 slot, bytes32 v) =
_verifyAndDecode(publicValues, proof);
if (stateRoot != expectedStateRoot) revert StateRootMismatch(stateRoot, expectedStateRoot);
if (msg.sender == CANONICAL_ANCHOR) {
_writeAnchored(blockNumber, stateRoot, account, slot, v);
} else {
_writeUnbound(blockNumber, stateRoot, account, slot, v);
}
return v;
}
function _writeAnchored(
uint256 blockNumber,
bytes32 stateRoot,
address account,
bytes32 slot,
bytes32 v
) internal {
bytes32 k = _key(blockNumber, account, slot);
ProvenSlot storage existing = _anchoredRecords[k];
// A block has exactly one canonical state root, so a re-anchor must agree. If it does
// not, the anchor is broken: fail loudly rather than rewrite canonical history.
if (existing.exists && existing.stateRoot != stateRoot) {
revert AnchoredRootConflict(existing.stateRoot, stateRoot);
}
_anchoredRecords[k] = ProvenSlot({
exists: true,
stateRoot: stateRoot,
value: v,
blockNumber: uint64(blockNumber),
at: uint64(block.timestamp),
provenance: Provenance.Anchored,
submitter: msg.sender
});
emit SlotProvenAnchored(account, slot, blockNumber, stateRoot, v);
}
function _writeUnbound(
uint256 blockNumber,
bytes32 stateRoot,
address account,
bytes32 slot,
bytes32 v
) internal {
_unboundRecords[_unboundKey(msg.sender, blockNumber, account, slot)] = ProvenSlot({
exists: true,
stateRoot: stateRoot,
value: v,
blockNumber: uint64(blockNumber),
at: uint64(block.timestamp),
provenance: Provenance.Unbound,
submitter: msg.sender
});
emit SlotProvenUnbound(account, slot, blockNumber, msg.sender, stateRoot, v);
}
/* ----------------------------------- views ------------------------------------- */
/// @notice Stateless verification: reverts if the proof is invalid or the chainId is wrong,
/// otherwise returns the decoded tuple. Records nothing.
/// @dev UNCHANGED from V1 and INTENTIONALLY unbound: this is the primitive the anchor
/// itself calls to read a proof's contents BEFORE deciding whether its root is
/// canonical. Its return value is a statement about the proof, not about chain
/// history — a consumer must not treat it as a canonical read. The canonical read
/// is {getProven}.
function verify(bytes calldata publicValues, bytes calldata proof)
external
view
returns (uint256 blockNumber, bytes32 stateRoot, address account, bytes32 slot, bytes32 value)
{
(, blockNumber, stateRoot, account, slot, value) = _verifyAndDecode(publicValues, proof);
}
/// @notice CANONICAL read: the anchored record for (blockNumber, account, slot), or
/// exists == false. Unbound submissions are NOT visible here and can never
/// influence it. This is the V1-shaped read, now fail-closed.
function getProven(uint256 blockNumber, address account, bytes32 slot)
external
view
returns (bool exists, bytes32 stateRoot, bytes32 value, uint64 at)
{
ProvenSlot memory p = _anchoredRecords[_key(blockNumber, account, slot)];
return (p.exists, p.stateRoot, p.value, p.at);
}
/// @notice CANONICAL read with full detail (explicitly named).
function getAnchoredProven(uint256 blockNumber, address account, bytes32 slot)
external
view
returns (bool exists, bytes32 stateRoot, bytes32 value, uint64 blockNum, uint64 at, address submitter)
{
ProvenSlot memory p = _anchoredRecords[_key(blockNumber, account, slot)];
return (p.exists, p.stateRoot, p.value, p.blockNumber, p.at, p.submitter);
}
/// @notice NON-CANONICAL read: what `submitter` proved against a root THEY chose.
/// Never treat this as chain history.
function getUnboundProven(address submitter, uint256 blockNumber, address account, bytes32 slot)
external
view
returns (bool exists, bytes32 stateRoot, bytes32 value, uint64 blockNum, uint64 at)
{
ProvenSlot memory p = _unboundRecords[_unboundKey(submitter, blockNumber, account, slot)];
return (p.exists, p.stateRoot, p.value, p.blockNumber, p.at);
}
/// @notice Provenance of the canonical-domain record for this key: Anchored or None.
function provenanceOf(uint256 blockNumber, address account, bytes32 slot) external view returns (Provenance) {
return _anchoredRecords[_key(blockNumber, account, slot)].provenance;
}
/// @notice True iff a trust-minimized (anchored) record exists for this key.
function isAnchoredProven(uint256 blockNumber, address account, bytes32 slot) external view returns (bool) {
return _anchoredRecords[_key(blockNumber, account, slot)].exists;
}
/// @notice Canonical value, or revert. For consumers that must never read a missing or
/// non-canonical record by accident.
function anchoredValue(uint256 blockNumber, address account, bytes32 slot) external view returns (bytes32) {
ProvenSlot memory p = _anchoredRecords[_key(blockNumber, account, slot)];
if (!p.exists) revert NotAnchored(blockNumber, account, slot);
return p.value;
}
}