diff --git a/cspell.json b/cspell.json index 2be05e7f38ed..f5d2ce15bbf4 100644 --- a/cspell.json +++ b/cspell.json @@ -189,6 +189,7 @@ "merkleizing", "messagebox", "mimc", + "mintable", "mktemp", "mload", "mockify", diff --git a/l1-contracts/src/governance/CoinIssuer.sol b/l1-contracts/src/governance/CoinIssuer.sol index b4767dcb07ea..a31edfbfb2c2 100644 --- a/l1-contracts/src/governance/CoinIssuer.sol +++ b/l1-contracts/src/governance/CoinIssuer.sol @@ -11,17 +11,49 @@ import {Ownable2Step} from "@oz/access/Ownable2Step.sol"; /** * @title CoinIssuer * @author Aztec Labs - * @notice A contract that allows minting of coins at a maximum fixed rate + * @notice A contract that allows minting of coins at a maximum percentage rate per year using discrete annual budgets + * + * This contract uses a discrete annual budget model: + * - Years are fixed periods from deployment: + * - year 0 = [deployment, deployment + 365d) + * - year 1 = [deployment + 365d, deployment + (2) * 365d) + * - ... + * - year n = [deployment + 365d * n, deployment + (n + 1) * 365d) + * - Each year's budget is calculated at the start of that year based on the actual supply at that moment + * - Budget = totalSupply() × NOMINAL_ANNUAL_PERCENTAGE_CAP / 1e18 + * - Unused budget from year N is LOST when year N+1 begins (use-it-or-lose-it) + * + * Rate semantics: If the full budget is minted every year, the effective annual inflation rate equals + * NOMINAL_ANNUAL_PERCENTAGE_CAP. For example, setting the rate to 0.10e18 (10%) and fully minting each + * year will result in supply growing by exactly 10% annually: supply(year N) = supply(year 0) × (1.10)^N + * + * Partial minting: If less than the full budget is minted in year N, the remaining allowance is lost + * at the year N→N+1 boundary. Year N+1's budget is calculated based on the actual supply at the start + * of year N+1, which reflects only what was actually minted. + * + * @dev The NOMINAL_ANNUAL_PERCENTAGE_CAP is in e18 precision where 1e18 = 100% + * + * @dev The token MUST have a non-zero initial supply at deployment, or an alternative way to mint the token. */ contract CoinIssuer is ICoinIssuer, Ownable { IMintableERC20 public immutable ASSET; - uint256 public immutable RATE; - uint256 public timeOfLastMint; + uint256 public immutable NOMINAL_ANNUAL_PERCENTAGE_CAP; + uint256 public immutable DEPLOYMENT_TIME; - constructor(IMintableERC20 _asset, uint256 _rate, address _owner) Ownable(_owner) { + // Note that the state variables below are "cached": + // they are only updated when minting after a year boundary. + uint256 public cachedBudgetYear; + uint256 public cachedBudget; + + constructor(IMintableERC20 _asset, uint256 _annualPercentage, address _owner) Ownable(_owner) { ASSET = _asset; - RATE = _rate; - timeOfLastMint = block.timestamp; + NOMINAL_ANNUAL_PERCENTAGE_CAP = _annualPercentage; + DEPLOYMENT_TIME = block.timestamp; + + cachedBudgetYear = 0; + cachedBudget = _getNewBudget(); + + emit BudgetReset(0, cachedBudget); } function acceptTokenOwnership() external override(ICoinIssuer) onlyOwner { @@ -29,26 +61,70 @@ contract CoinIssuer is ICoinIssuer, Ownable { } /** - * @notice Mint tokens up to the `mintAvailable` limit - * Beware that the mintAvailable will be reset to 0, and not just - * reduced by the amount minted. + * @notice Mint `_amount` tokens to `_to` + * + * @dev The `_amount` must be within the `cachedBudget` * * @param _to - The address to receive the funds * @param _amount - The amount to mint */ function mint(address _to, uint256 _amount) external override(ICoinIssuer) onlyOwner { - uint256 maxMint = mintAvailable(); - require(_amount <= maxMint, Errors.CoinIssuer__InsufficientMintAvailable(maxMint, _amount)); - timeOfLastMint = block.timestamp; + // Update state if we've crossed into a new year (will reset budget and forfeit unused amount) + _updateBudgetIfNeeded(); + + require(_amount <= cachedBudget, Errors.CoinIssuer__InsufficientMintAvailable(cachedBudget, _amount)); + cachedBudget -= _amount; + ASSET.mint(_to, _amount); } /** - * @notice The amount of funds that is available for "minting" + * @notice The amount of funds that is available for "minting" in the current year + * If we've crossed into a new year since the last mint, returns the fresh budget + * for the new year based on current supply. * * @return The amount mintable */ function mintAvailable() public view override(ICoinIssuer) returns (uint256) { - return RATE * (block.timestamp - timeOfLastMint); + uint256 currentYear = _yearSinceGenesis(); + + // Until the budget is stale, return the cached budget + if (cachedBudgetYear >= currentYear) { + return cachedBudget; + } + + // Crossed into new year(s): compute fresh budget + return _getNewBudget(); + } + + /** + * @notice Internal function to update year and budget when crossing year boundaries + * + * @dev If multiple years have passed without minting, jumps directly to current year + * and all intermediate years' budgets are lost + */ + function _updateBudgetIfNeeded() private { + uint256 currentYear = _yearSinceGenesis(); + // If the budget is for the past, update the budget. + if (cachedBudgetYear < currentYear) { + cachedBudgetYear = currentYear; + cachedBudget = _getNewBudget(); + + emit BudgetReset(currentYear, cachedBudget); + } + } + + /** + * @notice Internal function to compute the current year since genesis + */ + function _yearSinceGenesis() private view returns (uint256) { + return (block.timestamp - DEPLOYMENT_TIME) / 365 days; + } + + /** + * @notice Internal function to compute a fresh budget + */ + function _getNewBudget() private view returns (uint256) { + return ASSET.totalSupply() * NOMINAL_ANNUAL_PERCENTAGE_CAP / 1e18; } } diff --git a/l1-contracts/src/governance/interfaces/ICoinIssuer.sol b/l1-contracts/src/governance/interfaces/ICoinIssuer.sol index b770c11352d6..229cc139e331 100644 --- a/l1-contracts/src/governance/interfaces/ICoinIssuer.sol +++ b/l1-contracts/src/governance/interfaces/ICoinIssuer.sol @@ -3,6 +3,8 @@ pragma solidity >=0.8.27; interface ICoinIssuer { + event BudgetReset(uint256 indexed newYear, uint256 newBudget); + function mint(address _to, uint256 _amount) external; function acceptTokenOwnership() external; function mintAvailable() external view returns (uint256); diff --git a/l1-contracts/test/DateGatedRelayer.t.sol b/l1-contracts/test/DateGatedRelayer.t.sol index 5aa179ac9a98..0ca5771fba9f 100644 --- a/l1-contracts/test/DateGatedRelayer.t.sol +++ b/l1-contracts/test/DateGatedRelayer.t.sol @@ -35,7 +35,8 @@ contract DateGatedRelayerTest is Test { uint256 gatedUntil = bound(_gatedUntil, block.timestamp + 1, type(uint32).max); TestERC20 testERC20 = new TestERC20("test", "TEST", address(this)); - CoinIssuer coinIssuer = new CoinIssuer(testERC20, 100, address(this)); + testERC20.mint(address(this), 1e18); + CoinIssuer coinIssuer = new CoinIssuer(testERC20, 100e18, address(this)); testERC20.transferOwnership(address(coinIssuer)); coinIssuer.acceptTokenOwnership(); @@ -45,11 +46,15 @@ contract DateGatedRelayerTest is Test { uint256 warp = bound(_warp, gatedUntil, type(uint32).max); vm.expectRevert(); - coinIssuer.mint(address(this), 100); + coinIssuer.mint(address(this), 1); vm.warp(warp); - dateGatedRelayer.relay(address(coinIssuer), abi.encodeWithSelector(CoinIssuer.mint.selector, address(this), 100)); + uint256 mintAvailable = coinIssuer.mintAvailable(); + dateGatedRelayer.relay( + address(coinIssuer), abi.encodeWithSelector(CoinIssuer.mint.selector, address(this), mintAvailable) + ); - assertEq(testERC20.balanceOf(address(this)), 100); + assertEq(testERC20.balanceOf(address(this)), mintAvailable + 1e18, "balanceOf"); + assertEq(testERC20.totalSupply(), mintAvailable + 1e18, "totalSupply"); } } diff --git a/l1-contracts/test/governance/coin-issuer/Base.t.sol b/l1-contracts/test/governance/coin-issuer/Base.t.sol index 2acfb215bc87..5d99e3a1c186 100644 --- a/l1-contracts/test/governance/coin-issuer/Base.t.sol +++ b/l1-contracts/test/governance/coin-issuer/Base.t.sol @@ -13,9 +13,10 @@ contract CoinIssuerBase is Test { CoinIssuer internal nom; - function _deploy(uint256 _rate) internal { + function _deploy(uint256 _rate, uint256 _initialSupply) internal { TestERC20 testERC20 = new TestERC20("test", "TEST", address(this)); token = IMintableERC20(address(testERC20)); + token.mint(address(this), _initialSupply); nom = new CoinIssuer(token, _rate, address(this)); testERC20.transferOwnership(address(nom)); nom.acceptTokenOwnership(); diff --git a/l1-contracts/test/governance/coin-issuer/acceptTokenOwnership.t.sol b/l1-contracts/test/governance/coin-issuer/acceptTokenOwnership.t.sol new file mode 100644 index 000000000000..36f2c31651b6 --- /dev/null +++ b/l1-contracts/test/governance/coin-issuer/acceptTokenOwnership.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {Ownable} from "@oz/access/Ownable.sol"; +import {Ownable2Step} from "@oz/access/Ownable2Step.sol"; +import {CoinIssuerBase} from "./Base.t.sol"; +import {TestERC20} from "@aztec/mock/TestERC20.sol"; +import {IMintableERC20} from "@aztec/shared/interfaces/IMintableERC20.sol"; +import {CoinIssuer} from "@aztec/governance/CoinIssuer.sol"; + +contract AcceptTokenOwnershipTest is CoinIssuerBase { + function setUp() public { + _deploy(1e18, 1_000_000); + } + + function test_GivenCallerIsNotOwner(address _caller) external { + // it reverts + vm.assume(_caller != address(this)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _caller)); + vm.prank(_caller); + nom.acceptTokenOwnership(); + } + + function test_GivenCallerIsOwnerButNoOwnershipTransferPending() external { + // it reverts because ownership was already accepted in Base setup + // Attempting to accept again should fail + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(nom))); + nom.acceptTokenOwnership(); + } + + function test_GivenCallerIsOwnerAndOwnershipTransferPending() external { + // it successfully accepts ownership of the token + // We need to test the flow from a fresh deployment where ownership hasn't been accepted + + // Create token and CoinIssuer but don't call acceptTokenOwnership + TestERC20 testERC20 = new TestERC20("test", "TEST", address(this)); + IMintableERC20 newToken = IMintableERC20(address(testERC20)); + newToken.mint(address(this), 1_000_000); + CoinIssuer newNom = new CoinIssuer(newToken, 1e18, address(this)); + + // Transfer ownership but don't accept yet + testERC20.transferOwnership(address(newNom)); + + // Verify pendingOwner is set but owner hasn't changed + assertEq(Ownable(address(newToken)).owner(), address(this)); + assertEq(Ownable2Step(address(newToken)).pendingOwner(), address(newNom)); + + // Accept ownership through CoinIssuer + newNom.acceptTokenOwnership(); + + // Verify ownership was transferred + assertEq(Ownable(address(newToken)).owner(), address(newNom)); + assertEq(Ownable2Step(address(newToken)).pendingOwner(), address(0)); + } + + function test_GivenMultipleAcceptanceAttempts() external { + // it should fail on second attempt since ownership already accepted + // Create token and CoinIssuer + TestERC20 testERC20 = new TestERC20("test", "TEST", address(this)); + IMintableERC20 newToken = IMintableERC20(address(testERC20)); + newToken.mint(address(this), 1_000_000); + CoinIssuer newNom = new CoinIssuer(newToken, 1e18, address(this)); + + // Transfer ownership + testERC20.transferOwnership(address(newNom)); + + // First acceptance should succeed + newNom.acceptTokenOwnership(); + assertEq(Ownable(address(newToken)).owner(), address(newNom)); + + // Second acceptance should fail (no pending ownership) + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(newNom))); + newNom.acceptTokenOwnership(); + } +} diff --git a/l1-contracts/test/governance/coin-issuer/acceptTokenOwnership.tree b/l1-contracts/test/governance/coin-issuer/acceptTokenOwnership.tree new file mode 100644 index 000000000000..3369cb0640c8 --- /dev/null +++ b/l1-contracts/test/governance/coin-issuer/acceptTokenOwnership.tree @@ -0,0 +1,11 @@ +AcceptTokenOwnershipTest +├── given caller is not owner +│ └── it reverts +├── given caller is owner but no ownership transfer pending +│ └── it reverts because ownership was already accepted +├── given caller is owner and ownership transfer pending +│ ├── it successfully accepts ownership of the token +│ ├── it updates the token owner to the CoinIssuer +│ └── it clears the pendingOwner +└── given multiple acceptance attempts + └── it should fail on second attempt since ownership already accepted diff --git a/l1-contracts/test/governance/coin-issuer/mint.t.sol b/l1-contracts/test/governance/coin-issuer/mint.t.sol index 7602196c93c6..c8d05a650745 100644 --- a/l1-contracts/test/governance/coin-issuer/mint.t.sol +++ b/l1-contracts/test/governance/coin-issuer/mint.t.sol @@ -5,21 +5,19 @@ import {Ownable} from "@oz/access/Ownable.sol"; import {IERC20} from "@oz/token/ERC20/IERC20.sol"; import {Errors} from "@aztec/governance/libraries/Errors.sol"; import {CoinIssuerBase} from "./Base.t.sol"; +import {ICoinIssuer} from "@aztec/governance/interfaces/ICoinIssuer.sol"; contract MintTest is CoinIssuerBase { - uint256 internal constant RATE = 1e18; - uint256 internal maxMint; + uint256 internal constant INITIAL_SUPPLY = 1_000_000; - function setUp() public { - _deploy(RATE); - vm.warp(block.timestamp + 1000); - - maxMint = nom.mintAvailable(); - - assertGt(maxMint, 0); + modifier withFuzzedRate(uint256 _rate) { + uint256 rate = bound(_rate, 0.01e18, 10e18); // 1% to 1000% + _deploy(rate, INITIAL_SUPPLY); + assertGt(nom.mintAvailable(), 0); + _; } - function test_GivenCallerIsNotOwner(address _caller) external { + function test_WhenCallerIsNotOwner(uint256 _rate, address _caller) external withFuzzedRate(_rate) { // it reverts vm.assume(_caller != address(this)); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _caller)); @@ -27,32 +25,212 @@ contract MintTest is CoinIssuerBase { nom.mint(address(0xdead), 1); } - modifier givenCallerIsOwner() { + modifier whenCallerIsOwner() { _; } - function test_GivenAmountLargerThanMaxMint(uint256 _amount) external givenCallerIsOwner { + function test_WhenAmountExceedsMaxMint(uint256 _rate, uint256 _amount) + external + withFuzzedRate(_rate) + whenCallerIsOwner + { // it reverts - uint256 amount = bound(_amount, maxMint + 1, type(uint256).max); - vm.expectRevert(abi.encodeWithSelector(Errors.CoinIssuer__InsufficientMintAvailable.selector, maxMint, amount)); - nom.mint(address(0xdead), amount); + uint256 maxAvailable = nom.mintAvailable(); + vm.assume(maxAvailable < type(uint256).max); + uint256 excessAmount = bound(_amount, maxAvailable + 1, type(uint256).max); + vm.expectRevert( + abi.encodeWithSelector(Errors.CoinIssuer__InsufficientMintAvailable.selector, maxAvailable, excessAmount) + ); + nom.mint(address(0xdead), excessAmount); + } + + function test_WhenMintingToZeroAddress(uint256 _rate) external withFuzzedRate(_rate) whenCallerIsOwner { + // it reverts + uint256 maxAvailable = nom.mintAvailable(); + vm.expectRevert(); + nom.mint(address(0), maxAvailable); + } + + function test_WhenMintingZeroAmount(uint256 _rate) external withFuzzedRate(_rate) whenCallerIsOwner { + // it succeeds with no state changes + uint256 balanceBefore = token.balanceOf(address(0xdead)); + uint256 totalSupplyBefore = token.totalSupply(); + nom.mint(address(0xdead), 0); + assertEq(token.balanceOf(address(0xdead)), balanceBefore); + assertEq(token.totalSupply(), totalSupplyBefore); } - function test_GivenAmountLessThanOrEqualMaxMint(uint256 _amount) external givenCallerIsOwner { - // it updates timeOfLastMint - // it mints amount - // it emits a {Transfer} event - // it will return 0 for mintAvailable in same block - uint256 amount = bound(_amount, 1, maxMint); - assertGt(amount, 0); + function test_WhenMintingNonZeroAmount(uint256 _rate, uint256 _amount) + external + withFuzzedRate(_rate) + whenCallerIsOwner + { + // it mints correct amount + // it emits a Transfer event + // it preserves unused allowance + uint256 maxAvailable = nom.mintAvailable(); + uint256 amount = bound(_amount, 1, maxAvailable); uint256 balanceBefore = token.balanceOf(address(0xdead)); + uint256 availableBefore = nom.mintAvailable(); vm.expectEmit(true, true, true, false, address(token)); emit IERC20.Transfer(address(0), address(0xdead), amount); nom.mint(address(0xdead), amount); assertEq(token.balanceOf(address(0xdead)), balanceBefore + amount); - assertEq(nom.mintAvailable(), 0); - assertEq(nom.timeOfLastMint(), block.timestamp); + assertEq(nom.mintAvailable(), availableBefore - amount); + } + + function test_WhenMultipleMintsWithinSameYear( + uint256 _rate, + uint256 _numMints, + uint256[16] calldata _mintFractions, + bool _lastMintIsFull + ) external withFuzzedRate(_rate) whenCallerIsOwner { + // it draws from same annual budget + uint256 rate = nom.NOMINAL_ANNUAL_PERCENTAGE_CAP(); + uint256 deploymentTime = nom.DEPLOYMENT_TIME(); + uint256 totalMinted = 0; + + // Bound the number of mints between 1 and 16 + uint256 numMints = bound(_numMints, 1, 16); + + // Calculate the expected total budget for year 0 + uint256 expectedBudget = (INITIAL_SUPPLY * rate) / 1e18; + + // Perform sequential mints with fuzzed fractions + for (uint256 i = 0; i < numMints; i++) { + // Warp to a time within year 0, distributed evenly but still all in year 0 + // Using 364 days to ensure we stay within year 0 (before year 1 starts) + uint256 timeOffset = ((i + 1) * 364 days) / (numMints + 1); + vm.warp(deploymentTime + timeOffset); + + uint256 available = nom.mintAvailable(); + + // On the last mint, mint everything remaining + uint256 mintAmount; + if (i == numMints - 1 && _lastMintIsFull) { + mintAmount = available; + } else { + // Mint a random fraction of available (1-100% bounded to ensure progress) + // Bound fraction between 1% and 100% of available + uint256 fraction = bound(_mintFractions[i], 0.01e18, 1e18); + mintAmount = (available * fraction) / 1e18; + + // Ensure we mint at least 1 if available > 0 + if (available > 0 && mintAmount == 0) { + mintAmount = 1; + } + } + + if (mintAmount > 0) { + nom.mint(address(0xdead), mintAmount); + totalMinted += mintAmount; + } + } + + if (_lastMintIsFull) { + assertEq(totalMinted, expectedBudget, "Total minted should equal year 0 budget"); + assertEq(nom.mintAvailable(), 0, "No budget should remain in year 0"); + } else { + assertLe(totalMinted, expectedBudget, "Total minted should be less than or equal to year 0 budget"); + assertGe(nom.mintAvailable(), 0, "Budget should be greater than or equal to 0 in year 0"); + } + assertEq(token.balanceOf(address(0xdead)), totalMinted, "Balance should match total minted"); + } + + function test_WhenCrossingYearBoundaries(uint256 _rate, uint256 _year0MintFraction) + external + withFuzzedRate(_rate) + whenCallerIsOwner + { + // it demonstrates compounding + // it shows unused budget is LOST when crossing years + // it verifies state tracking + uint256 rate = nom.NOMINAL_ANNUAL_PERCENTAGE_CAP(); + uint256 deploymentTime = nom.DEPLOYMENT_TIME(); + uint256 initialTotalSupply = token.totalSupply(); + uint256 year0MintFraction = bound(_year0MintFraction, 1, 100); + + // Year 0: Mint a random fraction + uint256 year0Budget = nom.mintAvailable(); + uint256 expectedYear0Budget = (INITIAL_SUPPLY * rate) / 1e18; + assertEq(year0Budget, expectedYear0Budget); + assertEq(nom.cachedBudget(), expectedYear0Budget); + + uint256 year0Minted = (year0Budget * year0MintFraction) / 100; + if (year0Minted > 0) { + nom.mint(address(0xdead), year0Minted); + } + + assertEq(token.totalSupply(), initialTotalSupply + year0Minted); + assertEq(nom.mintAvailable(), year0Budget - year0Minted); + assertEq(nom.cachedBudget(), year0Budget - year0Minted); + + // Cross into year 1 + vm.warp(deploymentTime + 365 days); + + // Year 1 budget based on current supply (compounding), not year 0 remainder + uint256 currentSupply = token.totalSupply(); + uint256 year1Budget = nom.mintAvailable(); + uint256 expectedYear1Budget = (currentSupply * rate) / 1e18; + assertEq(year1Budget, expectedYear1Budget); + + if (year0Minted > 0) { + assertGt(year1Budget, year0Budget); // Compounding effect + assertEq(currentSupply, INITIAL_SUPPLY + year0Minted); + } else { + assertEq(year1Budget, year0Budget); + } + + // Mint in year 1 to update state + vm.expectEmit(true, true, true, false, address(nom)); + emit ICoinIssuer.BudgetReset(1, expectedYear1Budget); + nom.mint(address(0xdead), 1); + assertEq(nom.cachedBudgetYear(), 1); + assertEq(nom.mintAvailable(), expectedYear1Budget - 1); + assertEq(nom.cachedBudget(), expectedYear1Budget - 1); + + // Jump to year 2 + vm.warp(deploymentTime + 2 * 365 days); + uint256 year2Budget = nom.mintAvailable(); + uint256 supplyAtYear2 = token.totalSupply(); + assertEq(year2Budget, (supplyAtYear2 * rate) / 1e18); + assertGt(year2Budget, expectedYear0Budget); // Cumulative compounding + } + + function test_WhenSkippingYears(uint256 _rate, uint256 _yearsToSkip) external withFuzzedRate(_rate) whenCallerIsOwner { + // it shows that skipping years loses their budgets + uint256 rate = nom.NOMINAL_ANNUAL_PERCENTAGE_CAP(); + uint256 deploymentTime = nom.DEPLOYMENT_TIME(); + uint256 yearsToSkip = bound(_yearsToSkip, 1, 10); + + uint256 initialBudget = nom.mintAvailable(); + assertEq(nom.cachedBudgetYear(), 0); + + // Mint half of year 0 budget + nom.mint(address(0xdead), initialBudget / 2); + + // Jump to future year + vm.warp(deploymentTime + yearsToSkip * 365 days); + + // Budget is only for target year, not accumulated + uint256 availableAfterSkip = nom.mintAvailable(); + uint256 currentSupply = token.totalSupply(); + assertEq(availableAfterSkip, (currentSupply * rate) / 1e18); + assertGt(availableAfterSkip, initialBudget); // More due to prior minting + + // Mint triggers year jump + vm.expectEmit(true, true, true, false, address(nom)); + emit ICoinIssuer.BudgetReset(yearsToSkip, (currentSupply * rate) / 1e18); + nom.mint(address(0xdead), 1); + assertEq(nom.cachedBudgetYear(), yearsToSkip); + assertEq(nom.cachedBudget(), (currentSupply * rate) / 1e18 - 1); + + // Skip more years + vm.warp(deploymentTime + (yearsToSkip + 4) * 365 days); + uint256 newSupply = token.totalSupply(); + assertEq(nom.mintAvailable(), (newSupply * rate) / 1e18); + assertGt(nom.mintAvailable(), initialBudget); // More due to prior minting } } diff --git a/l1-contracts/test/governance/coin-issuer/mint.tree b/l1-contracts/test/governance/coin-issuer/mint.tree index 19134b170cdf..fe04250b8935 100644 --- a/l1-contracts/test/governance/coin-issuer/mint.tree +++ b/l1-contracts/test/governance/coin-issuer/mint.tree @@ -1,11 +1,22 @@ MintTest -├── given caller is not owner +├── when caller is not owner │ └── it reverts -└── given caller is owner - ├── given amount larger than maxMint +└── when caller is owner + ├── when amount exceeds max mint │ └── it reverts - └── given amount less than or equal maxMint - ├── it updates timeOfLastMint - ├── it mints amount - ├── it emits a {Transfer} event - └── it will return 0 for mintAvailable in same block \ No newline at end of file + ├── when minting to zero address + │ └── it reverts + ├── when minting zero amount + │ └── it succeeds with no state changes + ├── when minting non zero amount + │ ├── it mints correct amount + │ ├── it emits a Transfer event + │ └── it preserves unused allowance + ├── when multiple mints within same year + │ └── it draws from same annual budget + ├── when crossing year boundaries + │ ├── it demonstrates compounding + │ ├── it shows unused budget is LOST when crossing years + │ └── it verifies state tracking + └── when skipping years + └── it shows that skipping years loses their budgets diff --git a/l1-contracts/test/governance/coin-issuer/mintAvailable.t.sol b/l1-contracts/test/governance/coin-issuer/mintAvailable.t.sol index ea09e8630a37..86dee0a3a09a 100644 --- a/l1-contracts/test/governance/coin-issuer/mintAvailable.t.sol +++ b/l1-contracts/test/governance/coin-issuer/mintAvailable.t.sol @@ -2,37 +2,85 @@ pragma solidity >=0.8.27; import {CoinIssuerBase} from "./Base.t.sol"; +import {Math} from "@oz/utils/math/Math.sol"; +import {TestERC20} from "@aztec/mock/TestERC20.sol"; +import {IMintableERC20} from "@aztec/shared/interfaces/IMintableERC20.sol"; +import {CoinIssuer} from "@aztec/governance/CoinIssuer.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; contract MintAvailableTest is CoinIssuerBase { function test_GivenRateIs0(uint256 _time) external { // it returns 0 - _deploy(0); + _deploy(0, 1_000_000); uint256 timeJump = bound(_time, 0, type(uint64).max - block.timestamp - 1); vm.warp(block.timestamp + timeJump); assertEq(nom.mintAvailable(), 0); } - modifier givenRateIsNot0(uint256 _rate) { - uint256 rate = bound(_rate, 1, type(uint128).max); - _deploy(rate); + modifier givenRateIsNot0(uint256 _rate, uint256 _initialSupply) { + uint256 rate = bound(_rate, 0.01e18, 10e18); + uint256 initialSupply = bound(_initialSupply, 100, type(uint128).max); + _deploy(rate, initialSupply); - assertEq(rate, nom.RATE()); + assertEq(rate, nom.NOMINAL_ANNUAL_PERCENTAGE_CAP()); _; } - function test_GivenSameTimeAsDeployment(uint256 _rate) external givenRateIsNot0(_rate) { - // it returns 0 - assertEq(nom.mintAvailable(), 0); + function test_GivenSameTimeAsDeployment(uint256 _rate, uint256 _initialSupply) + external + givenRateIsNot0(_rate, _initialSupply) + { + // it returns full year 0 budget + uint256 currentSupply = token.totalSupply(); + uint256 expected = Math.mulDiv(currentSupply, nom.NOMINAL_ANNUAL_PERCENTAGE_CAP(), 1e18, Math.Rounding.Floor); + assertEq(nom.mintAvailable(), expected); } - function test_GivenAfterDeployment(uint256 _rate, uint256 _time) external givenRateIsNot0(_rate) { - // it returns >0 + function test_GivenAfterDeployment(uint256 _rate, uint256 _initialSupply, uint256 _time) + external + givenRateIsNot0(_rate, _initialSupply) + { + // it returns that year's budget + uint256 currentSupply = token.totalSupply(); + uint256 deploymentTime = nom.DEPLOYMENT_TIME(); - uint256 timeJump = bound(_time, 1, type(uint64).max - block.timestamp - 1); - vm.warp(block.timestamp + timeJump); + uint256 timeJump = bound(_time, 1, 10 * 365 days); + vm.warp(deploymentTime + timeJump); + + uint256 expected = Math.mulDiv(currentSupply, nom.NOMINAL_ANNUAL_PERCENTAGE_CAP(), 1e18, Math.Rounding.Floor); + + assertEq(nom.mintAvailable(), expected); + } + + function test_GivenExactlyOneYearElapsed(uint256 _rate, uint256 _initialSupply) + external + givenRateIsNot0(_rate, _initialSupply) + { + // it returns exactly rate * supply for year 1 + uint256 currentSupply = token.totalSupply(); + uint256 deploymentTime = nom.DEPLOYMENT_TIME(); + + vm.warp(deploymentTime + 365 days); + + uint256 expected = Math.mulDiv(currentSupply, nom.NOMINAL_ANNUAL_PERCENTAGE_CAP(), 1e18, Math.Rounding.Floor); + + assertEq(nom.mintAvailable(), expected); + } + + function test_GivenMultipleYearsElapsed(uint256 _years) external { + // it always caps at 1 year maximum + uint256 numYears = bound(_years, 2, 100); + _deploy(1e18, 1_000_000); + uint256 deploymentTime = nom.DEPLOYMENT_TIME(); + + vm.warp(deploymentTime + numYears * 365 days); + + uint256 available = nom.mintAvailable(); + + uint256 expectedOneYear = Math.mulDiv(token.totalSupply(), 1e18, 1e18, Math.Rounding.Floor); - assertGt(nom.mintAvailable(), 0); - assertEq(nom.mintAvailable(), nom.RATE() * timeJump); + assertEq(available, expectedOneYear); + assertEq(available, 1_000_000); } } diff --git a/l1-contracts/test/governance/coin-issuer/mintAvailable.tree b/l1-contracts/test/governance/coin-issuer/mintAvailable.tree index 4bede564c2b2..05a46d644bf4 100644 --- a/l1-contracts/test/governance/coin-issuer/mintAvailable.tree +++ b/l1-contracts/test/governance/coin-issuer/mintAvailable.tree @@ -1,8 +1,12 @@ MintAvailableTest ├── given rate is 0 -│ └── it returns 0 +│ └── it returns 0 └── given rate is not 0 ├── given same time as deployment - │ └── it returns 0 - └── given after deployment - └── it returns >0 \ No newline at end of file + │ └── it returns full year 0 budget + ├── given after deployment + │ └── it returns that year's budget + ├── given exactly one year elapsed + │ └── it returns exactly rate * supply for year 1 + └── given multiple years elapsed + └── it always caps at 1 year maximum diff --git a/yarn-project/ethereum/src/deploy_l1_contracts.ts b/yarn-project/ethereum/src/deploy_l1_contracts.ts index dc61cfa8023a..7231503c5241 100644 --- a/yarn-project/ethereum/src/deploy_l1_contracts.ts +++ b/yarn-project/ethereum/src/deploy_l1_contracts.ts @@ -279,6 +279,8 @@ export const deploySharedContracts = async ( const deployedStaking = await deployer.deploy(StakingAssetArtifact, ['Staking', 'STK', l1Client.account.address]); stakingAssetAddress = deployedStaking.address; logger.verbose(`Deployed Staking Asset at ${stakingAssetAddress}`); + + await deployer.waitForDeployments(); } const gseAddress = ( @@ -352,7 +354,7 @@ export const deploySharedContracts = async ( const coinIssuerAddress = ( await deployer.deploy(CoinIssuerArtifact, [ feeAssetAddress.toString(), - (25_000_000_000n * 10n ** 18n) / (60n * 60n * 24n * 365n), + 2n * 10n ** 17n, // hard cap of 20% per year l1Client.account.address, ]) ).address;