Skip to content

Commit

Permalink
Update locking formula (#375)
Browse files Browse the repository at this point in the history
### Description

Currently the voting power formula looks like this (slightly
simplified):

```math
P(t,s,c) = t * (0.2 + 0.8 * c / c_{max} + 0.6 * s / s_{max})
```

We want to bring the system closer to the classical veCurve formula, by
changing it to:

```math
P(t,s,c) = t * min(s/s_{max} + c/c_{max}, 1)
```

More details can be seen here
#373

Since this formula sits in the core of the voting power calculations,
reviews should be on a broader level. Please go through code changes,
test calculations and more importantly how this may affect the whole
locking mechanism.

Known issues:
- users can lock with a suboptimal schedule if the summed multiplier
goes above 1.

### Other changes

Updated the tests to use the exact amounts and not approximations
Removed unused imports

### Tested

Fixed the tests related to change. Actual calculations are added as
comments.
Some extra fuzz tests are WIP and will be added with a separate PR

### Related issues

- Fixes #373

---------

Co-authored-by: baroooo <[email protected]>
  • Loading branch information
bowd and baroooo authored Feb 22, 2024
1 parent ffa61c4 commit 472a1d9
Show file tree
Hide file tree
Showing 8 changed files with 380 additions and 156 deletions.
48 changes: 31 additions & 17 deletions contracts/governance/locking/LockingBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,7 @@ abstract contract LockingBase is OwnableUpgradeable, IVotesUpgradeable {
uint32 constant MAX_CLIFF_PERIOD = 103;
uint32 constant MAX_SLOPE_PERIOD = 104;

uint32 constant ST_FORMULA_DIVIDER = 1 * (10**8); //stFormula divider 100000000
uint32 constant ST_FORMULA_CONST_MULTIPLIER = 2 * (10**7); //stFormula const multiplier 20000000
uint32 constant ST_FORMULA_CLIFF_MULTIPLIER = 8 * (10**7); //stFormula cliff multiplier 80000000
uint32 constant ST_FORMULA_SLOPE_MULTIPLIER = 4 * (10**7); //stFormula slope multiplier 40000000

uint32 constant ST_FORMULA_BASIS = 1 * (10**8); // stFormula basis 100_000_000
/**
* @dev ERC20 token to lock
*/
Expand Down Expand Up @@ -190,12 +186,27 @@ abstract contract LockingBase is OwnableUpgradeable, IVotesUpgradeable {
}

/**
* Сalculate and return (newAmount, newSlope), using formula:
* locking = (tokens * (
* ST_FORMULA_CONST_MULTIPLIER
* + ST_FORMULA_CLIFF_MULTIPLIER * (cliffPeriod - minCliffPeriod))/(MAX_CLIFF_PERIOD - minCliffPeriod)
* + ST_FORMULA_SLOPE_MULTIPLIER * (slopePeriod - minSlopePeriod))/(MAX_SLOPE_PERIOD - minSlopePeriod)
* )) / ST_FORMULA_DIVIDER
* @dev Сalculate and return (lockAmount, lockSlope), using formula:
* P = t * min(c/c_max + s/s_max, 1),
*
* The formula has the following properties:
* - the voting power can't exceed the amount of tokens locked.
* - a voter can reach 100% voting power by relying on either the slope or the cliff,
* or a combination of both.
* - there is a parameter space above a diagonal on the (c, s) plane where the
* voting power is capped at 100%, moving past that diagonal is disadvantageous
* but the contract doesn't forbid it.
*
*
* The formula roughly translates to solidity as:
* votingPower = (
* tokens *
* min(
* (ST_FORMULA_BASIS * cliffPeriod) / MAX_CLIFF_PERIOD +
* (ST_FORMULA_BASIS * slopePeriod) / MAX_SLOPE_PERIOD,
* ST_FORMULA_BASIS
* )
* ) / ST_FORMULA_BASIS
**/
function getLock(
uint96 amount,
Expand All @@ -205,14 +216,17 @@ abstract contract LockingBase is OwnableUpgradeable, IVotesUpgradeable {
require(cliff >= minCliffPeriod, "cliff period < minimal lock period");
require(slopePeriod >= minSlopePeriod, "slope period < minimal lock period");

uint96 cliffSide = (uint96(cliff - uint32(minCliffPeriod)) * (ST_FORMULA_CLIFF_MULTIPLIER)) /
(MAX_CLIFF_PERIOD - uint32(minCliffPeriod));
uint96 slopeSide = (uint96((slopePeriod - uint32(minSlopePeriod))) * (ST_FORMULA_SLOPE_MULTIPLIER)) /
(MAX_SLOPE_PERIOD - uint32(minSlopePeriod));
uint96 multiplier = cliffSide + (slopeSide) + (ST_FORMULA_CONST_MULTIPLIER);
uint96 cliffSide = (uint96(cliff) * ST_FORMULA_BASIS) / MAX_CLIFF_PERIOD;
uint96 slopeSide = (uint96(slopePeriod) * ST_FORMULA_BASIS) / MAX_SLOPE_PERIOD;
uint96 multiplier = cliffSide + slopeSide;

if (multiplier > ST_FORMULA_BASIS) {
multiplier = ST_FORMULA_BASIS;
}

uint256 amountMultiplied = uint256(amount) * uint256(multiplier);
lockAmount = uint96(amountMultiplied / (ST_FORMULA_DIVIDER));
lockAmount = uint96(amountMultiplied / (ST_FORMULA_BASIS));
require(lockAmount > 0, "voting power is 0");
lockSlope = divUp(lockAmount, slopePeriod);
}

Expand Down
1 change: 0 additions & 1 deletion test/governance/Airgrab.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
pragma solidity 0.8.18;
// solhint-disable func-name-mixedcase, state-visibility, max-states-count, var-name-mixedcase

import { console } from "forge-std-next/console.sol";
import { Test } from "forge-std-next/Test.sol";
import { Arrays } from "test/utils/Arrays.sol";

Expand Down
43 changes: 27 additions & 16 deletions test/governance/IntegrationTests/GovernanceIntegration.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,6 @@ contract GovernanceIntegrationTest is TestSetup {
}

function test_locking_whenLocked_shouldMintveMentoInExchangeForMentoAndReleaseBySchedule() public {
// Since we use the min slope period of 1, calculations are slightly off as expected
uint256 negligibleAmount = 3e18;

vm.prank(governanceTimelockAddress);
mentoToken.transfer(alice, 1000e18);

Expand All @@ -224,32 +221,37 @@ contract GovernanceIntegrationTest is TestSetup {
vm.prank(bob);
uint256 bobsLockId = locking.lock(bob, bob, 1000e18, 52, 0);

// Difference between voting powers accounted correctly
assertApproxEqAbs(locking.getVotes(alice), 300e18, negligibleAmount);
assertApproxEqAbs(locking.getVotes(bob), 400e18, negligibleAmount);
// 1000e18 * ((uint96((26)) * (1e8)) / (104 ) / 1e8) = 250e18
assertEq(locking.getVotes(alice), 250e18);
// 1000e18 * ((uint96((52)) * (1e8)) / (104) / 1e8) = 500e18
assertEq(locking.getVotes(bob), 500e18);

vm.timeTravel(13 * BLOCKS_WEEK);

// Alice withdraws after ~3 months
vm.prank(alice);
locking.withdraw();

// Half of the tokens should be released, half of the voting power should be revoked
assertApproxEqAbs(locking.getVotes(alice), 150e18, negligibleAmount);
assertApproxEqAbs(mentoToken.balanceOf(alice), 500e18, negligibleAmount);
// (250e18 - 1) / 26 + 1 = 9615384615384615385
// 250e18 - 9615384615384615385 * 13 = 124999999999999999995
assertEq(locking.getVotes(alice), 124999999999999999995);
// Slight difference between calculated and returned amount due to rounding
assertApproxEqAbs(mentoToken.balanceOf(alice), 500e18, 10);

// Bob's voting power is 3/4 of the initial voting power
assertApproxEqAbs(locking.getVotes(bob), 300e18, negligibleAmount);
// (500e18 - 1) / 52 + 1 = 9615384615384615385
// 500e18 - 9615384615384615385 * 13 = 374999999999999999995
assertEq(locking.getVotes(bob), 374999999999999999995);
assertEq(mentoToken.balanceOf(bob), 0);

vm.timeTravel(13 * BLOCKS_WEEK);

// Bob relocks and delegates to alice
// 1000e18 * ((uint96((26)) * (1e8)) / (104 ) / 1e8) = 250e18
vm.prank(bob);
bobsLockId = locking.relock(bobsLockId, alice, 1000e18, 26, 0);

// Alice has the voting power from Bob's lock
assertApproxEqAbs(locking.getVotes(alice), 300e18, negligibleAmount);
assertEq(locking.getVotes(alice), 250e18);
assertEq(locking.getVotes(bob), 0);

vm.timeTravel(13 * BLOCKS_WEEK);
Expand All @@ -260,7 +262,9 @@ contract GovernanceIntegrationTest is TestSetup {

assertEq(locking.getVotes(alice), 0);
assertEq(locking.getVotes(bob), 0);
assertApproxEqAbs(locking.getVotes(charlie), 150e18, negligibleAmount);
// (250e18 - 1) / 26 + 1 = 9615384615384615385
// 250e18 - 9615384615384615385 * 13 = 124999999999999999995
assertEq(locking.getVotes(charlie), 124999999999999999995);

// End of the locking period
vm.timeTravel(13 * BLOCKS_WEEK);
Expand Down Expand Up @@ -374,18 +378,25 @@ contract GovernanceIntegrationTest is TestSetup {
validUntil,
approvedAt
);

// claimer0Amount = 100e18
// slope = 104
// cliff = 0
vm.prank(claimer0);
airgrab.claim(claimer0Amount, claimer0, claimer0Proof, fractalProof0, validUntil, approvedAt, "fractalId");

// claimer1Amount = 20_000e18
// slope = 104
// cliff = 0
// claim with a delegate
vm.prank(claimer1);
airgrab.claim(claimer1Amount, alice, claimer1Proof, fractalProof1, validUntil, approvedAt, "fractalId");

// claimed amounts are locked automatically
assertEq(locking.getVotes(claimer0), 60e18);
// 100e18 * (103 / 103) = 100e18
assertEq(locking.getVotes(claimer0), 100e18);
assertEq(locking.getVotes(claimer1), 0);
assertEq(locking.getVotes(alice), 12_000e18);
// 20_000e18 * (103 / 103) = 20_000e18
assertEq(locking.getVotes(alice), 20_000e18);

vm.timeTravel(BLOCKS_DAY);

Expand Down
170 changes: 170 additions & 0 deletions test/governance/IntegrationTests/LockingIntegration.fuzz.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.18;
// solhint-disable func-name-mixedcase, max-line-length, max-states-count

import { TestSetup } from "../TestSetup.sol";
import { Vm } from "forge-std-next/Vm.sol";
import { VmExtension } from "test/utils/VmExtension.sol";
import { Arrays } from "test/utils/Arrays.sol";

import { GovernanceFactory } from "contracts/governance/GovernanceFactory.sol";
import { MentoToken } from "contracts/governance/MentoToken.sol";
import { Locking } from "contracts/governance/locking/Locking.sol";
import { TimelockController } from "contracts/governance/TimelockController.sol";
import { console } from "forge-std-next/console.sol";

/**
* @title Fuzz Testing for Locking Integration
* @dev Fuzz tests to ensure the locking mechanism integrates correctly with the governance system, providing the expected voting power based on token lock amount and duration.
*/
contract FuzzLockingIntegrationTest is TestSetup {
using VmExtension for Vm;

GovernanceFactory public factory;

MentoToken public mentoToken;
TimelockController public governanceTimelock;
Locking public locking;

address public celoGovernance = makeAddr("CeloGovernance");
address public celoCommunityFund = makeAddr("CeloCommunityFund");
address public watchdogMultisig = makeAddr("WatchdogMultisig");
address public mentoLabsMultisig = makeAddr("MentoLabsMultisig");
address public fractalSigner = makeAddr("FractalSigner");

bytes32 public merkleRoot = bytes32("MockMerkleRoot");

function setUp() public {
vm.roll(21871402); // (Oct-11-2023 WED 12:00:01 PM +UTC)
vm.warp(1697025601); // (Oct-11-2023 WED 12:00:01 PM +UTC)

vm.prank(owner);
factory = new GovernanceFactory(celoGovernance);

GovernanceFactory.MentoTokenAllocationParams memory allocationParams = GovernanceFactory
.MentoTokenAllocationParams({
airgrabAllocation: 50,
mentoTreasuryAllocation: 100,
additionalAllocationRecipients: Arrays.addresses(address(mentoLabsMultisig)),
additionalAllocationAmounts: Arrays.uints(200)
});

vm.prank(celoGovernance);
factory.createGovernance(watchdogMultisig, celoCommunityFund, merkleRoot, fractalSigner, allocationParams);
mentoToken = factory.mentoToken();
governanceTimelock = factory.governanceTimelock();
locking = factory.locking();

vm.prank(alice);
mentoToken.approve(address(locking), type(uint256).max);
}

/**
* @dev Fuzz test to verify correct voting power allocation for locked tokens over a shorter timeframe.
*/
function test_lock_shouldGiveCorrectVotingPower_whenShorterTimeframe_Fuzz(
uint96 amount,
uint32 slope,
uint32 cliff,
uint96 period
) public {
vm.assume(amount < mentoToken.balanceOf(address(address(governanceTimelock))));
vm.assume(amount > 1000);
vm.assume(slope >= locking.minSlopePeriod());
vm.assume(slope <= 104);
vm.assume(cliff >= locking.minCliffPeriod());
vm.assume(cliff <= 103);
vm.assume(period <= 208); // 4 years

vm.prank(address(governanceTimelock));
mentoToken.transfer(alice, amount);

vm.prank(alice);
locking.lock(alice, alice, amount, slope, cliff);

assertEq(locking.getVotes(alice), calculateVotes(amount, slope, cliff));

vm.timeTravel(BLOCKS_WEEK * period);

uint256 balanceBefore = mentoToken.balanceOf(alice);

vm.prank(alice);
locking.withdraw();

uint256 balanceAfter = mentoToken.balanceOf(alice);

if (period > cliff) {
assert(balanceAfter > balanceBefore);
} else {
assertEq(balanceAfter, balanceBefore);
}

if (period > slope + cliff) {
assertEq(locking.getVotes(alice), 0);
assertEq(balanceAfter, amount);
}
}

/**
* @dev Fuzz test to verify correct voting power allocation for locked tokens over a longer timeframe.
* Focuses on longer periods to avoid sparse coverage for more frequent usecase: period being < 4 years
*/
function test_lock_shouldGiveCorrectVotingPower_whenLongerTimeframe_Fuzz(
uint96 amount,
uint32 slope,
uint32 cliff,
uint96 period
) public {
vm.assume(amount < mentoToken.balanceOf(address(address(governanceTimelock))));
vm.assume(amount > 1000);
vm.assume(slope >= locking.minSlopePeriod());
vm.assume(slope <= 104);
vm.assume(cliff >= locking.minCliffPeriod());
vm.assume(cliff <= 103);
vm.assume(period <= 2080); // 40 years

vm.prank(address(governanceTimelock));
mentoToken.transfer(alice, amount);

vm.prank(alice);
locking.lock(alice, alice, amount, slope, cliff);

assertEq(locking.getVotes(alice), calculateVotes(amount, slope, cliff));

vm.timeTravel(BLOCKS_WEEK * period);

uint256 balanceBefore = mentoToken.balanceOf(alice);

vm.prank(alice);
locking.withdraw();

uint256 balanceAfter = mentoToken.balanceOf(alice);

if (period > cliff) {
assert(balanceAfter > balanceBefore);
} else {
assertEq(balanceAfter, balanceBefore);
}

if (period > slope + cliff) {
assertEq(locking.getVotes(alice), 0);
assertEq(balanceAfter, amount);
}
}

/**
* @dev Calculates the expected voting power based on lock amount, slope, and cliff.
*/
function calculateVotes(
uint96 amount,
uint32 slope,
uint32 cliff
) public pure returns (uint96) {
uint96 cliffSide = (uint96(cliff) * 1e8) / 103;
uint96 slopeSide = (uint96(slope) * 1e8) / 104;
uint96 multiplier = cliffSide + slopeSide;
if (multiplier > 1e8) multiplier = 1e8;

return uint96((uint256(amount) * uint256(multiplier)) / 1e8);
}
}
Loading

0 comments on commit 472a1d9

Please sign in to comment.