Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OGNRewardsSource contract #408

Merged
merged 19 commits into from
May 16, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions contracts/OGNRewardsSource.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.10;

import {Governable} from "./Governable.sol";
import {Initializable} from "./upgrades/Initializable.sol";
import "OpenZeppelin/[email protected]/contracts/token/ERC20/IERC20.sol";

contract OGNRewardsSource is Governable, Initializable {
error UnauthorizedCaller();
error RewardsTargetNotSet();
error InvalidRewardRate();

event StrategistUpdated(address _address);
event RewardsTargetChange(address target, address previousTarget);
event RewardsPerSecondChanged(uint256 newRPS, uint256 oldRPS);
event RewardCollected(uint256 amountCollected);

address public immutable ogn;
shahthepro marked this conversation as resolved.
Show resolved Hide resolved

address public strategistAddr;

address public rewardsTarget;

struct RewardConfig {
// Inspired by (Copied from) `Dripper.Drip` struct.
uint64 lastCollect; // Overflows 262 billion years after the sun dies
uint192 rewardsPerSecond;
}

RewardConfig public rewardConfig;

/**
* @dev Verifies that the caller is either Governor or Strategist.
*/
modifier onlyGovernorOrStrategist() {
if (msg.sender != strategistAddr && !isGovernor()) {
revert UnauthorizedCaller();
}

_;
}

constructor(address _ogn) {
ogn = _ogn;
}

/// @dev Initialize the proxy implementation
/// @param _strategistAddr Address of the Strategist
/// @param _rewardsTarget Address that receives rewards
/// @param _rewardsPerSecond Rate of reward emission
function initialize(address _strategistAddr, address _rewardsTarget, uint256 _rewardsPerSecond)
external
initializer
{
_setStrategistAddr(_strategistAddr);
_setRewardsTarget(_rewardsTarget);

// Rewards start from the moment the contract is initialized
rewardConfig.lastCollect = uint64(block.timestamp);

_setRewardsPerSecond(_rewardsPerSecond);
}

/// @dev Collect pending rewards
/// @return rewardAmount Amount of reward collected
function collectRewards() external returns (uint256) {
return _collectRewards();
}

/// @dev Collect pending rewards
/// @return rewardAmount Amount of reward collected
function _collectRewards() internal returns (uint256 rewardAmount) {
shahthepro marked this conversation as resolved.
Show resolved Hide resolved
address _target = rewardsTarget;
if (_target == address(0)) {
revert RewardsTargetNotSet();
}

// Compute pending rewards
RewardConfig storage _config = rewardConfig;
rewardAmount = _previewRewards(_config);

// Update timestamp
_config.lastCollect = uint64(block.timestamp);
shahthepro marked this conversation as resolved.
Show resolved Hide resolved

if (rewardAmount > 0) {
// Should not revert if there's no reward to transfer.

emit RewardCollected(rewardAmount);

// Intentionally skipping balance check to save some gas
// since `transfer` anyway would fail in case of low balance
IERC20(ogn).transfer(_target, rewardAmount);
}
}

/// @dev Compute pending rewards since last collect
/// @return rewardAmount Amount of reward that'll be distributed if collected now
function previewRewards() external view returns (uint256) {
return _previewRewards(rewardConfig);
}

/// @dev Compute pending rewards since last collect
/// @param _rewardConfig RewardConfig
/// @return rewardAmount Amount of reward that'll be distributed if collected now
function _previewRewards(RewardConfig memory _rewardConfig) internal view returns (uint256) {
shahthepro marked this conversation as resolved.
Show resolved Hide resolved
return (block.timestamp - _rewardConfig.lastCollect) * rewardConfig.rewardsPerSecond;
}

/// @dev Set address of the strategist
/// @param _address Address of the Strategist
function setStrategistAddr(address _address) external onlyGovernor {
_setStrategistAddr(_address);
}

function _setStrategistAddr(address _address) internal {
emit StrategistUpdated(_address);
// Can be set to zero to disable
strategistAddr = _address;
}

/// @dev Set the address of the contract than can collect rewards
/// @param _rewardsTarget contract address that can collect rewards
function setRewardsTarget(address _rewardsTarget) external onlyGovernor {
_setRewardsTarget(_rewardsTarget);
}

/// @dev Set the address of the contract than can collect rewards
/// @param _rewardsTarget contract address that can collect rewards
function _setRewardsTarget(address _rewardsTarget) internal {
emit RewardsTargetChange(_rewardsTarget, rewardsTarget);
// Can be set to zero to disable
rewardsTarget = _rewardsTarget;
}

/// @dev Set the rate of reward emission
/// @param _rewardsPerSecond Amount of OGN to distribute per second
function setRewardsPerSecond(uint256 _rewardsPerSecond) external onlyGovernorOrStrategist {
_setRewardsPerSecond(_rewardsPerSecond);
}

/// @dev Set the rate of reward emission
/// @param _rewardsPerSecond Amount of OGN to distribute per second
function _setRewardsPerSecond(uint256 _rewardsPerSecond) internal {
if (_rewardsPerSecond > type(uint192).max) {
revert InvalidRewardRate();
}

// Collect any pending rewards at current rate
_collectRewards();
shahthepro marked this conversation as resolved.
Show resolved Hide resolved

// Update storage
RewardConfig storage _config = rewardConfig;
emit RewardsPerSecondChanged(_rewardsPerSecond, _config.rewardsPerSecond);
_config.rewardsPerSecond = uint192(_rewardsPerSecond);
}
}
12 changes: 12 additions & 0 deletions contracts/tests/MockOGN.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.10;

