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.
399 lines
20 KiB
Solidity
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;
|
|
}
|
|
}
|