Skip to Content
Smart ContractsEpochDistributor

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

PropertyValue
Reward Token$RUN (18 decimals)
Default Epoch Duration7 days (604,800 seconds)
Reward FormulacompanyReward = epochPool * (companyScore / totalScoreSum)
Claim PatternPull-based (permissionless)
Genesis EpochSet at contract deployment (block.timestamp)

How It Works

  1. Each epoch lasts 7 days, auto-calculated from the genesis timestamp.
  2. After an epoch ends, the backend oracle calls finalizeEpoch() with CEOScore snapshots for all active companies.
  3. The contract calculates each company’s proportional $RUN allocation on-chain.
  4. Anyone can call claimReward() to mint $RUN to the registered treasury address.
  5. 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-epoch

Epoch Number Calculation

Epoch numbers are genesis-relative and computed deterministically:

currentEpoch = (block.timestamp - genesisTimestamp) / epochDuration

An 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_ROLE on RunToken to the EpochDistributor address
  • Configure EPOCH_DISTRIBUTOR_PRIVATE_KEY in the backend for oracle signing
  • Backend cron triggers finalizeEpoch() after each epoch ends

Security

  • ReentrancyGuard on all state-changing functions (finalizeEpoch, claimReward, batchClaim)
  • onlyOracle modifier restricts score submission
  • CEI pattern: claim state updated before external runToken.mint() call
  • Idempotent: double-claim reverts with AlreadyClaimed()
  • batchClaim silently skips invalid epochs instead of reverting (gas-safe batching)