Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@
"merkleizing",
"messagebox",
"mimc",
"mintable",
"mktemp",
"mload",
"mockify",
Expand Down
104 changes: 90 additions & 14 deletions l1-contracts/src/governance/CoinIssuer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,44 +11,120 @@ 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 {
Ownable2Step(address(ASSET)).acceptOwnership();
}

/**
* @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;
}
}
2 changes: 2 additions & 0 deletions l1-contracts/src/governance/interfaces/ICoinIssuer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 9 additions & 4 deletions l1-contracts/test/DateGatedRelayer.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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");
}
}
3 changes: 2 additions & 1 deletion l1-contracts/test/governance/coin-issuer/Base.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
11 changes: 11 additions & 0 deletions l1-contracts/test/governance/coin-issuer/acceptTokenOwnership.tree
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading