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