diff --git a/.gitmodules b/.gitmodules index 0dab6dd..2ccdcb1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,3 +6,7 @@ path = lib/solmate url = https://github.com/transmissions11/solmate branch = v7 +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts + branch = v4.8.1 diff --git a/README.md b/README.md index 2f5c5e4..bef80db 100644 --- a/README.md +++ b/README.md @@ -31,3 +31,6 @@ To witdhraw a Compound III token like cUSDCv3, you may use either `withdraw` or - `cometWrapper.withdraw(amount, receiver, owner)` - `amount` is the number of Compound III tokens to be withdrawn. You can only withdraw tokens that you deposited. - `cometWrapper.redeem(amount, receiver, owner)` - `amount` is the number of Wrapped Compound III tokens to be redeemed in exchange for the deposited Compound III tokens. + +### Run fuzzy test +forge test --fork-url https://eth-mainnet.alchemyapi.io/v2/$(ALCHEMY_API_KEY) --match-path test/WrapperFuzz.t.sol \ No newline at end of file diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..0457042 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit 0457042d93d9dfd760dbaa06a4d2f1216fdbe297 diff --git a/test/DSTestPlus.sol b/test/DSTestPlus.sol new file mode 100644 index 0000000..a01a4f3 --- /dev/null +++ b/test/DSTestPlus.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.0; + +import {Test} from "forge-std/Test.sol"; + +import {Hevm} from "./Hevm.sol"; + +/// @notice Extended testing framework for DappTools projects. +/// @author Solmate (https://github.com/Rari-Capital/solmate/blob/main/src/test/utils/DSTestPlus.sol) +contract DSTestPlus is Test { + Hevm internal constant hevm = Hevm(HEVM_ADDRESS); + + address internal constant DEAD_ADDRESS = 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF; + + string private checkpointLabel; + uint256 private checkpointGasLeft; + + function startMeasuringGas(string memory label) internal virtual { + checkpointLabel = label; + checkpointGasLeft = gasleft(); + } + + function stopMeasuringGas() internal virtual { + uint256 checkpointGasLeft2 = gasleft(); + + string memory label = checkpointLabel; + + emit log_named_uint(string(abi.encodePacked(label, " Gas")), checkpointGasLeft - checkpointGasLeft2); + } + + function assertUint128Eq(uint128 a, uint128 b) internal virtual { + assertEq(uint256(a), uint256(b)); + } + + function assertUint64Eq(uint64 a, uint64 b) internal virtual { + assertEq(uint256(a), uint256(b)); + } + + function assertUint96Eq(uint96 a, uint96 b) internal virtual { + assertEq(uint256(a), uint256(b)); + } + + function assertUint32Eq(uint32 a, uint32 b) internal virtual { + assertEq(uint256(a), uint256(b)); + } + + function assertBoolEq(bool a, bool b) internal virtual { + b ? assertTrue(a) : assertFalse(a); + } + + function assertApproxEq( + uint256 a, + uint256 b, + uint256 maxDelta + ) internal virtual { + uint256 delta = a > b ? a - b : b - a; + + if (delta > maxDelta) { + emit log("Error: a ~= b not satisfied [uint]"); + emit log_named_uint(" Expected", a); + emit log_named_uint(" Actual", b); + emit log_named_uint(" Max Delta", maxDelta); + emit log_named_uint(" Delta", delta); + fail(); + } + } + + function assertRelApproxEq( + uint256 a, + uint256 b, + uint256 maxPercentDelta + ) internal virtual { + uint256 delta = a > b ? a - b : b - a; + uint256 abs = a > b ? a : b; + + uint256 percentDelta = (delta * 1e18) / abs; + + if (percentDelta > maxPercentDelta) { + emit log("Error: a ~= b not satisfied [uint]"); + emit log_named_uint(" Expected", a); + emit log_named_uint(" Actual", b); + emit log_named_uint(" Max % Delta", maxPercentDelta); + emit log_named_uint(" % Delta", percentDelta); + fail(); + } + } + + function assertBytesEq(bytes memory a, bytes memory b) internal virtual { + if (keccak256(a) != keccak256(b)) { + emit log("Error: a == b not satisfied [bytes]"); + emit log_named_bytes(" Expected", b); + emit log_named_bytes(" Actual", a); + fail(); + } + } + + function assertUintArrayEq(uint256[] memory a, uint256[] memory b) internal virtual { + require(a.length == b.length, "LENGTH_MISMATCH"); + + for (uint256 i = 0; i < a.length; i++) { + assertEq(a[i], b[i]); + } + } + + function expectError(string memory message) internal { + hevm.expectRevert(bytes(message)); + } + + function expectIllegalArgumentError(string memory message) internal { + hevm.expectRevert(abi.encodeWithSignature("IllegalArgument(string)", message)); + } + + function expectIllegalStateError(string memory message) internal { + hevm.expectRevert(abi.encodeWithSignature("IllegalState(string)", message)); + } + + function expectUnauthorizedError(string memory message) internal { + hevm.expectRevert(abi.encodeWithSignature("Unauthorized(string)", message)); + } + + function expectUnsupportedOperationError(string memory message) internal { + hevm.expectRevert(abi.encodeWithSignature("UnsupportedOperation(string)", message)); + } + + function min3( + uint256 a, + uint256 b, + uint256 c + ) internal pure returns (uint256) { + return a > b ? (b > c ? c : b) : (a > c ? c : a); + } + + function min2(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? b : a; + } +} \ No newline at end of file diff --git a/test/Hevm.sol b/test/Hevm.sol new file mode 100644 index 0000000..bf64ae7 --- /dev/null +++ b/test/Hevm.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.0; + +interface Hevm { + // Set block.timestamp + function warp(uint256) external; + // Set block.number + function roll(uint256) external; + // Set block.basefee + function fee(uint256) external; + // Loads a storage slot from an address + function load(address account, bytes32 slot) external returns (bytes32); + // Stores a value to an address' storage slot + function store(address account, bytes32 slot, bytes32 value) external; + // Signs data + function sign(uint256 privateKey, bytes32 digest) external returns (uint8 v, bytes32 r, bytes32 s); + // Computes address for a given private key + function addr(uint256 privateKey) external returns (address); + // Performs a foreign function call via terminal + function ffi(string[] calldata) external returns (bytes memory); + // Sets the *next* call's msg.sender to be the input address + function prank(address) external; + // Sets all subsequent calls' msg.sender to be the input address until `stopPrank` is called + function startPrank(address) external; + // Sets the *next* call's msg.sender to be the input address, and the tx.origin to be the second input + function prank(address, address) external; + // Sets all subsequent calls' msg.sender to be the input address until `stopPrank` is called, and the tx.origin to be the second input + + function startPrank(address, address) external; + // Resets subsequent calls' msg.sender to be `address(this)` + function stopPrank() external; + // Sets an address' balance + function deal(address who, uint256 newBalance) external; + // Sets an address' code + function etch(address who, bytes calldata code) external; + // Expects an error on next call + function expectRevert(bytes calldata) external; + function expectRevert(bytes4) external; + function expectRevert() external; + // Record all storage reads and writes + function record() external; + // Gets all accessed reads and write slot from a recording session, for a given address + function accesses(address) external returns (bytes32[] memory reads, bytes32[] memory writes); + // Prepare an expected log with (bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData). + // Call this function, then emit an event, then call a function. Internally after the call, we check if + // logs were emitted in the expected order with the expected topics and data (as specified by the booleans) + function expectEmit(bool, bool, bool, bool) external; + // Mocks a call to an address, returning specified data. + // Calldata can either be strict or a partial match, e.g. if you only + // pass a Solidity selector to the expected calldata, then the entire Solidity + // function will be mocked. + function mockCall(address, bytes calldata, bytes calldata) external; + // Clears all mocked calls + function clearMockedCalls() external; + // Expect a call to an address with the specified calldata. + // Calldata can either be strict or a partial match + function expectCall(address, bytes calldata) external; + function getCode(string calldata) external returns (bytes memory); + + // Label an address in test traces + function label(address addr, string calldata label) external; + + // When fuzzing, generate new inputs if conditional not met + function assume(bool) external; +} \ No newline at end of file diff --git a/test/WrapperFuzz.t.sol b/test/WrapperFuzz.t.sol new file mode 100644 index 0000000..3393cf6 --- /dev/null +++ b/test/WrapperFuzz.t.sol @@ -0,0 +1,95 @@ +pragma solidity ^0.8.13; + +import "../src/CometWrapper.sol"; +import "../src/vendor/CometInterface.sol"; +import "../src/vendor/ICometRewards.sol"; +// import "../lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import "./DSTestPlus.sol"; +import "../lib/openzeppelin-contracts/contracts/utils/math/Math.sol"; +import "../lib/forge-std/src/console.sol"; + +contract WrapperFuzzTest is DSTestPlus { + address constant public USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + ERC20 constant public CUSDC_V3 = ERC20(0xc3d688B66703497DAA19211EEdff47f25384cdc3); + address constant public COMP = 0xc00e94Cb662C3520282E6f5717214004A7f26888; + ICometRewards constant public REWARDS = ICometRewards(0x1B0e765F6224C21223AeA2af16c1C46E38885a40); + CometWrapper public wrapper; + CometInterface public comet = CometInterface(address(CUSDC_V3)); + uint256 public nRuns; + address[] public users; + uint256 public nUsers; + uint256 public modulo; + uint256 public rando; + + function setUp() external { + wrapper = new CometWrapper(CUSDC_V3, REWARDS, "Wrapped cUSDCv3", "wcUSDCv3"); + rando = 58973458937458395739; + modulo = 50000e6; + nRuns = 100; + nUsers = 10; + for (uint256 i = 1; i <= nUsers; i++) { + users.push(address(uint160(i))); + _mintCusdc(address(uint160(i)), modulo * 100); + } + } + + function testDepositWithdraw() external { + uint256 r = uint256(keccak256(abi.encodePacked(rando))) % modulo; + + // fire a bunch of random deposits and withdraws + for (uint256 i = 0; i < nRuns; i++) { + console.log("deposit/withdraw", i); + for (uint256 j = 0; j < nUsers; j++) { + uint256 currentBal = wrapper.underlyingBalance(users[j]); + if (currentBal == 0 || r % 2 == 0) { + uint256 cusdcBal = CUSDC_V3.balanceOf(users[j]); + hevm.prank(users[j]); + wrapper.deposit(Math.min(cusdcBal, r), users[j]); + } else { + hevm.prank(users[j]); + wrapper.withdraw(Math.min(currentBal, r), users[j], users[j]); + } + r = uint256(keccak256(abi.encodePacked(r * rando))) % modulo; + } + } + _checkBalances(); + + // draw down all users balances to 0 + for (uint256 k = 0; k < nUsers; k++) { + uint256 balBefore = wrapper.underlyingBalance(users[k]); + hevm.prank(users[k]); + wrapper.withdraw(balBefore, users[k], users[k]); + uint256 balAfter = wrapper.underlyingBalance(users[k]); + // console.log("do withdraw", balBefore, balAfter); + } + _checkBalances(); + } + + function _mintCusdc(address user, uint256 amt) internal { + deal(USDC, user, amt); + hevm.startPrank(user); + ERC20(USDC).approve(address(CUSDC_V3), amt); + comet.allow(address(wrapper), true); + comet.supply(USDC, amt); + hevm.stopPrank(); + } + + function _checkBalances() internal view { + uint256 totalUBal = 0; + for (uint256 i = 0; i < nUsers; i++) { + uint256 uBal = wrapper.underlyingBalance(users[i]); + totalUBal += uBal; + } + uint256 wrapperBal = comet.balanceOf(address(wrapper)); + console.log("totalUBal", totalUBal); + console.log("wrapperBal", wrapperBal); + console.log("==", totalUBal == wrapperBal); + if (totalUBal > wrapperBal) { + console.log("users win", totalUBal - wrapperBal); + } else if (wrapperBal >= totalUBal) { + console.log("wrapper win", wrapperBal - totalUBal); + } else { + console.log("EQUAL"); + } + } +}