diff --git a/cSpell.json b/cSpell.json index feead321b..90565c6eb 100644 --- a/cSpell.json +++ b/cSpell.json @@ -101,6 +101,7 @@ "typechain", "TYPEHASH", "underflowed", + "underflows", "visualstudio", "vsmarketplacebadge", "Vyper", diff --git a/contracts/MultiToken.sol b/contracts/MultiToken.sol index af2a7f317..c68c1f413 100644 --- a/contracts/MultiToken.sol +++ b/contracts/MultiToken.sol @@ -16,7 +16,7 @@ contract MultiToken is IMultiToken { // or names // Allows loading of each balance - mapping(uint256 => mapping(address => uint256)) public override balanceOf; + mapping(uint256 => mapping(address => uint256)) public balanceOf; // Allows loading of each total supply mapping(uint256 => uint256) public totalSupply; // Uniform approval for all tokens diff --git a/test/MultiToken.t.sol b/test/MultiToken.t.sol new file mode 100644 index 000000000..5107d6cce --- /dev/null +++ b/test/MultiToken.t.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.18; + +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; + +import { BaseTest, TestLib as Lib } from "test/Test.sol"; +import { MockMultiToken } from "test/mocks/MockMultiToken.sol"; +import { ForwarderFactory } from "contracts/ForwarderFactory.sol"; + +contract MultiTokenTest is BaseTest { + ForwarderFactory forwarderFactory; + MockMultiToken multiToken; + + function setUp() public override { + super.setUp(); + vm.startPrank(deployer); + forwarderFactory = new ForwarderFactory(); + multiToken = new MockMultiToken(bytes32(0), address(forwarderFactory)); + vm.stopPrank(); + } + + function test__name_symbol() public { + vm.startPrank(alice); + multiToken.__setNameAndSymbol(5, "Token", "TKN"); + vm.stopPrank(); + assertEq(multiToken.name(5), "Token"); + assertEq(multiToken.symbol(5), "TKN"); + } +} diff --git a/test/Test.sol b/test/Test.sol new file mode 100644 index 000000000..8babcc2d5 --- /dev/null +++ b/test/Test.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import "forge-std/console2.sol"; +import "forge-std/Vm.sol"; + +import { Test } from "forge-std/Test.sol"; +import { Hyperdrive } from "contracts/Hyperdrive.sol"; +import { HyperdriveMath } from "contracts/libraries/HyperdriveMath.sol"; +import { ERC20PresetFixedSupply } from "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetFixedSupply.sol"; +import { ForwarderFactory } from "contracts/ForwarderFactory.sol"; +import { FixedPointMath } from "contracts/libraries/FixedPointMath.sol"; + +library TestLib { + // @notice Generates a matrix of all of the different combinations of + // inputs for each row. + // @dev In order to generate the full testing matrix, we need to generate + // cases for each value that use all of the input values. In order + // to do this, we segment the set of test cases into subsets for each + // entry + // @param inputs A matrix of uint256 values that defines the inputs that + // will be used to generate combinations for each row. Increasing the + // number of inputs dramatically increases the amount of test cases + // that will be generated, so it's important to limit the amount of + // inputs to a small number of meaningful values. We use uint256 for + // generality, since uint256 can be converted to small width types. + // @return The full testing matrix. + function matrix( + uint256[][] memory inputs + ) internal pure returns (uint256[][] memory result) { + // Compute the divisors that will be used to compute the intervals for + // every input row. + uint256 base = 1; + uint256[] memory intervalDivisors = new uint256[](inputs.length); + for (uint256 i = 0; i < inputs.length; i++) { + base *= inputs[i].length; + intervalDivisors[i] = base; + } + // Generate the testing matrix. + result = new uint256[][](base); + for (uint256 i = 0; i < result.length; i++) { + result[i] = new uint256[](inputs.length); + for (uint256 j = 0; j < inputs.length; j++) { + // The idea behind this calculation is that we split the set of + // test cases into sections and assign one input value to each + // section. For the first row, we'll create {inputs[0].length} + // sections and assign these values to sections linearly. For + // row 1, we'll create inputs[0].length * inputs[1].length + // sections, and we'll assign the 0th input to the first + // section, the 1st input to the second section, and continue + // this process (wrapping around once we run out of input values + // to allocate). + // + // The proof that each row of this procedure is unique is easy + // using induction. Proving that every row is unique also shows + // that the full test matrix has been covered. + result[i][j] = inputs[j][ + (i / (result.length / intervalDivisors[j])) % + inputs[j].length + ]; + } + } + return result; + } + + function logArray( + string memory prelude, + uint256[] memory array + ) internal view { + console2.log(prelude, "["); + for (uint256 i = 0; i < array.length; i++) { + if (i < array.length - 1) { + console2.log(" ", array[i], ","); + } else { + console2.log(" ", array[i]); + } + } + console2.log(" ]"); + console2.log(""); + } + + function _arr( + uint256 a, + uint256 b + ) internal pure returns (uint256[] memory _arr) { + _arr = new uint256[](2); + _arr[0] = a; + _arr[1] = b; + } + + function _arr( + uint256 a, + uint256 b, + uint256 c + ) internal pure returns (uint256[] memory _arr) { + _arr = new uint256[](3); + _arr[0] = a; + _arr[1] = b; + _arr[2] = c; + } + + function _arr( + uint256 a, + uint256 b, + uint256 c, + uint256 d + ) internal pure returns (uint256[] memory _arr) { + _arr = new uint256[](4); + _arr[0] = a; + _arr[1] = b; + _arr[2] = c; + _arr[3] = d; + } + + function _arr( + uint256 a, + uint256 b, + uint256 c, + uint256 d, + uint256 e + ) internal pure returns (uint256[] memory _arr) { + _arr = new uint256[](5); + _arr[0] = a; + _arr[1] = b; + _arr[2] = c; + _arr[3] = d; + _arr[4] = e; + } + + function _arr( + uint256[] memory a, + uint256[] memory b + ) internal pure returns (uint256[][] memory _arr) { + _arr = new uint256[][](2); + _arr[0] = a; + _arr[1] = b; + } + + function _arr( + uint256[] memory a, + uint256[] memory b, + uint256[] memory c + ) internal pure returns (uint256[][] memory _arr) { + _arr = new uint256[][](3); + _arr[0] = a; + _arr[1] = b; + _arr[2] = c; + } + + function _arr( + uint256[] memory a, + uint256[] memory b, + uint256[] memory c, + uint256[] memory d + ) internal pure returns (uint256[][] memory _arr) { + _arr = new uint256[][](4); + _arr[0] = a; + _arr[1] = b; + _arr[2] = c; + _arr[3] = d; + } + + function _arr( + uint256[] memory a, + uint256[] memory b, + uint256[] memory c, + uint256[] memory d, + uint256[] memory e + ) internal pure returns (uint256[][] memory _arr) { + _arr = new uint256[][](5); + _arr[0] = a; + _arr[1] = b; + _arr[2] = c; + _arr[3] = d; + _arr[4] = e; + } + + function eq(bytes memory b1, bytes memory b2) public pure returns (bool) { + return + keccak256(abi.encodePacked(b1)) == keccak256(abi.encodePacked(b2)); + } + + function neq(bytes memory b1, bytes memory b2) public pure returns (bool) { + return + keccak256(abi.encodePacked(b1)) != keccak256(abi.encodePacked(b2)); + } +} + +contract BaseTest is Test { + using FixedPointMath for uint256; + + address alice; + address bob; + address eve; + + address minter; + address deployer; + + function setUp() public virtual { + alice = createUser("alice"); + bob = createUser("bob"); + eve = createUser("eve"); + deployer = createUser("deployer"); + minter = createUser("minter"); + } + + // creates a user + function createUser(string memory name) public returns (address _user) { + _user = address(uint160(uint256(keccak256(abi.encode(name))))); + vm.label(_user, name); + vm.deal(_user, 100 ether); + } +} + +contract CombinatorialTest is BaseTest { + enum CombinatorialTestKind { + Fail, + Success + } + + CombinatorialTestKind internal __combinatorialTestKind = + CombinatorialTestKind.Success; + + error ExpectedSuccess(); + error ExpectedFail(); + + error UnassignedCatch(); + error UnassignedFail(); + + bytes __error = abi.encodeWithSelector(UnassignedCatch.selector); + bytes __fail_error = abi.encodeWithSelector(UnassignedFail.selector); + + modifier __combinatorial_setup() { + __combinatorialTestKind = CombinatorialTestKind.Success; + __error = abi.encodeWithSelector(UnassignedCatch.selector); + __fail_error = abi.encodeWithSelector(UnassignedFail.selector); + _; + } + + modifier __combinatorial_success() { + // If the test case was set as a fail we short-circuit the __success function + if (__combinatorialTestKind == CombinatorialTestKind.Fail) { + return; + } + _; + } + + modifier __combinatorial_fail() { + _; + // Detect if the __fail call was caught + if ( + TestLib.neq( + __error, + abi.encodeWithSelector(UnassignedCatch.selector) + ) + ) { + // If a __fail call was caught then a __fail_error must be assigned + assertTrue( + !checkEq0( + __fail_error, + abi.encodeWithSelector(UnassignedFail.selector) + ), + "__fail_error should be assigned" + ); + // If the caught error and the expected error do not match then cause a test revert + if (TestLib.neq(__error, __fail_error)) { + assertEq(__error, __fail_error, "Expected different error"); + } + + // If an error was caught we set this so __success will short-circuit + __combinatorialTestKind = CombinatorialTestKind.Fail; + } else { + assertEq( + __fail_error, + abi.encodeWithSelector(UnassignedFail.selector), + "__fail_error should not be assigned" + ); + assertEq( + __error, + abi.encodeWithSelector(UnassignedCatch.selector), + "__error should not be assigned" + ); + } + } + + function setUp() public virtual override { + super.setUp(); + } +} diff --git a/test/combinatorial/MultiToken._transferFrom.t.sol b/test/combinatorial/MultiToken._transferFrom.t.sol new file mode 100644 index 000000000..c492bea1c --- /dev/null +++ b/test/combinatorial/MultiToken._transferFrom.t.sol @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.18; + +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; + +import { CombinatorialTest, TestLib as lib } from "test/Test.sol"; +import { MockMultiToken } from "test/mocks/MockMultiToken.sol"; +import { ForwarderFactory } from "contracts/ForwarderFactory.sol"; + +contract MultiToken__transferFrom is CombinatorialTest { + ForwarderFactory forwarderFactory; + MockMultiToken multiToken; + + function setUp() public override { + // MultiToken deployment + super.setUp(); + vm.startPrank(deployer); + forwarderFactory = new ForwarderFactory(); + multiToken = new MockMultiToken(bytes32(0), address(forwarderFactory)); + vm.stopPrank(); + } + + struct TestCase { + // -- args + uint256 tokenId; + address from; + address to; + uint256 amount; + address caller; + // -- context + uint256 approvals; + uint256 balanceFrom; + uint256 balanceTo; + bool approvedForAll; + } + + function test__MultiToken__transferFrom() public { + // Construction of combinatorial matrix + uint256[][] memory rawTestCases = lib.matrix( + lib._arr( + // amount + lib._arr(0, 1, 1e18, 1000000e18, type(uint256).max), + // caller + lib._arr(0, 1), + // approvals + lib._arr(0, 10e18, type(uint128).max, type(uint256).max), + // balanceOf(from/to) + lib._arr(0, 100e18, (2 ** 96) + 98237.12111e5) + ) + ); + + // Iterate through every test case combination and check if they __fail/__success + for (uint256 i = 0; i < rawTestCases.length; i++) { + uint256 approvals = rawTestCases[i][2]; + bool approvedForAll = approvals == type(uint128).max; + TestCase memory testCase = TestCase({ + tokenId: ((i + 5) ** 4) / 7, + from: alice, + to: bob, + amount: rawTestCases[i][0], + caller: rawTestCases[i][1] > 0 ? alice : eve, + approvals: approvals, + balanceFrom: rawTestCases[i][3], + balanceTo: rawTestCases[i][3], + approvedForAll: approvedForAll + }); + __log("--", i, testCase); + __setup(testCase); + __fail(testCase); + __success(testCase); + } + + console2.log( + "###- %s test cases passed for MultiToken._transferFrom() -###", + rawTestCases.length + ); + } + + function __setup(TestCase memory testCase) internal __combinatorial_setup { + // Set balances of the "from" and "to" addresses + multiToken.__setBalanceOf( + testCase.tokenId, + testCase.from, + testCase.balanceFrom + ); + multiToken.__setBalanceOf( + testCase.tokenId, + testCase.to, + testCase.balanceTo + ); + + // When the "caller" is not "from", then an approved transfer is the + // intention and so approvals must be made by "from" for "caller" + if (testCase.caller != testCase.from) { + vm.startPrank(testCase.from); + if (testCase.approvedForAll) { + multiToken.setApprovalForAll(testCase.caller, true); + } else { + multiToken.setApprovalForAll(testCase.caller, false); + multiToken.setApproval( + testCase.tokenId, + testCase.caller, + testCase.approvals + ); + } + vm.stopPrank(); + } + } + + function __fail(TestCase memory testCase) internal __combinatorial_fail { + // Approval underflows occur when the following conditions are met + // - "caller" is not "from" + // - isApprovedForAll[from][caller] is not true + // - "approvals" is non-infinite (max uint256) + // - "amount" to transfer is greater than "approvals" + bool approvalUnderflows = testCase.caller != testCase.from && + !testCase.approvedForAll && + testCase.approvals != type(uint256).max && + testCase.approvals < testCase.amount; + + // Underflow occurs when the "from" balance is less than "amount" + bool balanceFromUnderflows = testCase.balanceFrom < testCase.amount; + + // Balance overflows when the "to" balance + "amount" is greater than + // max uint256 + bool balanceToOverflows = (type(uint256).max - testCase.balanceTo) < + testCase.amount; + + // If the failure conditions are met then attempt a failing + // _transferFrom. If the call succeeds then code execution should revert + if (approvalUnderflows || balanceFromUnderflows || balanceToOverflows) { + try + multiToken.__external_transferFrom( + testCase.tokenId, + testCase.from, + testCase.to, + testCase.amount, + testCase.caller + ) + { + revert ExpectedFail(); + } catch (bytes memory e) { + // NOTE: __error and __fail_error must be assigned here to + // validate failure reason + __error = e; + __fail_error = stdError.arithmeticError; + } + } + } + + event TransferSingle( + address indexed operator, + address indexed from, + address indexed to, + uint256 id, + uint256 value + ); + + function __success( + TestCase memory testCase + ) internal __combinatorial_success { + // Fetch "from" and "to" balances prior to function under testing being + // executed to perform differential checking + uint256 preBalanceFrom = multiToken.balanceOf( + testCase.tokenId, + testCase.from + ); + uint256 preBalanceTo = multiToken.balanceOf( + testCase.tokenId, + testCase.to + ); + // Fetch "from's" approvals for "caller" prior to function under testing + // being executed to perform differential checking in the case of + // non-infinite approvals being set + uint256 preCallerApprovals = multiToken.perTokenApprovals( + testCase.tokenId, + testCase.from, + testCase.caller + ); + + // Register the TransferSingle event + vm.expectEmit(true, true, true, true); + emit TransferSingle( + testCase.caller, + testCase.from, + testCase.to, + testCase.tokenId, + testCase.amount + ); + + // Execute the function under test. It is expected to succeed and any + // failure will cause the code execution to revert + try + multiToken.__external_transferFrom( + testCase.tokenId, + testCase.from, + testCase.to, + testCase.amount, + testCase.caller + ) + {} catch { + revert ExpectedSuccess(); + } + + // When a non-infinite approval is set, validate that the difference + // of the before and after perTokenApprovals of the "caller" is equal + // to "amount" + if ( + testCase.caller != testCase.from && + !testCase.approvedForAll && + testCase.approvals != type(uint256).max + ) { + uint256 callerApprovalsDiff = preCallerApprovals - + multiToken.perTokenApprovals( + testCase.tokenId, + testCase.from, + testCase.caller + ); + + if (callerApprovalsDiff != testCase.amount) { + assertEq( + callerApprovalsDiff, + testCase.amount, + "number of approvals must have decreased by amount" + ); + } + } + + // Difference of before and after "from" balances should be "amount" + uint256 fromBalanceDiff = preBalanceFrom - + multiToken.balanceOf(testCase.tokenId, testCase.from); + if (fromBalanceDiff != testCase.amount) { + assertEq( + fromBalanceDiff, + testCase.amount, + "from account balance must have decreased by amount" + ); + } + + // Difference of before and after "to" balances should be "amount" + uint256 toBalanceDiff = multiToken.balanceOf( + testCase.tokenId, + testCase.to + ) - preBalanceTo; + if (toBalanceDiff != testCase.amount) { + assertEq( + toBalanceDiff, + testCase.amount, + "to account balance must have increased by amount" + ); + } + } + + function __log( + string memory prelude, + uint256 index, + TestCase memory testCase + ) internal view { + console2.log("%s :: { TestCase #%s }", prelude, index); + console2.log(""); + console2.log("\ttokenId = ", testCase.tokenId); + console2.log("\tfrom = ", testCase.from); + console2.log("\tto = ", testCase.to); + console2.log("\tamount = ", testCase.amount); + console2.log("\tcaller = ", testCase.caller); + console2.log("\tapprovals = ", testCase.approvals); + console2.log("\tbalanceFrom = ", testCase.balanceFrom); + console2.log("\tbalanceTo = ", testCase.balanceTo); + console2.log("\tapprovedForAll = ", testCase.approvedForAll); + console2.log(""); + } +} diff --git a/test/mocks/MockMultiToken.sol b/test/mocks/MockMultiToken.sol new file mode 100644 index 000000000..9746b46ac --- /dev/null +++ b/test/mocks/MockMultiToken.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.15; + +import { BaseTest, TestLib as Lib } from "test/Test.sol"; + +import { MultiToken } from "contracts/MultiToken.sol"; +import { ForwarderFactory } from "contracts/ForwarderFactory.sol"; + +contract MockMultiToken is MultiToken { + constructor( + bytes32 _linkerCodeHash, + address _factory + ) MultiToken(_linkerCodeHash, _factory) {} + + function __setNameAndSymbol( + uint256 tokenId, + string memory __name, + string memory __symbol + ) external { + _name[tokenId] = __name; + _symbol[tokenId] = __symbol; + } + + function __setBalanceOf( + uint256 _tokenId, + address _who, + uint256 _amount + ) external { + balanceOf[_tokenId][_who] = _amount; + } + + function __external_transferFrom( + uint256 tokenID, + address from, + address to, + uint256 amount, + address caller + ) external { + _transferFrom(tokenID, from, to, amount, caller); + } +}