diff --git a/src/core/VoltRoles.sol b/src/core/VoltRoles.sol index 2aa5e730..4cf96bc9 100644 --- a/src/core/VoltRoles.sol +++ b/src/core/VoltRoles.sol @@ -26,6 +26,9 @@ library VoltRoles { /// @notice can mint VOLT arbitrarily bytes32 internal constant MINTER = keccak256("MINTER_ROLE"); + /// @notice can mint GUILD arbitrarily + bytes32 internal constant GUILD_MINTER = keccak256("GUILD_MINTER_ROLE"); + /// @notice is able to withdraw whitelisted PCV deposits to a safe address bytes32 internal constant PCV_GUARD = keccak256("PCV_GUARD_ROLE"); @@ -55,6 +58,22 @@ library VoltRoles { bytes32 internal constant RATE_LIMIT_SYSTEM_EXIT_REPLENISH = keccak256("RATE_LIMIT_SYSTEM_EXIT_REPLENISH_ROLE"); + /// ----------- GUILD Token Gauge Management --------------- + + /// @notice can manage add new gauges to the system + bytes32 internal constant GAUGE_ADD = keccak256("GAUGE_ADD_ROLE"); + + /// @notice can remove gauges from the system + bytes32 internal constant GAUGE_REMOVE = keccak256("GAUGE_REMOVE_ROLE"); + + /// @notice can manage gauge parameters (max gauges, individual cap) + bytes32 internal constant GAUGE_PARAMETERS = + keccak256("GAUGE_PARAMETERS_ROLE"); + + /// @notice can notify of losses in a given gauge + bytes32 internal constant GAUGE_LOSS_NOTIFIER = + keccak256("GAUGE_LOSS_NOTIFIER_ROLE"); + /// ----------- Timelock management ---------------------------------------- /// The hashes are the same as OpenZeppelins's roles in TimelockController diff --git a/src/governance/ERC20Gauges.sol b/src/governance/ERC20Gauges.sol index 6e43f6d1..34f60060 100644 --- a/src/governance/ERC20Gauges.sol +++ b/src/governance/ERC20Gauges.sol @@ -281,7 +281,7 @@ abstract contract ERC20Gauges is ERC20 { function incrementGauge( address gauge, uint112 weight - ) external returns (uint112 newUserWeight) { + ) public virtual returns (uint112 newUserWeight) { require(isGauge(gauge), "ERC20Gauges: invalid gauge"); uint32 currentCycle = _getGaugeCycleEnd(); _incrementGaugeWeight(msg.sender, gauge, weight, currentCycle); @@ -339,7 +339,7 @@ abstract contract ERC20Gauges is ERC20 { function incrementGauges( address[] calldata gaugeList, uint112[] calldata weights - ) external returns (uint112 newUserWeight) { + ) public virtual returns (uint112 newUserWeight) { uint256 size = gaugeList.length; require(weights.length == size, "ERC20Gauges: size mismatch"); @@ -378,7 +378,7 @@ abstract contract ERC20Gauges is ERC20 { function decrementGauge( address gauge, uint112 weight - ) external returns (uint112 newUserWeight) { + ) public virtual returns (uint112 newUserWeight) { uint32 currentCycle = _getGaugeCycleEnd(); // All operations will revert on underflow, protecting against bad inputs @@ -391,7 +391,7 @@ abstract contract ERC20Gauges is ERC20 { address gauge, uint112 weight, uint32 cycle - ) internal { + ) internal virtual { uint112 oldWeight = getUserGaugeWeight[user][gauge]; getUserGaugeWeight[user][gauge] = oldWeight - weight; @@ -425,7 +425,7 @@ abstract contract ERC20Gauges is ERC20 { function decrementGauges( address[] calldata gaugeList, uint112[] calldata weights - ) external returns (uint112 newUserWeight) { + ) public virtual returns (uint112 newUserWeight) { uint256 size = gaugeList.length; require(weights.length == size, "ERC20Gauges: size mismatch"); diff --git a/src/governance/GuildToken.sol b/src/governance/GuildToken.sol new file mode 100644 index 00000000..8787255b --- /dev/null +++ b/src/governance/GuildToken.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity =0.8.13; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Gauges} from "@voltprotocol/governance/ERC20Gauges.sol"; +import {CoreRefV2} from "@voltprotocol/refs/CoreRefV2.sol"; +import {VoltRoles} from "@voltprotocol/core/VoltRoles.sol"; + +/** +@title GUILD ERC20 Token +@author eswak +@notice This is the governance token of the Ethereum Credit Guild. + On deploy, this token is non-transferrable. + During the non-transferrable period, GUILD can still be minted & burnt, only + `transfer` and `transferFrom` are reverting. + + The gauge system is used to define debt ceilings on a set of lending terms. + Lending terms can be whitelisted by adding a gauge for their address, if GUILD + holders vote for these lending terms in the gauge system, the lending terms will + have a non-zero debt ceiling, and CREDIT will be available to borrow under these terms. + + When a loan is called and there is bad debt, a loss is notified in a gauge on this + contract (`notifyGaugeLoss`). When a loss is notified, all the GUILD token weight voting + for this gauge becomes non-transferable and can be permissionlessly slashed. Until the + loss is realized (`applyGaugeLoss`), a user cannot transfer their locked tokens or + decrease the weight they assign to the gauge that suffered a loss. + Even when a loss occur, users can still transfer tokens with which they vote for gauges + that did not suffer a loss. +*/ +contract GuildToken is CoreRefV2, ERC20Gauges { + constructor( + address _core, + uint32 _gaugeCycleLength, + uint32 _incrementFreezeWindow + ) + CoreRefV2(_core) + ERC20("Ethereum Credit Guild - GUILD", "GUILD") + ERC20Gauges(_gaugeCycleLength, _incrementFreezeWindow) + {} + + /*/////////////////////////////////////////////////////////////// + GAUGE MANAGEMENT + //////////////////////////////////////////////////////////////*/ + function addGauge( + address gauge + ) external onlyVoltRole(VoltRoles.GAUGE_ADD) returns (uint112) { + return _addGauge(gauge); + } + + function removeGauge( + address gauge + ) external onlyVoltRole(VoltRoles.GAUGE_REMOVE) { + _removeGauge(gauge); + } + + function setMaxGauges( + uint256 max + ) external onlyVoltRole(VoltRoles.GAUGE_PARAMETERS) { + _setMaxGauges(max); + } + + function setCanExceedMaxGauges( + address who, + bool can + ) external onlyVoltRole(VoltRoles.GAUGE_PARAMETERS) { + _setCanExceedMaxGauges(who, can); + } + + /*/////////////////////////////////////////////////////////////// + LOSS MANAGEMENT + //////////////////////////////////////////////////////////////*/ + + /// @notice emitted when a loss in a gauge is notified. + event GaugeLossNotification(address indexed gauge, uint256 indexed when); + /// @notice emitted when a loss in a gauge is applied. + event GaugeLossApply( + address indexed gauge, + address indexed who, + uint256 weight, + uint256 when + ); + + /// @notice last block.timestamp when a loss occurred in a given gauge + mapping(address => uint256) public lastGaugeLoss; + + /// @notice last block.timestamp when a user apply a loss that occurred in a given gauge + mapping(address => mapping(address => uint256)) public lastGaugeLossApplied; + + /// @notice notify of a loss in a given gauge + function notifyGaugeLoss( + address gauge + ) external onlyVoltRole(VoltRoles.GAUGE_LOSS_NOTIFIER) { + lastGaugeLoss[gauge] = block.timestamp; + emit GaugeLossNotification(gauge, block.timestamp); + } + + /// @notice apply a loss that occurred in a given gauge + /// anyone can apply the loss on behalf of anyone else + function applyGaugeLoss(address gauge, address who) external { + // check preconditions + uint256 _lastGaugeLoss = lastGaugeLoss[gauge]; + uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][who]; + require( + _lastGaugeLoss != 0 && _lastGaugeLossApplied < _lastGaugeLoss, + "GuildToken: no loss to apply" + ); + + // read user weight allocated to the lossy gauge + uint112 _userGaugeWeight = getUserGaugeWeight[who][gauge]; + + // remove gauge weight allocation + lastGaugeLossApplied[gauge][who] = block.timestamp; + uint32 currentCycle = _getGaugeCycleEnd(); + _decrementGaugeWeight(who, gauge, _userGaugeWeight, currentCycle); + _decrementUserAndGlobalWeights(who, _userGaugeWeight, currentCycle); + + // apply loss + _burn(who, uint256(_userGaugeWeight)); + emit GaugeLossApply( + gauge, + who, + uint256(_userGaugeWeight), + block.timestamp + ); + } + + /// @dev prevent weight increment for gauge if user has an unapplied loss + function incrementGauge( + address gauge, + uint112 weight + ) public override returns (uint112) { + uint256 _lastGaugeLoss = lastGaugeLoss[gauge]; + uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][msg.sender]; + require( + _lastGaugeLossApplied >= _lastGaugeLoss, + "GuildToken: pending loss" + ); + + return super.incrementGauge(gauge, weight); + } + + /// @dev prevent weight increment for gauges if user has an unapplied loss + function incrementGauges( + address[] calldata gaugeList, + uint112[] calldata weights + ) public override returns (uint112 newUserWeight) { + for (uint256 i = 0; i < gaugeList.length; ) { + address gauge = gaugeList[i]; + uint256 _lastGaugeLoss = lastGaugeLoss[gauge]; + uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][ + msg.sender + ]; + require( + _lastGaugeLossApplied >= _lastGaugeLoss, + "GuildToken: pending loss" + ); + unchecked { + ++i; + } + } + + return super.incrementGauges(gaugeList, weights); + } + + /// @dev prevent weight decrement for gauge if user has an unapplied loss + function decrementGauge( + address gauge, + uint112 weight + ) public override returns (uint112) { + uint256 _lastGaugeLoss = lastGaugeLoss[gauge]; + uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][msg.sender]; + require( + _lastGaugeLossApplied >= _lastGaugeLoss, + "GuildToken: pending loss" + ); + + return super.decrementGauge(gauge, weight); + } + + /// @dev prevent weight decrement for gauges if user has an unapplied loss + function decrementGauges( + address[] calldata gaugeList, + uint112[] calldata weights + ) public override returns (uint112 newUserWeight) { + for (uint256 i = 0; i < gaugeList.length; ) { + address gauge = gaugeList[i]; + uint256 _lastGaugeLoss = lastGaugeLoss[gauge]; + uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][ + msg.sender + ]; + require( + _lastGaugeLossApplied >= _lastGaugeLoss, + "GuildToken: pending loss" + ); + unchecked { + ++i; + } + } + + return super.decrementGauges(gaugeList, weights); + } + + /*/////////////////////////////////////////////////////////////// + TRANSFERABILITY + //////////////////////////////////////////////////////////////*/ + + /// @notice at deployment, tokens are not transferable (can only mint/burn). + /// Governance can enable transfers with `enableTransfers()`. + bool public transferable; // default = false + + /// @notice emitted when transfers are enabled. + event TransfersEnabled(uint256 block, uint256 timestamp); + + /// @notice permanently enable token transfers. + function enableTransfer() external onlyVoltRole(VoltRoles.GOVERNOR) { + transferable = true; + emit TransfersEnabled(block.number, block.timestamp); + } + + /// @dev prevent transfers if they are not globally enabled. + /// mint and burn (transfers to and from address 0) are accepted. + function _beforeTokenTransfer( + address from, + address to, + uint256 /* amount*/ + ) internal view override { + require( + transferable || from == address(0) || to == address(0), + "GuildToken: transfers disabled" + ); + } + + /// @dev prevent outbound token transfers (_decrementWeightUntilFree) and gauge weight decrease + /// (decrementGauge, decrementGauges) for users who have an unrealized loss in a gauge. + function _decrementGaugeWeight( + address user, + address gauge, + uint112 weight, + uint32 cycle + ) internal override { + uint256 _lastGaugeLoss = lastGaugeLoss[gauge]; + uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][user]; + require( + _lastGaugeLossApplied >= _lastGaugeLoss, + "GuildToken: pending loss" + ); + + super._decrementGaugeWeight(user, gauge, weight, cycle); + } + + /*/////////////////////////////////////////////////////////////// + MINT / BURN + //////////////////////////////////////////////////////////////*/ + + /// @notice mint new tokens to the target address + function mint( + address to, + uint256 amount + ) external onlyVoltRole(VoltRoles.GUILD_MINTER) { + _mint(to, amount); + } + + /// @notice burn a given amount of owned tokens + function burn(uint256 amount) external { + _burn(msg.sender, amount); + } +} diff --git a/test/unit/governance/GuildToken.t.sol b/test/unit/governance/GuildToken.t.sol new file mode 100644 index 00000000..7e3d91c3 --- /dev/null +++ b/test/unit/governance/GuildToken.t.sol @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.13; + +import {Test} from "@forge-std/Test.sol"; +import {CoreV2} from "@voltprotocol/core/CoreV2.sol"; +import {getCoreV2} from "@test/unit/utils/Fixtures.sol"; +import {VoltRoles} from "@voltprotocol/core/VoltRoles.sol"; +import {GuildToken} from "@voltprotocol/governance/GuildToken.sol"; +import {TestAddresses as addresses} from "@test/unit/utils/TestAddresses.sol"; + +contract GuildTokenUnitTest is Test { + CoreV2 private core; + GuildToken token; + address constant alice = address(0x616c696365); + address constant bob = address(0xB0B); + address constant gauge1 = address(0xDEAD); + address constant gauge2 = address(0xBEEF); + + uint32 constant _CYCLE_LENGTH = 1 hours; + uint32 constant _FREEZE_PERIOD = 10 minutes; + + function setUp() public { + vm.warp(1679067867); + vm.roll(16848497); + core = CoreV2(address(getCoreV2())); + token = new GuildToken(address(core), _CYCLE_LENGTH, _FREEZE_PERIOD); + + // labels + vm.label(address(core), "core"); + vm.label(address(token), "token"); + vm.label(alice, "alice"); + vm.label(bob, "bob"); + vm.label(gauge1, "gauge1"); + vm.label(gauge2, "gauge2"); + } + + /*/////////////////////////////////////////////////////////////// + TEST INITIAL STATE + //////////////////////////////////////////////////////////////*/ + + function testInitialState() public { + assertEq(address(token.core()), address(core)); + assertEq(token.transferable(), false); + } + + /*/////////////////////////////////////////////////////////////// + TEST MINT/BURN + //////////////////////////////////////////////////////////////*/ + + function testCanMintAndBurnWithoutTransfersEnabled() public { + // grant minter role to self + vm.startPrank(addresses.governorAddress); + core.createRole(VoltRoles.GUILD_MINTER, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GUILD_MINTER, address(this)); + vm.stopPrank(); + + assertEq(token.totalSupply(), 0); + + // mint to self + token.mint(address(this), 100e18); + assertEq(token.balanceOf(address(this)), 100e18); + assertEq(token.totalSupply(), 100e18); + + // burn from self + token.burn(100e18); + assertEq(token.balanceOf(address(this)), 0); + assertEq(token.totalSupply(), 0); + } + + /*/////////////////////////////////////////////////////////////// + TRANSFERABILITY + //////////////////////////////////////////////////////////////*/ + + function testEnableTransfer() public { + vm.expectRevert("UNAUTHORIZED"); + token.enableTransfer(); + vm.prank(addresses.governorAddress); + token.enableTransfer(); + assertEq(token.transferable(), true); + } + + function testRevertTransferIfNotEnabled() public { + // grant minter role to self + vm.startPrank(addresses.governorAddress); + core.createRole(VoltRoles.GUILD_MINTER, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GUILD_MINTER, address(this)); + vm.stopPrank(); + + // revert because transfers are not enabled + token.mint(alice, 100e18); + vm.expectRevert("GuildToken: transfers disabled"); + vm.prank(alice); + token.transfer(bob, 100e18); + + // enable transfers & transfer + vm.prank(addresses.governorAddress); + token.enableTransfer(); + vm.prank(alice); + token.transfer(bob, 100e18); + + // check the tokens moved + assertEq(token.balanceOf(alice), 0); + assertEq(token.balanceOf(bob), 100e18); + } + + function testRevertTransferFromIfNotEnabled() public { + // grant minter role to self + vm.startPrank(addresses.governorAddress); + core.createRole(VoltRoles.GUILD_MINTER, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GUILD_MINTER, address(this)); + vm.stopPrank(); + + // revert because transfers are not enabled + token.mint(alice, 100e18); + vm.prank(alice); + token.approve(bob, 100e18); + vm.expectRevert("GuildToken: transfers disabled"); + vm.prank(bob); + token.transferFrom(alice, bob, 100e18); + + // enable transfers & transfer + vm.prank(addresses.governorAddress); + token.enableTransfer(); + vm.prank(bob); + token.transferFrom(alice, bob, 100e18); + + // check the tokens moved + assertEq(token.balanceOf(alice), 0); + assertEq(token.balanceOf(bob), 100e18); + } + + /*/////////////////////////////////////////////////////////////// + GAUGE MANAGEMENT + //////////////////////////////////////////////////////////////*/ + + function testAddGauge() public { + // revert because user doesn't have role + vm.expectRevert("UNAUTHORIZED"); + token.addGauge(gauge1); + + // grant role to test contract + vm.startPrank(addresses.governorAddress); + core.createRole(VoltRoles.GAUGE_ADD, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GAUGE_ADD, address(this)); + vm.stopPrank(); + + // successful call & check + token.addGauge(gauge1); + assertEq(token.isGauge(gauge1), true); + } + + function testRemoveGauge() public { + // add gauge + vm.startPrank(addresses.governorAddress); + core.createRole(VoltRoles.GAUGE_ADD, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GAUGE_ADD, address(this)); + vm.stopPrank(); + token.addGauge(gauge1); + assertEq(token.isGauge(gauge1), true); + + // revert because user doesn't have role + vm.expectRevert("UNAUTHORIZED"); + token.removeGauge(gauge1); + + // grant role to test contract + vm.startPrank(addresses.governorAddress); + core.createRole(VoltRoles.GAUGE_REMOVE, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GAUGE_REMOVE, address(this)); + vm.stopPrank(); + + // successful call & check + token.removeGauge(gauge1); + assertEq(token.isGauge(gauge1), false); + } + + function testSetMaxGauges() public { + // revert because user doesn't have role + vm.expectRevert("UNAUTHORIZED"); + token.setMaxGauges(42); + + // grant role to test contract + vm.startPrank(addresses.governorAddress); + core.createRole(VoltRoles.GAUGE_PARAMETERS, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GAUGE_PARAMETERS, address(this)); + vm.stopPrank(); + + // successful call & check + token.setMaxGauges(42); + assertEq(token.maxGauges(), 42); + } + + function testSetCanExceedMaxGauges() public { + // revert because user doesn't have role + vm.expectRevert("UNAUTHORIZED"); + token.setCanExceedMaxGauges(alice, true); + assertEq(token.canExceedMaxGauges(alice), false); + + // grant role to test contract + vm.startPrank(addresses.governorAddress); + core.createRole(VoltRoles.GAUGE_PARAMETERS, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GAUGE_PARAMETERS, address(this)); + vm.stopPrank(); + + // successful call & check + token.setCanExceedMaxGauges(alice, true); + assertEq(token.canExceedMaxGauges(alice), true); + } + + /*/////////////////////////////////////////////////////////////// + LOSS MANAGEMENT + //////////////////////////////////////////////////////////////*/ + + function testNotifyGaugeLoss() public { + assertEq(token.lastGaugeLoss(gauge1), 0); + + // revert because user doesn't have role + vm.expectRevert("UNAUTHORIZED"); + token.notifyGaugeLoss(gauge1); + + // grant roles to test contract + vm.startPrank(addresses.governorAddress); + core.createRole(VoltRoles.GAUGE_LOSS_NOTIFIER, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GAUGE_LOSS_NOTIFIER, address(this)); + vm.stopPrank(); + + // successful call & check + token.notifyGaugeLoss(gauge1); + assertEq(token.lastGaugeLoss(gauge1), block.timestamp); + } + + function _setupAliceLossInGauge1() internal { + // grant roles to test contract + vm.startPrank(addresses.governorAddress); + core.createRole(VoltRoles.GUILD_MINTER, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GUILD_MINTER, address(this)); + core.createRole(VoltRoles.GAUGE_ADD, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GAUGE_ADD, address(this)); + core.createRole(VoltRoles.GAUGE_PARAMETERS, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GAUGE_PARAMETERS, address(this)); + core.createRole(VoltRoles.GAUGE_LOSS_NOTIFIER, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.GAUGE_LOSS_NOTIFIER, address(this)); + vm.stopPrank(); + + // setup + token.setMaxGauges(2); + token.addGauge(gauge1); + token.addGauge(gauge2); + token.mint(alice, 100e18); + vm.startPrank(alice); + token.incrementGauge(gauge1, 40e18); + token.incrementGauge(gauge2, 40e18); + vm.stopPrank(); + assertEq(token.userUnusedWeight(alice), 20e18); + assertEq(token.getUserWeight(alice), 80e18); + + // loss in gauge 1 + token.notifyGaugeLoss(gauge1); + } + + function testApplyGaugeLoss() public { + // revert if the gauge has no reported loss yet + vm.expectRevert("GuildToken: no loss to apply"); + token.applyGaugeLoss(gauge1, alice); + + _setupAliceLossInGauge1(); + + // realize loss in gauge 1 + token.applyGaugeLoss(gauge1, alice); + assertEq(token.lastGaugeLossApplied(gauge1, alice), block.timestamp); + assertEq(token.balanceOf(alice), 60e18); + assertEq(token.userUnusedWeight(alice), 20e18); + assertEq(token.getUserWeight(alice), 40e18); + assertEq(token.getUserGaugeWeight(alice, gauge1), 0); + assertEq(token.getUserGaugeWeight(alice, gauge2), 40e18); + + // can decrement gauge weights again since loss has been applied + vm.prank(alice); + token.decrementGauge(gauge2, 40e18); + assertEq(token.balanceOf(alice), 60e18); + assertEq(token.userUnusedWeight(alice), 60e18); + assertEq(token.getUserWeight(alice), 0); + } + + function testCannotTransferIfLossUnapplied() public { + _setupAliceLossInGauge1(); + + // enable transfers + vm.prank(addresses.governorAddress); + token.enableTransfer(); + + // alice cannot transfer tokens because of unrealized loss + vm.expectRevert("GuildToken: pending loss"); + vm.prank(alice); + token.transfer(bob, 60e18); + + // realize loss in gauge 1 + token.applyGaugeLoss(gauge1, alice); + + // can transfer + vm.prank(alice); + token.transfer(bob, 60e18); + assertEq(token.balanceOf(alice), 0); + assertEq(token.balanceOf(bob), 60e18); + } + + function testCannotTransferFromIfLossUnapplied() public { + _setupAliceLossInGauge1(); + + // enable transfers + vm.prank(addresses.governorAddress); + token.enableTransfer(); + + // alice approve bob to transferFrom + vm.prank(alice); + token.approve(bob, 100e18); + + // bob cannot transferFrom alice because of unrealized loss + vm.prank(bob); + vm.expectRevert("GuildToken: pending loss"); + token.transferFrom(alice, bob, 100e18); + + // realize loss in gauge 1 + token.applyGaugeLoss(gauge1, alice); + + // bob can transferFrom the unslashed tokens + vm.prank(bob); + token.transferFrom(alice, bob, 60e18); + + assertEq(token.balanceOf(alice), 0); + assertEq(token.balanceOf(bob), 60e18); + } + + function testCannotIncrementGaugeIfLossUnapplied() public { + _setupAliceLossInGauge1(); + + // can increment gauges that haven't been affected by the loss + vm.prank(alice); + token.incrementGauge(gauge2, 20e18); + + // cannot increment gauges that have been affected by the loss + vm.prank(alice); + vm.expectRevert("GuildToken: pending loss"); + token.incrementGauge(gauge1, 20e18); + + // realize loss in gauge 1 + token.applyGaugeLoss(gauge1, alice); + + assertEq(token.balanceOf(alice), 60e18); + } + + function testCannotIncrementGaugesIfLossUnapplied() public { + _setupAliceLossInGauge1(); + + // can increment gauges that haven't been affected by the loss + vm.prank(alice); + address[] memory gaugesToIncrement1 = new address[](1); + gaugesToIncrement1[0] = gauge2; + uint112[] memory amountsToIncrement1 = new uint112[](1); + amountsToIncrement1[0] = 20e18; + token.incrementGauges(gaugesToIncrement1, amountsToIncrement1); + + // cannot increment gauges that have been affected by the loss + address[] memory gaugesToIncrement2 = new address[](1); + gaugesToIncrement2[0] = gauge1; + uint112[] memory amountsToIncrement2 = new uint112[](1); + amountsToIncrement2[0] = 20e18; + vm.prank(alice); + vm.expectRevert("GuildToken: pending loss"); + token.incrementGauges(gaugesToIncrement2, amountsToIncrement2); + + // realize loss in gauge 1 + token.applyGaugeLoss(gauge1, alice); + + assertEq(token.balanceOf(alice), 60e18); + } + + function testCannotDecrementGaugeIfLossUnapplied() public { + _setupAliceLossInGauge1(); + + // can decrement gauges that haven't been affected by the loss + vm.prank(alice); + token.decrementGauge(gauge2, 40e18); + + // cannot decrement gauges that have been affected by the loss + vm.prank(alice); + vm.expectRevert("GuildToken: pending loss"); + token.decrementGauge(gauge1, 40e18); + + // realize loss in gauge 1 + token.applyGaugeLoss(gauge1, alice); + + assertEq(token.balanceOf(alice), 60e18); + } + + function testCannotDecrementGaugesIfLossUnapplied() public { + _setupAliceLossInGauge1(); + + // can decrement gauges that haven't been affected by the loss + vm.prank(alice); + address[] memory gaugesToDecrement1 = new address[](1); + gaugesToDecrement1[0] = gauge2; + uint112[] memory amountsToDecrement1 = new uint112[](1); + amountsToDecrement1[0] = 40e18; + token.decrementGauges(gaugesToDecrement1, amountsToDecrement1); + + // cannot decrement gauges that have been affected by the loss + address[] memory gaugesToDecrement2 = new address[](1); + gaugesToDecrement2[0] = gauge1; + uint112[] memory amountsToDecrement2 = new uint112[](1); + amountsToDecrement2[0] = 40e18; + vm.prank(alice); + vm.expectRevert("GuildToken: pending loss"); + token.decrementGauges(gaugesToDecrement2, amountsToDecrement2); + + // realize loss in gauge 1 + token.applyGaugeLoss(gauge1, alice); + + assertEq(token.balanceOf(alice), 60e18); + } +}