From 3081b6b98bc19c3a70a66a316fa73bdef937fd99 Mon Sep 17 00:00:00 2001 From: ashhanai Date: Mon, 1 Dec 2025 11:51:29 -0300 Subject: [PATCH 1/2] feat: allow hodlers to burn unit tokens only on L1 --- src/unit/ERC20Mintable.sol | 36 ++------------ src/unit/GenericUnit.sol | 22 +++++++++ tests/unit/unit/ERC20Mintable.t.sol | 75 +---------------------------- tests/unit/unit/GenericUnit.t.sol | 75 +++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 107 deletions(-) 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..c33f9a5 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 != msg.sender) _spendAllowance(from, msg.sender, 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(); From 9b0fb19532b410ade22004c46ff94876ae40fc0c Mon Sep 17 00:00:00 2001 From: ashhanai Date: Mon, 1 Dec 2025 12:00:13 -0300 Subject: [PATCH 2/2] refactor: use context sender function --- src/unit/GenericUnit.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unit/GenericUnit.sol b/src/unit/GenericUnit.sol index c33f9a5..775a19e 100644 --- a/src/unit/GenericUnit.sol +++ b/src/unit/GenericUnit.sol @@ -49,7 +49,7 @@ contract GenericUnit is IGenericShare, ERC20Mintable { */ function burn(address from, uint256 amount) external { _burn(from, amount); - if (from != msg.sender) _spendAllowance(from, msg.sender, amount); + if (from != _msgSender()) _spendAllowance(from, _msgSender(), amount); emit Burn(from, amount); }