import {ERC20} from "OpenZeppelin/[email protected]/contracts/token/ERC20/ERC20.sol";

contract MockOGN is ERC20 {
constructor() ERC20("OGN", "OGN") {}

function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
39 changes: 39 additions & 0 deletions contracts/upgrades/Initializable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/**
* @title Base contract any contracts that need to initialize state after deployment.
* @author Origin Protocol Inc
*/
abstract contract Initializable {
/**
* @dev Indicates that the contract has been initialized.
*/
bool private initialized;

/**
* @dev Indicates that the contract is in the process of being initialized.
*/
bool private initializing;

/**
* @dev Modifier to protect an initializer function from being invoked twice.
*/
modifier initializer() {
require(initializing || !initialized, "Initializable: contract is already initialized");

bool isTopLevelCall = !initializing;
if (isTopLevelCall) {
initializing = true;
initialized = true;
}

_;

if (isTopLevelCall) {
initializing = false;
}
}

uint256[50] private ______gap;
}
7 changes: 7 additions & 0 deletions contracts/upgrades/OGNRewardsSourceProxy.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: MIT

pragma solidity 0.8.10;

import {InitializeGovernedUpgradeabilityProxy} from "./InitializeGovernedUpgradeabilityProxy.sol";

contract OGNRewardsSourceProxy is InitializeGovernedUpgradeabilityProxy {}
181 changes: 181 additions & 0 deletions tests/staking/OGNRewardsSource.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import "forge-std/Test.sol";
import "contracts/upgrades/OGNRewardsSourceProxy.sol";
import "contracts/OGNRewardsSource.sol";
import "contracts/tests/MockOGN.sol";

contract OGNRewardsSourceTest is Test {
MockOGN ogn;
OGNRewardsSource rewards;

address staking = address(0x42);
address governor = address(0x43);
address alice = address(0x44);
address strategist = address(0x45);

function setUp() public {
vm.startPrank(governor);
ogn = new MockOGN();
rewards = new OGNRewardsSource(address(ogn));

// Setup Rewards Proxy
OGNRewardsSourceProxy rewardsProxy = new OGNRewardsSourceProxy();
rewardsProxy.initialize(address(rewards), governor, "");
rewards = OGNRewardsSource(address(rewardsProxy));

// Configure Rewards
rewards.initialize(strategist, staking, uint192(100 ether)); // 100 OGN per second

// Make sure contract has enough OGN for rewards
ogn.mint(address(rewardsProxy), 1000000 ether);
vm.stopPrank();
}

function testPreviewRewards() public {
// Should show correct rewards for a block
vm.warp(block.number + 100);

assertEq(rewards.previewRewards(), 10000 ether, "Pending reward mismatch");

vm.warp(block.number + 149);

assertEq(rewards.previewRewards(), 14900 ether, "Pending reward mismatch");
}

function testCollectRewards() public {
// Accumulate some rewards
vm.warp(block.number + 100);

// Should allow collecting rewards
rewards.collectRewards();

assertEq(rewards.previewRewards(), 0 ether, "Pending reward mismatch");

assertEq(ogn.balanceOf(address(staking)), 10000 ether, "Rewards not distributed to staking");
}

function testCollectBeforeChangeRate() public {
// Accumulate some rewards
vm.warp(block.number + 100);

// Should allow Strategist to change
vm.prank(strategist);
rewards.setRewardsPerSecond(1.25 ether);

// Should've collected reward before change
assertEq(ogn.balanceOf(address(staking)), 10000 ether, "Rewards not distributed to staking");
}

function testRevertWhenTargetNotSet() public {
// Disable rewards
vm.prank(governor);
rewards.setRewardsTarget(address(0));

// Time travel
vm.warp(block.number + 100);

vm.expectRevert(bytes4(keccak256("RewardsTargetNotSet()")));
rewards.collectRewards();
}

function testNoRevertCollect() public {
// Disable rewards
vm.prank(strategist);
rewards.setRewardsPerSecond(0 ether);

// Time travel
vm.warp(block.number + 100);

// Should allow collecting rewards
rewards.collectRewards();

// Shouldn't have any change
assertEq(ogn.balanceOf(address(staking)), 0 ether, "Invalid reward distributed");
}

function testDisableRewards() public {
// Should also allow disabling rewards
vm.prank(strategist);
rewards.setRewardsPerSecond(0);

assertEq(rewards.previewRewards(), 0 ether, "Pending reward mismatch");

vm.warp(block.number + 1234);

assertEq(rewards.previewRewards(), 0 ether, "Pending reward mismatch");
}

function testInvalidRewardRate() public {
vm.prank(strategist);
vm.expectRevert(bytes4(keccak256("InvalidRewardRate()")));
rewards.setRewardsPerSecond(type(uint256).max);
}

function testRewardRatePermission() public {
// Should allow Strategist to change
vm.prank(strategist);
rewards.setRewardsPerSecond(1 ether);

// Should allow Governor to change
vm.prank(governor);
rewards.setRewardsPerSecond(2 ether);

// Should not allow anyone else to change
vm.prank(alice);
vm.expectRevert(bytes4(keccak256("UnauthorizedCaller()")));
rewards.setRewardsPerSecond(2 ether);

vm.prank(staking);
vm.expectRevert(bytes4(keccak256("UnauthorizedCaller()")));
rewards.setRewardsPerSecond(2 ether);
}

function testDisableRewardsTarget() public {
// Should allow Governor to disable rewards
vm.prank(governor);
rewards.setRewardsTarget(address(0x0));

assertEq(rewards.rewardsTarget(), address(0x0), "Storage not updated");
}

function testSetRewardsTargetPermission() public {
// Should allow Governor to change
vm.prank(governor);
rewards.setRewardsTarget(address(0xdead));

assertEq(rewards.rewardsTarget(), address(0xdead), "Storage not updated");

// Should not allow anyone else to change
vm.prank(alice);
vm.expectRevert("Caller is not the Governor");
rewards.setRewardsTarget(address(0xdead));

vm.prank(strategist);
vm.expectRevert("Caller is not the Governor");
rewards.setRewardsTarget(address(0xdead));
}

function testDisableStrategistAddr() public {
// Should allow Governor to disable rewards
vm.prank(governor);
rewards.setStrategistAddr(address(0x0));

assertEq(rewards.strategistAddr(), address(0x0), "Storage not updated");
}

function testSetStrategistAddrPermission() public {
// Should allow Governor to change
vm.prank(governor);
rewards.setStrategistAddr(address(0xdead));

assertEq(rewards.strategistAddr(), address(0xdead), "Storage not updated");

// Should not allow anyone else to change
vm.prank(alice);
vm.expectRevert("Caller is not the Governor");
rewards.setStrategistAddr(address(0xdead));

vm.prank(strategist);
vm.expectRevert("Caller is not the Governor");
rewards.setStrategistAddr(address(0xdead));
}
}
Loading