diff --git a/src/interfaces/IERC6909.sol b/src/interfaces/IERC6909.sol new file mode 100644 index 00000000..1de05a9d --- /dev/null +++ b/src/interfaces/IERC6909.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/// @title ERC-6909 Minimal Multi-Token Interface +/// @notice Interface for ERC-6909 multi-token contracts with custom errors. +interface IERC6909 { + /// @notice Thrown when the sender has insufficient balance. + error ERC6909InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _id); + + /// @notice Thrown when the spender has insufficient allowance. + error ERC6909InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed, uint256 _id); + + /// @notice Thrown when the approver address is invalid. + error ERC6909InvalidApprover(address _approver); + + /// @notice Thrown when the receiver address is invalid. + error ERC6909InvalidReceiver(address _receiver); + + /// @notice Thrown when the sender address is invalid. + error ERC6909InvalidSender(address _sender); + + /// @notice Thrown when the spender address is invalid. + error ERC6909InvalidSpender(address _spender); + + /// @notice Emitted when a transfer occurs. + event Transfer( + address _caller, address indexed _sender, address indexed _receiver, uint256 indexed _id, uint256 _amount + ); + + /// @notice Emitted when an operator is set. + event OperatorSet(address indexed _owner, address indexed _spender, bool _approved); + + /// @notice Emitted when an approval occurs. + event Approval(address indexed _owner, address indexed _spender, uint256 indexed _id, uint256 _amount); + + /// @notice Owner balance of an id. + /// @param _owner The address of the owner. + /// @param _id The id of the token. + /// @return The balance of the token. + function balanceOf(address _owner, uint256 _id) external view returns (uint256); + + /// @notice Spender allowance of an id. + /// @param _owner The address of the owner. + /// @param _spender The address of the spender. + /// @param _id The id of the token. + /// @return The allowance of the token. + function allowance(address _owner, address _spender, uint256 _id) external view returns (uint256); + + /// @notice Checks if a spender is approved by an owner as an operator. + /// @param _owner The address of the owner. + /// @param _spender The address of the spender. + /// @return The approval status. + function isOperator(address _owner, address _spender) external view returns (bool); + + /// @notice Approves an amount of an id to a spender. + /// @param _spender The address of the spender. + /// @param _id The id of the token. + /// @param _amount The amount of the token. + /// @return Whether the approval succeeded. + function approve(address _spender, uint256 _id, uint256 _amount) external returns (bool); + + /// @notice Sets or removes a spender as an operator for the caller. + /// @param _spender The address of the spender. + /// @param _approved The approval status. + /// @return Whether the operator update succeeded. + function setOperator(address _spender, bool _approved) external returns (bool); + + /// @notice Transfers an amount of an id from the caller to a receiver. + /// @param _receiver The address of the receiver. + /// @param _id The id of the token. + /// @param _amount The amount of the token. + /// @return Whether the transfer succeeded. + function transfer(address _receiver, uint256 _id, uint256 _amount) external returns (bool); + + /// @notice Transfers an amount of an id from a sender to a receiver. + /// @param _sender The address of the sender. + /// @param _receiver The address of the receiver. + /// @param _id The id of the token. + /// @param _amount The amount of the token. + /// @return Whether the transfer succeeded. + function transferFrom(address _sender, address _receiver, uint256 _id, uint256 _amount) external returns (bool); +} diff --git a/src/token/ERC6909/ERC6909/ERC6909Facet.sol b/src/token/ERC6909/ERC6909/ERC6909Facet.sol new file mode 100644 index 00000000..a216a6c3 --- /dev/null +++ b/src/token/ERC6909/ERC6909/ERC6909Facet.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/// @title ERC-6909 Minimal Multi-Token Interface +/// @notice A complete, dependency-free ERC-6909 implementation using the diamond storage pattern. +contract ERC6909Facet { + /// @notice Thrown when the sender has insufficient balance. + error ERC6909InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _id); + + /// @notice Thrown when the spender has insufficient allowance. + error ERC6909InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed, uint256 _id); + + /// @notice Thrown when the receiver address is invalid. + error ERC6909InvalidReceiver(address _receiver); + + /// @notice Thrown when the sender address is invalid. + error ERC6909InvalidSender(address _sender); + + /// @notice Thrown when the spender address is invalid. + error ERC6909InvalidSpender(address _spender); + + /// @notice Emitted when a transfer occurs. + event Transfer( + address _caller, address indexed _sender, address indexed _receiver, uint256 indexed _id, uint256 _amount + ); + + /// @notice Emitted when an operator is set. + event OperatorSet(address indexed _owner, address indexed _spender, bool _approved); + + /// @notice Emitted when an approval occurs. + event Approval(address indexed _owner, address indexed _spender, uint256 indexed _id, uint256 _amount); + + /// @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + bytes32 constant STORAGE_POSITION = keccak256("compose.erc6909"); + + /// @custom:storage-location erc8042:compose.erc6909 + struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; + } + + /// @notice Returns a pointer to the ERC-6909 storage struct. + /// @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + /// @return s The ERC6909Storage struct in storage. + function getStorage() internal pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /// @notice Owner balance of an id. + /// @param _owner The address of the owner. + /// @param _id The id of the token. + /// @return The balance of the token. + function balanceOf(address _owner, uint256 _id) external view returns (uint256) { + return getStorage().balanceOf[_owner][_id]; + } + + /// @notice Spender allowance of an id. + /// @param _owner The address of the owner. + /// @param _spender The address of the spender. + /// @param _id The id of the token. + /// @return The allowance of the token. + function allowance(address _owner, address _spender, uint256 _id) external view returns (uint256) { + return getStorage().allowance[_owner][_spender][_id]; + } + + /// @notice Checks if a spender is approved by an owner as an operator. + /// @param _owner The address of the owner. + /// @param _spender The address of the spender. + /// @return The approval status. + function isOperator(address _owner, address _spender) external view returns (bool) { + return getStorage().isOperator[_owner][_spender]; + } + + /// @notice Transfers an amount of an id from the caller to a receiver. + /// @param _receiver The address of the receiver. + /// @param _id The id of the token. + /// @param _amount The amount of the token. + /// @return Whether the transfer succeeded. + function transfer(address _receiver, uint256 _id, uint256 _amount) external returns (bool) { + if (_receiver == address(0)) { + revert ERC6909InvalidReceiver(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + uint256 fromBalance = s.balanceOf[msg.sender][_id]; + + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(msg.sender, fromBalance, _amount, _id); + } + + unchecked { + s.balanceOf[msg.sender][_id] = fromBalance - _amount; + } + + s.balanceOf[_receiver][_id] += _amount; + + emit Transfer(msg.sender, msg.sender, _receiver, _id, _amount); + + return true; + } + + /// @notice Transfers an amount of an id from a sender to a receiver. + /// @param _sender The address of the sender. + /// @param _receiver The address of the receiver. + /// @param _id The id of the token. + /// @param _amount The amount of the token. + /// @return Whether the transfer succeeded. + function transferFrom(address _sender, address _receiver, uint256 _id, uint256 _amount) external returns (bool) { + if (_sender == address(0)) { + revert ERC6909InvalidSender(address(0)); + } + + if (_receiver == address(0)) { + revert ERC6909InvalidReceiver(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + if (msg.sender != _sender && !s.isOperator[_sender][msg.sender]) { + uint256 currentAllowance = s.allowance[_sender][msg.sender][_id]; + if (currentAllowance < type(uint256).max) { + if (currentAllowance < _amount) { + revert ERC6909InsufficientAllowance(msg.sender, currentAllowance, _amount, _id); + } + unchecked { + s.allowance[_sender][msg.sender][_id] = currentAllowance - _amount; + } + } + } + + uint256 fromBalance = s.balanceOf[_sender][_id]; + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(_sender, fromBalance, _amount, _id); + } + unchecked { + s.balanceOf[_sender][_id] = fromBalance - _amount; + } + + s.balanceOf[_receiver][_id] += _amount; + + emit Transfer(msg.sender, _sender, _receiver, _id, _amount); + + return true; + } + + /// @notice Approves an amount of an id to a spender. + /// @param _spender The address of the spender. + /// @param _id The id of the token. + /// @param _amount The amount of the token. + /// @return Whether the approval succeeded. + function approve(address _spender, uint256 _id, uint256 _amount) external returns (bool) { + if (_spender == address(0)) { + revert ERC6909InvalidSpender(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + s.allowance[msg.sender][_spender][_id] = _amount; + + emit Approval(msg.sender, _spender, _id, _amount); + + return true; + } + + /// @notice Sets or removes a spender as an operator for the caller. + /// @param _spender The address of the spender. + /// @param _approved The approval status. + /// @return Whether the operator update succeeded. + function setOperator(address _spender, bool _approved) external returns (bool) { + if (_spender == address(0)) { + revert ERC6909InvalidSpender(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + s.isOperator[msg.sender][_spender] = _approved; + + emit OperatorSet(msg.sender, _spender, _approved); + + return true; + } +} diff --git a/src/token/ERC6909/ERC6909/LibERC6909.sol b/src/token/ERC6909/ERC6909/LibERC6909.sol new file mode 100644 index 00000000..4ab5a411 --- /dev/null +++ b/src/token/ERC6909/ERC6909/LibERC6909.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/// @title LibERC6909 — ERC-6909 Library +/// @notice Provides internal functions and storage layout for ERC-6909 minimal multi-token logic. +/// @dev Uses ERC-8042 for storage location standardization. +/// This library is intended to be used by custom facets to integrate with ERC-6909 functionality. +/// @dev Adapted from: https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC6909.sol +library LibERC6909 { + /// @notice Thrown when the sender has insufficient balance. + error ERC6909InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _id); + + /// @notice Thrown when the spender has insufficient allowance. + error ERC6909InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed, uint256 _id); + + /// @notice Thrown when the approver address is invalid. + error ERC6909InvalidApprover(address _approver); + + /// @notice Thrown when the receiver address is invalid. + error ERC6909InvalidReceiver(address _receiver); + + /// @notice Thrown when the sender address is invalid. + error ERC6909InvalidSender(address _sender); + + /// @notice Thrown when the spender address is invalid. + error ERC6909InvalidSpender(address _spender); + + /// @notice Emitted when a transfer occurs. + event Transfer( + address _caller, address indexed _sender, address indexed _receiver, uint256 indexed _id, uint256 _amount + ); + + /// @notice Emitted when an operator is set. + event OperatorSet(address indexed _owner, address indexed _spender, bool _approved); + + /// @notice Emitted when an approval occurs. + event Approval(address indexed _owner, address indexed _spender, uint256 indexed _id, uint256 _amount); + + /// @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + bytes32 internal constant STORAGE_POSITION = keccak256("compose.erc6909"); + + /// @custom:storage-location erc8042:compose.erc6909 + struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; + } + + /// @notice Returns a pointer to the ERC-6909 storage struct. + /// @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + /// @return s The ERC6909Storage struct in storage. + function getStorage() internal pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /// @notice Mints `_amount` of token id `_id` to `_to`. + /// @param _to The address of the receiver. + /// @param _id The id of the token. + /// @param _amount The amount of the token. + function mint(address _to, uint256 _id, uint256 _amount) internal { + if (_to == address(0)) { + revert ERC6909InvalidReceiver(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + s.balanceOf[_to][_id] += _amount; + + emit Transfer(msg.sender, address(0), _to, _id, _amount); + } + + /// @notice Burns `_amount` of token id `_id` from `_from`. + /// @param _from The address of the sender. + /// @param _id The id of the token. + /// @param _amount The amount of the token. + function burn(address _from, uint256 _id, uint256 _amount) internal { + if (_from == address(0)) { + revert ERC6909InvalidSender(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + uint256 fromBalance = s.balanceOf[_from][_id]; + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(_from, fromBalance, _amount, _id); + } + + unchecked { + s.balanceOf[_from][_id] = fromBalance - _amount; + } + + emit Transfer(msg.sender, _from, address(0), _id, _amount); + } + + /// @notice Transfers `_amount` of token id `_id` from `_from` to `_to`. + /// @dev Allowance is not deducted if it is `type(uint256).max` + /// @dev Allowance is not deducted if `_by` is an operator for `_from`. + /// @param _by The address initiating the transfer. + /// @param _from The address of the sender. + /// @param _to The address of the receiver. + /// @param _id The id of the token. + /// @param _amount The amount of the token. + function transfer(address _by, address _from, address _to, uint256 _id, uint256 _amount) internal { + if (_from == address(0)) { + revert ERC6909InvalidSender(address(0)); + } + + if (_to == address(0)) { + revert ERC6909InvalidReceiver(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + if (_by != _from && !s.isOperator[_from][_by]) { + uint256 currentAllowance = s.allowance[_from][_by][_id]; + if (currentAllowance < type(uint256).max) { + if (currentAllowance < _amount) { + revert ERC6909InsufficientAllowance(_by, currentAllowance, _amount, _id); + } + unchecked { + s.allowance[_from][_by][_id] = currentAllowance - _amount; + } + } + } + + uint256 fromBalance = s.balanceOf[_from][_id]; + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(_from, fromBalance, _amount, _id); + } + unchecked { + s.balanceOf[_from][_id] = fromBalance - _amount; + } + + s.balanceOf[_to][_id] += _amount; + + emit Transfer(_by, _from, _to, _id, _amount); + } + + /// @notice Approves an amount of an id to a spender. + /// @param _owner The token owner. + /// @param _spender The address of the spender. + /// @param _id The id of the token. + /// @param _amount The amount of the token. + function approve(address _owner, address _spender, uint256 _id, uint256 _amount) internal { + if (_owner == address(0)) { + revert ERC6909InvalidApprover(address(0)); + } + if (_spender == address(0)) { + revert ERC6909InvalidSpender(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + s.allowance[_owner][_spender][_id] = _amount; + + emit Approval(_owner, _spender, _id, _amount); + } + + /// @notice Sets or removes a spender as an operator for the caller. + /// @param _owner The address of the owner. + /// @param _spender The address of the spender. + /// @param _approved The approval status. + function setOperator(address _owner, address _spender, bool _approved) internal { + if (_owner == address(0)) { + revert ERC6909InvalidApprover(address(0)); + } + if (_spender == address(0)) { + revert ERC6909InvalidSpender(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + s.isOperator[_owner][_spender] = _approved; + + emit OperatorSet(_owner, _spender, _approved); + } +} diff --git a/test/token/ERC6909/ERC6909/ERC6909Facet.t.sol b/test/token/ERC6909/ERC6909/ERC6909Facet.t.sol new file mode 100644 index 00000000..75e12458 --- /dev/null +++ b/test/token/ERC6909/ERC6909/ERC6909Facet.t.sol @@ -0,0 +1,477 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {stdError} from "forge-std/StdError.sol"; +import {ERC6909FacetHarness} from "./harnesses/ERC6909FacetHarness.sol"; +import {ERC6909Facet} from "../../../../src/token/ERC6909/ERC6909/ERC6909Facet.sol"; + +contract ERC6909FacetTest is Test { + ERC6909FacetHarness internal facet; + + address internal alice; + + uint256 internal constant TOKEN_ID = 72; + uint256 internal constant AMOUNT = 1e24; + + function setUp() public { + alice = makeAddr("alice"); + + facet = new ERC6909FacetHarness(); + } + + // ============================================ + // Mint Tests + // ============================================ + + function test_Mint() external { + vm.expectEmit(); + emit ERC6909Facet.Transfer(address(this), address(0), alice, TOKEN_ID, AMOUNT); + + facet.mint(alice, TOKEN_ID, AMOUNT); + + assertEq(facet.balanceOf(alice, TOKEN_ID), AMOUNT); + } + + function test_ShouldRevert_Mint_BalanceOf_Overflows() external { + facet.mint(alice, TOKEN_ID, type(uint256).max); + + vm.expectRevert(stdError.arithmeticError); + facet.mint(alice, TOKEN_ID, 1); + } + + function testFuzz_Mint(address caller, address to, uint256 id, uint256 amount) external { + vm.expectEmit(); + emit ERC6909Facet.Transfer(caller, address(0), to, id, amount); + + vm.prank(caller); + facet.mint(to, id, amount); + + assertEq(facet.balanceOf(to, id), amount); + } + + // ============================================ + // Approve Tests + // ============================================ + + function test_ShouldRevert_Approve_SpenderIsZero() external { + vm.expectRevert(abi.encodeWithSelector(ERC6909Facet.ERC6909InvalidSpender.selector, address(0))); + facet.approve(address(0), TOKEN_ID, AMOUNT); + } + + function test_Approve() external { + vm.prank(alice); + + vm.expectEmit(); + emit ERC6909Facet.Approval(alice, address(this), TOKEN_ID, AMOUNT); + + facet.approve(address(this), TOKEN_ID, AMOUNT); + + assertEq(facet.allowance(alice, address(this), TOKEN_ID), AMOUNT); + } + + function testFuzz_Approve(address owner, address spender, uint256 id, uint256 amount) external { + vm.assume(spender != address(0)); + + vm.expectEmit(); + emit ERC6909Facet.Approval(owner, spender, id, amount); + + vm.prank(owner); + facet.approve(spender, id, amount); + + assertEq(facet.allowance(owner, spender, id), amount); + } + + // ============================================ + // Set Operator Tests + // ============================================ + + function test_ShouldRevert_SetOperator_SpenderIsZero() external { + vm.expectRevert(abi.encodeWithSelector(ERC6909Facet.ERC6909InvalidSpender.selector, address(0))); + facet.setOperator(address(0), true); + } + + function test_SetOperator_IsApproved() external { + vm.prank(alice); + + vm.expectEmit(); + emit ERC6909Facet.OperatorSet(alice, address(this), true); + + facet.setOperator(address(this), true); + + assertEq(facet.isOperator(alice, address(this)), true); + } + + function test_SetOperator_RevokeOperator() external { + vm.prank(alice); + facet.setOperator(address(this), true); + + vm.prank(alice); + vm.expectEmit(); + emit ERC6909Facet.OperatorSet(alice, address(this), false); + + facet.setOperator(address(this), false); + + assertEq(facet.isOperator(alice, address(this)), false); + } + + function testFuzz_SetOperator(address owner, address spender, bool approved) external { + vm.assume(spender != address(0)); + + vm.expectEmit(); + emit ERC6909Facet.OperatorSet(owner, spender, approved); + + vm.prank(owner); + facet.setOperator(spender, approved); + + assertEq(facet.isOperator(owner, spender), approved); + } + + // ============================================ + // Transfer Tests + // ============================================ + + function test_ShouldRevert_Transfer_ReceiverIsZero() external { + vm.expectRevert(abi.encodeWithSelector(ERC6909Facet.ERC6909InvalidReceiver.selector, address(0))); + facet.transfer(address(0), TOKEN_ID, AMOUNT); + } + + function test_ShouldRevert_Transfer_InsufficientBalance() external { + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector(ERC6909Facet.ERC6909InsufficientBalance.selector, alice, 0, AMOUNT, TOKEN_ID) + ); + facet.transfer(alice, TOKEN_ID, AMOUNT); + } + + function test_ShouldRevert_Transfer_ReceiverBalanceOverflows() external { + facet.mint(alice, TOKEN_ID, 1); + facet.mint(address(this), TOKEN_ID, type(uint256).max); + vm.prank(alice); + vm.expectRevert(stdError.arithmeticError); + facet.transfer(address(this), TOKEN_ID, 1); + } + + function test_Transfer() external { + facet.mint(alice, TOKEN_ID, AMOUNT); + vm.prank(alice); + vm.expectEmit(); + emit ERC6909Facet.Transfer(alice, alice, address(this), TOKEN_ID, AMOUNT); + bool success = facet.transfer(address(this), TOKEN_ID, AMOUNT); + assertTrue(success); + assertEq(facet.balanceOf(alice, TOKEN_ID), 0); + assertEq(facet.balanceOf(address(this), TOKEN_ID), AMOUNT); + } + + function testFuzz_Transfer(address caller, address receiver, uint256 id, uint256 amount) external { + vm.assume(receiver != caller); + vm.assume(receiver != address(0)); + vm.assume(caller != address(0)); + + facet.mint(caller, id, amount); + + vm.expectEmit(); + emit ERC6909Facet.Transfer(caller, caller, receiver, id, amount); + + vm.prank(caller); + bool success = facet.transfer(receiver, id, amount); + + assertTrue(success); + assertEq(facet.balanceOf(caller, id), 0); + assertEq(facet.balanceOf(receiver, id), amount); + } + + function testFuzz_Transfer_Self(address caller, uint256 id, uint256 amount) external { + vm.assume(caller != address(0)); + + facet.mint(caller, id, amount); + + vm.expectEmit(); + emit ERC6909Facet.Transfer(caller, caller, caller, id, amount); + + vm.prank(caller); + bool success = facet.transfer(caller, id, amount); + + assertTrue(success); + assertEq(facet.balanceOf(caller, id), amount); + } + + function testFuzz_ShouldRevert_Transfer_ToZeroAddress(address caller, uint256 id, uint256 amount) external { + vm.assume(caller != address(0)); + + facet.mint(caller, id, amount); + + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(ERC6909Facet.ERC6909InvalidReceiver.selector, address(0))); + facet.transfer(address(0), id, amount); + } + + function testFuzz_Transfer_ZeroAmount(address caller, address receiver, uint256 id, uint256 balance) external { + vm.assume(receiver != caller); + vm.assume(receiver != address(0)); + vm.assume(caller != address(0)); + + balance = bound(balance, 1, type(uint256).max); + + facet.mint(caller, id, balance); + + vm.expectEmit(); + emit ERC6909Facet.Transfer(caller, caller, receiver, id, 0); + + vm.prank(caller); + bool success = facet.transfer(receiver, id, 0); + + assertTrue(success); + assertEq(facet.balanceOf(caller, id), balance); + assertEq(facet.balanceOf(receiver, id), 0); + } + + // ============================================ + // Transfer From Tests + // ============================================ + + function testFuzz_ShouldRevert_TransferFrom_InsufficientBalance( + address by, + address from, + address to, + uint256 id, + uint256 amount + ) external { + vm.assume(amount > 0); + vm.assume(from != address(0)); + vm.assume(to != address(0)); + vm.assume(by != address(0)); + + vm.prank(from); + facet.approve(by, id, type(uint256).max); + + vm.prank(by); + vm.expectRevert(abi.encodeWithSelector(ERC6909Facet.ERC6909InsufficientBalance.selector, from, 0, amount, id)); + facet.transferFrom(from, to, id, amount); + } + + function testFuzz_TransferFrom_BySender(address from, address to, uint256 id, uint256 amount) external { + vm.assume(to != from); + vm.assume(from != address(0)); + vm.assume(to != address(0)); + + amount = bound(amount, 1, type(uint256).max); + + facet.mint(from, id, amount); + + vm.expectEmit(); + emit ERC6909Facet.Transfer(from, from, to, id, amount); + + vm.prank(from); + bool success = facet.transferFrom(from, to, id, amount); + + assertTrue(success); + assertEq(facet.balanceOf(from, id), 0); + assertEq(facet.balanceOf(to, id), amount); + } + + function testFuzz_TransferFrom_IsOperator(address by, address from, address to, uint256 id, uint256 amount) + external + { + vm.assume(from != by); + vm.assume(from != to); + vm.assume(from != address(0)); + vm.assume(to != address(0)); + vm.assume(by != address(0)); + + facet.mint(from, id, amount); + + vm.prank(from); + facet.setOperator(by, true); + + vm.expectEmit(); + emit ERC6909Facet.Transfer(by, from, to, id, amount); + + vm.prank(by); + bool success = facet.transferFrom(from, to, id, amount); + + assertTrue(success); + assertEq(facet.balanceOf(from, id), 0); + assertEq(facet.balanceOf(to, id), amount); + assertEq(facet.allowance(from, by, id), 0); + } + + function testFuzz_TransferFrom_NonOperator_MaxAllowance( + address by, + address from, + address to, + uint256 id, + uint256 amount + ) external { + vm.assume(from != by); + vm.assume(from != to); + vm.assume(from != address(0)); + vm.assume(to != address(0)); + vm.assume(by != address(0)); + + amount = bound(amount, 1, type(uint256).max); + + facet.mint(from, id, amount); + + vm.prank(from); + facet.approve(by, id, type(uint256).max); + + vm.expectEmit(); + emit ERC6909Facet.Transfer(by, from, to, id, amount); + + vm.prank(by); + bool success = facet.transferFrom(from, to, id, amount); + + assertTrue(success); + assertEq(facet.balanceOf(from, id), 0); + assertEq(facet.balanceOf(to, id), amount); + assertEq(facet.allowance(from, by, id), type(uint256).max); + } + + function testFuzz_TransferFrom_NonOperator_AllowanceLtMax( + address by, + address from, + address to, + uint256 id, + uint256 amount, + uint256 spend + ) external { + vm.assume(from != by); + vm.assume(from != to); + vm.assume(from != address(0)); + vm.assume(to != address(0)); + vm.assume(by != address(0)); + + amount = bound(amount, 1, type(uint256).max - 1); + spend = bound(spend, 1, amount); + + facet.mint(from, id, amount); + + vm.prank(from); + facet.approve(by, id, amount); + + vm.expectEmit(); + emit ERC6909Facet.Transfer(by, from, to, id, spend); + + vm.prank(by); + bool success = facet.transferFrom(from, to, id, spend); + + assertTrue(success); + assertEq(facet.balanceOf(from, id), amount - spend); + assertEq(facet.balanceOf(to, id), spend); + assertEq(facet.allowance(from, by, id), amount - spend); + } + + function testFuzz_ShouldRevert_TransferFrom_NonOperator_AllowanceUnderflow( + address by, + address from, + address to, + uint256 id, + uint256 amount, + uint256 spend + ) external { + vm.assume(from != by); + vm.assume(from != to); + vm.assume(from != address(0)); + vm.assume(by != address(0)); + vm.assume(to != address(0)); + + amount = bound(amount, 1, type(uint256).max - 1); + vm.assume(spend > amount); + + facet.mint(from, id, amount); + + vm.prank(from); + facet.approve(by, id, amount); + + vm.prank(by); + vm.expectRevert( + abi.encodeWithSelector(ERC6909Facet.ERC6909InsufficientAllowance.selector, by, amount, spend, id) + ); + facet.transferFrom(from, to, id, spend); + } + + function testFuzz_TransferFrom_SelfTransfer_NonOperator_FiniteAllowance( + address by, + address from, + uint256 id, + uint256 amount, + uint256 spend + ) external { + vm.assume(from != by); + vm.assume(from != address(0)); + vm.assume(by != address(0)); + + amount = bound(amount, 1, type(uint256).max - 1); + spend = bound(spend, 1, amount); + + facet.mint(from, id, amount); + + vm.prank(from); + facet.approve(by, id, amount); + + vm.expectEmit(); + emit ERC6909Facet.Transfer(by, from, from, id, spend); + + vm.prank(by); + bool success = facet.transferFrom(from, from, id, spend); + + assertTrue(success); + assertEq(facet.balanceOf(from, id), amount); + assertEq(facet.allowance(from, by, id), amount - spend); + } + + function testFuzz_TransferFrom_ToZeroAddress_NonOperator_MaxAllowance( + address by, + address from, + uint256 id, + uint256 amount + ) external { + vm.assume(from != address(0)); + vm.assume(from != by); + vm.assume(by != address(0)); + + amount = bound(amount, 1, type(uint256).max); + + facet.mint(from, id, amount); + + vm.prank(from); + facet.approve(by, id, type(uint256).max); + + vm.prank(by); + vm.expectRevert(abi.encodeWithSelector(ERC6909Facet.ERC6909InvalidReceiver.selector, address(0))); + facet.transferFrom(from, address(0), id, amount); + } + + function testFuzz_TransferFrom_ZeroAmount_NonOperator_FiniteAllowance( + address by, + address from, + address to, + uint256 id, + uint256 allowanceAmount + ) external { + vm.assume(from != by); + vm.assume(from != to); + vm.assume(from != address(0)); + vm.assume(to != address(0)); + vm.assume(by != address(0)); + + allowanceAmount = bound(allowanceAmount, 1, type(uint256).max - 1); + + facet.mint(from, id, allowanceAmount); + + vm.prank(from); + facet.approve(by, id, allowanceAmount); + + vm.expectEmit(); + emit ERC6909Facet.Transfer(by, from, to, id, 0); + + vm.prank(by); + bool success = facet.transferFrom(from, to, id, 0); + + assertTrue(success); + assertEq(facet.balanceOf(from, id), allowanceAmount); + assertEq(facet.balanceOf(to, id), 0); + assertEq(facet.allowance(from, by, id), allowanceAmount); + } +} diff --git a/test/token/ERC6909/ERC6909/LibERC6909.t.sol b/test/token/ERC6909/ERC6909/LibERC6909.t.sol new file mode 100644 index 00000000..859ad79c --- /dev/null +++ b/test/token/ERC6909/ERC6909/LibERC6909.t.sol @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {stdError} from "forge-std/StdError.sol"; +import {LibERC6909Harness} from "./harnesses/LibERC6909Harness.sol"; +import {LibERC6909} from "../../../../src/token/ERC6909/ERC6909/LibERC6909.sol"; + +contract LibERC6909Test is Test { + LibERC6909Harness internal harness; + + address internal alice; + + uint256 internal constant TOKEN_ID = 72; + uint256 internal constant AMOUNT = 1e24; + + function setUp() public { + alice = makeAddr("alice"); + + harness = new LibERC6909Harness(); + } + + // ============================================ + // Mint Tests + // ============================================ + + function test_ShouldRevert_Mint_ToIsZero() external { + vm.expectRevert(abi.encodeWithSelector(LibERC6909.ERC6909InvalidReceiver.selector, address(0))); + harness.mint(address(0), TOKEN_ID, AMOUNT); + } + + function test_Mint() external { + vm.expectEmit(); + emit LibERC6909.Transfer(address(this), address(0), alice, TOKEN_ID, AMOUNT); + + harness.mint(alice, TOKEN_ID, AMOUNT); + + assertEq(harness.balanceOf(alice, TOKEN_ID), AMOUNT); + } + + function test_ShouldRevert_Mint_BalanceOf_Overflows() external { + harness.mint(alice, TOKEN_ID, type(uint256).max); + vm.expectRevert(stdError.arithmeticError); + harness.mint(alice, TOKEN_ID, 1); + } + + function testFuzz_Mint(address caller, address to, uint256 id, uint256 amount) external { + vm.assume(to != address(0)); + + vm.expectEmit(); + emit LibERC6909.Transfer(caller, address(0), to, id, amount); + + vm.prank(caller); + harness.mint(to, id, amount); + + assertEq(harness.balanceOf(to, id), amount); + } + + // ============================================ + // Burn Tests + // ============================================ + + function test_ShouldRevert_Burn_InsufficientBalance() external { + vm.expectRevert(abi.encodeWithSelector(LibERC6909.ERC6909InsufficientBalance.selector, alice, 0, 1, TOKEN_ID)); + harness.burn(alice, TOKEN_ID, 1); + } + + function test_ShouldRevert_Burn_FromIsZero() external { + vm.expectRevert(abi.encodeWithSelector(LibERC6909.ERC6909InvalidSender.selector, address(0))); + harness.burn(address(0), TOKEN_ID, 1); + } + + function test_Burn() external { + harness.mint(alice, TOKEN_ID, AMOUNT); + + vm.expectEmit(); + emit LibERC6909.Transfer(address(this), alice, address(0), TOKEN_ID, AMOUNT); + + harness.burn(alice, TOKEN_ID, AMOUNT); + + assertEq(harness.balanceOf(alice, TOKEN_ID), 0); + } + + /// @dev First mints tokens and then burns a fraction of them. + function testFuzz_Burn(address caller, address from, uint256 id, uint256 amount, uint256 burnFrac) external { + vm.assume(from != address(0)); + amount = bound(amount, 1, type(uint256).max / 1e4); + burnFrac = bound(burnFrac, 1, 1e4); + uint256 burnAmount = (amount * burnFrac) / 1e4; + + harness.mint(from, id, amount); + + vm.expectEmit(); + emit LibERC6909.Transfer(caller, from, address(0), id, burnAmount); + + vm.prank(caller); + harness.burn(from, id, burnAmount); + + assertEq(harness.balanceOf(from, id), amount - burnAmount); + } + + // ============================================ + // Approve Tests + // ============================================ + + function test_ShouldRevert_Approve_OwnerIsZero() external { + vm.expectRevert(abi.encodeWithSelector(LibERC6909.ERC6909InvalidApprover.selector, address(0))); + harness.approve(address(0), alice, TOKEN_ID, AMOUNT); + } + + function test_ShouldRevert_Approve_SpenderIsZero() external { + vm.expectRevert(abi.encodeWithSelector(LibERC6909.ERC6909InvalidSpender.selector, address(0))); + harness.approve(alice, address(0), TOKEN_ID, AMOUNT); + } + + function test_Approve() external { + vm.expectEmit(); + emit LibERC6909.Approval(alice, address(this), TOKEN_ID, AMOUNT); + + harness.approve(alice, address(this), TOKEN_ID, AMOUNT); + + assertEq(harness.allowance(alice, address(this), TOKEN_ID), AMOUNT); + } + + function testFuzz_Approve(address owner, address spender, uint256 id, uint256 amount) external { + vm.assume(owner != address(0)); + vm.assume(spender != address(0)); + + vm.expectEmit(); + emit LibERC6909.Approval(owner, spender, id, amount); + + harness.approve(owner, spender, id, amount); + + assertEq(harness.allowance(owner, spender, id), amount); + } + + // ============================================ + // Set Operator Tests + // ============================================ + + function test_ShouldRevert_SetOperator_OwnerIsZero() external { + vm.expectRevert(abi.encodeWithSelector(LibERC6909.ERC6909InvalidApprover.selector, address(0))); + harness.setOperator(address(0), alice, true); + } + + function test_ShouldRevert_SetOperator_SpenderIsZero() external { + vm.expectRevert(abi.encodeWithSelector(LibERC6909.ERC6909InvalidSpender.selector, address(0))); + harness.setOperator(alice, address(0), true); + } + + function test_SetOperator_IsApproved() external { + vm.expectEmit(); + emit LibERC6909.OperatorSet(alice, address(this), true); + + harness.setOperator(alice, address(this), true); + assertEq(harness.isOperator(alice, address(this)), true); + } + + function test_SetOperator_RevokeOperator() external { + harness.setOperator(alice, address(this), true); + + vm.expectEmit(); + emit LibERC6909.OperatorSet(alice, address(this), false); + + harness.setOperator(alice, address(this), false); + + assertEq(harness.isOperator(alice, address(this)), false); + } + + function testFuzz_SetOperator(address owner, address spender, bool approved) external { + vm.assume(owner != address(0)); + vm.assume(spender != address(0)); + + vm.expectEmit(); + emit LibERC6909.OperatorSet(owner, spender, approved); + + harness.setOperator(owner, spender, approved); + + assertEq(harness.isOperator(owner, spender), approved); + } + + // ============================================ + // Transfer Tests + // ============================================ + + function testFuzz_Transfer(address from, address to, uint256 id, uint256 amount) external { + vm.assume(from != to); + vm.assume(from != address(0)); + vm.assume(to != address(0)); + + harness.mint(from, id, amount); + + vm.expectEmit(); + emit LibERC6909.Transfer(from, from, to, id, amount); + + harness.transfer(from, from, to, id, amount); + + assertEq(harness.balanceOf(from, id), 0); + assertEq(harness.balanceOf(to, id), amount); + } + + function testFuzz_Transfer_IsOperator(address by, address from, address to, uint256 id, uint256 amount) external { + vm.assume(from != by); + vm.assume(from != to); + vm.assume(from != address(0)); + vm.assume(to != address(0)); + vm.assume(by != address(0)); + + harness.mint(from, id, amount); + harness.setOperator(from, by, true); + + vm.expectEmit(); + emit LibERC6909.Transfer(by, from, to, id, amount); + + harness.transfer(by, from, to, id, amount); + + assertEq(harness.balanceOf(from, id), 0); + assertEq(harness.balanceOf(to, id), amount); + } + + function testFuzz_Transfer_NonOperator_MaxAllowance( + address by, + address from, + address to, + uint256 id, + uint256 amount + ) external { + vm.assume(from != by); + vm.assume(from != to); + vm.assume(from != address(0)); + vm.assume(to != address(0)); + vm.assume(by != address(0)); + + amount = bound(amount, 1, type(uint256).max); + + harness.mint(from, id, amount); + harness.approve(from, by, id, type(uint256).max); + + vm.expectEmit(); + emit LibERC6909.Transfer(by, from, to, id, amount); + + harness.transfer(by, from, to, id, amount); + + assertEq(harness.balanceOf(from, id), 0); + assertEq(harness.balanceOf(to, id), amount); + assertEq(harness.allowance(from, by, id), type(uint256).max); + } + + function testFuzz_Transfer_NonOperator_AllowanceLtMax( + address by, + address from, + address to, + uint256 id, + uint256 amount, + uint256 spend + ) external { + vm.assume(from != by); + vm.assume(from != to); + vm.assume(from != address(0)); + vm.assume(to != address(0)); + vm.assume(by != address(0)); + + amount = bound(amount, 1, type(uint256).max - 1); + spend = bound(spend, 1, amount); + + harness.mint(from, id, amount); + harness.approve(from, by, id, amount); + + vm.expectEmit(); + emit LibERC6909.Transfer(by, from, to, id, spend); + + harness.transfer(by, from, to, id, spend); + + assertEq(harness.balanceOf(from, id), amount - spend); + assertEq(harness.balanceOf(to, id), spend); + assertEq(harness.allowance(from, by, id), amount - spend); + } + + function test_ShouldRevert_Transfer_InvalidSender() external { + vm.expectRevert(abi.encodeWithSelector(LibERC6909.ERC6909InvalidSender.selector, address(0))); + + harness.transfer(address(0), address(0), alice, TOKEN_ID, AMOUNT); + } + + function testFuzz_ShouldRevert_NonOperator_InsufficientBalance( + address by, + address from, + address to, + uint256 id, + uint256 amount, + uint256 balance + ) external { + vm.assume(from != by); + vm.assume(from != address(0)); + vm.assume(to != address(0)); + vm.assume(by != address(0)); + + balance = bound(balance, 1, type(uint256).max - 1); + amount = bound(amount, balance + 1, type(uint256).max); + + harness.mint(from, id, balance); + harness.approve(from, by, id, amount); + + vm.expectRevert( + abi.encodeWithSelector(LibERC6909.ERC6909InsufficientBalance.selector, from, balance, amount, id) + ); + harness.transfer(by, from, to, id, amount); + } + + function testFuzz_ShouldRevert_Operator_InsufficientBalance( + address by, + address from, + address to, + uint256 id, + uint256 amount, + uint256 balance + ) external { + vm.assume(from != address(0)); + vm.assume(to != address(0)); + vm.assume(by != address(0)); + + balance = bound(balance, 1, type(uint256).max - 1); + amount = bound(amount, balance + 1, type(uint256).max); + + harness.mint(from, id, balance); + harness.setOperator(from, by, true); + + vm.expectRevert( + abi.encodeWithSelector(LibERC6909.ERC6909InsufficientBalance.selector, from, balance, amount, id) + ); + harness.transfer(by, from, to, id, amount); + } + + function testFuzz_ShouldRevert_NonOperator_AllowanceUnderflow( + address by, + address from, + address to, + uint256 id, + uint256 amount, + uint256 spend + ) external { + vm.assume(from != by); + vm.assume(from != address(0)); + vm.assume(by != address(0)); + vm.assume(to != address(0)); + + amount = bound(amount, 1, type(uint256).max - 1); + vm.assume(spend > amount); + + harness.mint(from, id, amount); + harness.approve(from, by, id, amount); + + vm.expectRevert(abi.encodeWithSelector(LibERC6909.ERC6909InsufficientAllowance.selector, by, amount, spend, id)); + harness.transfer(by, from, to, id, spend); + } + + function testFuzz_ShouldRevert_NonOperator_NoAllowance( + address by, + address from, + address to, + uint256 id, + uint256 amount + ) external { + vm.assume(from != by); + vm.assume(from != address(0)); + vm.assume(by != address(0)); + vm.assume(to != address(0)); + + amount = bound(amount, 1, type(uint256).max); + + harness.mint(from, id, amount); + + vm.expectRevert(abi.encodeWithSelector(LibERC6909.ERC6909InsufficientAllowance.selector, by, 0, amount, id)); + harness.transfer(by, from, to, id, amount); + } + + function testFuzz_Transfer_SelfTransfer_NonOperator_FiniteAllowance( + address by, + address from, + uint256 id, + uint256 amount, + uint256 spend + ) external { + vm.assume(from != by); + vm.assume(from != address(0)); + vm.assume(by != address(0)); + + amount = bound(amount, 1, type(uint256).max - 1); + spend = bound(spend, 1, amount); + + harness.mint(from, id, amount); + harness.approve(from, by, id, amount); + + vm.expectEmit(); + emit LibERC6909.Transfer(by, from, from, id, spend); + + harness.transfer(by, from, from, id, spend); + + assertEq(harness.balanceOf(from, id), amount); + assertEq(harness.allowance(from, by, id), amount - spend); + } + + function testFuzz_Transfer_ToZeroAddress_NonOperator_MaxAllowance( + address by, + address from, + uint256 id, + uint256 amount + ) external { + vm.assume(from != address(0)); + vm.assume(by != address(0)); + + amount = bound(amount, 1, type(uint256).max); + + harness.mint(from, id, amount); + harness.approve(from, by, id, type(uint256).max); + + vm.expectRevert(abi.encodeWithSelector(LibERC6909.ERC6909InvalidReceiver.selector, address(0))); + harness.transfer(by, from, address(0), id, amount); + } + + function testFuzz_Transfer_ZeroAmount_NonOperator_FiniteAllowance( + address by, + address from, + address to, + uint256 id, + uint256 allowance + ) external { + vm.assume(from != address(0)); + vm.assume(to != address(0)); + vm.assume(by != address(0)); + vm.assume(from != to); + + allowance = bound(allowance, 1, type(uint256).max - 1); + + harness.mint(from, id, allowance); + harness.approve(from, by, id, allowance); + + vm.expectEmit(); + emit LibERC6909.Transfer(by, from, to, id, 0); + + harness.transfer(by, from, to, id, 0); + + assertEq(harness.balanceOf(from, id), allowance); + assertEq(harness.balanceOf(to, id), 0); + assertEq(harness.allowance(from, by, id), allowance); + } +} diff --git a/test/token/ERC6909/ERC6909/harnesses/ERC6909FacetHarness.sol b/test/token/ERC6909/ERC6909/harnesses/ERC6909FacetHarness.sol new file mode 100644 index 00000000..5afb5fc0 --- /dev/null +++ b/test/token/ERC6909/ERC6909/harnesses/ERC6909FacetHarness.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {ERC6909Facet} from "../../../../../src/token/ERC6909/ERC6909/ERC6909Facet.sol"; + +/// @title ERC6909FacetHarness +/// @notice Test harness for ERC6909Facet that adds initialization and minting for testing +error ERC6909InvalidReceiver(address _receiver); +contract ERC6909FacetHarness is ERC6909Facet { + function mint(address _to, uint256 _id, uint256 _amount) external { + if (_to == address(0)) { + revert ERC6909InvalidReceiver(address(0)); + } + ERC6909Storage storage s = getStorage(); + + s.balanceOf[_to][_id] += _amount; + + emit Transfer(msg.sender, address(0), _to, _id, _amount); + } +} diff --git a/test/token/ERC6909/ERC6909/harnesses/LibERC6909Harness.sol b/test/token/ERC6909/ERC6909/harnesses/LibERC6909Harness.sol new file mode 100644 index 00000000..ffc89492 --- /dev/null +++ b/test/token/ERC6909/ERC6909/harnesses/LibERC6909Harness.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {LibERC6909} from "../../../../../src/token/ERC6909/ERC6909/LibERC6909.sol"; + +/// @title LibERC6909Harness +/// @notice Test harness that exposes LibERC6909's internal functions as external +/// @dev Required for testing since LibERC6909 only has internal functions +contract LibERC6909Harness { + function mint(address _to, uint256 _id, uint256 _amount) external { + LibERC6909.mint(_to, _id, _amount); + } + + function burn(address _from, uint256 _id, uint256 _amount) external { + LibERC6909.burn(_from, _id, _amount); + } + + function transfer(address _by, address _from, address _to, uint256 _id, uint256 _amount) external { + LibERC6909.transfer(_by, _from, _to, _id, _amount); + } + + function approve(address _owner, address _spender, uint256 _id, uint256 _amount) external { + LibERC6909.approve(_owner, _spender, _id, _amount); + } + + function setOperator(address _owner, address _spender, bool _approved) external { + LibERC6909.setOperator(_owner, _spender, _approved); + } + + function balanceOf(address _owner, uint256 _id) external view returns (uint256) { + return LibERC6909.getStorage().balanceOf[_owner][_id]; + } + + function allowance(address _owner, address _spender, uint256 _id) external view returns (uint256) { + return LibERC6909.getStorage().allowance[_owner][_spender][_id]; + } + + function isOperator(address _owner, address _spender) external view returns (bool) { + return LibERC6909.getStorage().isOperator[_owner][_spender]; + } +}