From 53b1b298e2f3d2a50e8a4361b4d8e29d228907f7 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Wed, 29 Oct 2025 08:51:54 -0500 Subject: [PATCH 1/5] feat: safe transfer library for external ERC20 tokens --- src/libs/TransferHelper.sol | 86 +++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/libs/TransferHelper.sol diff --git a/src/libs/TransferHelper.sol b/src/libs/TransferHelper.sol new file mode 100644 index 0000000..bfe380b --- /dev/null +++ b/src/libs/TransferHelper.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.6.0; + +import {IERC20} from "../interfaces/IERC20.sol"; + +/// @notice Safe ERC20 transfer library that safely handles missing return values. +/// @author Modified from Uniswap (https://github.com/Uniswap/uniswap-v3-periphery/blob/main/contracts/libraries/TransferHelper.sol) +/// @dev Only change from Uniswap is the addition of the "safeTransferExact*" function variants which check that there was no fee on transfer. +library TransferHelper { + /// @notice Transfers tokens from the targeted address to the given destination + /// @dev Errors with 'STF' if transfer fails + /// @param token The contract address of the token to be transferred + /// @param from The originating address from which the tokens will be transferred + /// @param to The destination address of the transfer + /// @param value The amount to be transferred + function safeTransferFrom( + IERC20 token, + address from, + address to, + uint256 value + ) internal { + (bool success, bytes memory data) = + address(token).call(abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value)); + require(success && (data.length == 0 || abi.decode(data, (bool))), "STF"); + } + + /// @notice Transfer tokens from the targeted address to the given destination with a balance check + /// @dev Errors with 'STFE' if transfer fails or there is a fee on transfer + /// @param token The contract address of the token to be transferred + /// @param from The originating address from which the tokens will be transferred + /// @param to The destination address of the transfer + /// @param value The amount to be transferred + function safeTransferExactFrom( + IERC20 token, + address from, + address to, + uint256 value + ) internal { + uint256 balanceBefore = token.balanceOf(to); + safeTransferFrom(token, from, to, value); + require(token.balanceOf(to) - balanceBefore >= value, "STFE"); + } + + /// @notice Transfers tokens from msg.sender to a recipient + /// @dev Errors with ST if transfer fails + /// @param token The contract address of the token which will be transferred + /// @param to The recipient of the transfer + /// @param value The value of the transfer + function safeTransfer( + IERC20 token, + address to, + uint256 value + ) internal { + (bool success, bytes memory data) = address(token).call(abi.encodeWithSelector(IERC20.transfer.selector, to, value)); + require(success && (data.length == 0 || abi.decode(data, (bool))), "ST"); + } + + /// @notice Transfer tokens from msg.sender to a recipient with a balance check + /// @dev Errors with 'STE' if transfer fails or there is a fee on transfer + /// @param token The contract address of the token which will be transferred + /// @param to The recipient of the transfer + /// @param value The value of the transfer + function safeTransferExact( + IERC20 token, + address to, + uint256 value + ) internal { + uint256 balanceBefore = token.balanceOf(to); + safeTransfer(token, to, value); + require(token.balanceOf(to) - balanceBefore >= value, "STE"); + } + + /// @notice Approves the stipulated contract to spend the given allowance in the given token + /// @dev Errors with 'SA' if transfer fails + /// @param token The contract address of the token to be approved + /// @param to The target of the approval + /// @param value The amount of the given token the target will be allowed to spend + function safeApprove( + IERC20 token, + address to, + uint256 value + ) internal { + (bool success, bytes memory data) = address(token).call(abi.encodeWithSelector(IERC20.approve.selector, to, value)); + require(success && (data.length == 0 || abi.decode(data, (bool))), "SA"); + } +} From b7da5ec43c76cfc4863e16e1bee4ae835de9b340 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Wed, 29 Oct 2025 08:55:16 -0500 Subject: [PATCH 2/5] feat: type converter library --- src/libs/TransferHelper.sol | 2 +- src/libs/TypeConverter.sol | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/libs/TypeConverter.sol diff --git a/src/libs/TransferHelper.sol b/src/libs/TransferHelper.sol index bfe380b..2e4ddf9 100644 --- a/src/libs/TransferHelper.sol +++ b/src/libs/TransferHelper.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.6.0; +pragma solidity >=0.6.0 <0.9.0; import {IERC20} from "../interfaces/IERC20.sol"; diff --git a/src/libs/TypeConverter.sol b/src/libs/TypeConverter.sol new file mode 100644 index 0000000..6ff4600 --- /dev/null +++ b/src/libs/TypeConverter.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.20 <0.9.0; + +/// @title TypeConverter +/// @author M0 Labs +/// @notice Utilities for converting between different data types. +library TypeConverter { + /// @notice Thrown when a uint256 value exceeds the max uint64 value. + error Uint64Overflow(); + /// @notice Thrown when a uint256 value exceeds the max uint128 value. + error Uint128Overflow(); + + /// @notice Thrown when a bytes32 value doesn't represent a valid Ethereum address. + error InvalidAddress(bytes32 value); + + /// @notice Converts a uint256 to uint64, reverting if the value overflows. + /// @param value The uint256 value to convert. + /// @return The uint64 representation of the value. + function toUint64(uint256 value) internal pure returns (uint64) { + if (value > type(uint64).max) revert Uint64Overflow(); + return uint64(value); + } + + /// @notice Converts a uint256 to uint128, reverting if the value overflows. + /// @param value The uint256 value to convert. + /// @return The uint128 representation of the value. + function toUint128(uint256 value) internal pure returns (uint128) { + if (value > type(uint128).max) revert Uint128Overflow(); + return uint128(value); + } + + /// @notice Convert an Ethereum address to bytes32. + /// @dev Pads the 20-byte address to 32 bytes by converting to uint160, then uint256, then bytes32. + /// @param addressValue The address to convert. + /// @return The bytes32 representation of the address. + function toBytes32(address addressValue) internal pure returns (bytes32) { + return bytes32(uint256(uint160(addressValue))); + } + + /// @notice Convert bytes32 to an Ethereum address. + /// @dev Truncates the 32-byte value to 20 bytes by converting to uint256, then uint160, then address. + /// @param bytes32Value The bytes32 value to convert. + /// @return The address representation of the bytes32 value. + function toAddress(bytes32 bytes32Value) internal pure returns (address) { + if (!isValidAddress(bytes32Value)) revert InvalidAddress(bytes32Value); + return address(uint160(uint256(bytes32Value))); + } + + /// @notice Check if a bytes32 value represents a valid Ethereum address. + /// @dev An Ethereum address must have the top 12 bytes as zero. + /// @param bytes32Value The bytes32 value to check. + /// @return True if the bytes32 value can be safely converted to an Ethereum address. + function isValidAddress(bytes32 bytes32Value) internal pure returns (bool) { + // The top 12 bytes must be zero for a valid Ethereum address + return uint256(bytes32Value) >> 160 == 0; + } +} From 563d2ed5f152f00b0a1243bd92fabf3492b4abfd Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Wed, 29 Oct 2025 14:52:09 -0500 Subject: [PATCH 3/5] test: type converter library tests --- test/TypeConverter.t.sol | 188 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 test/TypeConverter.t.sol diff --git a/test/TypeConverter.t.sol b/test/TypeConverter.t.sol new file mode 100644 index 0000000..c7c8e91 --- /dev/null +++ b/test/TypeConverter.t.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import { Test } from "../lib/forge-std/src/Test.sol"; + +import { TypeConverter } from "../src/libs/TypeConverter.sol"; + +contract TypeConverterTest is Test { + /////////////////////////////////////////////////////////////////////////// + // toUint64 // + /////////////////////////////////////////////////////////////////////////// + + function test_toUint64_basic() external pure { + uint256 value = 100; + uint64 result = TypeConverter.toUint64(value); + assertEq(result, uint64(100)); + } + + function test_toUint64_maxUint64() external pure { + uint256 value = type(uint64).max; + uint64 result = TypeConverter.toUint64(value); + assertEq(result, type(uint64).max); + } + + function test_toUint64_zero() external pure { + uint256 value = 0; + uint64 result = TypeConverter.toUint64(value); + assertEq(result, 0); + } + + function testFuzz_toUint64(uint64 value) external pure { + uint256 uint256Value = uint256(value); + uint64 result = TypeConverter.toUint64(uint256Value); + assertEq(result, value); + } + + /// forge-config: default.allow_internal_expect_revert = true + function test_toUint64_overflow() external { + uint256 value = uint256(type(uint64).max) + 1; + vm.expectRevert(TypeConverter.Uint64Overflow.selector); + TypeConverter.toUint64(value); + } + + /// forge-config: default.allow_internal_expect_revert = true + function testFuzz_toUint64_overflow(uint256 value) external { + vm.assume(value > type(uint64).max); + vm.expectRevert(TypeConverter.Uint64Overflow.selector); + TypeConverter.toUint64(value); + } + + /////////////////////////////////////////////////////////////////////////// + // toUint128 // + /////////////////////////////////////////////////////////////////////////// + + function test_toUint128_basic() external pure { + uint256 value = 100; + uint128 result = TypeConverter.toUint128(value); + assertEq(result, uint128(100)); + } + + function test_toUint128_maxUint128() external pure { + uint256 value = type(uint128).max; + uint128 result = TypeConverter.toUint128(value); + assertEq(result, type(uint128).max); + } + + function test_toUint128_zero() external pure { + uint256 value = 0; + uint128 result = TypeConverter.toUint128(value); + assertEq(result, 0); + } + + function testFuzz_toUint128(uint128 value) external pure { + uint256 uint256Value = uint256(value); + uint128 result = TypeConverter.toUint128(uint256Value); + assertEq(result, value); + } + + /// forge-config: default.allow_internal_expect_revert = true + function test_toUint128_overflow() external { + uint256 value = uint256(type(uint128).max) + 1; + vm.expectRevert(TypeConverter.Uint128Overflow.selector); + TypeConverter.toUint128(value); + } + + /// forge-config: default.allow_internal_expect_revert = true + function testFuzz_toUint128_overflow(uint256 value) external { + vm.assume(value > type(uint128).max); + vm.expectRevert(TypeConverter.Uint128Overflow.selector); + TypeConverter.toUint128(value); + } + + /////////////////////////////////////////////////////////////////////////// + // toBytes32 // + /////////////////////////////////////////////////////////////////////////// + + function test_toBytes32_basic() external pure { + address addressValue = address(0x1234567890123456789012345678901234567890); + bytes32 actual = TypeConverter.toBytes32(addressValue); + bytes32 expected = bytes32(uint256(uint160(addressValue))); + assertEq(actual, expected); + } + + function test_toBytes32_zeroAddress() external pure { + address addressValue = address(0); + bytes32 actual = TypeConverter.toBytes32(addressValue); + assertEq(actual, bytes32(0)); + } + + function test_toBytes32_maxAddress() external pure { + address addressValue = address(uint160(type(uint160).max)); + bytes32 actual = TypeConverter.toBytes32(addressValue); + bytes32 expected = bytes32(uint256(type(uint160).max)); + assertEq(actual, expected); + } + + function testFuzz_toBytes32(address addressValue) external pure { + bytes32 actual = TypeConverter.toBytes32(addressValue); + bytes32 expected = bytes32(uint256(uint160(addressValue))); + assertEq(actual, expected); + } + + /////////////////////////////////////////////////////////////////////////// + // toAddress // + /////////////////////////////////////////////////////////////////////////// + + function test_toAddress_basic() external pure { + address expected = address(0x1234567890123456789012345678901234567890); + bytes32 bytes32Value = bytes32(uint256(uint160(expected))); + address actual = TypeConverter.toAddress(bytes32Value); + assertEq(actual, expected); + } + + function test_toAddress_zeroAddress() external pure { + bytes32 bytes32Value = bytes32(0); + assertEq(TypeConverter.toAddress(bytes32Value), address(0)); + } + + function testFuzz_toAddress_valid(address addressValue) external pure { + bytes32 value = bytes32(uint256(uint160(addressValue))); + address actual = TypeConverter.toAddress(value); + assertEq(actual, addressValue); + } + + /// forge-config: default.allow_internal_expect_revert = true + function test_toAddress_invalidAddress_topBytesNotZero() external { + // Create a bytes32 value where the top 12 bytes are not zero (invalid address) + bytes32 bytes32Value = bytes32(uint256(1) << 160); // Set a bit in the top 12 bytes + vm.expectRevert(abi.encodeWithSelector(TypeConverter.InvalidAddress.selector, bytes32Value)); + TypeConverter.toAddress(bytes32Value); + } + + /////////////////////////////////////////////////////////////////////////// + // isValidAddress // + /////////////////////////////////////////////////////////////////////////// + + function test_isValidAddress_valid() external pure { + address validAddress = address(0x1234567890123456789012345678901234567890); + bytes32 bytes32Value = bytes32(uint256(uint160(validAddress))); + assertTrue(TypeConverter.isValidAddress(bytes32Value)); + } + + function test_isValidAddress_zeroAddress() external pure { + bytes32 bytes32Value = bytes32(0); + assertTrue(TypeConverter.isValidAddress(bytes32Value)); + } + + function test_isValidAddress_maxAddress() external pure { + bytes32 bytes32Value = bytes32(uint256(type(uint160).max)); + assertTrue(TypeConverter.isValidAddress(bytes32Value)); + } + + function testFuzz_isValidAddress_valid(address validAddress) external pure { + bytes32 bytes32Value = bytes32(uint256(uint160(validAddress))); + assertTrue(TypeConverter.isValidAddress(bytes32Value)); + } + + function test_isValidAddress_invalidTopBitSet() external pure { + bytes32 bytes32Value = bytes32(uint256(1) << 160); + assertFalse(TypeConverter.isValidAddress(bytes32Value)); + } + + function testFuzz_isValidAddress_invalid(uint96 topBits) external pure { + vm.assume(topBits != 0); + bytes32 bytes32Value = bytes32((uint256(topBits) << 160) | uint256(uint160(address(0x1234567890123456789012345678901234567890)))); + assertFalse(TypeConverter.isValidAddress(bytes32Value)); + } +} From be72d5a256720201032ad01e2c13ccbc90d87975 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Wed, 29 Oct 2025 17:29:25 -0500 Subject: [PATCH 4/5] test: transfer helper lib tests --- test/TransferHelper.t.sol | 346 +++++++++++++++++++++++++++ test/utils/TransferHelperHarness.sol | 51 ++++ 2 files changed, 397 insertions(+) create mode 100644 test/TransferHelper.t.sol create mode 100644 test/utils/TransferHelperHarness.sol diff --git a/test/TransferHelper.t.sol b/test/TransferHelper.t.sol new file mode 100644 index 0000000..37e667f --- /dev/null +++ b/test/TransferHelper.t.sol @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import { Test } from "../lib/forge-std/src/Test.sol"; + +import { TransferHelper } from "../src/libs/TransferHelper.sol"; +import { TypeConverter } from "../src/libs/TypeConverter.sol"; +import { IERC20 } from "../src/interfaces/IERC20.sol"; + +import { TransferHelperHarness } from "./utils/TransferHelperHarness.sol"; + +abstract contract ERC20Base { + string public constant name = "Test Token"; + string public constant symbol = "TTK"; + uint8 public constant decimals = 18; + uint256 public totalSupply; + mapping(address owner => uint256) public balanceOf; + mapping(address owner => mapping(address spender => uint256)) public allowance; + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + totalSupply += amount; + } + + function _transfer(address from, address to, uint256 amount) internal { + balanceOf[from] -= amount; + balanceOf[to] += amount; + } + + function _approve(address owner, address spender, uint256 amount) internal { + require(spender != address(0), "approve to the zero address"); // add check to have failure case + allowance[owner][spender] = amount; + } + + function _spendAllowance(address owner, address spender, uint256 amount) internal { + if (owner == spender) return; + uint256 currentAllowance = allowance[owner][spender]; + require(currentAllowance >= amount, "ERC20: insufficient allowance"); + allowance[owner][spender] = currentAllowance - amount; + } + + function _afterTransfer(address from, address to, uint256 amount) internal virtual {} +} + +contract StandardERC20 is ERC20Base { + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + _afterTransfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + _spendAllowance(from, msg.sender, amount); + _transfer(from, to, amount); + _afterTransfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + _approve(msg.sender, spender, amount); + return true; + } +} + +contract NoReturnsERC20 is ERC20Base { + function transfer(address to, uint256 amount) external { + _transfer(msg.sender, to, amount); + _afterTransfer(msg.sender, to, amount); + } + + function transferFrom(address from, address to, uint256 amount) external { + _spendAllowance(from, msg.sender, amount); + _transfer(from, to, amount); + _afterTransfer(from, to, amount); + } + + function approve(address spender, uint256 amount) external { + _approve(msg.sender, spender, amount); + } +} + +contract FeeOnTransferERC20 is StandardERC20 { + uint256 public feeBps; + + function setFeeBps(uint256 bps) external { + feeBps = bps; + } + + function _feeAmount(uint256 amount) internal view returns (uint256) { + if (feeBps == 0) { + return 0; + } + return (amount * feeBps) / 10_000; + } + + function _afterTransfer(address, address to, uint256 amount) internal override { + uint256 feeAmount = _feeAmount(amount); + if (feeAmount > 0) { + balanceOf[to] -= feeAmount; + // For simplicity, send fees to address(0) + balanceOf[address(0)] += feeAmount; + } + } +} + +contract TransferHelperTest is Test { + using TypeConverter for *; + using TransferHelper for IERC20; + + TransferHelperHarness public transferHelper; + address public alice; + address public bob; + IERC20 public token; + uint256 public constant INITIAL_BALANCE = 100e18; + + function setUp() public { + transferHelper = new TransferHelperHarness(); + + alice = (keccak256("alice") >> 96).toAddress(); + bob = (keccak256("bob") >> 96).toAddress(); + + vm.deal(alice, 1 ether); + vm.deal(bob, 1 ether); + } + + modifier givenStandardToken() { + StandardERC20 standardToken = new StandardERC20(); + standardToken.mint(address(transferHelper), INITIAL_BALANCE); + standardToken.mint(alice, INITIAL_BALANCE); + token = IERC20(address(standardToken)); + _; + } + + modifier givenNoReturnsToken() { + NoReturnsERC20 noReturnsToken = new NoReturnsERC20(); + noReturnsToken.mint(address(transferHelper), INITIAL_BALANCE); + noReturnsToken.mint(alice, INITIAL_BALANCE); + token = IERC20(address(noReturnsToken)); + _; + } + + modifier givenFeeOnTransferToken(uint256 feeBps) { + FeeOnTransferERC20 feeToken = new FeeOnTransferERC20(); + feeToken.mint(address(transferHelper), INITIAL_BALANCE); + feeToken.mint(alice, INITIAL_BALANCE); + feeToken.setFeeBps(feeBps); + token = IERC20(address(feeToken)); + _; + } + + // Test cases (by function) + + // safeTransfer + // [X] given the token returns a boolean from the transfer call + // [X] given the transfer succeeds + // [X] it succeeds + // [X] given the transfer fails + // [X] it reverts with 'ST' + // [X] given the token does not return a boolean from the transfer call + // [X] given the transfer succeeds + // [X] it succeeds + // [X] given the transfer fails + // [X] it reverts with 'ST' + + function testFuzz_safeTransfer_standardToken_success(uint256 amount) external givenStandardToken { + amount = amount % (INITIAL_BALANCE + 1); + + transferHelper.safeTransfer(token, bob, amount); + + assertEq(token.balanceOf(bob), amount); + assertEq(token.balanceOf(address(transferHelper)), INITIAL_BALANCE - amount); + } + + function testFuzz_safeTransfer_standardToken_reverts(uint256 amount) external givenStandardToken { + vm.assume(amount > INITIAL_BALANCE); + + vm.expectRevert(abi.encodePacked("ST")); + transferHelper.safeTransfer(token, bob, amount); + } + + function testFuzz_safeTransfer_noReturnsToken_success(uint256 amount) external givenNoReturnsToken { + amount = amount % (INITIAL_BALANCE + 1); + + transferHelper.safeTransfer(token, bob, amount); + + assertEq(token.balanceOf(bob), amount); + assertEq(token.balanceOf(address(transferHelper)), INITIAL_BALANCE - amount); + } + + function testFuzz_safeTransfer_noReturnsToken_reverts(uint256 amount) external givenNoReturnsToken { + vm.assume(amount > INITIAL_BALANCE); + + vm.expectRevert(abi.encodePacked("ST")); + transferHelper.safeTransfer(token, bob, amount); + } + + // safeTransferExact + // [X] given the token does not charge a fee on transfer + // [X] it succeeds + // [X] given the token charges a fee on transfer + // [X] it reverts with 'STE' + + function testFuzz_safeTransferExact_noFee_success(uint256 amount) external givenStandardToken { + amount = amount % (INITIAL_BALANCE + 1); + + transferHelper.safeTransferExact(token, bob, amount); + + assertEq(token.balanceOf(bob), amount); + assertEq(token.balanceOf(address(transferHelper)), INITIAL_BALANCE - amount); + } + + function testFuzz_safeTransferExact_feeOnTransfer_reverts(uint256 amount) external givenFeeOnTransferToken(100) { + amount = (amount % (INITIAL_BALANCE - 99)) + 100; // ensure amount >= 100 + + vm.expectRevert(abi.encodePacked("STE")); + transferHelper.safeTransferExact(token, bob, amount); + } + + // safeTransferFrom + // [X] given the token returns a boolean from the transferFrom call + // [X] given the transferFrom succeeds + // [X] it succeeds + // [X] given the transferFrom fails + // [X] it reverts with 'STF' + // [X] given the token does not return a boolean from the transferFrom call + // [X] given the transferFrom succeeds + // [X] it succeeds + // [X] given the transferFrom fails + // [X] it reverts with 'STF' + + function testFuzz_safeTransferFrom_standardToken_success(uint256 amount) external givenStandardToken { + amount = amount % (INITIAL_BALANCE + 1); + + vm.prank(alice); + token.approve(address(transferHelper), type(uint256).max); + + transferHelper.safeTransferFrom(token, alice, bob, amount); + + assertEq(token.balanceOf(bob), amount); + assertEq(token.balanceOf(alice), INITIAL_BALANCE - amount); + } + + function testFuzz_safeTransferFrom_standardToken_reverts(uint256 amount) external givenStandardToken { + vm.assume(amount > INITIAL_BALANCE); + + vm.prank(alice); + token.approve(address(transferHelper), type(uint256).max); + + vm.expectRevert(abi.encodePacked("STF")); + transferHelper.safeTransferFrom(token, alice, bob, amount); + } + + function testFuzz_safeTransferFrom_noReturnsToken_success(uint256 amount) external givenNoReturnsToken { + amount = amount % (INITIAL_BALANCE + 1); + + vm.prank(alice); + token.safeApprove(address(transferHelper), type(uint256).max); + + transferHelper.safeTransferFrom(token, alice, bob, amount); + + assertEq(token.balanceOf(bob), amount); + assertEq(token.balanceOf(alice), INITIAL_BALANCE - amount); + } + + function testFuzz_safeTransferFrom_noReturnsToken_reverts(uint256 amount) external givenNoReturnsToken { + vm.assume(amount > INITIAL_BALANCE); + + vm.prank(alice); + token.safeApprove(address(transferHelper), type(uint256).max); + + vm.expectRevert(abi.encodePacked("STF")); + transferHelper.safeTransferFrom(token, alice, bob, amount); + } + + // safeTransferExactFrom + // [X] given the token does not charge a fee on transfer + // [X] it succeeds + // [X] given the token charges a fee on transfer + // [X] it reverts with 'STFE' + + function testFuzz_safeTransferExactFrom_noFee_success(uint256 amount) external givenStandardToken { + amount = amount % (INITIAL_BALANCE + 1); + + vm.prank(alice); + token.approve(address(transferHelper), type(uint256).max); + + transferHelper.safeTransferExactFrom(token, alice, bob, amount); + + assertEq(token.balanceOf(alice), INITIAL_BALANCE - amount); + assertEq(token.balanceOf(bob), amount); + } + + function testFuzz_safeTransferExactFrom_feeOnTransfer_reverts(uint256 amount) external givenFeeOnTransferToken(100) { + amount = (amount % (INITIAL_BALANCE - 99)) + 100; // ensure amount >= 100 + + vm.prank(alice); + token.approve(address(transferHelper), type(uint256).max); + + vm.expectRevert(abi.encodePacked("STFE")); + transferHelper.safeTransferExactFrom(token, alice, bob, amount); + } + + + // safeApprove + // [X] given the token returns a boolean from the approve call + // [X] given the approve succeeds + // [X] it succeeds + // [X] given the approve fails + // [X] it reverts with 'SA' + // [X] given the token does not return a boolean from the approve call + // [X] given the approve succeeds + // [X] it succeeds + // [X] given the approve fails + // [X] it reverts with 'SA' + + function testFuzz_safeApprove_standardToken_success(address spender, uint256 amount) external givenStandardToken { + vm.assume(spender != address(0)); + + transferHelper.safeApprove(token, spender, amount); + + assertEq(token.allowance(address(transferHelper), spender), amount); + } + + function testFuzz_safeApprove_standardToken_reverts(uint256 amount) external givenStandardToken { + // Test token reverts when approving address(0) + + vm.expectRevert(abi.encodePacked("SA")); + transferHelper.safeApprove(token, address(0), amount); + } + + function testFuzz_safeApprove_noReturnsToken_success(address spender, uint256 amount) external givenNoReturnsToken { + vm.assume(spender != address(0)); + + transferHelper.safeApprove(token, spender, amount); + + assertEq(token.allowance(address(transferHelper), spender), amount); + } + + function testFuzz_safeApprove_noReturnsToken_reverts(uint256 amount) external givenNoReturnsToken { + // Test token reverts when approving address(0) + + vm.expectRevert(abi.encodePacked("SA")); + transferHelper.safeApprove(token, address(0), amount); + } + +} diff --git a/test/utils/TransferHelperHarness.sol b/test/utils/TransferHelperHarness.sol new file mode 100644 index 0000000..af82eb6 --- /dev/null +++ b/test/utils/TransferHelperHarness.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity >=0.8.20 <0.9.0; + +import { TransferHelper } from "../../src/libs/TransferHelper.sol"; +import { IERC20 } from "../../src/interfaces/IERC20.sol"; + +/// @title TransferHelper harness used to correctly display test coverage. +contract TransferHelperHarness { + function safeTransferFrom( + IERC20 token, + address from, + address to, + uint256 value + ) external { + TransferHelper.safeTransferFrom(token, from, to, value); + } + + function safeTransferExactFrom( + IERC20 token, + address from, + address to, + uint256 value + ) external { + TransferHelper.safeTransferExactFrom(token, from, to, value); + } + + function safeTransfer( + IERC20 token, + address to, + uint256 value + ) external { + TransferHelper.safeTransfer(token, to, value); + } + + function safeTransferExact( + IERC20 token, + address to, + uint256 value + ) external { + TransferHelper.safeTransferExact(token, to, value); + } + + function safeApprove( + IERC20 token, + address spender, + uint256 value + ) external { + TransferHelper.safeApprove(token, spender, value); + } +} From e3da92df87f4aa99fa5c736b75018997e3329904 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Wed, 29 Oct 2025 17:30:28 -0500 Subject: [PATCH 5/5] chore: prettier --- src/libs/TransferHelper.sol | 47 +++++++++------------------- test/TransferHelper.t.sol | 8 ++--- test/TypeConverter.t.sol | 4 ++- test/utils/TransferHelperHarness.sol | 32 +++---------------- 4 files changed, 27 insertions(+), 64 deletions(-) diff --git a/src/libs/TransferHelper.sol b/src/libs/TransferHelper.sol index 2e4ddf9..a75e451 100644 --- a/src/libs/TransferHelper.sol +++ b/src/libs/TransferHelper.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity >=0.6.0 <0.9.0; -import {IERC20} from "../interfaces/IERC20.sol"; +import { IERC20 } from "../interfaces/IERC20.sol"; /// @notice Safe ERC20 transfer library that safely handles missing return values. /// @author Modified from Uniswap (https://github.com/Uniswap/uniswap-v3-periphery/blob/main/contracts/libraries/TransferHelper.sol) @@ -13,14 +13,10 @@ library TransferHelper { /// @param from The originating address from which the tokens will be transferred /// @param to The destination address of the transfer /// @param value The amount to be transferred - function safeTransferFrom( - IERC20 token, - address from, - address to, - uint256 value - ) internal { - (bool success, bytes memory data) = - address(token).call(abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value)); + function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal { + (bool success, bytes memory data) = address(token).call( + abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value) + ); require(success && (data.length == 0 || abi.decode(data, (bool))), "STF"); } @@ -30,12 +26,7 @@ library TransferHelper { /// @param from The originating address from which the tokens will be transferred /// @param to The destination address of the transfer /// @param value The amount to be transferred - function safeTransferExactFrom( - IERC20 token, - address from, - address to, - uint256 value - ) internal { + function safeTransferExactFrom(IERC20 token, address from, address to, uint256 value) internal { uint256 balanceBefore = token.balanceOf(to); safeTransferFrom(token, from, to, value); require(token.balanceOf(to) - balanceBefore >= value, "STFE"); @@ -46,12 +37,10 @@ library TransferHelper { /// @param token The contract address of the token which will be transferred /// @param to The recipient of the transfer /// @param value The value of the transfer - function safeTransfer( - IERC20 token, - address to, - uint256 value - ) internal { - (bool success, bytes memory data) = address(token).call(abi.encodeWithSelector(IERC20.transfer.selector, to, value)); + function safeTransfer(IERC20 token, address to, uint256 value) internal { + (bool success, bytes memory data) = address(token).call( + abi.encodeWithSelector(IERC20.transfer.selector, to, value) + ); require(success && (data.length == 0 || abi.decode(data, (bool))), "ST"); } @@ -60,11 +49,7 @@ library TransferHelper { /// @param token The contract address of the token which will be transferred /// @param to The recipient of the transfer /// @param value The value of the transfer - function safeTransferExact( - IERC20 token, - address to, - uint256 value - ) internal { + function safeTransferExact(IERC20 token, address to, uint256 value) internal { uint256 balanceBefore = token.balanceOf(to); safeTransfer(token, to, value); require(token.balanceOf(to) - balanceBefore >= value, "STE"); @@ -75,12 +60,10 @@ library TransferHelper { /// @param token The contract address of the token to be approved /// @param to The target of the approval /// @param value The amount of the given token the target will be allowed to spend - function safeApprove( - IERC20 token, - address to, - uint256 value - ) internal { - (bool success, bytes memory data) = address(token).call(abi.encodeWithSelector(IERC20.approve.selector, to, value)); + function safeApprove(IERC20 token, address to, uint256 value) internal { + (bool success, bytes memory data) = address(token).call( + abi.encodeWithSelector(IERC20.approve.selector, to, value) + ); require(success && (data.length == 0 || abi.decode(data, (bool))), "SA"); } } diff --git a/test/TransferHelper.t.sol b/test/TransferHelper.t.sol index 37e667f..783a3d0 100644 --- a/test/TransferHelper.t.sol +++ b/test/TransferHelper.t.sol @@ -290,7 +290,9 @@ contract TransferHelperTest is Test { assertEq(token.balanceOf(bob), amount); } - function testFuzz_safeTransferExactFrom_feeOnTransfer_reverts(uint256 amount) external givenFeeOnTransferToken(100) { + function testFuzz_safeTransferExactFrom_feeOnTransfer_reverts( + uint256 amount + ) external givenFeeOnTransferToken(100) { amount = (amount % (INITIAL_BALANCE - 99)) + 100; // ensure amount >= 100 vm.prank(alice); @@ -300,13 +302,12 @@ contract TransferHelperTest is Test { transferHelper.safeTransferExactFrom(token, alice, bob, amount); } - // safeApprove // [X] given the token returns a boolean from the approve call // [X] given the approve succeeds // [X] it succeeds // [X] given the approve fails - // [X] it reverts with 'SA' + // [X] it reverts with 'SA' // [X] given the token does not return a boolean from the approve call // [X] given the approve succeeds // [X] it succeeds @@ -342,5 +343,4 @@ contract TransferHelperTest is Test { vm.expectRevert(abi.encodePacked("SA")); transferHelper.safeApprove(token, address(0), amount); } - } diff --git a/test/TypeConverter.t.sol b/test/TypeConverter.t.sol index c7c8e91..0440ec5 100644 --- a/test/TypeConverter.t.sol +++ b/test/TypeConverter.t.sol @@ -182,7 +182,9 @@ contract TypeConverterTest is Test { function testFuzz_isValidAddress_invalid(uint96 topBits) external pure { vm.assume(topBits != 0); - bytes32 bytes32Value = bytes32((uint256(topBits) << 160) | uint256(uint160(address(0x1234567890123456789012345678901234567890)))); + bytes32 bytes32Value = bytes32( + (uint256(topBits) << 160) | uint256(uint160(address(0x1234567890123456789012345678901234567890))) + ); assertFalse(TypeConverter.isValidAddress(bytes32Value)); } } diff --git a/test/utils/TransferHelperHarness.sol b/test/utils/TransferHelperHarness.sol index af82eb6..348b596 100644 --- a/test/utils/TransferHelperHarness.sol +++ b/test/utils/TransferHelperHarness.sol @@ -7,45 +7,23 @@ import { IERC20 } from "../../src/interfaces/IERC20.sol"; /// @title TransferHelper harness used to correctly display test coverage. contract TransferHelperHarness { - function safeTransferFrom( - IERC20 token, - address from, - address to, - uint256 value - ) external { + function safeTransferFrom(IERC20 token, address from, address to, uint256 value) external { TransferHelper.safeTransferFrom(token, from, to, value); } - function safeTransferExactFrom( - IERC20 token, - address from, - address to, - uint256 value - ) external { + function safeTransferExactFrom(IERC20 token, address from, address to, uint256 value) external { TransferHelper.safeTransferExactFrom(token, from, to, value); } - function safeTransfer( - IERC20 token, - address to, - uint256 value - ) external { + function safeTransfer(IERC20 token, address to, uint256 value) external { TransferHelper.safeTransfer(token, to, value); } - function safeTransferExact( - IERC20 token, - address to, - uint256 value - ) external { + function safeTransferExact(IERC20 token, address to, uint256 value) external { TransferHelper.safeTransferExact(token, to, value); } - function safeApprove( - IERC20 token, - address spender, - uint256 value - ) external { + function safeApprove(IERC20 token, address spender, uint256 value) external { TransferHelper.safeApprove(token, spender, value); } }