Skip to content

Commit 4a2218a

Browse files
authored
feat: coin issuer uses percentage of total supply (#17738)
# Redesign CoinIssuer to use annual percentage-based minting budgets This PR redesigns the CoinIssuer contract to implement a more sophisticated token issuance model: - Replaces the continuous rate-based minting with a discrete annual budget system - Budgets are calculated as a percentage of total supply at the start of each year - Implements a "use-it-or-lose-it" model where unused budget is lost when crossing year boundaries - Enables proper compounding as each year's budget is based on the actual supply at that time - Sets a hard cap of 20% annual inflation in the deployment script The implementation includes: - New state variables to track current year, budget, and cumulative minted amount - Logic to handle year boundary transitions and budget recalculation - Comprehensive test coverage for various scenarios including partial minting, year transitions, and skipping years This approach provides more predictable token issuance with clear annual caps while still allowing flexibility in the actual minting schedule within each year.
2 parents 4705e3d + 124fa94 commit 4a2218a

File tree

12 files changed

+484
-70
lines changed

12 files changed

+484
-70
lines changed

cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@
189189
"merkleizing",
190190
"messagebox",
191191
"mimc",
192+
"mintable",
192193
"mktemp",
193194
"mload",
194195
"mockify",

l1-contracts/src/governance/CoinIssuer.sol

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,44 +11,120 @@ import {Ownable2Step} from "@oz/access/Ownable2Step.sol";
1111
/**
1212
* @title CoinIssuer
1313
* @author Aztec Labs
14-
* @notice A contract that allows minting of coins at a maximum fixed rate
14+
* @notice A contract that allows minting of coins at a maximum percentage rate per year using discrete annual budgets
15+
*
16+
* This contract uses a discrete annual budget model:
17+
* - Years are fixed periods from deployment:
18+
* - year 0 = [deployment, deployment + 365d)
19+
* - year 1 = [deployment + 365d, deployment + (2) * 365d)
20+
* - ...
21+
* - year n = [deployment + 365d * n, deployment + (n + 1) * 365d)
22+
* - Each year's budget is calculated at the start of that year based on the actual supply at that moment
23+
* - Budget = totalSupply() × NOMINAL_ANNUAL_PERCENTAGE_CAP / 1e18
24+
* - Unused budget from year N is LOST when year N+1 begins (use-it-or-lose-it)
25+
*
26+
* Rate semantics: If the full budget is minted every year, the effective annual inflation rate equals
27+
* NOMINAL_ANNUAL_PERCENTAGE_CAP. For example, setting the rate to 0.10e18 (10%) and fully minting each
28+
* year will result in supply growing by exactly 10% annually: supply(year N) = supply(year 0) × (1.10)^N
29+
*
30+
* Partial minting: If less than the full budget is minted in year N, the remaining allowance is lost
31+
* at the year N→N+1 boundary. Year N+1's budget is calculated based on the actual supply at the start
32+
* of year N+1, which reflects only what was actually minted.
33+
*
34+
* @dev The NOMINAL_ANNUAL_PERCENTAGE_CAP is in e18 precision where 1e18 = 100%
35+
*
36+
* @dev The token MUST have a non-zero initial supply at deployment, or an alternative way to mint the token.
1537
*/
1638
contract CoinIssuer is ICoinIssuer, Ownable {
1739
IMintableERC20 public immutable ASSET;
18-
uint256 public immutable RATE;
19-
uint256 public timeOfLastMint;
40+
uint256 public immutable NOMINAL_ANNUAL_PERCENTAGE_CAP;
41+
uint256 public immutable DEPLOYMENT_TIME;
2042

21-
constructor(IMintableERC20 _asset, uint256 _rate, address _owner) Ownable(_owner) {
43+
// Note that the state variables below are "cached":
44+
// they are only updated when minting after a year boundary.
45+
uint256 public cachedBudgetYear;
46+
uint256 public cachedBudget;
47+
48+
constructor(IMintableERC20 _asset, uint256 _annualPercentage, address _owner) Ownable(_owner) {
2249
ASSET = _asset;
23-
RATE = _rate;
24-
timeOfLastMint = block.timestamp;
50+
NOMINAL_ANNUAL_PERCENTAGE_CAP = _annualPercentage;
51+
DEPLOYMENT_TIME = block.timestamp;
52+
53+
cachedBudgetYear = 0;
54+
cachedBudget = _getNewBudget();
55+
56+
emit BudgetReset(0, cachedBudget);
2557
}
2658

2759
function acceptTokenOwnership() external override(ICoinIssuer) onlyOwner {
2860
Ownable2Step(address(ASSET)).acceptOwnership();
2961
}
3062

3163
/**
32-
* @notice Mint tokens up to the `mintAvailable` limit
33-
* Beware that the mintAvailable will be reset to 0, and not just
34-
* reduced by the amount minted.
64+
* @notice Mint `_amount` tokens to `_to`
65+
*
66+
* @dev The `_amount` must be within the `cachedBudget`
3567
*
3668
* @param _to - The address to receive the funds
3769
* @param _amount - The amount to mint
3870
*/
3971
function mint(address _to, uint256 _amount) external override(ICoinIssuer) onlyOwner {
40-
uint256 maxMint = mintAvailable();
41-
require(_amount <= maxMint, Errors.CoinIssuer__InsufficientMintAvailable(maxMint, _amount));
42-
timeOfLastMint = block.timestamp;
72+
// Update state if we've crossed into a new year (will reset budget and forfeit unused amount)
73+
_updateBudgetIfNeeded();
74+
75+
require(_amount <= cachedBudget, Errors.CoinIssuer__InsufficientMintAvailable(cachedBudget, _amount));
76+
cachedBudget -= _amount;
77+
4378
ASSET.mint(_to, _amount);
4479
}
4580

4681
/**
47-
* @notice The amount of funds that is available for "minting"
82+
* @notice The amount of funds that is available for "minting" in the current year
83+
* If we've crossed into a new year since the last mint, returns the fresh budget
84+
* for the new year based on current supply.
4885
*
4986
* @return The amount mintable
5087
*/
5188
function mintAvailable() public view override(ICoinIssuer) returns (uint256) {
52-
return RATE * (block.timestamp - timeOfLastMint);
89+
uint256 currentYear = _yearSinceGenesis();
90+
91+
// Until the budget is stale, return the cached budget
92+
if (cachedBudgetYear >= currentYear) {
93+
return cachedBudget;
94+
}
95+
96+
// Crossed into new year(s): compute fresh budget
97+
return _getNewBudget();
98+
}
99+
100+
/**
101+
* @notice Internal function to update year and budget when crossing year boundaries
102+
*
103+
* @dev If multiple years have passed without minting, jumps directly to current year
104+
* and all intermediate years' budgets are lost
105+
*/
106+
function _updateBudgetIfNeeded() private {
107+
uint256 currentYear = _yearSinceGenesis();
108+
// If the budget is for the past, update the budget.
109+
if (cachedBudgetYear < currentYear) {
110+
cachedBudgetYear = currentYear;
111+
cachedBudget = _getNewBudget();
112+
113+
emit BudgetReset(currentYear, cachedBudget);
114+
}
115+
}
116+
117+
/**
118+
* @notice Internal function to compute the current year since genesis
119+
*/
120+
function _yearSinceGenesis() private view returns (uint256) {
121+
return (block.timestamp - DEPLOYMENT_TIME) / 365 days;
122+
}
123+
124+
/**
125+
* @notice Internal function to compute a fresh budget
126+
*/
127+
function _getNewBudget() private view returns (uint256) {
128+
return ASSET.totalSupply() * NOMINAL_ANNUAL_PERCENTAGE_CAP / 1e18;
53129
}
54130
}

l1-contracts/src/governance/interfaces/ICoinIssuer.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
pragma solidity >=0.8.27;
44

55
interface ICoinIssuer {
6+
event BudgetReset(uint256 indexed newYear, uint256 newBudget);
7+
68
function mint(address _to, uint256 _amount) external;
79
function acceptTokenOwnership() external;
810
function mintAvailable() external view returns (uint256);

l1-contracts/test/DateGatedRelayer.t.sol

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ contract DateGatedRelayerTest is Test {
3535
uint256 gatedUntil = bound(_gatedUntil, block.timestamp + 1, type(uint32).max);
3636

3737
TestERC20 testERC20 = new TestERC20("test", "TEST", address(this));
38-
CoinIssuer coinIssuer = new CoinIssuer(testERC20, 100, address(this));
38+
testERC20.mint(address(this), 1e18);
39+
CoinIssuer coinIssuer = new CoinIssuer(testERC20, 100e18, address(this));
3940
testERC20.transferOwnership(address(coinIssuer));
4041
coinIssuer.acceptTokenOwnership();
4142

@@ -45,11 +46,15 @@ contract DateGatedRelayerTest is Test {
4546
uint256 warp = bound(_warp, gatedUntil, type(uint32).max);
4647

4748
vm.expectRevert();
48-
coinIssuer.mint(address(this), 100);
49+
coinIssuer.mint(address(this), 1);
4950

5051
vm.warp(warp);
51-
dateGatedRelayer.relay(address(coinIssuer), abi.encodeWithSelector(CoinIssuer.mint.selector, address(this), 100));
52+
uint256 mintAvailable = coinIssuer.mintAvailable();
53+
dateGatedRelayer.relay(
54+
address(coinIssuer), abi.encodeWithSelector(CoinIssuer.mint.selector, address(this), mintAvailable)
55+
);
5256

53-
assertEq(testERC20.balanceOf(address(this)), 100);
57+
assertEq(testERC20.balanceOf(address(this)), mintAvailable + 1e18, "balanceOf");
58+
assertEq(testERC20.totalSupply(), mintAvailable + 1e18, "totalSupply");
5459
}
5560
}

l1-contracts/test/governance/coin-issuer/Base.t.sol

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ contract CoinIssuerBase is Test {
1313

1414
CoinIssuer internal nom;
1515

16-
function _deploy(uint256 _rate) internal {
16+
function _deploy(uint256 _rate, uint256 _initialSupply) internal {
1717
TestERC20 testERC20 = new TestERC20("test", "TEST", address(this));
1818
token = IMintableERC20(address(testERC20));
19+
token.mint(address(this), _initialSupply);
1920
nom = new CoinIssuer(token, _rate, address(this));
2021
testERC20.transferOwnership(address(nom));
2122
nom.acceptTokenOwnership();
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity >=0.8.27;
3+
4+
import {Ownable} from "@oz/access/Ownable.sol";
5+
import {Ownable2Step} from "@oz/access/Ownable2Step.sol";
6+
import {CoinIssuerBase} from "./Base.t.sol";
7+
import {TestERC20} from "@aztec/mock/TestERC20.sol";
8+
import {IMintableERC20} from "@aztec/shared/interfaces/IMintableERC20.sol";
9+
import {CoinIssuer} from "@aztec/governance/CoinIssuer.sol";
10+
11+
contract AcceptTokenOwnershipTest is CoinIssuerBase {
12+
function setUp() public {
13+
_deploy(1e18, 1_000_000);
14+
}
15+
16+
function test_GivenCallerIsNotOwner(address _caller) external {
17+
// it reverts
18+
vm.assume(_caller != address(this));
19+
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _caller));
20+
vm.prank(_caller);
21+
nom.acceptTokenOwnership();
22+
}
23+
24+
function test_GivenCallerIsOwnerButNoOwnershipTransferPending() external {
25+
// it reverts because ownership was already accepted in Base setup
26+
// Attempting to accept again should fail
27+
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(nom)));
28+
nom.acceptTokenOwnership();
29+
}
30+
31+
function test_GivenCallerIsOwnerAndOwnershipTransferPending() external {
32+
// it successfully accepts ownership of the token
33+
// We need to test the flow from a fresh deployment where ownership hasn't been accepted
34+
35+
// Create token and CoinIssuer but don't call acceptTokenOwnership
36+
TestERC20 testERC20 = new TestERC20("test", "TEST", address(this));
37+
IMintableERC20 newToken = IMintableERC20(address(testERC20));
38+
newToken.mint(address(this), 1_000_000);
39+
CoinIssuer newNom = new CoinIssuer(newToken, 1e18, address(this));
40+
41+
// Transfer ownership but don't accept yet
42+
testERC20.transferOwnership(address(newNom));
43+
44+
// Verify pendingOwner is set but owner hasn't changed
45+
assertEq(Ownable(address(newToken)).owner(), address(this));
46+
assertEq(Ownable2Step(address(newToken)).pendingOwner(), address(newNom));
47+
48+
// Accept ownership through CoinIssuer
49+
newNom.acceptTokenOwnership();
50+
51+
// Verify ownership was transferred
52+
assertEq(Ownable(address(newToken)).owner(), address(newNom));
53+
assertEq(Ownable2Step(address(newToken)).pendingOwner(), address(0));
54+
}
55+
56+
function test_GivenMultipleAcceptanceAttempts() external {
57+
// it should fail on second attempt since ownership already accepted
58+
// Create token and CoinIssuer
59+
TestERC20 testERC20 = new TestERC20("test", "TEST", address(this));
60+
IMintableERC20 newToken = IMintableERC20(address(testERC20));
61+
newToken.mint(address(this), 1_000_000);
62+
CoinIssuer newNom = new CoinIssuer(newToken, 1e18, address(this));
63+
64+
// Transfer ownership
65+
testERC20.transferOwnership(address(newNom));
66+
67+
// First acceptance should succeed
68+
newNom.acceptTokenOwnership();
69+
assertEq(Ownable(address(newToken)).owner(), address(newNom));
70+
71+
// Second acceptance should fail (no pending ownership)
72+
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(newNom)));
73+
newNom.acceptTokenOwnership();
74+
}
75+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
AcceptTokenOwnershipTest
2+
├── given caller is not owner
3+
│ └── it reverts
4+
├── given caller is owner but no ownership transfer pending
5+
│ └── it reverts because ownership was already accepted
6+
├── given caller is owner and ownership transfer pending
7+
│ ├── it successfully accepts ownership of the token
8+
│ ├── it updates the token owner to the CoinIssuer
9+
│ └── it clears the pendingOwner
10+
└── given multiple acceptance attempts
11+
└── it should fail on second attempt since ownership already accepted

0 commit comments

Comments
 (0)