EpochDistributor
The EpochDistributor implements pull-based $RUN token distribution to company treasuries proportional to their CEOScore. An off-chain oracle submits score snapshots at the end of each epoch, and anyone can permissionlessly claim rewards on behalf of any company.
Deployed at: 0x9f96314b2c2b1Cbb7e104D50f7ce5686775D4759 (Base Sepolia)
Key Properties
| Property | Value |
|---|---|
| Reward Token | $RUN (18 decimals) |
| Default Epoch Duration | 7 days (604,800 seconds) |
| Reward Formula | companyReward = epochPool * (companyScore / totalScoreSum) |
| Claim Pattern | Pull-based (permissionless) |
| Genesis Epoch | Set at contract deployment (block.timestamp) |
How It Works
- Each epoch lasts 7 days, auto-calculated from the genesis timestamp.
- After an epoch ends, the backend oracle calls
finalizeEpoch()with CEOScore snapshots for all active companies. - The contract calculates each company’s proportional $RUN allocation on-chain.
- Anyone can call
claimReward()to mint $RUN to the registered treasury address. batchClaim()allows claiming across multiple epochs in a single transaction.
The EpochDistributor must hold MINTER_ROLE on the RunToken contract to mint $RUN on demand during claims.
Structs
struct EpochAllocation {
uint256 score; // CEOScore snapshot
uint256 reward; // Calculated $RUN reward (18 decimals)
bool claimed; // Whether already claimed
address claimTo; // Treasury address receiving $RUN
}
struct EpochInfo {
uint256 epochNumber;
uint256 startTime;
uint256 endTime;
uint256 rewardPool; // Total $RUN available this epoch
uint256 totalScore; // Sum of all company scores
uint256 totalClaimed; // $RUN already claimed
uint256 companyCount; // Number of companies with allocations
bool finalized; // Whether scores have been submitted
}Interface
Oracle Functions
/// @notice Submit CEOScore snapshots and finalize an epoch's allocations.
/// Only callable by the authorized oracle address.
/// @param epochNumber The epoch to finalize
/// @param companies Array of company addresses
/// @param scores Array of CEOScore values (matching companies array)
/// @param claimToAddresses Array of treasury addresses to receive $RUN
function finalizeEpoch(
uint256 epochNumber,
address[] calldata companies,
uint256[] calldata scores,
address[] calldata claimToAddresses
) external;The oracle is a backend cron job signed by EPOCH_DISTRIBUTOR_PRIVATE_KEY. Score submission is trustless in that the reward math is computed on-chain — the oracle only provides the CEOScore inputs.
Claim Functions
/// @notice Claim $RUN for a company in a finalized epoch.
/// Permissionless -- reward always goes to the registered treasury address.
/// @param epochNumber The epoch to claim from
/// @param company The company address to claim for
function claimReward(uint256 epochNumber, address company) external;
/// @notice Batch claim rewards across multiple epochs.
/// Skips non-finalized, already-claimed, and zero-reward epochs silently.
/// @param epochNumbers Array of epoch numbers
/// @param company The company address to claim for
function batchClaim(uint256[] calldata epochNumbers, address company) external;The claimReward function follows the CEI (Checks-Effects-Interactions) pattern: it marks the allocation as claimed before calling runToken.mint().
View Functions
/// @notice Get the current epoch number (auto-calculated from genesis).
function getCurrentEpoch() external view returns (uint256);
/// @notice Get detailed info about a specific epoch.
function getEpochInfo(uint256 epochNumber) external view returns (EpochInfo memory);
/// @notice Get a company's allocation in a specific epoch.
function getAllocation(uint256 epochNumber, address company)
external view returns (EpochAllocation memory);
/// @notice Get the claimable $RUN amount (0 if claimed or no allocation).
function getClaimable(uint256 epochNumber, address company)
external view returns (uint256 amount);Admin Functions (Owner Only)
/// @notice Update the $RUN reward pool per epoch.
/// @param newRewardPool New pool amount (18 decimals)
function setRewardPool(uint256 newRewardPool) external;
/// @notice Update the epoch duration.
/// @param newDuration New duration in seconds
function setEpochDuration(uint256 newDuration) external;
/// @notice Update the oracle address.
/// @param newOracle New oracle address
function setOracle(address newOracle) external;Events
event EpochStarted(uint256 indexed epochNumber, uint256 startTime, uint256 rewardPool);
event EpochFinalized(uint256 indexed epochNumber, uint256 totalScore, uint256 companyCount);
event RewardClaimed(
uint256 indexed epochNumber,
address indexed company,
address indexed claimTo,
uint256 amount
);
event RewardPoolUpdated(uint256 oldPool, uint256 newPool);
event EpochDurationUpdated(uint256 oldDuration, uint256 newDuration);
event OracleUpdated(address indexed oldOracle, address indexed newOracle);Errors
error EpochNotFinalized(); // Scores not yet submitted
error EpochAlreadyFinalized(); // Cannot re-finalize
error AlreadyClaimed(); // Double-claim prevention
error NoAllocation(); // Company not in this epoch
error UnauthorizedOracle(); // Caller is not the oracle
error ArrayLengthMismatch(); // companies.length != scores.length
error EmptyArrays(); // Zero-length input
error ZeroAddress(); // Invalid address
error ZeroAmount(); // Invalid amount or config
error EpochNotEnded(); // Cannot finalize mid-epochEpoch Number Calculation
Epoch numbers are genesis-relative and computed deterministically:
currentEpoch = (block.timestamp - genesisTimestamp) / epochDurationAn epoch’s time window:
startTime = genesisTimestamp + (epochNumber * epochDuration)
endTime = genesisTimestamp + ((epochNumber + 1) * epochDuration)The oracle can only finalize an epoch after block.timestamp >= endTime.
Reward Formula
For each company in a finalized epoch:
companyReward = epochRewardPool * (companyScore / totalScoreSum)The calculation uses integer division: (scores[i] * epochRewardPool) / totalScore. Rounding dust remains unclaimed.
Integration
The frontend reads getClaimable(epochNumber, company) to determine whether to show a “Claim $RUN” button. A company’s treasury page aggregates unclaimed epochs and offers a single batchClaim() call.
Required setup:
- Grant
MINTER_ROLEon RunToken to the EpochDistributor address - Configure
EPOCH_DISTRIBUTOR_PRIVATE_KEYin the backend for oracle signing - Backend cron triggers
finalizeEpoch()after each epoch ends
Security
ReentrancyGuardon all state-changing functions (finalizeEpoch,claimReward,batchClaim)onlyOraclemodifier restricts score submission- CEI pattern: claim state updated before external
runToken.mint()call - Idempotent: double-claim reverts with
AlreadyClaimed() batchClaimsilently skips invalid epochs instead of reverting (gas-safe batching)