// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.27;
import {ISatellite} from "@HerodotusDev/satellite/solidity/src/interfaces/ISatellite.sol";
import {IEvmFactRegistryModule} from "@HerodotusDev/satellite/solidity/src/interfaces/modules/IEvmFactRegistryModule.sol";
/**
* @notice Proof generation via the Storage Proof API takes non-trivial time. After initializing
* a vote at a checkpoint timestamp, submit a proof request for that timestamp before the voting
* window opens. The same checkpoint timestamp maps to the correct block number on every chain, so
* cross-chain balances can be aggregated in a single vote without tracking per-chain block numbers.
*
* @notice Only the block header and token account storage root need to be proven via the Storage
* Proof API — once per vote. Each voter's balance is then verified on-demand using an MPT proof
* they supply themselves (via eth_getProof from any RPC node), checked against the proven storage
* root with `verifyOnlyStorage`. No per-voter Storage Proof API requests are required.
*/
contract HistoricalBalanceVoting {
ISatellite public immutable satellite;
/// @notice Account allowed to initialize and close votes.
address public immutable operator;
/// @notice The ERC-20 token contract whose balance determines voting power.
address public immutable token;
/// @notice The chain ID on which the token lives.
uint256 public immutable tokenChainId;
/**
* @notice Solidity storage slot index of the token's `_balances` mapping.
* @dev For most OpenZeppelin ERC-20 tokens this is 0.
* Verify with `forge inspect <Token> storage-layout`.
*/
uint256 public immutable balancesSlot;
// ─── Vote storage ─────────────────────────────────────────────────────────
struct VoteInfo {
uint256 checkpointTimestamp;
bool active;
bool closed;
bytes32 winner;
bytes32[] outcomes;
}
uint256 public voteCount;
mapping(uint256 voteId => VoteInfo) public voteInfo;
mapping(uint256 voteId => mapping(bytes32 outcome => uint256 power)) public votesByOutcome;
mapping(uint256 voteId => mapping(address voter => bool)) public hasVoted;
// ─── Events ───────────────────────────────────────────────────────────────
event VoteInitialized(uint256 indexed voteId, uint256 checkpointTimestamp, bytes32[] outcomes);
event Voted(uint256 indexed voteId, address indexed voter, bytes32 outcome, uint256 power);
event VoteClosed(uint256 indexed voteId, bytes32 winner);
modifier onlyOperator() {
require(msg.sender == operator, "Only operator");
_;
}
constructor(
address _satellite,
address _operator,
address _token,
uint256 _tokenChainId,
uint256 _balancesSlot
) {
satellite = ISatellite(_satellite);
operator = _operator;
token = _token;
tokenChainId = _tokenChainId;
balancesSlot = _balancesSlot;
}
// ─── Operator actions ─────────────────────────────────────────────────────
/**
* @notice Opens a new vote anchored to a historical checkpoint timestamp.
* @dev Before the voting window opens, request proofs for `checkpointTimestamp` via the
* Storage Proof API. The API needs to prove the block header (anchoring the timestamp
* on-chain) and the token account's storage root (one value that commits to all balances
* at that block). Because the API works with timestamps, the same request can cover data
* from multiple chains at once. Individual balance slots are verified on-demand by voters.
* @param checkpointTimestamp Unix timestamp of the balance checkpoint (must be in the past).
* @param outcomes At least two distinct outcome labels (e.g. keccak256("FOR")).
* @return voteId Identifier for this vote.
*/
function initializeVote(uint256 checkpointTimestamp, bytes32[] calldata outcomes)
external
onlyOperator
returns (uint256 voteId)
{
require(checkpointTimestamp < block.timestamp, "Timestamp must be in the past");
require(outcomes.length >= 2, "At least 2 outcomes required");
voteId = voteCount++;
VoteInfo storage v = voteInfo[voteId];
v.checkpointTimestamp = checkpointTimestamp;
v.active = true;
v.outcomes = outcomes;
emit VoteInitialized(voteId, checkpointTimestamp, outcomes);
}
/**
* @notice Closes an active vote and records the winning outcome on-chain.
*/
function closeVote(uint256 voteId) external onlyOperator {
VoteInfo storage v = voteInfo[voteId];
require(v.active && !v.closed, "Vote not active");
bytes32 winner;
uint256 highestVotes;
for (uint256 i = 0; i < v.outcomes.length; i++) {
bytes32 outcome = v.outcomes[i];
if (votesByOutcome[voteId][outcome] > highestVotes) {
highestVotes = votesByOutcome[voteId][outcome];
winner = outcome;
}
}
v.active = false;
v.closed = true;
v.winner = winner;
emit VoteClosed(voteId, winner);
}
// ─── Voter actions ────────────────────────────────────────────────────────
/**
* @notice Cast a vote. Voting power equals the token balance at the checkpoint timestamp,
* verified on-demand against the proven storage root using the caller-supplied MPT proof.
* @dev The block header and token account storage root must already be proven in Satellite via
* the Storage Proof API. The `storageSlotMptProof` can be fetched from any RPC node via
* `eth_getProof` at the checkpoint block number — no Storage Proof API call is needed.
* @param storageSlotMptProof MPT proof for the caller's balance slot, from eth_getProof.
*/
function vote(uint256 voteId, bytes32 outcome, bytes calldata storageSlotMptProof) external {
VoteInfo storage v = voteInfo[voteId];
require(v.active && !v.closed, "Vote not active");
require(!hasVoted[voteId][msg.sender], "Already voted");
bool valid = false;
for (uint256 i = 0; i < v.outcomes.length; i++) {
if (v.outcomes[i] == outcome) {
valid = true;
break;
}
}
require(valid, "Invalid outcome");
uint256 power = getVotingPower(voteId, msg.sender, storageSlotMptProof);
require(power > 0, "No voting power at this checkpoint");
hasVoted[voteId][msg.sender] = true;
votesByOutcome[voteId][outcome] += power;
emit Voted(voteId, msg.sender, outcome, power);
}
// ─── Views ────────────────────────────────────────────────────────────────
/**
* @notice Returns the voting power of `account` for a given vote.
* @dev Resolves the checkpoint timestamp to a block number via Satellite, reads the proven
* storage root of the token account, then verifies the caller-supplied MPT proof against
* it on-demand. Reverts if the block header or storage root are not yet proven in Satellite.
* @param storageSlotMptProof MPT proof for `account`'s balance slot, obtained via eth_getProof
* at the checkpoint block number from any standard RPC node.
*/
function getVotingPower(uint256 voteId, address account, bytes calldata storageSlotMptProof)
public
view
returns (uint256)
{
VoteInfo storage v = voteInfo[voteId];
require(v.checkpointTimestamp != 0, "Vote does not exist");
(bool tsOk, uint256 blockNumber) = satellite.timestampSafe(tokenChainId, v.checkpointTimestamp);
require(tsOk, "Checkpoint not yet proven — request proof via Storage Proof API first");
(bool rootOk, bytes32 storageRoot) = satellite.accountFieldSafe(
tokenChainId,
blockNumber,
token,
IEvmFactRegistryModule.AccountField.STORAGE_ROOT
);
require(rootOk, "Storage root not yet proven — request proof via Storage Proof API first");
// Storage slot for `_balances[account]` in a standard ERC-20.
// The MPT proof is verified on-demand against the proven storage root — no Storage Proof
// API request is needed per voter.
bytes32 slot = keccak256(abi.encode(account, balancesSlot));
bytes32 balance = satellite.verifyOnlyStorage(slot, storageRoot, storageSlotMptProof);
return uint256(balance);
}
}