diff --git a/.circleci/config.yml b/.circleci/config.yml index 97a9a7acd55..a54abbca4e9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -562,7 +562,7 @@ jobs: command: | set -e cd packages/celotool - ./ci_test_governance.sh checkout master + ./ci_test_governance.sh checkout asaj/pos-4 end-to-end-geth-sync-test: <<: *e2e-defaults diff --git a/packages/celotool/src/e2e-tests/blockchain_parameters_tests.ts b/packages/celotool/src/e2e-tests/blockchain_parameters_tests.ts index 03955381546..a8db6bb4a1a 100644 --- a/packages/celotool/src/e2e-tests/blockchain_parameters_tests.ts +++ b/packages/celotool/src/e2e-tests/blockchain_parameters_tests.ts @@ -13,7 +13,7 @@ describe('Blockchain parameters tests', function(this: any) { let parameters: BlockchainParametersWrapper const gethConfig: GethTestConfig = { - migrateTo: 17, + migrateTo: 18, instances: [ { name: 'validator', validating: true, syncmode: 'full', port: 30303, rpcport: 8545 }, ], diff --git a/packages/celotool/src/e2e-tests/governance_tests.ts b/packages/celotool/src/e2e-tests/governance_tests.ts index 71d318a64b5..785c7809188 100644 --- a/packages/celotool/src/e2e-tests/governance_tests.ts +++ b/packages/celotool/src/e2e-tests/governance_tests.ts @@ -4,7 +4,14 @@ import { fromFixed, toFixed } from '@celo/utils/lib/fixidity' import BigNumber from 'bignumber.js' import { assert } from 'chai' import Web3 from 'web3' -import { getContext, getEnode, importGenesis, initAndStartGeth, sleep } from './utils' +import { + assertAlmostEqual, + getContext, + getEnode, + importGenesis, + initAndStartGeth, + sleep, +} from './utils' describe('governance tests', () => { const gethConfig = { @@ -21,9 +28,12 @@ describe('governance tests', () => { const context: any = getContext(gethConfig) let web3: any let election: any - let validators: any + let stableToken: any + let sortedOracles: any + let epochRewards: any let goldToken: any let registry: any + let validators: any let accounts: AccountsWrapper let kit: ContractKit @@ -39,9 +49,12 @@ describe('governance tests', () => { web3 = new Web3('http://localhost:8545') kit = newKitFromWeb3(web3) goldToken = await kit._web3Contracts.getGoldToken() + stableToken = await kit._web3Contracts.getStableToken() + sortedOracles = await kit._web3Contracts.getSortedOracles() validators = await kit._web3Contracts.getValidators() registry = await kit._web3Contracts.getRegistry() election = await kit._web3Contracts.getElection() + epochRewards = await kit._web3Contracts.getEpochRewards() accounts = await kit.contracts.getAccounts() } @@ -143,8 +156,13 @@ describe('governance tests', () => { epoch = new BigNumber(await validators.methods.getEpochSize().call()).toNumber() assert.equal(epoch, 10) - // Give the node time to sync, and time for an epoch transition so we can activate our vote. - await sleep(20) + // Give the nodes time to sync, and time for an epoch transition so we can activate our vote. + let blockNumber: number + do { + blockNumber = await web3.eth.getBlockNumber() + await sleep(0.1) + } while (blockNumber % epoch !== 1) + await activate(allValidators[0]) const groupWeb3 = new Web3('ws://localhost:8567') const groupKit = newKitFromWeb3(groupWeb3) @@ -278,10 +296,9 @@ describe('governance tests', () => { }) it('should distribute epoch payments at the end of each epoch', async () => { - const stableToken = await kit._web3Contracts.getStableToken() const commission = 0.1 - const validatorEpochPayment = new BigNumber( - await validators.methods.validatorEpochPayment().call() + const targetValidatorEpochPayment = new BigNumber( + await epochRewards.methods.targetValidatorEpochPayment().call() ) const [group] = await validators.methods.getRegisteredValidatorGroups().call() @@ -298,7 +315,7 @@ describe('governance tests', () => { ) assert.isNotNaN(currentBalance) assert.isNotNaN(previousBalance) - assert.equal(expected.toFixed(), currentBalance.minus(previousBalance).toFixed()) + assertAlmostEqual(currentBalance.minus(previousBalance), expected) } const assertBalanceUnchanged = async (validator: string, blockNumber: number) => { @@ -310,7 +327,14 @@ describe('governance tests', () => { (await validators.methods.getValidator(validator).call({}, blockNumber))[2] ) assert.isNotNaN(score) - return validatorEpochPayment.times(fromFixed(score)) + // We need to calculate the rewards multiplier for the previous block, before + // the rewards actually are awarded. + const rewardsMultiplier = new BigNumber( + await epochRewards.methods.getRewardsMultiplier().call({}, blockNumber - 1) + ) + return targetValidatorEpochPayment + .times(fromFixed(score)) + .times(fromFixed(rewardsMultiplier)) } for (const blockNumber of blockNumbers) { @@ -346,8 +370,6 @@ describe('governance tests', () => { it('should distribute epoch rewards at the end of each epoch', async () => { const lockedGold = await kit._web3Contracts.getLockedGold() const governance = await kit._web3Contracts.getGovernance() - const epochReward = new BigNumber(10).pow(18) - const infraReward = new BigNumber(10).pow(18) const [group] = await validators.methods.getRegisteredValidatorGroups().call() const assertVotesChanged = async (blockNumber: number, expected: BigNumber) => { @@ -357,7 +379,7 @@ describe('governance tests', () => { const previousVotes = new BigNumber( await election.methods.getTotalVotesForGroup(group).call({}, blockNumber - 1) ) - assert.equal(expected.toFixed(), currentVotes.minus(previousVotes).toFixed()) + assertAlmostEqual(currentVotes.minus(previousVotes), expected) } const assertGoldTokenTotalSupplyChanged = async ( @@ -370,7 +392,7 @@ describe('governance tests', () => { const previousSupply = new BigNumber( await goldToken.methods.totalSupply().call({}, blockNumber - 1) ) - assert.equal(expected.toFixed(), currentSupply.minus(previousSupply).toFixed()) + assertAlmostEqual(currentSupply.minus(previousSupply), expected) } const assertBalanceChanged = async ( @@ -384,7 +406,7 @@ describe('governance tests', () => { const previousBalance = new BigNumber( await goldToken.methods.balanceOf(address).call({}, blockNumber - 1) ) - assert.equal(expected.toFixed(), currentBalance.minus(previousBalance).toFixed()) + assertAlmostEqual(currentBalance.minus(previousBalance), expected) } const assertLockedGoldBalanceChanged = async (blockNumber: number, expected: BigNumber) => { @@ -411,12 +433,52 @@ describe('governance tests', () => { await assertGovernanceBalanceChanged(blockNumber, new BigNumber(0)) } + const getStableTokenSupplyChange = async (blockNumber: number) => { + const currentSupply = new BigNumber( + await stableToken.methods.totalSupply().call({}, blockNumber) + ) + const previousSupply = new BigNumber( + await stableToken.methods.totalSupply().call({}, blockNumber - 1) + ) + return currentSupply.minus(previousSupply) + } + + const getStableTokenExchangeRate = async (blockNumber: number) => { + const rate = await sortedOracles.methods + .medianRate(stableToken.options.address) + .call({}, blockNumber) + return new BigNumber(rate[0]).div(rate[1]) + } + for (const blockNumber of blockNumbers) { if (isLastBlockOfEpoch(blockNumber, epoch)) { - await assertVotesChanged(blockNumber, epochReward) - await assertGoldTokenTotalSupplyChanged(blockNumber, epochReward.plus(infraReward)) - await assertLockedGoldBalanceChanged(blockNumber, epochReward) - await assertGovernanceBalanceChanged(blockNumber, infraReward) + // We use the number of active votes from the previous block to calculate the expected + // epoch reward as the number of active votes for the current block will include the + // epoch reward. + const activeVotes = new BigNumber( + await election.methods.getActiveVotes().call({}, blockNumber - 1) + ) + const targetVotingYield = new BigNumber( + (await epochRewards.methods.getTargetVotingYieldParameters().call({}, blockNumber))[0] + ) + // We need to calculate the rewards multiplier for the previous block, before + // the rewards actually are awarded. + const rewardsMultiplier = new BigNumber( + await epochRewards.methods.getRewardsMultiplier().call({}, blockNumber - 1) + ) + const expectedEpochReward = activeVotes + .times(fromFixed(targetVotingYield)) + .times(fromFixed(rewardsMultiplier)) + const expectedInfraReward = new BigNumber(10).pow(18) + const stableTokenSupplyChange = await getStableTokenSupplyChange(blockNumber) + const exchangeRate = await getStableTokenExchangeRate(blockNumber) + const expectedGoldTotalSupplyChange = expectedInfraReward + .plus(expectedEpochReward) + .plus(stableTokenSupplyChange.div(exchangeRate)) + await assertVotesChanged(blockNumber, expectedEpochReward) + await assertLockedGoldBalanceChanged(blockNumber, expectedEpochReward) + await assertGovernanceBalanceChanged(blockNumber, expectedInfraReward) + await assertGoldTokenTotalSupplyChanged(blockNumber, expectedGoldTotalSupplyChange) } else { await assertVotesUnchanged(blockNumber) await assertGoldTokenTotalSupplyUnchanged(blockNumber) @@ -425,6 +487,54 @@ describe('governance tests', () => { } } }) + + it('should update the target voting yield', async () => { + const assertTargetVotingYieldChanged = async (blockNumber: number, expected: BigNumber) => { + const currentTarget = new BigNumber( + (await epochRewards.methods.getTargetVotingYieldParameters().call({}, blockNumber))[0] + ) + const previousTarget = new BigNumber( + (await epochRewards.methods.getTargetVotingYieldParameters().call({}, blockNumber - 1))[0] + ) + const difference = currentTarget.minus(previousTarget) + + // Assert equal to 10 decimal places due to rounding errors. + assert.equal( + fromFixed(difference) + .dp(10) + .toFixed(), + fromFixed(expected) + .dp(10) + .toFixed() + ) + } + + const assertTargetVotingYieldUnchanged = async (blockNumber: number) => { + await assertTargetVotingYieldChanged(blockNumber, new BigNumber(0)) + } + + for (const blockNumber of blockNumbers) { + if (isLastBlockOfEpoch(blockNumber, epoch)) { + // We use the voting gold fraction from before the rewards are granted. + const votingGoldFraction = new BigNumber( + await epochRewards.methods.getVotingGoldFraction().call({}, blockNumber - 1) + ) + const targetVotingGoldFraction = new BigNumber( + await epochRewards.methods.getTargetVotingGoldFraction().call({}, blockNumber) + ) + const difference = targetVotingGoldFraction.minus(votingGoldFraction) + const adjustmentFactor = fromFixed( + new BigNumber( + (await epochRewards.methods.getTargetVotingYieldParameters().call({}, blockNumber))[2] + ) + ) + const delta = difference.times(adjustmentFactor) + await assertTargetVotingYieldChanged(blockNumber, delta) + } else { + await assertTargetVotingYieldUnchanged(blockNumber) + } + } + }) }) describe('after the gold token smart contract is registered', () => { diff --git a/packages/celotool/src/e2e-tests/transfer_tests.ts b/packages/celotool/src/e2e-tests/transfer_tests.ts index 52f3cd711aa..eab473053ae 100644 --- a/packages/celotool/src/e2e-tests/transfer_tests.ts +++ b/packages/celotool/src/e2e-tests/transfer_tests.ts @@ -180,7 +180,7 @@ describe('Transfer tests', function(this: any) { const syncModes = ['full', 'fast', 'light', 'ultralight'] const gethConfig: GethTestConfig = { - migrateTo: 17, + migrateTo: 18, instances: [ { name: 'validator', validating: true, syncmode: 'full', port: 30303, rpcport: 8545 }, ], diff --git a/packages/celotool/src/e2e-tests/utils.ts b/packages/celotool/src/e2e-tests/utils.ts index 608fa7c9139..bfe5e018db0 100644 --- a/packages/celotool/src/e2e-tests/utils.ts +++ b/packages/celotool/src/e2e-tests/utils.ts @@ -1,3 +1,4 @@ +import BigNumber from 'bignumber.js' import { assert } from 'chai' import { spawn, SpawnOptions } from 'child_process' import fs from 'fs' @@ -40,6 +41,22 @@ const GENESIS_PATH = `${TEST_DIR}/genesis.json` const NetworkId = 1101 const MonorepoRoot = resolvePath(joinPath(__dirname, '../..', '../..')) +export function assertAlmostEqual( + actual: BigNumber, + expected: BigNumber, + delta: BigNumber = new BigNumber(10).pow(12).times(5) +) { + if (expected.isZero()) { + assert.equal(actual.toFixed(), expected.toFixed()) + } else { + const isCloseTo = actual.plus(delta).gte(expected) || actual.minus(delta).lte(expected) + assert( + isCloseTo, + `expected ${actual.toString()} to almost equal ${expected.toString()} +/- ${delta.toString()}` + ) + } +} + export function spawnWithLog(cmd: string, args: string[], logsFilepath: string) { try { fs.unlinkSync(logsFilepath) diff --git a/packages/celotool/src/e2e-tests/validator_order_tests.ts b/packages/celotool/src/e2e-tests/validator_order_tests.ts index 2e71aafc78f..8051b43cecf 100644 --- a/packages/celotool/src/e2e-tests/validator_order_tests.ts +++ b/packages/celotool/src/e2e-tests/validator_order_tests.ts @@ -10,7 +10,7 @@ const BLOCK_COUNT = EPOCH * EPOCHS_TO_WAIT describe('governance tests', () => { const gethConfig: GethTestConfig = { - migrateTo: 14, + migrateTo: 15, instances: _.range(VALIDATORS).map((i) => ({ name: `validator${i}`, validating: true, diff --git a/packages/contractkit/src/base.ts b/packages/contractkit/src/base.ts index 8c1afe7bb38..9ebd96abc9b 100644 --- a/packages/contractkit/src/base.ts +++ b/packages/contractkit/src/base.ts @@ -5,6 +5,7 @@ export enum CeloContract { Attestations = 'Attestations', BlockchainParameters = 'BlockchainParameters', Election = 'Election', + EpochRewards = 'EpochRewards', Escrow = 'Escrow', Exchange = 'Exchange', GasCurrencyWhitelist = 'GasCurrencyWhitelist', diff --git a/packages/contractkit/src/contract-cache.ts b/packages/contractkit/src/contract-cache.ts index a3d7eee89c0..760f6359889 100644 --- a/packages/contractkit/src/contract-cache.ts +++ b/packages/contractkit/src/contract-cache.ts @@ -4,6 +4,7 @@ import { AccountsWrapper } from './wrappers/Accounts' import { AttestationsWrapper } from './wrappers/Attestations' import { BlockchainParametersWrapper } from './wrappers/BlockchainParameters' import { ElectionWrapper } from './wrappers/Election' +// import { EpochRewardsWrapper } from './wrappers/EpochRewards' import { EscrowWrapper } from './wrappers/Escrow' import { ExchangeWrapper } from './wrappers/Exchange' import { GasPriceMinimumWrapper } from './wrappers/GasPriceMinimum' @@ -20,6 +21,7 @@ const WrapperFactories = { [CeloContract.Attestations]: AttestationsWrapper, [CeloContract.BlockchainParameters]: BlockchainParametersWrapper, [CeloContract.Election]: ElectionWrapper, + // [CeloContract.EpochRewards]?: EpochRewardsWrapper, [CeloContract.Escrow]: EscrowWrapper, [CeloContract.Exchange]: ExchangeWrapper, // [CeloContract.GasCurrencyWhitelist]: GasCurrencyWhitelistWrapper, @@ -44,6 +46,7 @@ interface WrapperCacheMap { [CeloContract.Attestations]?: AttestationsWrapper [CeloContract.BlockchainParameters]?: BlockchainParametersWrapper [CeloContract.Election]?: ElectionWrapper + // [CeloContract.EpochRewards]?: EpochRewardsWrapper [CeloContract.Escrow]?: EscrowWrapper [CeloContract.Exchange]?: ExchangeWrapper // [CeloContract.GasCurrencyWhitelist]?: GasCurrencyWhitelistWrapper, @@ -83,6 +86,9 @@ export class WrapperCache { getElection() { return this.getContract(CeloContract.Election) } + // getEpochRewards() { + // return this.getContract(CeloContract.EpochRewards) + // } getEscrow() { return this.getContract(CeloContract.Escrow) } diff --git a/packages/contractkit/src/web3-contract-cache.ts b/packages/contractkit/src/web3-contract-cache.ts index b228a98d3cb..2eab4113c6e 100644 --- a/packages/contractkit/src/web3-contract-cache.ts +++ b/packages/contractkit/src/web3-contract-cache.ts @@ -4,6 +4,7 @@ import { newAccounts } from './generated/Accounts' import { newAttestations } from './generated/Attestations' import { newBlockchainParameters } from './generated/BlockchainParameters' import { newElection } from './generated/Election' +import { newEpochRewards } from './generated/EpochRewards' import { newEscrow } from './generated/Escrow' import { newExchange } from './generated/Exchange' import { newGasCurrencyWhitelist } from './generated/GasCurrencyWhitelist' @@ -26,6 +27,7 @@ const ContractFactories = { [CeloContract.Attestations]: newAttestations, [CeloContract.BlockchainParameters]: newBlockchainParameters, [CeloContract.Election]: newElection, + [CeloContract.EpochRewards]: newEpochRewards, [CeloContract.Escrow]: newEscrow, [CeloContract.Exchange]: newExchange, [CeloContract.GasCurrencyWhitelist]: newGasCurrencyWhitelist, @@ -68,6 +70,9 @@ export class Web3ContractCache { getElection() { return this.getContract(CeloContract.Election) } + getEpochRewards() { + return this.getContract(CeloContract.EpochRewards) + } getEscrow() { return this.getContract(CeloContract.Escrow) } diff --git a/packages/protocol/contracts/common/UsingRegistry.sol b/packages/protocol/contracts/common/UsingRegistry.sol index 8b125d8be30..39c539adf1d 100644 --- a/packages/protocol/contracts/common/UsingRegistry.sol +++ b/packages/protocol/contracts/common/UsingRegistry.sol @@ -13,6 +13,7 @@ import "../governance/interfaces/IValidators.sol"; import "../identity/interfaces/IRandom.sol"; +import "../stability/interfaces/ISortedOracles.sol"; import "../stability/interfaces/IStableToken.sol"; // Ideally, UsingRegistry should inherit from Initializable and implement initialize() which calls @@ -81,6 +82,10 @@ contract UsingRegistry is Ownable { return IRandom(registry.getAddressForOrDie(RANDOM_REGISTRY_ID)); } + function getSortedOracles() internal view returns (ISortedOracles) { + return ISortedOracles(registry.getAddressForOrDie(SORTED_ORACLES_REGISTRY_ID)); + } + function getStableToken() internal view returns (IStableToken) { return IStableToken(registry.getAddressForOrDie(STABLE_TOKEN_REGISTRY_ID)); } diff --git a/packages/protocol/contracts/stability/test/MockGoldToken.sol b/packages/protocol/contracts/common/test/MockGoldToken.sol similarity index 76% rename from packages/protocol/contracts/stability/test/MockGoldToken.sol rename to packages/protocol/contracts/common/test/MockGoldToken.sol index 9fe34a96da3..97c4e114461 100644 --- a/packages/protocol/contracts/stability/test/MockGoldToken.sol +++ b/packages/protocol/contracts/common/test/MockGoldToken.sol @@ -6,6 +6,11 @@ pragma solidity ^0.5.3; */ contract MockGoldToken { uint8 public constant decimals = 18; + uint256 public totalSupply; + + function setTotalSupply(uint256 value) external { + totalSupply = value; + } function transfer(address, uint256) external pure returns (bool) { return true; diff --git a/packages/protocol/contracts/governance/Election.sol b/packages/protocol/contracts/governance/Election.sol index eb2ef515a9a..a79f2a1c0f5 100644 --- a/packages/protocol/contracts/governance/Election.sol +++ b/packages/protocol/contracts/governance/Election.sol @@ -672,6 +672,14 @@ contract Election is return votes.active.total.add(votes.pending.total); } + /** + * @notice Returns the active votes received across all groups. + * @return The active votes received across all groups. + */ + function getActiveVotes() public view returns (uint256) { + return votes.active.total; + } + /** * @notice Returns the list of validator groups eligible to elect validators. * @return The list of validator groups eligible to elect validators. diff --git a/packages/protocol/contracts/governance/EpochRewards.sol b/packages/protocol/contracts/governance/EpochRewards.sol new file mode 100644 index 00000000000..6642ee226d6 --- /dev/null +++ b/packages/protocol/contracts/governance/EpochRewards.sol @@ -0,0 +1,384 @@ +pragma solidity ^0.5.3; + +import "openzeppelin-solidity/contracts/math/SafeMath.sol"; +import "openzeppelin-solidity/contracts/ownership/Ownable.sol"; + +import "../common/FixidityLib.sol"; +import "../common/Initializable.sol"; +import "../common/UsingRegistry.sol"; +import "../common/UsingPrecompiles.sol"; + +/** + * @title Contract for calculating epoch rewards. + */ +contract EpochRewards is Ownable, Initializable, UsingPrecompiles, UsingRegistry { + using FixidityLib for FixidityLib.Fraction; + using SafeMath for uint256; + + uint256 constant GENESIS_GOLD_SUPPLY = 600000000 ether; // 600 million Gold + uint256 constant GOLD_SUPPLY_CAP = 1000000000 ether; // 1 billion Gold + uint256 constant YEARS_LINEAR = 15; + uint256 constant SECONDS_LINEAR = YEARS_LINEAR * 365 * 1 days; + + // This struct governs how the rewards multiplier should deviate from 1.0 based on the ratio of + // supply remaining to target supply remaining. + struct RewardsMultiplierAdjustmentFactors { + FixidityLib.Fraction underspend; + FixidityLib.Fraction overspend; + } + + // This struct governs the multiplier on the target rewards to give out in a given epoch due to + // potential deviations in the actual Gold total supply from the target total supply. + // In the case where the actual exceeds the target (i.e. the protocol has "overspent" with + // respect to epoch rewards and payments) the rewards multiplier will be less than one. + // In the case where the actual is less than the target (i.e. the protocol has "underspent" with + // respect to epoch rewards and payments) the rewards multiplier will be greater than one. + struct RewardsMultiplierParameters { + RewardsMultiplierAdjustmentFactors adjustmentFactors; + // The maximum rewards multiplier. + FixidityLib.Fraction max; + } + + // This struct governs the target yield awarded to voters in validator elections. + struct TargetVotingYieldParameters { + // The target yield awarded to users voting in validator elections. + FixidityLib.Fraction target; + // Governs the adjustment of the target yield based on the deviation of the percentage of + // Gold voting in validator elections from the `targetVotingGoldFraction`. + FixidityLib.Fraction adjustmentFactor; + // The maximum target yield awarded to users voting in validator elections. + FixidityLib.Fraction max; + } + + uint256 private startTime = 0; + RewardsMultiplierParameters private rewardsMultiplierParams; + TargetVotingYieldParameters private targetVotingYieldParams; + FixidityLib.Fraction private targetVotingGoldFraction; + uint256 public targetValidatorEpochPayment; + + event TargetVotingGoldFractionSet(uint256 fraction); + event TargetValidatorEpochPaymentSet(uint256 payment); + event TargetVotingYieldParametersSet(uint256 max, uint256 adjustmentFactor); + event RewardsMultiplierParametersSet( + uint256 max, + uint256 underspendAdjustmentFactor, + uint256 overspendAdjustmentFactor + ); + + /** + * @notice Initializes critical variables. + * @param registryAddress The address of the registry contract. + * @param targetVotingYieldInitial The initial relative target block reward for voters. + * @param targetVotingYieldMax The max relative target block reward for voters. + * @param targetVotingYieldAdjustmentFactor The target block reward adjustment factor for voters. + * @param rewardsMultiplierMax The max multiplier on target epoch rewards. + * @param rewardsMultiplierUnderspendAdjustmentFactor Adjusts the multiplier on target epoch + * rewards when the protocol is running behind the target Gold supply. + * @param rewardsMultiplierOverspendAdjustmentFactor Adjusts the multiplier on target epoch + * rewards when the protocol is running ahead of the target Gold supply. + * @param _targetVotingGoldFraction The percentage of floating Gold voting to target. + * @param _targetValidatorEpochPayment The target validator epoch payment. + * @dev Should be called only once. + */ + function initialize( + address registryAddress, + uint256 targetVotingYieldInitial, + uint256 targetVotingYieldMax, + uint256 targetVotingYieldAdjustmentFactor, + uint256 rewardsMultiplierMax, + uint256 rewardsMultiplierUnderspendAdjustmentFactor, + uint256 rewardsMultiplierOverspendAdjustmentFactor, + uint256 _targetVotingGoldFraction, + uint256 _targetValidatorEpochPayment + ) external initializer { + _transferOwnership(msg.sender); + setRegistry(registryAddress); + setTargetVotingYieldParameters(targetVotingYieldMax, targetVotingYieldAdjustmentFactor); + setRewardsMultiplierParameters( + rewardsMultiplierMax, + rewardsMultiplierUnderspendAdjustmentFactor, + rewardsMultiplierOverspendAdjustmentFactor + ); + setTargetVotingGoldFraction(_targetVotingGoldFraction); + setTargetValidatorEpochPayment(_targetValidatorEpochPayment); + targetVotingYieldParams.target = FixidityLib.wrap(targetVotingYieldInitial); + startTime = now; + } + + /** + * @notice Returns the target voting yield parameters. + * @return The target, max, and adjustment factor for target voting yield. + */ + function getTargetVotingYieldParameters() external view returns (uint256, uint256, uint256) { + TargetVotingYieldParameters storage params = targetVotingYieldParams; + return (params.target.unwrap(), params.max.unwrap(), params.adjustmentFactor.unwrap()); + } + + /** + * @notice Returns the rewards multiplier parameters. + * @return The max multiplier and under/over spend adjustment factors. + */ + function getRewardsMultiplierParameters() external view returns (uint256, uint256, uint256) { + RewardsMultiplierParameters storage params = rewardsMultiplierParams; + return ( + params.max.unwrap(), + params.adjustmentFactors.underspend.unwrap(), + params.adjustmentFactors.overspend.unwrap() + ); + } + + /** + * @notice Sets the target voting Gold fraction. + * @param value The percentage of floating Gold voting to target. + * @return True upon success. + */ + function setTargetVotingGoldFraction(uint256 value) public onlyOwner returns (bool) { + require(value != targetVotingGoldFraction.unwrap() && value < FixidityLib.fixed1().unwrap()); + targetVotingGoldFraction = FixidityLib.wrap(value); + emit TargetVotingGoldFractionSet(value); + return true; + } + + /** + * @notice Returns the target voting Gold fraction. + * @return The percentage of floating Gold voting to target. + */ + function getTargetVotingGoldFraction() external view returns (uint256) { + return targetVotingGoldFraction.unwrap(); + } + + /** + * @notice Sets the target per-epoch payment in Celo Dollars for validators. + * @param value The value in Celo Dollars. + * @return True upon success. + */ + function setTargetValidatorEpochPayment(uint256 value) public onlyOwner returns (bool) { + require(value != targetValidatorEpochPayment); + targetValidatorEpochPayment = value; + emit TargetValidatorEpochPaymentSet(value); + return true; + } + + /** + * @notice Sets the rewards multiplier parameters. + * @param max The max multiplier on target epoch rewards. + * @param underspendAdjustmentFactor Adjusts the multiplier on target epoch rewards when the + * protocol is running behind the target Gold supply. + * @param overspendAdjustmentFactor Adjusts the multiplier on target epoch rewards when the + * protocol is running ahead of the target Gold supply. + * @return True upon success. + */ + function setRewardsMultiplierParameters( + uint256 max, + uint256 underspendAdjustmentFactor, + uint256 overspendAdjustmentFactor + ) public onlyOwner returns (bool) { + require( + max != rewardsMultiplierParams.max.unwrap() || + overspendAdjustmentFactor != rewardsMultiplierParams.adjustmentFactors.overspend.unwrap() || + underspendAdjustmentFactor != rewardsMultiplierParams.adjustmentFactors.underspend.unwrap() + ); + rewardsMultiplierParams = RewardsMultiplierParameters( + RewardsMultiplierAdjustmentFactors( + FixidityLib.wrap(underspendAdjustmentFactor), + FixidityLib.wrap(overspendAdjustmentFactor) + ), + FixidityLib.wrap(max) + ); + emit RewardsMultiplierParametersSet(max, underspendAdjustmentFactor, overspendAdjustmentFactor); + return true; + } + + /** + * @notice Sets the target voting yield parameters. + * @param max The max relative target block reward for voters. + * @param adjustmentFactor The target block reward adjustment factor for voters. + * @return True upon success. + */ + function setTargetVotingYieldParameters(uint256 max, uint256 adjustmentFactor) + public + onlyOwner + returns (bool) + { + require( + max != targetVotingYieldParams.max.unwrap() || + adjustmentFactor != targetVotingYieldParams.adjustmentFactor.unwrap() + ); + targetVotingYieldParams.max = FixidityLib.wrap(max); + targetVotingYieldParams.adjustmentFactor = FixidityLib.wrap(adjustmentFactor); + require( + targetVotingYieldParams.max.lt(FixidityLib.fixed1()), + "Max target voting yield must be lower than 100%" + ); + emit TargetVotingYieldParametersSet(max, adjustmentFactor); + return true; + } + + /** + * @notice Returns the target Gold supply according to the epoch rewards target schedule. + * @return The target Gold supply according to the epoch rewards target schedule. + */ + function getTargetGoldTotalSupply() public view returns (uint256) { + uint256 timeSinceInitialization = now.sub(startTime); + if (timeSinceInitialization < SECONDS_LINEAR) { + // Pay out half of all block rewards linearly. + uint256 linearRewards = GOLD_SUPPLY_CAP.sub(GENESIS_GOLD_SUPPLY).div(2); + uint256 targetRewards = linearRewards.mul(timeSinceInitialization).div(SECONDS_LINEAR); + return targetRewards.add(GENESIS_GOLD_SUPPLY); + } else { + // TODO(asa): Implement block reward calculation for years 15-30. + require(false); + return 0; + } + } + + /** + * @notice Returns the rewards multiplier based on the current and target Gold supplies. + * @param targetGoldSupplyIncrease The target increase in current Gold supply. + * @return The rewards multiplier based on the current and target Gold supplies. + */ + function _getRewardsMultiplier(uint256 targetGoldSupplyIncrease) + internal + view + returns (FixidityLib.Fraction memory) + { + uint256 targetSupply = getTargetGoldTotalSupply(); + uint256 totalSupply = getGoldToken().totalSupply(); + uint256 remainingSupply = GOLD_SUPPLY_CAP.sub(totalSupply.add(targetGoldSupplyIncrease)); + uint256 targetRemainingSupply = GOLD_SUPPLY_CAP.sub(targetSupply); + FixidityLib.Fraction memory remainingToTargetRatio = FixidityLib + .newFixed(remainingSupply) + .divide(FixidityLib.newFixed(targetRemainingSupply)); + if (remainingToTargetRatio.gt(FixidityLib.fixed1())) { + FixidityLib.Fraction memory delta = remainingToTargetRatio + .subtract(FixidityLib.fixed1()) + .multiply(rewardsMultiplierParams.adjustmentFactors.underspend); + FixidityLib.Fraction memory multiplier = FixidityLib.fixed1().add(delta); + if (multiplier.lt(rewardsMultiplierParams.max)) { + return multiplier; + } else { + return rewardsMultiplierParams.max; + } + } else if (remainingToTargetRatio.lt(FixidityLib.fixed1())) { + FixidityLib.Fraction memory delta = FixidityLib + .fixed1() + .subtract(remainingToTargetRatio) + .multiply(rewardsMultiplierParams.adjustmentFactors.overspend); + if (delta.lt(FixidityLib.fixed1())) { + return FixidityLib.fixed1().subtract(delta); + } else { + return FixidityLib.wrap(0); + } + } else { + return FixidityLib.fixed1(); + } + } + + /** + * @notice Returns the rewards multiplier based on the current and target Gold supplies. + * @return The rewards multiplier based on the current and target Gold supplies. + */ + function getRewardsMultiplier() external view returns (uint256) { + uint256 targetEpochRewards = getTargetEpochRewards(); + uint256 targetTotalEpochPaymentsInGold = getTargetTotalEpochPaymentsInGold(); + uint256 targetGoldSupplyIncrease = targetEpochRewards.add(targetTotalEpochPaymentsInGold); + return _getRewardsMultiplier(targetGoldSupplyIncrease).unwrap(); + } + + /** + * @notice Returns the total target epoch rewards for voters. + * @return the total target epoch rewards for voters. + */ + function getTargetEpochRewards() public view returns (uint256) { + return + FixidityLib + .newFixed(getElection().getActiveVotes()) + .multiply(targetVotingYieldParams.target) + .fromFixed(); + } + + /** + * @notice Returns the total target epoch payments to validators, converted to Gold. + * @return The total target epoch payments to validators, converted to Gold. + */ + function getTargetTotalEpochPaymentsInGold() public view returns (uint256) { + address stableTokenAddress = registry.getAddressForOrDie(STABLE_TOKEN_REGISTRY_ID); + (uint256 numerator, uint256 denominator) = getSortedOracles().medianRate(stableTokenAddress); + return + numberValidatorsInCurrentSet().mul(targetValidatorEpochPayment).mul(denominator).div( + numerator + ); + } + + /** + * @notice Returns the fraction of floating Gold being used for voting in validator elections. + * @return The fraction of floating Gold being used for voting in validator elections. + */ + function getVotingGoldFraction() public view returns (uint256) { + // TODO(asa): Ignore custodial accounts. + address reserveAddress = registry.getAddressForOrDie(RESERVE_REGISTRY_ID); + uint256 liquidGold = getGoldToken().totalSupply().sub(reserveAddress.balance); + // TODO(asa): Should this be active votes? + uint256 votingGold = getElection().getTotalVotes(); + return FixidityLib.newFixed(votingGold).divide(FixidityLib.newFixed(liquidGold)).unwrap(); + } + + /** + * @notice Updates the target voting yield based on the difference between the target and current + * voting Gold fraction. + */ + function _updateTargetVotingYield() internal { + FixidityLib.Fraction memory votingGoldFraction = FixidityLib.wrap(getVotingGoldFraction()); + if (votingGoldFraction.gt(targetVotingGoldFraction)) { + FixidityLib.Fraction memory votingGoldFractionDelta = votingGoldFraction.subtract( + targetVotingGoldFraction + ); + FixidityLib.Fraction memory targetVotingYieldDelta = votingGoldFractionDelta.multiply( + targetVotingYieldParams.adjustmentFactor + ); + if (targetVotingYieldDelta.gte(targetVotingYieldParams.target)) { + targetVotingYieldParams.target = FixidityLib.newFixed(0); + } else { + targetVotingYieldParams.target = targetVotingYieldParams.target.subtract( + targetVotingYieldDelta + ); + } + } else if (votingGoldFraction.lt(targetVotingGoldFraction)) { + FixidityLib.Fraction memory votingGoldFractionDelta = targetVotingGoldFraction.subtract( + votingGoldFraction + ); + FixidityLib.Fraction memory targetVotingYieldDelta = votingGoldFractionDelta.multiply( + targetVotingYieldParams.adjustmentFactor + ); + targetVotingYieldParams.target = targetVotingYieldParams.target.add(targetVotingYieldDelta); + if (targetVotingYieldParams.target.gt(targetVotingYieldParams.max)) { + targetVotingYieldParams.target = targetVotingYieldParams.max; + } + } + } + + /** + * @notice Updates the target voting yield based on the difference between the target and current + * voting Gold fraction. + * @dev Only called directly by the protocol. + */ + function updateTargetVotingYield() external { + require(msg.sender == address(0)); + _updateTargetVotingYield(); + } + + /** + * @notice Calculates the per validator epoch payment and the total rewards to voters. + * @return The per validator epoch payment and the total rewards to voters. + */ + function calculateTargetEpochPaymentAndRewards() external view returns (uint256, uint256) { + uint256 targetEpochRewards = getTargetEpochRewards(); + uint256 targetTotalEpochPaymentsInGold = getTargetTotalEpochPaymentsInGold(); + uint256 targetGoldSupplyIncrease = targetEpochRewards.add(targetTotalEpochPaymentsInGold); + FixidityLib.Fraction memory rewardsMultiplier = _getRewardsMultiplier(targetGoldSupplyIncrease); + return ( + FixidityLib.newFixed(targetValidatorEpochPayment).multiply(rewardsMultiplier).fromFixed(), + FixidityLib.newFixed(targetEpochRewards).multiply(rewardsMultiplier).fromFixed() + ); + } +} diff --git a/packages/protocol/contracts/governance/Validators.sol b/packages/protocol/contracts/governance/Validators.sol index 6537e78ef55..f5ef68f71b9 100644 --- a/packages/protocol/contracts/governance/Validators.sol +++ b/packages/protocol/contracts/governance/Validators.sol @@ -105,7 +105,6 @@ contract Validators is LockedGoldRequirements public validatorLockedGoldRequirements; LockedGoldRequirements public groupLockedGoldRequirements; ValidatorScoreParameters private validatorScoreParameters; - uint256 public validatorEpochPayment; uint256 public membershipHistoryLength; uint256 public maxGroupSize; @@ -141,7 +140,6 @@ contract Validators is * @param validatorRequirementDuration The Locked Gold requirement duration for validators. * @param validatorScoreExponent The exponent used in calculating validator scores. * @param validatorScoreAdjustmentSpeed The speed at which validator scores are adjusted. - * @param _validatorEpochPayment The duration the above gold remains locked after deregistration. * @param _membershipHistoryLength The max number of entries for validator membership history. * @param _maxGroupSize The maximum group size. * @dev Should be called only once. @@ -154,7 +152,6 @@ contract Validators is uint256 validatorRequirementDuration, uint256 validatorScoreExponent, uint256 validatorScoreAdjustmentSpeed, - uint256 _validatorEpochPayment, uint256 _membershipHistoryLength, uint256 _maxGroupSize ) external initializer { @@ -164,7 +161,6 @@ contract Validators is setValidatorLockedGoldRequirements(validatorRequirementValue, validatorRequirementDuration); setValidatorScoreParameters(validatorScoreExponent, validatorScoreAdjustmentSpeed); setMaxGroupSize(_maxGroupSize); - setValidatorEpochPayment(_validatorEpochPayment); setMembershipHistoryLength(_membershipHistoryLength); } @@ -192,18 +188,6 @@ contract Validators is return true; } - /** - * @notice Sets the per-epoch payment in Celo Dollars for validators, less group commission. - * @param value The value in Celo Dollars. - * @return True upon success. - */ - function setValidatorEpochPayment(uint256 value) public onlyOwner returns (bool) { - require(value != validatorEpochPayment); - validatorEpochPayment = value; - emit ValidatorEpochPaymentSet(value); - return true; - } - /** * @notice Updates the validator score parameters. * @param exponent The exponent used in calculating the score. @@ -378,15 +362,30 @@ contract Validators is /** * @notice Distributes epoch payments to `validator` and its group. + * @param validator The validator to distribute the epoch payment to. + * @param maxPayment The maximum payment to the validator. Actual payment is based on score and + * group commission. + * @return The total payment paid to the validator and their group. */ - function distributeEpochPayment(address validator) external onlyVm() { - _distributeEpochPayment(validator); + function distributeEpochPayment(address validator, uint256 maxPayment) + external + onlyVm() + returns (uint256) + { + return _distributeEpochPayment(validator, maxPayment); } /** * @notice Distributes epoch payments to `validator` and its group. - */ - function _distributeEpochPayment(address validator) internal { + * @param validator The validator to distribute the epoch payment to. + * @param maxPayment The maximum payment to the validator. Actual payment is based on score and + * group commission. + * @return The total payment paid to the validator and their group. + */ + function _distributeEpochPayment(address validator, uint256 maxPayment) + internal + returns (uint256) + { address account = getAccounts().validationSignerToAccount(validator); require(isValidator(account)); // The group that should be paid is the group that the validator was a member of at the @@ -395,13 +394,16 @@ contract Validators is // Both the validator and the group must maintain the minimum locked gold balance in order to // receive epoch payments. if (meetsAccountLockedGoldRequirements(account) && meetsAccountLockedGoldRequirements(group)) { - FixidityLib.Fraction memory totalPayment = FixidityLib - .newFixed(validatorEpochPayment) - .multiply(validators[account].score); + FixidityLib.Fraction memory totalPayment = FixidityLib.newFixed(maxPayment).multiply( + validators[account].score + ); uint256 groupPayment = totalPayment.multiply(groups[group].commission).fromFixed(); uint256 validatorPayment = totalPayment.fromFixed().sub(groupPayment); getStableToken().mint(group, groupPayment); getStableToken().mint(account, validatorPayment); + return totalPayment.fromFixed(); + } else { + return 0; } } diff --git a/packages/protocol/contracts/governance/interfaces/IElection.sol b/packages/protocol/contracts/governance/interfaces/IElection.sol index 194f31088cf..55c545b0d21 100644 --- a/packages/protocol/contracts/governance/interfaces/IElection.sol +++ b/packages/protocol/contracts/governance/interfaces/IElection.sol @@ -2,6 +2,7 @@ pragma solidity ^0.5.3; interface IElection { function getTotalVotes() external view returns (uint256); + function getActiveVotes() external view returns (uint256); function getTotalVotesByAccount(address) external view returns (uint256); function markGroupIneligible(address) external; function markGroupEligible(address, address, address) external; diff --git a/packages/protocol/contracts/governance/proxies/EpochRewardsProxy.sol b/packages/protocol/contracts/governance/proxies/EpochRewardsProxy.sol new file mode 100644 index 00000000000..2adeb479d67 --- /dev/null +++ b/packages/protocol/contracts/governance/proxies/EpochRewardsProxy.sol @@ -0,0 +1,6 @@ +pragma solidity ^0.5.3; + +import "../../common/Proxy.sol"; + +/* solhint-disable no-empty-blocks */ +contract EpochRewardsProxy is Proxy {} diff --git a/packages/protocol/contracts/governance/test/EpochRewardsTest.sol b/packages/protocol/contracts/governance/test/EpochRewardsTest.sol new file mode 100644 index 00000000000..e46e78f77be --- /dev/null +++ b/packages/protocol/contracts/governance/test/EpochRewardsTest.sol @@ -0,0 +1,29 @@ +pragma solidity ^0.5.8; + +import "../EpochRewards.sol"; + +/** + * @title A wrapper around EpochRewards that exposes internal functions for testing. + */ +contract EpochRewardsTest is EpochRewards { + uint256 private numValidatorsInCurrentSet; + function getRewardsMultiplier(uint256 targetGoldTotalSupplyIncrease) + external + view + returns (uint256) + { + return _getRewardsMultiplier(targetGoldTotalSupplyIncrease).unwrap(); + } + + function updateTargetVotingYield() external { + _updateTargetVotingYield(); + } + + function numberValidatorsInCurrentSet() public view returns (uint256) { + return numValidatorsInCurrentSet; + } + + function setNumberValidatorsInCurrentSet(uint256 value) external { + numValidatorsInCurrentSet = value; + } +} diff --git a/packages/protocol/contracts/governance/test/MockElection.sol b/packages/protocol/contracts/governance/test/MockElection.sol index 12d5c60187e..ddc157abe79 100644 --- a/packages/protocol/contracts/governance/test/MockElection.sol +++ b/packages/protocol/contracts/governance/test/MockElection.sol @@ -9,6 +9,8 @@ contract MockElection is IElection { mapping(address => bool) public isIneligible; mapping(address => bool) public isEligible; address[] public electedValidators; + uint256 active; + uint256 total; function markGroupIneligible(address account) external { isIneligible[account] = true; @@ -19,13 +21,25 @@ contract MockElection is IElection { } function getTotalVotes() external view returns (uint256) { - return 0; + return total; + } + + function getActiveVotes() external view returns (uint256) { + return active; } function getTotalVotesByAccount(address) external view returns (uint256) { return 0; } + function setActiveVotes(uint256 value) external { + active = value; + } + + function setTotalVotes(uint256 value) external { + total = value; + } + function setElectedValidators(address[] calldata _electedValidators) external { electedValidators = _electedValidators; } diff --git a/packages/protocol/contracts/governance/test/ValidatorsTest.sol b/packages/protocol/contracts/governance/test/ValidatorsTest.sol index fe92df31f71..a049ddc6efb 100644 --- a/packages/protocol/contracts/governance/test/ValidatorsTest.sol +++ b/packages/protocol/contracts/governance/test/ValidatorsTest.sol @@ -11,7 +11,10 @@ contract ValidatorsTest is Validators { return _updateValidatorScore(validator, uptime); } - function distributeEpochPayment(address validator) external { - return _distributeEpochPayment(validator); + function distributeEpochPayment(address validator, uint256 maxPayment) + external + returns (uint256) + { + return _distributeEpochPayment(validator, maxPayment); } } diff --git a/packages/protocol/contracts/stability/SortedOracles.sol b/packages/protocol/contracts/stability/SortedOracles.sol index 61ce31ebc62..f4429d57e72 100644 --- a/packages/protocol/contracts/stability/SortedOracles.sol +++ b/packages/protocol/contracts/stability/SortedOracles.sol @@ -7,7 +7,7 @@ import "../common/Initializable.sol"; import "../common/linkedlists/AddressSortedLinkedListWithMedian.sol"; import "../common/linkedlists/SortedLinkedListWithMedian.sol"; -// TODO: don't treat timestamps as Fixidity values +// TODO: Move SortedOracles to Fixidity /** * @title Maintains a sorted list of oracle exchange rates between Celo Gold and other currencies. */ @@ -122,6 +122,7 @@ contract SortedOracles is ISortedOracles, Ownable, Initializable { * @notice Updates an oracle value and the median. * @param token The address of the token for which the Celo Gold exchange rate is being reported. * @param numerator The amount of tokens equal to `denominator` Celo Gold. + * @param denominator The amount of Celo Gold equal to `numerator` tokens. * @param lesserKey The element which should be just left of the new oracle value. * @param greaterKey The element which should be just right of the new oracle value. * @dev Note that only one of `lesserKey` or `greaterKey` needs to be correct to reduce friction. diff --git a/packages/protocol/contracts/stability/test/MockSortedOracles.sol b/packages/protocol/contracts/stability/test/MockSortedOracles.sol index cc4df5fdb72..29a605776a9 100644 --- a/packages/protocol/contracts/stability/test/MockSortedOracles.sol +++ b/packages/protocol/contracts/stability/test/MockSortedOracles.sol @@ -4,21 +4,17 @@ pragma solidity ^0.5.3; * @title A mock SortedOracles for testing. */ contract MockSortedOracles { - mapping(address => uint128) public numerators; - mapping(address => uint128) public denominators; - mapping(address => uint128) public medianTimestamp; - mapping(address => uint128) public numRates; + uint256 public constant DENOMINATOR = 0x10000000000000000; + mapping(address => uint256) public numerators; + mapping(address => uint256) public medianTimestamp; + mapping(address => uint256) public numRates; - function setMedianRate(address token, uint128 numerator, uint128 denominator) - external - returns (bool) - { + function setMedianRate(address token, uint256 numerator) external returns (bool) { numerators[token] = numerator; - denominators[token] = denominator; return true; } - function setMedianTimestamp(address token, uint128 timestamp) external { + function setMedianTimestamp(address token, uint256 timestamp) external { medianTimestamp[token] = timestamp; } @@ -27,11 +23,11 @@ contract MockSortedOracles { medianTimestamp[token] = uint128(now); } - function setNumRates(address token, uint128 rate) external { + function setNumRates(address token, uint256 rate) external { numRates[token] = rate; } - function medianRate(address token) external view returns (uint128, uint128) { - return (numerators[token], denominators[token]); + function medianRate(address token) external view returns (uint256, uint256) { + return (numerators[token], DENOMINATOR); } } diff --git a/packages/protocol/lib/registry-utils.ts b/packages/protocol/lib/registry-utils.ts index a7e8500f458..2ea7927f0a0 100644 --- a/packages/protocol/lib/registry-utils.ts +++ b/packages/protocol/lib/registry-utils.ts @@ -3,6 +3,7 @@ export enum CeloContractName { Attestations = 'Attestations', BlockchainParameters = 'BlockchainParameters', Election = 'Election', + EpochRewards = 'EpochRewards', Escrow = 'Escrow', Exchange = 'Exchange', GasCurrencyWhitelist = 'GasCurrencyWhitelist', diff --git a/packages/protocol/lib/test-utils.ts b/packages/protocol/lib/test-utils.ts index 2e9ff68d5d5..812669b0f5a 100644 --- a/packages/protocol/lib/test-utils.ts +++ b/packages/protocol/lib/test-utils.ts @@ -239,6 +239,21 @@ export function assertEqualBN( ) } +export function assertEqualDpBN( + value: number | BN | BigNumber, + expected: number | BN | BigNumber, + decimals: number, + msg?: string +) { + const valueDp = new BigNumber(value.toString()).dp(decimals) + const expectedDp = new BigNumber(expected.toString()).dp(decimals) + assert( + valueDp.isEqualTo(expectedDp), + `expected ${expectedDp.toString()} and got ${valueDp.toString()}. ${msg || ''}` + ) +} + + export function assertEqualBNArray(value: number[] | BN[] | BigNumber[], expected: number[] | BN[] | BigNumber[], msg?: string) { assert.equal(value.length, expected.length, msg) value.forEach((x, i) => assertEqualBN(x, expected[i])) diff --git a/packages/protocol/lib/web3-utils.ts b/packages/protocol/lib/web3-utils.ts index 34d312fdca4..d5b96f21e8e 100644 --- a/packages/protocol/lib/web3-utils.ts +++ b/packages/protocol/lib/web3-utils.ts @@ -49,7 +49,7 @@ export async function sendTransactionWithPrivateKey( ...txArgs, data: encodedTxData, from: address, - gas: estimatedGas * 2, + gas: estimatedGas * 10, }, privateKey ) diff --git a/packages/protocol/migrations/12_validators.ts b/packages/protocol/migrations/12_validators.ts index 2d692d15040..138187900de 100644 --- a/packages/protocol/migrations/12_validators.ts +++ b/packages/protocol/migrations/12_validators.ts @@ -13,7 +13,6 @@ const initializeArgs = async (): Promise => { config.validators.validatorLockedGoldRequirements.duration, config.validators.validatorScoreParameters.exponent, toFixed(config.validators.validatorScoreParameters.adjustmentSpeed).toFixed(), - config.validators.validatorEpochPayment, config.validators.membershipHistoryLength, config.validators.maxGroupSize, ] diff --git a/packages/protocol/migrations/14_epoch_rewards.ts b/packages/protocol/migrations/14_epoch_rewards.ts new file mode 100644 index 00000000000..43dfa88fc49 --- /dev/null +++ b/packages/protocol/migrations/14_epoch_rewards.ts @@ -0,0 +1,26 @@ +import { CeloContractName } from '@celo/protocol/lib/registry-utils' +import { deploymentForCoreContract } from '@celo/protocol/lib/web3-utils' +import { config } from '@celo/protocol/migrationsConfig' +import { toFixed } from '@celo/utils/lib/fixidity' +import { EpochRewardsInstance } from 'types' + +const initializeArgs = async (): Promise => { + return [ + config.registry.predeployedProxyAddress, + toFixed(config.epochRewards.targetVotingYieldParameters.initial).toFixed(), + toFixed(config.epochRewards.targetVotingYieldParameters.max).toFixed(), + toFixed(config.epochRewards.targetVotingYieldParameters.adjustmentFactor).toFixed(), + toFixed(config.epochRewards.rewardsMultiplierParameters.max).toFixed(), + toFixed(config.epochRewards.rewardsMultiplierParameters.adjustmentFactors.underspend).toFixed(), + toFixed(config.epochRewards.rewardsMultiplierParameters.adjustmentFactors.overspend).toFixed(), + toFixed(config.epochRewards.targetVotingGoldFraction).toFixed(), + config.epochRewards.maxValidatorEpochPayment, + ] +} + +module.exports = deploymentForCoreContract( + web3, + artifacts, + CeloContractName.EpochRewards, + initializeArgs +) diff --git a/packages/protocol/migrations/14_random.ts b/packages/protocol/migrations/15_random.ts similarity index 100% rename from packages/protocol/migrations/14_random.ts rename to packages/protocol/migrations/15_random.ts diff --git a/packages/protocol/migrations/15_attestations.ts b/packages/protocol/migrations/16_attestations.ts similarity index 100% rename from packages/protocol/migrations/15_attestations.ts rename to packages/protocol/migrations/16_attestations.ts diff --git a/packages/protocol/migrations/16_escrow.ts b/packages/protocol/migrations/17_escrow.ts similarity index 100% rename from packages/protocol/migrations/16_escrow.ts rename to packages/protocol/migrations/17_escrow.ts diff --git a/packages/protocol/migrations/17_blockchainparams.ts b/packages/protocol/migrations/18_blockchainparams.ts similarity index 100% rename from packages/protocol/migrations/17_blockchainparams.ts rename to packages/protocol/migrations/18_blockchainparams.ts diff --git a/packages/protocol/migrations/18_governance.ts b/packages/protocol/migrations/19_governance.ts similarity index 100% rename from packages/protocol/migrations/18_governance.ts rename to packages/protocol/migrations/19_governance.ts diff --git a/packages/protocol/migrations/19_elect_validators.ts b/packages/protocol/migrations/20_elect_validators.ts similarity index 100% rename from packages/protocol/migrations/19_elect_validators.ts rename to packages/protocol/migrations/20_elect_validators.ts diff --git a/packages/protocol/migrationsConfig.js b/packages/protocol/migrationsConfig.js index 164294fb57c..7ff2433457d 100644 --- a/packages/protocol/migrationsConfig.js +++ b/packages/protocol/migrationsConfig.js @@ -27,6 +27,22 @@ const DefaultConfig = { maxVotesPerAccount: 3, electabilityThreshold: 1 / 100, }, + epochRewards: { + targetVotingYieldParameters: { + initial: 5 / 100, + max: 2 / 10, + adjustmentFactor: 1 / 365, + }, + rewardsMultiplierParameters: { + max: 2, + adjustmentFactors: { + underspend: 1 / 2, + overspend: 5, + }, + }, + targetVotingGoldFraction: 2 / 3, + maxValidatorEpochPayment: '205479452054794520547', // (75,000 / 365) * 10 ^ 18 + }, exchange: { spread: 5 / 1000, reserveFraction: 1, @@ -95,7 +111,6 @@ const DefaultConfig = { exponent: 1, adjustmentSpeed: 0.1, }, - validatorEpochPayment: '1000000000000000000', membershipHistoryLength: 60, maxGroupSize: '70', diff --git a/packages/protocol/scripts/build.ts b/packages/protocol/scripts/build.ts index c9edab19f57..c49eebfadff 100644 --- a/packages/protocol/scripts/build.ts +++ b/packages/protocol/scripts/build.ts @@ -11,6 +11,7 @@ export const ProxyContracts = [ 'AccountsProxy', 'AttestationsProxy', 'ElectionProxy', + 'EpochRewardsProxy', 'EscrowProxy', 'ExchangeProxy', 'GasCurrencyWhitelistProxy', @@ -30,11 +31,13 @@ export const CoreContracts = [ 'Accounts', 'GasPriceMinimum', 'GasCurrencyWhitelist', + 'GoldToken', 'MultiSig', 'Registry', // governance 'Election', + 'EpochRewards', 'Governance', 'BlockchainParameters', 'LockedGold', @@ -47,7 +50,6 @@ export const CoreContracts = [ // stability 'Exchange', - 'GoldToken', 'Reserve', 'StableToken', 'SortedOracles', diff --git a/packages/protocol/test/governance/epochrewards.ts b/packages/protocol/test/governance/epochrewards.ts new file mode 100644 index 00000000000..b0605142ad1 --- /dev/null +++ b/packages/protocol/test/governance/epochrewards.ts @@ -0,0 +1,572 @@ +import { CeloContractName } from '@celo/protocol/lib/registry-utils' +import { + assertContainSubset, + assertEqualBN, + assertEqualDpBN, + assertRevert, + timeTravel, +} from '@celo/protocol/lib/test-utils' +import BigNumber from 'bignumber.js' +import { + MockElectionContract, + MockElectionInstance, + MockGoldTokenContract, + MockGoldTokenInstance, + MockSortedOraclesContract, + MockSortedOraclesInstance, + EpochRewardsTestContract, + EpochRewardsTestInstance, + RegistryContract, + RegistryInstance, +} from 'types' +import { fromFixed, toFixed } from '@celo/utils/lib/fixidity' + +const EpochRewards: EpochRewardsTestContract = artifacts.require('EpochRewardsTest') +const MockElection: MockElectionContract = artifacts.require('MockElection') +const MockGoldToken: MockGoldTokenContract = artifacts.require('MockGoldToken') +const MockSortedOracles: MockSortedOraclesContract = artifacts.require('MockSortedOracles') +const Registry: RegistryContract = artifacts.require('Registry') + +// @ts-ignore +// TODO(mcortesi): Use BN +EpochRewards.numberFormat = 'BigNumber' + +const YEAR = new BigNumber(365 * 24 * 60 * 60) +const SUPPLY_CAP = new BigNumber(web3.utils.toWei('1000000000')) + +const getExpectedTargetTotalSupply = (timeDelta: BigNumber) => { + const genesisSupply = new BigNumber(web3.utils.toWei('600000000')) + const linearRewards = new BigNumber(web3.utils.toWei('200000000')) + return genesisSupply + .plus(timeDelta.times(linearRewards).div(YEAR.times(15))) + .integerValue(BigNumber.ROUND_FLOOR) +} + +contract('EpochRewards', (accounts: string[]) => { + let epochRewards: EpochRewardsTestInstance + let mockElection: MockElectionInstance + let mockGoldToken: MockGoldTokenInstance + let mockSortedOracles: MockSortedOraclesInstance + let registry: RegistryInstance + const nonOwner = accounts[1] + + const targetVotingYieldParams = { + initial: toFixed(new BigNumber(1 / 20)), + max: toFixed(new BigNumber(1 / 5)), + adjustmentFactor: toFixed(new BigNumber(1 / 365)), + } + const rewardsMultiplier = { + max: toFixed(new BigNumber(2)), + adjustments: { + underspend: toFixed(new BigNumber(1 / 2)), + overspend: toFixed(new BigNumber(5)), + }, + } + const targetVotingGoldFraction = toFixed(new BigNumber(2 / 3)) + const targetValidatorEpochPayment = new BigNumber(10000000000000) + const exchangeRate = 7 + const mockStableTokenAddress = web3.utils.randomHex(20) + const sortedOraclesDenominator = new BigNumber('0x10000000000000000') + beforeEach(async () => { + epochRewards = await EpochRewards.new() + mockElection = await MockElection.new() + mockGoldToken = await MockGoldToken.new() + mockSortedOracles = await MockSortedOracles.new() + registry = await Registry.new() + await registry.setAddressFor(CeloContractName.Election, mockElection.address) + await registry.setAddressFor(CeloContractName.GoldToken, mockGoldToken.address) + await registry.setAddressFor(CeloContractName.SortedOracles, mockSortedOracles.address) + await registry.setAddressFor(CeloContractName.StableToken, mockStableTokenAddress) + await mockSortedOracles.setMedianRate( + mockStableTokenAddress, + sortedOraclesDenominator.times(exchangeRate) + ) + + await epochRewards.initialize( + registry.address, + targetVotingYieldParams.initial, + targetVotingYieldParams.max, + targetVotingYieldParams.adjustmentFactor, + rewardsMultiplier.max, + rewardsMultiplier.adjustments.underspend, + rewardsMultiplier.adjustments.overspend, + targetVotingGoldFraction, + targetValidatorEpochPayment + ) + }) + + describe('#initialize()', () => { + it('should have set the owner', async () => { + const owner: string = await epochRewards.owner() + assert.equal(owner, accounts[0]) + }) + + it('should have set the target validator epoch payment', async () => { + assertEqualBN(await epochRewards.targetValidatorEpochPayment(), targetValidatorEpochPayment) + }) + + it('should have set the target voting yield parameters', async () => { + const [target, max, adjustmentFactor] = await epochRewards.getTargetVotingYieldParameters() + assertEqualBN(target, targetVotingYieldParams.initial) + assertEqualBN(max, targetVotingYieldParams.max) + assertEqualBN(adjustmentFactor, targetVotingYieldParams.adjustmentFactor) + }) + + it('should have set the rewards multiplier parameters', async () => { + const [max, underspend, overspend] = await epochRewards.getRewardsMultiplierParameters() + assertEqualBN(max, rewardsMultiplier.max) + assertEqualBN(underspend, rewardsMultiplier.adjustments.underspend) + assertEqualBN(overspend, rewardsMultiplier.adjustments.overspend) + }) + + it('should not be callable again', async () => { + await assertRevert( + epochRewards.initialize( + registry.address, + targetVotingYieldParams.initial, + targetVotingYieldParams.max, + targetVotingYieldParams.adjustmentFactor, + rewardsMultiplier.max, + rewardsMultiplier.adjustments.underspend, + rewardsMultiplier.adjustments.overspend, + targetVotingGoldFraction, + targetValidatorEpochPayment + ) + ) + }) + }) + + describe('#setTargetVotingGoldFraction()', () => { + describe('when the fraction is different', () => { + const newFraction = targetVotingGoldFraction.plus(1) + + describe('when called by the owner', () => { + it('should set the target voting gold fraction', async () => { + await epochRewards.setTargetVotingGoldFraction(newFraction) + assertEqualBN(await epochRewards.getTargetVotingGoldFraction(), newFraction) + }) + + it('should emit the TargetVotingGoldFractionSet event', async () => { + const resp = await epochRewards.setTargetVotingGoldFraction(newFraction) + assert.equal(resp.logs.length, 1) + const log = resp.logs[0] + assertContainSubset(log, { + event: 'TargetVotingGoldFractionSet', + args: { + fraction: newFraction, + }, + }) + }) + + describe('when called by a non-owner', () => { + it('should revert', async () => { + await assertRevert( + epochRewards.setTargetVotingGoldFraction(newFraction, { + from: nonOwner, + }) + ) + }) + }) + }) + + describe('when the fraction is the same', () => { + it('should revert', async () => { + await assertRevert(epochRewards.setTargetVotingGoldFraction(targetVotingGoldFraction)) + }) + }) + }) + }) + + describe('#setTargetValidatorEpochPayment()', () => { + describe('when the payment is different', () => { + const newPayment = targetValidatorEpochPayment.plus(1) + + describe('when called by the owner', () => { + it('should set the target validator epoch payment', async () => { + await epochRewards.setTargetValidatorEpochPayment(newPayment) + assertEqualBN(await epochRewards.targetValidatorEpochPayment(), newPayment) + }) + + it('should emit the TargetValidatorEpochPaymentSet event', async () => { + const resp = await epochRewards.setTargetValidatorEpochPayment(newPayment) + assert.equal(resp.logs.length, 1) + const log = resp.logs[0] + assertContainSubset(log, { + event: 'TargetValidatorEpochPaymentSet', + args: { + payment: newPayment, + }, + }) + }) + + describe('when the payment is the same', () => { + it('should revert', async () => { + await assertRevert( + epochRewards.setTargetValidatorEpochPayment(targetValidatorEpochPayment) + ) + }) + }) + }) + + describe('when called by a non-owner', () => { + it('should revert', async () => { + await assertRevert( + epochRewards.setTargetValidatorEpochPayment(newPayment, { + from: nonOwner, + }) + ) + }) + }) + }) + }) + + describe('#setRewardsMultiplierParameters()', () => { + describe('when one of the parameters is different', () => { + const newParams = { + max: rewardsMultiplier.max, + underspend: rewardsMultiplier.adjustments.underspend.plus(1), + overspend: rewardsMultiplier.adjustments.overspend, + } + + describe('when called by the owner', () => { + let resp: any + + beforeEach(async () => { + resp = await epochRewards.setRewardsMultiplierParameters( + newParams.max, + newParams.underspend, + newParams.overspend + ) + }) + + it('should set the new rewards multiplier adjustment params', async () => { + const [max, underspend, overspend] = await epochRewards.getRewardsMultiplierParameters() + assertEqualBN(max, newParams.max) + assertEqualBN(underspend, newParams.underspend) + assertEqualBN(overspend, newParams.overspend) + }) + + it('should emit the RewardsMultiplierParametersSet event', async () => { + assert.equal(resp.logs.length, 1) + const log = resp.logs[0] + assertContainSubset(log, { + event: 'RewardsMultiplierParametersSet', + args: { + max: newParams.max, + underspendAdjustmentFactor: newParams.underspend, + overspendAdjustmentFactor: newParams.overspend, + }, + }) + }) + + describe('when called by a non-owner', () => { + it('should revert', async () => { + await assertRevert( + epochRewards.setRewardsMultiplierParameters( + newParams.max, + newParams.underspend, + newParams.overspend, + { + from: nonOwner, + } + ) + ) + }) + }) + }) + + describe('when the parameters are the same', () => { + it('should revert', async () => { + await assertRevert( + epochRewards.setRewardsMultiplierParameters( + rewardsMultiplier.max, + rewardsMultiplier.adjustments.underspend, + rewardsMultiplier.adjustments.overspend + ) + ) + }) + }) + }) + }) + + describe('#setTargetVotingYieldParameters()', () => { + describe('when the parameters are different', () => { + const newMax = targetVotingYieldParams.max.plus(1) + const newFactor = targetVotingYieldParams.adjustmentFactor.plus(1) + + describe('when called by the owner', () => { + let resp: any + + beforeEach(async () => { + resp = await epochRewards.setTargetVotingYieldParameters(newMax, newFactor) + }) + + it('should set the new target voting yield parameters', async () => { + const [, max, adjustmentFactor] = await epochRewards.getTargetVotingYieldParameters() + assertEqualBN(max, newMax) + assertEqualBN(adjustmentFactor, newFactor) + }) + + it('should emit the TargetVotingYieldParametersSet event', async () => { + assert.equal(resp.logs.length, 1) + const log = resp.logs[0] + assertContainSubset(log, { + event: 'TargetVotingYieldParametersSet', + args: { + max: newMax, + adjustmentFactor: newFactor, + }, + }) + }) + + describe('when called by a non-owner', () => { + it('should revert', async () => { + await assertRevert( + epochRewards.setTargetVotingYieldParameters(newMax, newFactor, { + from: nonOwner, + }) + ) + }) + }) + }) + + describe('when the parameters are the same', () => { + it('should revert', async () => { + await assertRevert( + epochRewards.setTargetVotingYieldParameters( + targetVotingYieldParams.max, + targetVotingYieldParams.adjustmentFactor + ) + ) + }) + }) + }) + }) + + describe('#getTargetGoldTotalSupply()', () => { + describe('when it has been fewer than 15 years since genesis', () => { + const timeDelta = YEAR.times(10) + beforeEach(async () => { + await timeTravel(timeDelta.toNumber(), web3) + }) + + it('should return 600MM + 200MM * t / 15', async () => { + assertEqualBN( + await epochRewards.getTargetGoldTotalSupply(), + getExpectedTargetTotalSupply(timeDelta) + ) + }) + }) + }) + + describe('#getTargetEpochRewards()', () => { + describe('when there are active votes', () => { + const activeVotes = 1000000 + beforeEach(async () => { + await mockElection.setActiveVotes(activeVotes) + }) + + it('should return a percentage of the active votes', async () => { + const expected = fromFixed(targetVotingYieldParams.initial).times(activeVotes) + assertEqualBN(await epochRewards.getTargetEpochRewards(), expected) + }) + }) + }) + + describe('#getTargetTotalEpochPaymentsInGold()', () => { + describe('when a StableToken exchange rate is set', () => { + const numberValidators = 100 + beforeEach(async () => { + await epochRewards.setNumberValidatorsInCurrentSet(numberValidators) + }) + + it('should return the number of validators times the max payment divided by the exchange rate', async () => { + const expected = targetValidatorEpochPayment + .times(numberValidators) + .div(exchangeRate) + .integerValue(BigNumber.ROUND_FLOOR) + assertEqualBN(await epochRewards.getTargetTotalEpochPaymentsInGold(), expected) + }) + }) + }) + + describe('#getRewardsMultiplier()', () => { + const timeDelta = YEAR.times(10) + const expectedTargetTotalSupply = getExpectedTargetTotalSupply(timeDelta) + const expectedTargetRemainingSupply = SUPPLY_CAP.minus(expectedTargetTotalSupply) + let targetEpochReward: BigNumber + beforeEach(async () => { + await timeTravel(timeDelta.toNumber(), web3) + targetEpochReward = await epochRewards.getTargetEpochRewards() + targetEpochReward = targetEpochReward.plus( + await epochRewards.getTargetTotalEpochPaymentsInGold() + ) + }) + + describe('when the target supply is equal to the actual supply after rewards', () => { + beforeEach(async () => { + await mockGoldToken.setTotalSupply(expectedTargetTotalSupply.minus(targetEpochReward)) + }) + + it('should return one', async () => { + assertEqualBN(await epochRewards.getRewardsMultiplier(), toFixed(1)) + }) + }) + + describe('when the actual remaining supply is 10% more than the target remaining supply after rewards', () => { + beforeEach(async () => { + const actualRemainingSupply = expectedTargetRemainingSupply.times(1.1) + const totalSupply = SUPPLY_CAP.minus(actualRemainingSupply) + .minus(targetEpochReward) + .integerValue(BigNumber.ROUND_FLOOR) + await mockGoldToken.setTotalSupply(totalSupply) + }) + + it('should return one plus 10% times the underspend adjustment', async () => { + const actual = fromFixed(await epochRewards.getRewardsMultiplier()) + const expected = new BigNumber(1).plus( + fromFixed(rewardsMultiplier.adjustments.underspend).times(0.1) + ) + // Assert equal to 7 decimal places due to fixidity imprecision. + assertEqualDpBN(actual, expected, 7) + }) + }) + + describe('when the actual remaining supply is 10% less than the target remaining supply after rewards', () => { + beforeEach(async () => { + const actualRemainingSupply = expectedTargetRemainingSupply.times(0.9) + const totalSupply = SUPPLY_CAP.minus(actualRemainingSupply) + .minus(targetEpochReward) + .integerValue(BigNumber.ROUND_FLOOR) + await mockGoldToken.setTotalSupply(totalSupply) + }) + + it('should return one minus 10% times the underspend adjustment', async () => { + const actual = fromFixed(await epochRewards.getRewardsMultiplier()) + const expected = new BigNumber(1).minus( + fromFixed(rewardsMultiplier.adjustments.overspend).times(0.1) + ) + // Assert equal to 7 decimal places due to fixidity imprecision. + assertEqualDpBN(actual, expected, 7) + }) + }) + }) + + describe('#updateTargetVotingYield()', () => { + // Arbitrary numbers + const totalSupply = new BigNumber(129762987346298761037469283746) + const reserveBalance = new BigNumber(2397846127684712867321) + const floatingSupply = totalSupply.minus(reserveBalance) + const mockReserveAddress = web3.utils.randomHex(20) + beforeEach(async () => { + await mockGoldToken.setTotalSupply(totalSupply) + await web3.eth.sendTransaction({ + from: accounts[9], + to: mockReserveAddress, + value: reserveBalance.toString(), + }) + await registry.setAddressFor(CeloContractName.Reserve, mockReserveAddress) + }) + + describe('when the percentage of voting gold is equal to the target', () => { + beforeEach(async () => { + const totalVotes = floatingSupply + .times(fromFixed(targetVotingGoldFraction)) + .integerValue(BigNumber.ROUND_FLOOR) + await mockElection.setTotalVotes(totalVotes) + await epochRewards.updateTargetVotingYield() + }) + + it('should not change the target voting yield', async () => { + assertEqualBN( + (await epochRewards.getTargetVotingYieldParameters())[0], + targetVotingYieldParams.initial + ) + }) + }) + + describe('when the percentage of voting gold is 10% less than the target', () => { + beforeEach(async () => { + const totalVotes = floatingSupply + .times(fromFixed(targetVotingGoldFraction).minus(0.1)) + .integerValue(BigNumber.ROUND_FLOOR) + await mockElection.setTotalVotes(totalVotes) + await epochRewards.updateTargetVotingYield() + }) + + it('should increase the target voting yield by 10% times the adjustment factor', async () => { + const expected = fromFixed( + targetVotingYieldParams.initial.plus(targetVotingYieldParams.adjustmentFactor.times(0.1)) + ) + const actual = fromFixed((await epochRewards.getTargetVotingYieldParameters())[0]) + // Assert equal to 9 decimal places due to fixidity imprecision. + assert.equal(expected.dp(9).toFixed(), actual.dp(9).toFixed()) + }) + }) + + describe('when the percentage of voting gold is 10% more than the target', () => { + beforeEach(async () => { + const totalVotes = floatingSupply + .times(fromFixed(targetVotingGoldFraction).plus(0.1)) + .integerValue(BigNumber.ROUND_FLOOR) + await mockElection.setTotalVotes(totalVotes) + await epochRewards.updateTargetVotingYield() + }) + + it('should decrease the target voting yield by 10% times the adjustment factor', async () => { + const expected = fromFixed( + targetVotingYieldParams.initial.minus(targetVotingYieldParams.adjustmentFactor.times(0.1)) + ) + const actual = fromFixed((await epochRewards.getTargetVotingYieldParameters())[0]) + // Assert equal to 9 decimal places due to fixidity imprecision. + assert.equal(expected.dp(9).toFixed(), actual.dp(9).toFixed()) + }) + }) + }) + + describe('#calculateTargetEpochPaymentAndRewards()', () => { + describe('when there are active votes, a stable token exchange rate is set and the actual remaining supply is 10% more than the target remaining supply after rewards', () => { + const activeVotes = 1000000 + const timeDelta = YEAR.times(10) + const numberValidators = 100 + let expectedMultiplier: BigNumber + beforeEach(async () => { + await epochRewards.setNumberValidatorsInCurrentSet(numberValidators) + await mockElection.setActiveVotes(activeVotes) + await timeTravel(timeDelta.toNumber(), web3) + const expectedTargetTotalEpochPaymentsInGold = targetValidatorEpochPayment + .times(numberValidators) + .div(exchangeRate) + .integerValue(BigNumber.ROUND_FLOOR) + const expectedTargetEpochRewards = fromFixed(targetVotingYieldParams.initial).times( + activeVotes + ) + const expectedTargetGoldSupplyIncrease = expectedTargetEpochRewards.plus( + expectedTargetTotalEpochPaymentsInGold + ) + const expectedTargetTotalSupply = getExpectedTargetTotalSupply(timeDelta) + const expectedTargetRemainingSupply = SUPPLY_CAP.minus(expectedTargetTotalSupply) + const actualRemainingSupply = expectedTargetRemainingSupply.times(1.1) + const totalSupply = SUPPLY_CAP.minus(actualRemainingSupply) + .minus(expectedTargetGoldSupplyIncrease) + .integerValue(BigNumber.ROUND_FLOOR) + await mockGoldToken.setTotalSupply(totalSupply) + expectedMultiplier = new BigNumber(1).plus( + fromFixed(rewardsMultiplier.adjustments.underspend).times(0.1) + ) + }) + + it('should return the target validator epoch payment times the rewards multiplier', async () => { + const expected = targetValidatorEpochPayment.times(expectedMultiplier) + assertEqualBN((await epochRewards.calculateTargetEpochPaymentAndRewards())[0], expected) + }) + + it('should return the target yield times the number of active votes times the rewards multiplier', async () => { + const expected = fromFixed(targetVotingYieldParams.initial) + .times(activeVotes) + .times(expectedMultiplier) + assertEqualBN((await epochRewards.calculateTargetEpochPaymentAndRewards())[1], expected) + }) + }) + }) +}) diff --git a/packages/protocol/test/governance/validators.ts b/packages/protocol/test/governance/validators.ts index e3125cc2f70..749a8581df3 100644 --- a/packages/protocol/test/governance/validators.ts +++ b/packages/protocol/test/governance/validators.ts @@ -66,7 +66,6 @@ const DAY = 24 * HOUR // Hard coded in ganache. const EPOCH = 100 -// TODO(asa): Test epoch payment distribution contract('Validators', (accounts: string[]) => { let accountsInstance: AccountsInstance let validators: ValidatorsTestInstance @@ -87,7 +86,6 @@ contract('Validators', (accounts: string[]) => { exponent: new BigNumber(5), adjustmentSpeed: toFixed(0.25), } - const validatorEpochPayment = new BigNumber(10000000000000) const membershipHistoryLength = new BigNumber(5) const maxGroupSize = new BigNumber(5) @@ -118,7 +116,6 @@ contract('Validators', (accounts: string[]) => { validatorLockedGoldRequirements.duration, validatorScoreParameters.exponent, validatorScoreParameters.adjustmentSpeed, - validatorEpochPayment, membershipHistoryLength, maxGroupSize ) @@ -175,27 +172,6 @@ contract('Validators', (accounts: string[]) => { assertEqualBN(adjustmentSpeed, validatorScoreParameters.adjustmentSpeed) }) - it('should have set the validator epoch payment', async () => { - const actual = await validators.validatorEpochPayment() - assertEqualBN(actual, validatorEpochPayment) - }) - - it('should have set the membership history length', async () => { - const actual = await validators.membershipHistoryLength() - assertEqualBN(actual, membershipHistoryLength) - }) - - it('should have set the validator score parameters', async () => { - const [exponent, adjustmentSpeed] = await validators.getValidatorScoreParameters() - assertEqualBN(exponent, validatorScoreParameters.exponent) - assertEqualBN(adjustmentSpeed, validatorScoreParameters.adjustmentSpeed) - }) - - it('should have set the validator epoch payment', async () => { - const actual = await validators.validatorEpochPayment() - assertEqualBN(actual, validatorEpochPayment) - }) - it('should have set the membership history length', async () => { const actual = await validators.membershipHistoryLength() assertEqualBN(actual, membershipHistoryLength) @@ -216,7 +192,6 @@ contract('Validators', (accounts: string[]) => { validatorLockedGoldRequirements.duration, validatorScoreParameters.exponent, validatorScoreParameters.adjustmentSpeed, - validatorEpochPayment, membershipHistoryLength, maxGroupSize ) @@ -224,51 +199,6 @@ contract('Validators', (accounts: string[]) => { }) }) - describe('#setValidatorEpochPayment()', () => { - describe('when the payment is different', () => { - const newPayment = validatorEpochPayment.plus(1) - - describe('when called by the owner', () => { - let resp: any - - beforeEach(async () => { - resp = await validators.setValidatorEpochPayment(newPayment) - }) - - it('should set the validator epoch payment', async () => { - assertEqualBN(await validators.validatorEpochPayment(), newPayment) - }) - - it('should emit the ValidatorEpochPaymentSet event', async () => { - assert.equal(resp.logs.length, 1) - const log = resp.logs[0] - assertContainSubset(log, { - event: 'ValidatorEpochPaymentSet', - args: { - value: new BigNumber(newPayment), - }, - }) - }) - - describe('when called by a non-owner', () => { - it('should revert', async () => { - await assertRevert( - validators.setValidatorEpochPayment(newPayment, { - from: nonOwner, - }) - ) - }) - }) - }) - - describe('when the payment is the same', () => { - it('should revert', async () => { - await assertRevert(validators.setValidatorEpochPayment(validatorEpochPayment)) - }) - }) - }) - }) - describe('#setMembershipHistoryLength()', () => { describe('when the length is different', () => { const newLength = membershipHistoryLength.plus(1) @@ -544,69 +474,6 @@ contract('Validators', (accounts: string[]) => { }) }) - describe('#setValidatorScoreParameters()', () => { - describe('when the parameters are different', () => { - const newParameters = { - exponent: validatorScoreParameters.exponent.plus(1), - adjustmentSpeed: validatorScoreParameters.adjustmentSpeed.plus(1), - } - - describe('when called by the owner', () => { - let resp: any - - beforeEach(async () => { - resp = await validators.setValidatorScoreParameters( - newParameters.exponent, - newParameters.adjustmentSpeed - ) - }) - - it('should set the exponent and adjustment speed', async () => { - const [exponent, adjustmentSpeed] = await validators.getValidatorScoreParameters() - assertEqualBN(exponent, newParameters.exponent) - assertEqualBN(adjustmentSpeed, newParameters.adjustmentSpeed) - }) - - it('should emit the ValidatorScoreParametersSet event', async () => { - assert.equal(resp.logs.length, 1) - const log = resp.logs[0] - assertContainSubset(log, { - event: 'ValidatorScoreParametersSet', - args: { - exponent: new BigNumber(newParameters.exponent), - adjustmentSpeed: new BigNumber(newParameters.adjustmentSpeed), - }, - }) - }) - - describe('when called by a non-owner', () => { - it('should revert', async () => { - await assertRevert( - validators.setValidatorScoreParameters( - newParameters.exponent, - newParameters.adjustmentSpeed, - { - from: nonOwner, - } - ) - ) - }) - }) - }) - - describe('when the requirements are the same', () => { - it('should revert', async () => { - await assertRevert( - validators.setValidatorScoreParameters( - validatorScoreParameters.exponent, - validatorScoreParameters.adjustmentSpeed - ) - ) - }) - }) - }) - }) - describe('#setMaxGroupSize()', () => { describe('when the size is different', () => { describe('when called by the owner', () => { @@ -1971,6 +1838,7 @@ contract('Validators', (accounts: string[]) => { describe('#distributeEpochPayment', () => { const validator = accounts[0] const group = accounts[1] + const maxPayment = new BigNumber(20122394876) let mockStableToken: MockStableTokenInstance beforeEach(async () => { await registerValidatorGroupWithMembers(group, [validator]) @@ -1979,11 +1847,12 @@ contract('Validators', (accounts: string[]) => { }) describe('when the validator score is non-zero', () => { + let ret: BigNumber const uptime = new BigNumber(0.99) const adjustmentSpeed = fromFixed(validatorScoreParameters.adjustmentSpeed) // @ts-ignore const expectedScore = adjustmentSpeed.times(uptime.pow(validatorScoreParameters.exponent)) - const expectedTotalPayment = expectedScore.times(validatorEpochPayment) + const expectedTotalPayment = expectedScore.times(maxPayment).dp(0, BigNumber.ROUND_FLOOR) const expectedGroupPayment = expectedTotalPayment .times(fromFixed(commission)) .dp(0, BigNumber.ROUND_FLOOR) @@ -1994,7 +1863,8 @@ contract('Validators', (accounts: string[]) => { describe('when the validator and group meet the balance requirements', () => { beforeEach(async () => { - await validators.distributeEpochPayment(validator) + ret = await validators.distributeEpochPayment.call(validator, maxPayment) + await validators.distributeEpochPayment(validator, maxPayment) }) it('should pay the validator', async () => { @@ -2004,6 +1874,10 @@ contract('Validators', (accounts: string[]) => { it('should pay the group', async () => { assertEqualBN(await mockStableToken.balanceOf(group), expectedGroupPayment) }) + + it('should return the expected total payment', async () => { + assertEqualBN(ret, expectedTotalPayment) + }) }) describe('when the validator does not meet the balance requirements', () => { @@ -2012,7 +1886,8 @@ contract('Validators', (accounts: string[]) => { validator, validatorLockedGoldRequirements.value.minus(1) ) - await validators.distributeEpochPayment(validator) + ret = await validators.distributeEpochPayment.call(validator, maxPayment) + await validators.distributeEpochPayment(validator, maxPayment) }) it('should not pay the validator', async () => { @@ -2022,6 +1897,10 @@ contract('Validators', (accounts: string[]) => { it('should not pay the group', async () => { assertEqualBN(await mockStableToken.balanceOf(group), 0) }) + + it('should return zero', async () => { + assertEqualBN(ret, 0) + }) }) describe('when the group does not meet the balance requirements', () => { @@ -2030,7 +1909,8 @@ contract('Validators', (accounts: string[]) => { group, groupLockedGoldRequirements.value.minus(1) ) - await validators.distributeEpochPayment(validator) + ret = await validators.distributeEpochPayment.call(validator, maxPayment) + await validators.distributeEpochPayment(validator, maxPayment) }) it('should not pay the validator', async () => { @@ -2040,6 +1920,10 @@ contract('Validators', (accounts: string[]) => { it('should not pay the group', async () => { assertEqualBN(await mockStableToken.balanceOf(group), 0) }) + + it('should return zero', async () => { + assertEqualBN(ret, 0) + }) }) }) }) diff --git a/packages/protocol/test/stability/exchange.ts b/packages/protocol/test/stability/exchange.ts index b9f2e81b1a9..a75de0bb0bd 100644 --- a/packages/protocol/test/stability/exchange.ts +++ b/packages/protocol/test/stability/exchange.ts @@ -60,8 +60,8 @@ contract('Exchange', (accounts: string[]) => { const initialGoldBucket = initialReserveBalance .times(fromFixed(reserveFraction)) .integerValue(BigNumber.ROUND_FLOOR) - const stableAmountForRate = new BigNumber(2) - const goldAmountForRate = new BigNumber(1) + const goldAmountForRate = new BigNumber('0x10000000000000000') + const stableAmountForRate = new BigNumber(2).times(goldAmountForRate) const initialStableBucket = initialGoldBucket.times(stableAmountForRate).div(goldAmountForRate) function getBuyTokenAmount( sellAmount: BigNumber, @@ -109,11 +109,7 @@ contract('Exchange', (accounts: string[]) => { mockSortedOracles = await MockSortedOracles.new() await registry.setAddressFor(CeloContractName.SortedOracles, mockSortedOracles.address) - await mockSortedOracles.setMedianRate( - stableToken.address, - stableAmountForRate, - goldAmountForRate - ) + await mockSortedOracles.setMedianRate(stableToken.address, stableAmountForRate) await mockSortedOracles.setMedianTimestampToNow(stableToken.address) await mockSortedOracles.setNumRates(stableToken.address, 2) @@ -330,7 +326,7 @@ contract('Exchange', (accounts: string[]) => { describe('after an oracle update', () => { beforeEach(async () => { - await mockSortedOracles.setMedianRate(stableToken.address, 4, 1) + await mockSortedOracles.setMedianRate(stableToken.address, goldAmountForRate.times(4)) }) it(`should return the same value if updateFrequency seconds haven't passed yet`, async () => { diff --git a/packages/protocol/test/stability/reserve.ts b/packages/protocol/test/stability/reserve.ts index 0f660a9d920..b73153b2731 100644 --- a/packages/protocol/test/stability/reserve.ts +++ b/packages/protocol/test/stability/reserve.ts @@ -35,7 +35,7 @@ contract('Reserve', (accounts: string[]) => { const nonOwner: string = accounts[1] const spender: string = accounts[2] const aTobinTaxStalenessThreshold: number = 600 - + const sortedOraclesDenominator = new BigNumber('0x10000000000000000') beforeEach(async () => { reserve = await Reserve.new() registry = await Registry.new() @@ -80,7 +80,7 @@ contract('Reserve', (accounts: string[]) => { describe('#addToken()', () => { beforeEach(async () => { - await mockSortedOracles.setMedianRate(anAddress, 1, 1) + await mockSortedOracles.setMedianRate(anAddress, sortedOraclesDenominator) }) it('should allow owner to add a token', async () => { @@ -124,7 +124,7 @@ contract('Reserve', (accounts: string[]) => { describe('when the token has already been added', async () => { beforeEach(async () => { - await mockSortedOracles.setMedianRate(anAddress, 1, 1) + await mockSortedOracles.setMedianRate(anAddress, sortedOraclesDenominator) await reserve.addToken(anAddress) const tokenList = await reserve.getTokens() index = -1 @@ -186,7 +186,10 @@ contract('Reserve', (accounts: string[]) => { beforeEach(async () => { mockStableToken = await MockStableToken.new() await registry.setAddressFor(CeloContractName.SortedOracles, mockSortedOracles.address) - await mockSortedOracles.setMedianRate(mockStableToken.address, 10, 1) + await mockSortedOracles.setMedianRate( + mockStableToken.address, + sortedOraclesDenominator.times(10) + ) await reserve.addToken(mockStableToken.address) const reserveGoldBalance = new BigNumber(10).pow(19) await web3.eth.sendTransaction({ @@ -230,7 +233,10 @@ contract('Reserve', (accounts: string[]) => { let anotherMockStableToken: MockStableTokenInstance beforeEach(async () => { anotherMockStableToken = await MockStableToken.new() - await mockSortedOracles.setMedianRate(anotherMockStableToken.address, 10, 1) + await mockSortedOracles.setMedianRate( + anotherMockStableToken.address, + sortedOraclesDenominator.times(10) + ) await reserve.addToken(anotherMockStableToken.address) })