Skip to main content
For easier integration, you can add the Satellite contract as a dependency and use its interfaces inside your smart contract. To do that using Foundry, you should first install it using:
forge install HerodotusDev/satellite
and then we recommend configuring a remapping inside your foundry.toml file:
remappings = [
  # Your other remappings go here
  "@HerodotusDev/satellite/=lib/satellite/",
]
Or if you are using NPM (e.g. with Hardhat), you can just install it using:
npm install https://github.com/HerodotusDev/satellite

How it works

The contract uses historical token balances as voting power. Because the system operates on timestamps rather than block numbers, the same historical moment can be proven across multiple chains simultaneously — Satellite resolves the correct block number per chain internally. This makes it possible to aggregate cross-chain balances in a single vote without manually tracking chain-specific block heights. Proving individual storage slots for every voter via the Storage Proof API would be impractical at scale. Instead, only two things need to be proven once per vote: the block header (to anchor the timestamp on-chain) and the token account’s storage root (a single 32-byte value that commits to the entire account storage at that block). After that, each voter’s balance is verified on-demand at vote time using an MPT proof they fetch themselves from any standard RPC node (eth_getProof). The contract checks this proof against the already-proven storage root using verifyOnlyStorage from Satellite — no Storage Proof API call is needed per voter.
In practice, getting the MPT proof and constructing the transaction would be abstracted away from the user behind a frontend (or a frontend + backend). The UI would fetch the MPT proof from an RPC node, construct the transaction, and submit it on the user’s behalf — voters would just click “Vote” without being aware of the proof mechanics.
Our example flow is:
  1. Operator initializes a vote at a historical checkpoint timestamp via initializeVote.
  2. One-time proof request is submitted to the Storage Proof API for that timestamp. It only needs to cover the block header (timestamp → block number) and the token account’s storage root. Proof generation takes non-trivial time — submit requests as early as possible, well before the voting window opens.
  3. Voters call vote with an MPT proof for their own balance slot, fetched from any RPC node via eth_getProof at the checkpoint block. The contract verifies the proof on-chain against the proven storage root and derives voting power without any additional Storage Proof API requests.
  4. Operator closes the vote via closeVote, which tallies results and records the winning outcome on-chain.
To find the correct balancesSlot for your token, run forge inspect <YourToken> storage-layout. For most OpenZeppelin ERC-20 tokens the _balances mapping is at slot 0.

Example contract

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