diff --git a/brownie-config.yaml b/brownie-config.yaml index 27c8540..9a3c15a 100644 --- a/brownie-config.yaml +++ b/brownie-config.yaml @@ -1,4 +1,4 @@ -networks: - default: mainnet-fork - autofetch_sources: true + +dependencies: + - curvefi/curve-dao-contracts@1.3.0 diff --git a/contracts/CRVFunder.vy b/contracts/CRVFunder.vy index 4cb1a02..f82981f 100644 --- a/contracts/CRVFunder.vy +++ b/contracts/CRVFunder.vy @@ -9,6 +9,10 @@ interface CRV20: def rate() -> uint256: view def future_epoch_time_write() -> uint256: nonpayable +interface Factory: + def owner() -> address: view + def fallback_receiver() -> address: view + interface GaugeController: def checkpoint_gauge(_gauge: address): nonpayable def gauge_relative_weight(_gauge: address, _time: uint256) -> uint256: view @@ -25,7 +29,6 @@ event TransferOwnership: CRV: constant(address) = 0xD533a949740bb3306d119CC777fa900bA034cd52 GAUGE_CONTROLLER: constant(address) = 0x2F50D538606Fa9EDD2B11E2446BEb18C9D5846bB -TREASURY_ADDRESS: constant(address) = 0x2F50D538606Fa9EDD2B11E2446BEb18C9D5846bB # todo: add treasury adddress here WEEK: constant(uint256) = 604800 YEAR: constant(uint256) = 86400 * 365 @@ -42,29 +45,18 @@ inflation_params: uint256 integrate_fraction: public(HashMap[address, uint256]) last_checkpoint: public(uint256) -owner: public(address) -future_owner: public(address) -fund_receipient: public(address) +receiver: public(address) +# [uint216 max_emissions][uint40 deadline] +receiver_data: uint256 -funding_end_timestamp: public(uint256) -max_integrate_fraction: public(uint256) +factory: public(address) +cached_fallback_receiver: public(address) @external -def __init__( - fund_receipient: address, - funding_end_timestamp: uint256, - max_integrate_fraction: uint256 -): - self.fund_receipient = fund_receipient - self.funding_end_timestamp = funding_end_timestamp - self.max_integrate_fraction = max_integrate_fraction - - self.inflation_params = shift(CRV20(CRV).rate(), 40) + CRV20(CRV).future_epoch_time_write() - self.last_checkpoint = block.timestamp - - self.owner = msg.sender - log TransferOwnership(ZERO_ADDRESS, msg.sender) +def __init__(): + # prevent initialization of the implementation contract + self.factory = 0x000000000000000000000000000000000000dEaD @external @@ -73,65 +65,95 @@ def user_checkpoint(_user: address) -> bool: @notice Checkpoint the gauge updating total emissions @param _user The user to checkpoint and update accumulated emissions for """ - # timestamp of the last checkpoint - last_checkpoint: uint256 = self.last_checkpoint + # timestamp of the last checkpoint and start point for calculating new emissions + prev_week_time: uint256 = self.last_checkpoint # if time has not advanced since the last checkpoint - if block.timestamp == last_checkpoint: + if block.timestamp == prev_week_time: return True - # if funding duration has expired, direct to treasury: - fund_receipient: address = self.fund_receipient - if block.timestamp >= self.funding_end_timestamp: - fund_receipient = TREASURY_ADDRESS - - # checkpoint the gauge filling in gauge data across weeks - GaugeController(GAUGE_CONTROLLER).checkpoint_gauge(self) - # load and unpack inflation params inflation_params: uint256 = self.inflation_params rate: uint256 = shift(inflation_params, -40) future_epoch_time: uint256 = bitwise_and(inflation_params, 2 ** 40 - 1) - # initialize variables for tracking timedelta between weeks - prev_week_time: uint256 = last_checkpoint - # either the start of the next week or the current timestamp - week_time: uint256 = min((last_checkpoint + WEEK) / WEEK * WEEK, block.timestamp) + # load the receiver + receiver: address = self.receiver + # load and unpack receiver data + receiver_data: uint256 = self.receiver_data + deadline: uint256 = bitwise_and(receiver_data, 2 ** 40 - 1) + max_emissions: uint256 = shift(receiver_data, -40) - # track total new emissions while we loop + # initialize emission tracking variables new_emissions: uint256 = 0 + multisig_emissions: uint256 = 0 + receiver_emissions: uint256 = self.integrate_fraction[receiver] + + # checkpoint the gauge filling in any missing gauge data across weeks + GaugeController(GAUGE_CONTROLLER).checkpoint_gauge(self) - # iterate over at maximum 512 weeks + # either the start of the next week or the current timestamp + week_time: uint256 = min((prev_week_time + WEEK) / WEEK * WEEK, block.timestamp) + + # if the deadline is between our previous checkpoint and the end of the week + # set the week_time var to our deadline, so we can calculate up to it only + if prev_week_time < deadline and deadline < week_time: + week_time = deadline + + # iterate 512 times at maximum for i in range(512): dt: uint256 = week_time - prev_week_time w: uint256 = GaugeController(GAUGE_CONTROLLER).gauge_relative_weight(self, prev_week_time / WEEK * WEEK) + emissions: uint256 = 0 + # if we cross over an inflation epoch, calculate the emissions using old and new rate if prev_week_time <= future_epoch_time and future_epoch_time < week_time: # calculate up to the epoch using the old rate - new_emissions += rate * w * (future_epoch_time - prev_week_time) / 10 ** 18 + emissions = rate * w * (future_epoch_time - prev_week_time) / 10 ** 18 # update the rate in memory rate = rate * RATE_DENOMINATOR / RATE_REDUCTION_COEFFICIENT - # calculate past the epoch to the start of the next week - new_emissions += rate * w * (week_time - future_epoch_time) / 10 ** 18 + # calculate using the new rate for the rest of the time period + emissions += rate * w * (week_time - future_epoch_time) / 10 ** 18 # update the new future epoch time future_epoch_time += RATE_REDUCTION_TIME # update storage self.inflation_params = shift(rate, 40) + future_epoch_time else: - new_emissions += rate * w * dt / 10 ** 18 + emissions = rate * w * dt / 10 ** 18 + + new_emissions += emissions + # if the time period we are calculating for ends before or at the deadline + if week_time <= deadline: + # if the receiver emissions + emissions from this period is greater than max_emissions + if receiver_emissions + emissions > max_emissions: + # the emissions from this period - amount given to receiver goes to multisig + multisig_emissions += emissions - (max_emissions - receiver_emissions) + # how much does the receiver get from this period + receiver_emissions = max_emissions + else: + receiver_emissions += emissions + else: + multisig_emissions += emissions if week_time == block.timestamp: break + # update timestamps for tracking timedelta prev_week_time = week_time - week_time = min(week_time + WEEK, block.timestamp) + week_time = min((week_time + WEEK) / WEEK * WEEK, block.timestamp) + + if prev_week_time < deadline and deadline < week_time: + week_time = deadline - # cap accumulated emissions only for fund receipient - # todo: check with skelletor if this is the right approach - if fund_receipient == self.fund_receipient: - new_emissions = min(self.max_integrate_fraction, new_emissions) - self.integrate_fraction[fund_receipient] += new_emissions + # multisig has received emissions + if multisig_emissions != 0: + self.integrate_fraction[self.cached_fallback_receiver] += multisig_emissions + + # this will only be the case if receiver got emissions + if multisig_emissions != new_emissions: + self.integrate_fraction[receiver] = receiver_emissions + self.last_checkpoint = block.timestamp log Checkpoint(block.timestamp, new_emissions) @@ -144,7 +166,7 @@ def set_killed(_is_killed: bool): @notice Set the gauge status @dev Inflation params are modified accordingly to disable/enable emissions """ - assert msg.sender == self.owner + assert msg.sender == Factory(self.factory).owner() if _is_killed: self.inflation_params = 0 @@ -153,23 +175,77 @@ def set_killed(_is_killed: bool): @external -def commit_transfer_ownership(_future_owner: address): +def update_cached_fallback_receiver(): """ - @notice Commit the transfer of ownership to `_future_owner` - @param _future_owner The account to commit as the future owner + @notice Update the cached fallback receiver """ - assert msg.sender == self.owner # dev: only owner + self.cached_fallback_receiver = Factory(self.factory).fallback_receiver() - self.future_owner = _future_owner +@view +@external +def max_emissions() -> uint256: + """ + @notice Get the maximum amount of emissions distributed to the receiver, afterwards + emissions are diverted to the Grant Council Multisig + """ + return shift(self.receiver_data, -40) + +@view +@external +def deadline() -> uint256: + """ + @notice Get the timestamp at which emissions are diverted to the Grant Council Multisig + """ + return bitwise_and(self.receiver_data, 2 ** 40 - 1) + + +@view +@external +def inflation_rate() -> uint256: + """ + @notice Get the locally stored inflation rate + """ + return shift(self.inflation_params, -40) + + +@view @external -def accept_transfer_ownership(): +def future_epoch_time() -> uint256: """ - @notice Accept the transfer of ownership - @dev Only the committed future owner can call this function + @notice Get the locally stored timestamp of the inflation rate epoch end """ - assert msg.sender == self.future_owner # dev: only future owner + return bitwise_and(self.inflation_params, 2 ** 40 - 1) - log TransferOwnership(self.owner, msg.sender) - self.owner = msg.sender + +@external +def initialize( + _receiver: address, + _deadline: uint256, + _max_emissions: uint256 +): + """ + @notice Proxy initializer method + @dev Placed last in the source file to save some gas, this fn is called only once. + Additional checks should be made by the DAO before voting in this gauge, specifically + to make sure that `_fund_recipient` is capable of collecting emissions. + @param _receiver The address which will receive CRV emissions + @param _deadline The timestamp at which emissions will redirect to + the Curve Grant Council Multisig + @param _max_emissions The maximum amount of emissions which `_receiver` will + receive + """ + assert self.factory == ZERO_ADDRESS # dev: already initialized + + assert _deadline < 2 ** 40 # dev: invalid deadline + assert _max_emissions < 2 ** 216 # dev: invalid maximum emissions + + self.factory = msg.sender + + self.receiver = _receiver + self.receiver_data = shift(_max_emissions, 40) + _deadline + self.cached_fallback_receiver = Factory(msg.sender).fallback_receiver() + + self.inflation_params = shift(CRV20(CRV).rate(), 40) + CRV20(CRV).future_epoch_time_write() + self.last_checkpoint = block.timestamp diff --git a/contracts/Factory.vy b/contracts/Factory.vy new file mode 100644 index 0000000..b3a0195 --- /dev/null +++ b/contracts/Factory.vy @@ -0,0 +1,92 @@ +# @version 0.3.1 +""" +@title veFunder Factory +@license MIT +""" + + +interface Funder: + def initialize(_receiver: address, _deadline: uint256, _max_emissions: uint256): nonpayable + + +event UpdateImplementation: + _old_implementation: address + _new_implementation: address + +event UpdateFallbackReceiver: + _old_fallback: address + _new_fallback: address + +event TransferOwnership: + _old_owner: address + _new_owner: address + +event NewFunder: + _receiver: indexed(address) + _deadline: uint256 + _max_emissions: uint256 + _funder_instance: address + + +implementation: public(address) +fallback_receiver: public(address) + +owner: public(address) +future_owner: public(address) + +get_funders_count: public(uint256) +funders: public(address[1000000]) + + +@external +def __init__(_fallback_receiver: address): + self.owner = msg.sender + self.fallback_receiver = _fallback_receiver + + log TransferOwnership(ZERO_ADDRESS, msg.sender) + log UpdateFallbackReceiver(ZERO_ADDRESS, _fallback_receiver) + + +@external +def deploy(_receiver: address, _deadline: uint256, _max_emissions: uint256) -> address: + funder: address = create_forwarder_to(self.implementation) + Funder(funder).initialize(_receiver, _deadline, _max_emissions) + + # update for easy enumeration + funders_count: uint256 = self.get_funders_count + self.funders[funders_count] = funder + self.get_funders_count = funders_count + 1 + + log NewFunder(_receiver, _deadline, _max_emissions, funder) + return funder + + +@external +def set_implementation(_implementation: address): + assert msg.sender == self.owner + + log UpdateImplementation(self.implementation, _implementation) + self.implementation = _implementation + + +@external +def set_fallback_receiver(_fallback_receiver: address): + assert msg.sender == self.owner + + log UpdateFallbackReceiver(self.fallback_receiver, _fallback_receiver) + self.fallback_receiver = _fallback_receiver + + +@external +def commit_transfer_ownership(_future_owner: address): + assert msg.sender == self.owner + + self.future_owner = _future_owner + + +@external +def accept_transfer_ownership(): + assert msg.sender == self.future_owner + + log TransferOwnership(self.owner, msg.sender) + self.owner = msg.sender diff --git a/tests/conftest.py b/tests/conftest.py index 3378679..0296740 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,14 @@ import pytest +pytest_plugins = ["fixtures.accounts", "fixtures.deployments"] + def pytest_sessionfinish(session, exitstatus): if exitstatus == pytest.ExitCode.NO_TESTS_COLLECTED: # we treat "no tests collected" as passing session.exitstatus = pytest.ExitCode.OK + + +@pytest.fixture(autouse=True) +def isolation(module_isolation, fn_isolation): + pass diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/accounts.py b/tests/fixtures/accounts.py new file mode 100644 index 0000000..b334c83 --- /dev/null +++ b/tests/fixtures/accounts.py @@ -0,0 +1,16 @@ +import pytest + + +@pytest.fixture(scope="session") +def alice(accounts): + return accounts[0] + + +@pytest.fixture(scope="session") +def bob(accounts): + return accounts[1] + + +@pytest.fixture(scope="session") +def charlie(accounts): + return accounts[2] diff --git a/tests/fixtures/deployments.py b/tests/fixtures/deployments.py new file mode 100644 index 0000000..61be88c --- /dev/null +++ b/tests/fixtures/deployments.py @@ -0,0 +1,63 @@ +import pytest +from brownie import compile_source + + +@pytest.fixture(scope="module") +def curve_dao(pm): + return pm("curvefi/curve-dao-contracts@1.3.0") + + +@pytest.fixture(scope="module") +def crv20(alice, chain, curve_dao): + crv = curve_dao.ERC20CRV.deploy("Curve DAO Token", "CRV", 18, {"from": alice}) + chain.sleep(86400 * 14) # let emissions begin + crv.update_mining_parameters({"from": alice}) + return crv + + +@pytest.fixture(scope="module") +def voting_escrow(alice, crv20, curve_dao): + return curve_dao.VotingEscrow.deploy(crv20, "Dummy VECRV", "veCRV", "v1", {"from": alice}) + + +@pytest.fixture(scope="module") +def gauge_controller(alice, crv20, voting_escrow, curve_dao): + return curve_dao.GaugeController.deploy(crv20, voting_escrow, {"from": alice}) + + +@pytest.fixture(scope="module") +def minter(alice, crv20, gauge_controller, curve_dao): + minter = curve_dao.Minter.deploy(crv20, gauge_controller, {"from": alice}) + crv20.set_minter(minter, {"from": alice}) + return minter + + +@pytest.fixture(scope="module") +def factory(alice, bob, Factory): + return Factory.deploy(bob, {"from": alice}) + + +@pytest.fixture(scope="module") +def CRVFunderLocal(alice, CRVFunder, crv20, gauge_controller): + src = CRVFunder._build["source"] + addrs = [ + "0xD533a949740bb3306d119CC777fa900bA034cd52", + "0x2F50D538606Fa9EDD2B11E2446BEb18C9D5846bB", + ] + for old, new in zip(addrs, [crv20.address, gauge_controller.address]): + src = src.replace(old, new, 1) + + return compile_source(src, vyper_version="0.3.1").Vyper + + +@pytest.fixture(scope="module") +def implementation(alice, CRVFunderLocal): + return CRVFunderLocal.deploy({"from": alice}) + + +@pytest.fixture(scope="module") +def funder(alice, factory, implementation, CRVFunderLocal): + factory.set_implementation(implementation, {"from": alice}) + return CRVFunderLocal.at( + factory.deploy(alice, 2**40 - 1, 2**128 - 1, {"from": alice}).return_value + ) diff --git a/tests/test_user_checkpoint.py b/tests/test_user_checkpoint.py new file mode 100644 index 0000000..c36e2f5 --- /dev/null +++ b/tests/test_user_checkpoint.py @@ -0,0 +1,138 @@ +import pytest +from brownie import chain + +WEEK = 7 * 86400 +YEAR = 365 * 86400 + + +@pytest.mark.skip_coverage +def test_emissions_against_expected(alice, gauge_controller, funder, crv20): + gauge_controller.add_type("Test", 10**18, {"from": alice}) + gauge_controller.add_gauge(funder, 0, 10**18, {"from": alice}) + + rate = crv20.rate() + future_epoch_time = crv20.future_epoch_time_write({"from": alice}).return_value + total_emissions = 0 + + prev_week_time = funder.last_checkpoint() + chain.mine(timedelta=2 * YEAR) + tx = funder.user_checkpoint(alice, {"from": alice}) + + while True: + week_time = min((prev_week_time + WEEK) // WEEK * WEEK, tx.timestamp) + gauge_weight = gauge_controller.gauge_relative_weight(funder, prev_week_time) + + if prev_week_time <= future_epoch_time < week_time: + total_emissions += ( + gauge_weight * rate * (future_epoch_time - prev_week_time) // 10**18 + ) + rate = rate * 10**18 // 1189207115002721024 + total_emissions += gauge_weight * rate * (week_time - future_epoch_time) // 10**18 + future_epoch_time += YEAR + else: + total_emissions += gauge_weight * rate * (week_time - prev_week_time) // 10**18 + + if week_time == tx.timestamp: + break + + prev_week_time = week_time + + assert total_emissions == funder.integrate_fraction(alice) + + +@pytest.mark.skip_coverage +def test_reach_deadline(alice, gauge_controller, factory, CRVFunderLocal, crv20): + deadline = chain.time() + WEEK * 5 + funder = CRVFunderLocal.at( + factory.deploy(alice, deadline, 2**216 - 1, {"from": alice}).return_value + ) + + gauge_controller.add_type("Test", 10**18, {"from": alice}) + gauge_controller.add_gauge(funder, 0, 10**18, {"from": alice}) + + rate = crv20.rate() + future_epoch_time = crv20.future_epoch_time_write({"from": alice}).return_value + + alice_emissions = 0 + fallback_emissions = 0 + + prev_week_time = funder.last_checkpoint() + chain.mine(timedelta=2 * YEAR) + tx = funder.user_checkpoint(alice, {"from": alice}) + + while True: + week_time = min((prev_week_time + WEEK) // WEEK * WEEK, tx.timestamp) + if prev_week_time < deadline < week_time: + week_time = deadline + + gauge_weight = gauge_controller.gauge_relative_weight(funder, prev_week_time) + emissions = 0 + + if prev_week_time <= future_epoch_time < week_time: + emissions += gauge_weight * rate * (future_epoch_time - prev_week_time) // 10**18 + rate = rate * 10**18 // 1189207115002721024 + emissions += gauge_weight * rate * (week_time - future_epoch_time) // 10**18 + future_epoch_time += YEAR + else: + emissions += gauge_weight * rate * (week_time - prev_week_time) // 10**18 + + if week_time <= deadline: + alice_emissions += emissions + else: + fallback_emissions += emissions + + if week_time == tx.timestamp: + break + + prev_week_time = week_time + + assert alice_emissions == funder.integrate_fraction(alice) + assert fallback_emissions == funder.integrate_fraction(funder.cached_fallback_receiver()) + + +@pytest.mark.skip_coverage +def test_reach_emissions_max(alice, gauge_controller, factory, CRVFunderLocal, crv20): + max_emissions = 500_000 * 10**18 + funder = CRVFunderLocal.at( + factory.deploy(alice, 2**40 - 1, max_emissions, {"from": alice}).return_value + ) + + gauge_controller.add_type("Test", 10**18, {"from": alice}) + gauge_controller.add_gauge(funder, 0, 10**18, {"from": alice}) + + rate = crv20.rate() + future_epoch_time = crv20.future_epoch_time_write({"from": alice}).return_value + + alice_emissions = 0 + fallback_emissions = 0 + + prev_week_time = funder.last_checkpoint() + chain.mine(timedelta=2 * YEAR) + tx = funder.user_checkpoint(alice, {"from": alice}) + + while True: + week_time = min((prev_week_time + WEEK) // WEEK * WEEK, tx.timestamp) + gauge_weight = gauge_controller.gauge_relative_weight(funder, prev_week_time) + emissions = 0 + + if prev_week_time <= future_epoch_time < week_time: + emissions += gauge_weight * rate * (future_epoch_time - prev_week_time) // 10**18 + rate = rate * 10**18 // 1189207115002721024 + emissions += gauge_weight * rate * (week_time - future_epoch_time) // 10**18 + future_epoch_time += YEAR + else: + emissions += gauge_weight * rate * (week_time - prev_week_time) // 10**18 + + if alice_emissions + emissions > max_emissions: + fallback_emissions += emissions - (max_emissions - alice_emissions) + alice_emissions = max_emissions + else: + alice_emissions += emissions + + if week_time == tx.timestamp: + break + + prev_week_time = week_time + + assert alice_emissions == funder.integrate_fraction(alice) == max_emissions + assert fallback_emissions == funder.integrate_fraction(funder.cached_fallback_receiver())