diff --git a/src/unit/ERC20Mintable.sol b/src/unit/ERC20Mintable.sol index 29d602b..f52145f 100644 --- a/src/unit/ERC20Mintable.sol +++ b/src/unit/ERC20Mintable.sol @@ -91,27 +91,9 @@ contract ERC20Mintable is IERC20Mintable, Ownable2Step, ERC20Permit { * @custom:security Owner-only access prevents unauthorized deflation */ function burn(address from, address spender, uint256 amount) external onlyOwner { - _burn(from, spender, amount); - } - - /** - * @notice Burns tokens from the specified address, decreasing total supply. - * @dev Callable by any address. Decreases both total supply and target balance. - * - * Requirements: - * - `from` cannot be the zero address - * - `from` must have sufficient balance - * - If caller is different from `from`, caller must have allowance for `from`'s tokens - * - * Emits: - * - {Burn} event with source address and amount - * - {Transfer} event from source address to zero address - * - * @param from Address to burn tokens from - * @param amount Amount of tokens to burn - */ - function burn(address from, uint256 amount) external { - _burn(from, _msgSender(), amount); + _burn(from, amount); + if (from != spender) _spendAllowance(from, spender, amount); + emit Burn(from, amount); } /** @@ -125,16 +107,4 @@ contract ERC20Mintable is IERC20Mintable, Ownable2Step, ERC20Permit { function renounceOwnership() public pure override { revert RenounceOwnershipDisabled(); } - - /** - * @dev Internal function to burn tokens, handling allowance checks if necessary. - * @param from Address to burn tokens from - * @param spender Address initiating the burn (for allowance checks if different from `from`) - * @param amount Amount of tokens to burn - */ - function _burn(address from, address spender, uint256 amount) internal { - _burn(from, amount); - if (from != spender) _spendAllowance(from, spender, amount); - emit Burn(from, amount); - } } diff --git a/src/unit/GenericUnit.sol b/src/unit/GenericUnit.sol index b39b4a2..775a19e 100644 --- a/src/unit/GenericUnit.sol +++ b/src/unit/GenericUnit.sol @@ -31,6 +31,28 @@ contract GenericUnit is IGenericShare, ERC20Mintable { ERC20Mintable(controller, name, symbol) { } + /** + * @notice Burns tokens from the specified address, decreasing total supply. + * @dev Callable by any address. Decreases both total supply and target balance. + * + * Requirements: + * - `from` cannot be the zero address + * - `from` must have sufficient balance + * - If caller is different from `from`, caller must have allowance for `from`'s tokens + * + * Emits: + * - {Burn} event with source address and amount + * - {Transfer} event from source address to zero address + * + * @param from Address to burn tokens from + * @param amount Amount of tokens to burn + */ + function burn(address from, uint256 amount) external { + _burn(from, amount); + if (from != _msgSender()) _spendAllowance(from, _msgSender(), amount); + emit Burn(from, amount); + } + /** * @notice Returns the address of the Vault for the given asset. * @dev Vault changes do not emit VaultChange event as this is handled by the Controller. diff --git a/tests/unit/unit/ERC20Mintable.t.sol b/tests/unit/unit/ERC20Mintable.t.sol index ec42838..67f0272 100644 --- a/tests/unit/unit/ERC20Mintable.t.sol +++ b/tests/unit/unit/ERC20Mintable.t.sol @@ -54,7 +54,7 @@ contract ERC20Mintable_Mint_Test is ERC20MintableTest { } } -contract ERC20Mintable_BurnOnlyOwner_Test is ERC20MintableTest { +contract ERC20Mintable_Burn_Test is ERC20MintableTest { address from = makeAddr("from"); address spender = from; uint256 initialBalance = 100 ether; @@ -136,79 +136,6 @@ contract ERC20Mintable_BurnOnlyOwner_Test is ERC20MintableTest { } } -contract ERC20Mintable_Burn_Test is ERC20MintableTest { - address from = makeAddr("from"); - uint256 initialBalance = 100 ether; - - function setUp() public override { - super.setUp(); - - vm.prank(owner); - token.mint(from, initialBalance); - } - - function testFuzz_shouldBurn(uint256 amount) public { - amount = bound(amount, 0, initialBalance); - - vm.prank(from); - token.burn(from, amount); - - assertEq(token.totalSupply(), initialBalance - amount); - assertEq(token.balanceOf(from), initialBalance - amount); - } - - function test_shouldSpendAllowance() public { - uint256 amount = initialBalance / 2; - address spender = makeAddr("spender"); - - vm.prank(from); - token.approve(spender, amount); - - vm.prank(spender); - token.burn(from, amount); - - assertEq(token.totalSupply(), initialBalance - amount); - assertEq(token.balanceOf(from), initialBalance - amount); - assertEq(token.allowance(from, spender), 0); - } - - function testFuzz_shouldEmit_Burn(uint256 amount) public { - amount = bound(amount, 0, initialBalance); - - vm.expectEmit(); - emit IERC20Mintable.Burn(from, amount); - - vm.prank(from); - token.burn(from, amount); - } - - function test_shouldRevert_whenFromZero() public { - vm.prank(from); - vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidSender.selector, address(0))); - token.burn(address(0), 1); - } - - function testFuzz_shouldRevert_whenInsufficientBalance(uint256 amount) public { - amount = bound(amount, initialBalance + 1, type(uint256).max); - - vm.prank(from); - vm.expectRevert( - abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, from, initialBalance, amount) - ); - token.burn(from, amount); - } - - function test_shouldRevert_whenSpenderNotApproved() public { - address spender = makeAddr("spender"); - - vm.expectRevert( - abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, spender, 0, initialBalance) - ); - vm.prank(spender); - token.burn(from, initialBalance); - } -} - contract ERC20Mintable_RenounceOwnership_Test is ERC20MintableTest { function test_shouldRevert_whenRenounceOwnership_whenOwner() public { vm.prank(owner); diff --git a/tests/unit/unit/GenericUnit.t.sol b/tests/unit/unit/GenericUnit.t.sol index 25ed79a..4ae9d9f 100644 --- a/tests/unit/unit/GenericUnit.t.sol +++ b/tests/unit/unit/GenericUnit.t.sol @@ -4,8 +4,10 @@ pragma solidity 0.8.29; import { Test } from "forge-std/Test.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { IERC20Errors } from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import { GenericUnit, IController } from "../../../src/unit/GenericUnit.sol"; +import { IERC20Mintable } from "../../../src/interfaces/IERC20Mintable.sol"; abstract contract GenericUnitTest is Test { GenericUnit unit; @@ -35,6 +37,79 @@ contract GenericUnit_Constructor_Test is GenericUnitTest { } } +contract GenericUnit_Burn_Test is GenericUnitTest { + address from = makeAddr("from"); + uint256 initialBalance = 100 ether; + + function setUp() public override { + super.setUp(); + + vm.prank(owner); + unit.mint(from, initialBalance); + } + + function testFuzz_shouldBurn(uint256 amount) public { + amount = bound(amount, 0, initialBalance); + + vm.prank(from); + unit.burn(from, amount); + + assertEq(unit.totalSupply(), initialBalance - amount); + assertEq(unit.balanceOf(from), initialBalance - amount); + } + + function test_shouldSpendAllowance() public { + uint256 amount = initialBalance / 2; + address spender = makeAddr("spender"); + + vm.prank(from); + unit.approve(spender, amount); + + vm.prank(spender); + unit.burn(from, amount); + + assertEq(unit.totalSupply(), initialBalance - amount); + assertEq(unit.balanceOf(from), initialBalance - amount); + assertEq(unit.allowance(from, spender), 0); + } + + function testFuzz_shouldEmit_Burn(uint256 amount) public { + amount = bound(amount, 0, initialBalance); + + vm.expectEmit(); + emit IERC20Mintable.Burn(from, amount); + + vm.prank(from); + unit.burn(from, amount); + } + + function test_shouldRevert_whenFromZero() public { + vm.prank(from); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidSender.selector, address(0))); + unit.burn(address(0), 1); + } + + function testFuzz_shouldRevert_whenInsufficientBalance(uint256 amount) public { + amount = bound(amount, initialBalance + 1, type(uint256).max); + + vm.prank(from); + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, from, initialBalance, amount) + ); + unit.burn(from, amount); + } + + function test_shouldRevert_whenSpenderNotApproved() public { + address spender = makeAddr("spender"); + + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, spender, 0, initialBalance) + ); + vm.prank(spender); + unit.burn(from, initialBalance); + } +} + contract GenericUnit_Vault_Test is GenericUnitTest { function setUp() public override { super.setUp();