From c38c40af6bcda7f844bfe21674530e0203631ad0 Mon Sep 17 00:00:00 2001 From: Erwan Beauvois Date: Fri, 7 Apr 2023 15:05:29 +0200 Subject: [PATCH 1/2] Add smart contract check to ERC20Gauges --- src/governance/ERC20Gauges.sol | 7 +++++++ test/unit/governance/ERC20Gauges.t.sol | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/governance/ERC20Gauges.sol b/src/governance/ERC20Gauges.sol index 6e43f6d1..df410b06 100644 --- a/src/governance/ERC20Gauges.sol +++ b/src/governance/ERC20Gauges.sol @@ -560,6 +560,13 @@ abstract contract ERC20Gauges is ERC20 { address account, bool canExceedMax ) internal { + if (canExceedMax) { + require( + account.code.length != 0, + "ERC20Gauges: not a smart contract" + ); + } + canExceedMaxGauges[account] = canExceedMax; emit CanExceedMaxGaugesUpdate(account, canExceedMax); diff --git a/test/unit/governance/ERC20Gauges.t.sol b/test/unit/governance/ERC20Gauges.t.sol index ca174654..ef0ba184 100644 --- a/test/unit/governance/ERC20Gauges.t.sol +++ b/test/unit/governance/ERC20Gauges.t.sol @@ -61,6 +61,10 @@ contract ERC20GaugesUnitTest is Test { function testSetCanExceedMaxGauges() public { token.setCanExceedMaxGauges(address(this), true); require(token.canExceedMaxGauges(address(this))); + + // revert for non-smart contracts + vm.expectRevert("ERC20Gauges: not a smart contract"); + token.setCanExceedMaxGauges(address(0xBEEF), true); } function testAddGauge(address[8] memory gauges) public { From 6aa069c367befc9d158f12a753650e6007b595ef Mon Sep 17 00:00:00 2001 From: Erwan Beauvois Date: Fri, 7 Apr 2023 17:57:34 +0200 Subject: [PATCH 2/2] Add CREDIT token & its rate limited minter --- src/core/VoltRoles.sol | 7 + src/governance/CreditToken.sol | 26 ++++ src/rate-limits/RateLimitedCreditMinter.sol | 62 +++++++++ test/unit/governance/CreditToken.t.sol | 60 +++++++++ .../limiter/RateLimitedCreditMinter.t.sol | 127 ++++++++++++++++++ 5 files changed, 282 insertions(+) create mode 100644 src/governance/CreditToken.sol create mode 100644 src/rate-limits/RateLimitedCreditMinter.sol create mode 100644 test/unit/governance/CreditToken.t.sol create mode 100644 test/unit/limiter/RateLimitedCreditMinter.t.sol diff --git a/src/core/VoltRoles.sol b/src/core/VoltRoles.sol index 4cf96bc9..a50f07e7 100644 --- a/src/core/VoltRoles.sol +++ b/src/core/VoltRoles.sol @@ -26,6 +26,13 @@ library VoltRoles { /// @notice can mint VOLT arbitrarily bytes32 internal constant MINTER = keccak256("MINTER_ROLE"); + /// @notice can mint CREDIT arbitrarily + bytes32 internal constant CREDIT_MINTER = keccak256("CREDIT_MINTER_ROLE"); + + /// @notice can mint CREDIT within rate limits + bytes32 internal constant RATE_LIMITED_CREDIT_MINTER = + keccak256("RATE_LIMITED_CREDIT_MINTER_ROLE"); + /// @notice can mint GUILD arbitrarily bytes32 internal constant GUILD_MINTER = keccak256("GUILD_MINTER_ROLE"); diff --git a/src/governance/CreditToken.sol b/src/governance/CreditToken.sol new file mode 100644 index 00000000..29d04957 --- /dev/null +++ b/src/governance/CreditToken.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity =0.8.13; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import {CoreRefV2} from "@voltprotocol/refs/CoreRefV2.sol"; +import {VoltRoles} from "@voltprotocol/core/VoltRoles.sol"; + +/** +@title CREDIT ERC20 Token +@author eswak +@notice This is the debt token of the Ethereum Credit Guild. +*/ +contract CreditToken is CoreRefV2, ERC20Burnable { + constructor( + address _core + ) CoreRefV2(_core) ERC20("Ethereum Credit Guild - CREDIT", "CREDIT") {} + + /// @notice mint new tokens to the target address + function mint( + address to, + uint256 amount + ) external onlyVoltRole(VoltRoles.CREDIT_MINTER) { + _mint(to, amount); + } +} diff --git a/src/rate-limits/RateLimitedCreditMinter.sol b/src/rate-limits/RateLimitedCreditMinter.sol new file mode 100644 index 00000000..73cc42c8 --- /dev/null +++ b/src/rate-limits/RateLimitedCreditMinter.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity =0.8.13; + +import {CoreRefV2} from "@voltprotocol/refs/CoreRefV2.sol"; +import {VoltRoles} from "@voltprotocol/core/VoltRoles.sol"; +import {CreditToken} from "@voltprotocol/governance/CreditToken.sol"; +import {RateLimitedV2} from "@voltprotocol/utils/RateLimitedV2.sol"; + +/// @notice contract to mint CREDIT on a rate limit. +/// All minting should flow through this smart contract, as it should be the only one with +/// minting capabilities. +contract RateLimitedCreditMinter is RateLimitedV2 { + /// @notice the reference to CREDIT token + address public token; + + /// @param _core reference to the core smart contract + /// @param _token reference to the token to mint + /// @param _maxRateLimitPerSecond maximum rate limit per second that governance can set + /// @param _rateLimitPerSecond starting rate limit per second for Volt minting + /// @param _bufferCap cap on buffer size for this rate limited instance + constructor( + address _core, + address _token, + uint256 _maxRateLimitPerSecond, + uint128 _rateLimitPerSecond, + uint128 _bufferCap + ) + CoreRefV2(_core) + RateLimitedV2(_maxRateLimitPerSecond, _rateLimitPerSecond, _bufferCap) + { + token = _token; + } + + /// @notice Mint new tokens. + /// Pausable and depletes the buffer, reverts if buffer is used. + /// @param to the recipient address of the minted tokens. + /// @param amount the amount of tokens to mint. + function mint( + address to, + uint256 amount + ) + external + onlyVoltRole(VoltRoles.RATE_LIMITED_CREDIT_MINTER) + whenNotPaused + { + _depleteBuffer(amount); /// check and effects + CreditToken(token).mint(to, amount); /// interactions + } + + /// @notice replenish the buffer. + /// This can be used when tokens are burnt, for instance. + /// @param amount of tokens to replenish buffer by + function replenishBuffer( + uint256 amount + ) + external + onlyVoltRole(VoltRoles.RATE_LIMITED_CREDIT_MINTER) + whenNotPaused + { + _replenishBuffer(amount); /// effects + } +} diff --git a/test/unit/governance/CreditToken.t.sol b/test/unit/governance/CreditToken.t.sol new file mode 100644 index 00000000..b9766b98 --- /dev/null +++ b/test/unit/governance/CreditToken.t.sol @@ -0,0 +1,60 @@ +// 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 {CreditToken} from "@voltprotocol/governance/CreditToken.sol"; +import {TestAddresses as addresses} from "@test/unit/utils/TestAddresses.sol"; + +contract CreditTokenUnitTest is Test { + CoreV2 private core; + CreditToken token; + address constant alice = address(0x616c696365); + address constant bob = address(0xB0B); + + function setUp() public { + vm.warp(1679067867); + vm.roll(16848497); + core = CoreV2(address(getCoreV2())); + token = new CreditToken(address(core)); + + // labels + vm.label(address(core), "core"); + vm.label(address(token), "token"); + vm.label(alice, "alice"); + vm.label(bob, "bob"); + } + + function testInitialState() public { + assertEq(address(token.core()), address(core)); + assertEq(token.balanceOf(alice), 0); + assertEq(token.totalSupply(), 0); + } + + function testMintAccessControl() public { + // without role, mint reverts + vm.expectRevert("UNAUTHORIZED"); + token.mint(alice, 100); + + // create/grant role + vm.startPrank(addresses.governorAddress); + core.createRole(VoltRoles.CREDIT_MINTER, VoltRoles.GOVERNOR); + core.grantRole(VoltRoles.CREDIT_MINTER, address(this)); + vm.stopPrank(); + + // mint tokens for alice + token.mint(alice, 100); + assertEq(token.balanceOf(alice), 100); + assertEq(token.balanceOf(bob), 0); + assertEq(token.totalSupply(), 100); + + // alice transfers to bob + vm.prank(alice); + token.transfer(bob, 100); + assertEq(token.balanceOf(alice), 0); + assertEq(token.balanceOf(bob), 100); + assertEq(token.totalSupply(), 100); + } +} diff --git a/test/unit/limiter/RateLimitedCreditMinter.t.sol b/test/unit/limiter/RateLimitedCreditMinter.t.sol new file mode 100644 index 00000000..579a60d2 --- /dev/null +++ b/test/unit/limiter/RateLimitedCreditMinter.t.sol @@ -0,0 +1,127 @@ +// 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 {MockERC20} from "@test/mock/MockERC20.sol"; +import {getCoreV2} from "@test/unit/utils/Fixtures.sol"; +import {VoltRoles} from "@voltprotocol/core/VoltRoles.sol"; +import {RateLimitedCreditMinter} from "@voltprotocol/rate-limits/RateLimitedCreditMinter.sol"; +import {TestAddresses as addresses} from "@test/unit/utils/TestAddresses.sol"; + +contract RateLimitedCreditMinterUnitTest is Test { + RateLimitedCreditMinter public rlcm; + MockERC20 private token; + CoreV2 private core; + address constant alice = address(0x616c696365); + + uint256 MAX_RATE_LIMIT_PER_SECOND = 10 ether; + uint128 RATE_LIMIT_PER_SECOND = 10 ether; + uint128 BUFFER_CAP = 10_000_000 ether; + + function setUp() public { + vm.warp(1679067867); + vm.roll(16848497); + core = getCoreV2(); + token = new MockERC20(); + rlcm = new RateLimitedCreditMinter( + address(core), + address(token), + MAX_RATE_LIMIT_PER_SECOND, + RATE_LIMIT_PER_SECOND, + BUFFER_CAP + ); + + vm.label(address(token), "token"); + vm.label(address(core), "core"); + vm.label(address(rlcm), "rlcm"); + vm.label(address(this), "test"); + } + + function testInitialState() public { + assertEq(address(rlcm.core()), address(core)); + assertEq(rlcm.token(), address(token)); + } + + function testMint() public { + // without role, minting reverts + vm.expectRevert("UNAUTHORIZED"); + rlcm.mint(address(this), 100); + + // create/grant role + vm.startPrank(addresses.governorAddress); + core.createRole( + VoltRoles.RATE_LIMITED_CREDIT_MINTER, + VoltRoles.GOVERNOR + ); + core.grantRole(VoltRoles.RATE_LIMITED_CREDIT_MINTER, address(this)); + vm.stopPrank(); + + // mint tokens for alice + rlcm.mint(alice, 100); + assertEq(token.balanceOf(alice), 100); + assertEq(rlcm.buffer(), BUFFER_CAP - 100); + } + + function testReplenishBuffer() public { + // without role, replenishBuffer reverts + vm.expectRevert("UNAUTHORIZED"); + rlcm.replenishBuffer(100); + + // create/grant role + vm.startPrank(addresses.governorAddress); + core.createRole( + VoltRoles.RATE_LIMITED_CREDIT_MINTER, + VoltRoles.GOVERNOR + ); + core.grantRole(VoltRoles.RATE_LIMITED_CREDIT_MINTER, address(this)); + vm.stopPrank(); + + // mint all the available buffer for alice + rlcm.mint(alice, rlcm.buffer()); + assertEq(token.balanceOf(alice), BUFFER_CAP); + + // trying to mint more reverts + vm.expectRevert("RateLimited: no rate limit buffer"); + rlcm.mint(alice, 100); + + // replenish buffer + rlcm.replenishBuffer(100); + assertEq(rlcm.buffer(), 100); + + // can mint the replenished amount + rlcm.mint(alice, 100); + } + + function testMintPausable() public { + // create/grant role + vm.startPrank(addresses.governorAddress); + core.createRole( + VoltRoles.RATE_LIMITED_CREDIT_MINTER, + VoltRoles.GOVERNOR + ); + core.grantRole(VoltRoles.RATE_LIMITED_CREDIT_MINTER, address(this)); + rlcm.pause(); + vm.stopPrank(); + + // minting reverts because the contract is paused + vm.expectRevert("Pausable: paused"); + rlcm.mint(alice, 100); + } + + function testReplenishBufferPausable() public { + // create/grant role + vm.startPrank(addresses.governorAddress); + core.createRole( + VoltRoles.RATE_LIMITED_CREDIT_MINTER, + VoltRoles.GOVERNOR + ); + core.grantRole(VoltRoles.RATE_LIMITED_CREDIT_MINTER, address(this)); + rlcm.pause(); + vm.stopPrank(); + + // replenishBuffer reverts because the contract is paused + vm.expectRevert("Pausable: paused"); + rlcm.replenishBuffer(100); + } +}