diff --git a/.gitmodules b/.gitmodules index 6dcad44..dc6bac2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -11,3 +11,6 @@ path = lib/openzeppelin-contracts-upgradeable url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable branch = v4.8.3 +[submodule "lib/solmate"] + path = lib/solmate + url = https://github.com/transmissions11/solmate diff --git a/lib/solmate b/lib/solmate new file mode 160000 index 0000000..bfc9c25 --- /dev/null +++ b/lib/solmate @@ -0,0 +1 @@ +Subproject commit bfc9c25865a274a7827fea5abf6e4fb64fc64e6c diff --git a/remappings.txt b/remappings.txt index bdfc2ce..c343a0d 100644 --- a/remappings.txt +++ b/remappings.txt @@ -3,3 +3,5 @@ ds-test/=lib/forge-std/lib/ds-test/src/ forge-std/=lib/forge-std/src/ openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/ openzeppelin-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ + +@solmate/=lib/solmate/src/ diff --git a/src/ERC2330.sol b/src/ERC2330.sol new file mode 100644 index 0000000..3b126cc --- /dev/null +++ b/src/ERC2330.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IERC2330} from "./interfaces/IERC2330.sol"; + +/// @dev Gas-optimized extsload getters to allow anyone to read storage from this contract. +/// Enables the benefit of https://eips.ethereum.org/EIPS/eip-2330 without requiring changes to the execution layer. +contract ERC2330 is IERC2330 { + /* EXTERNAL */ + + /// @inheritdoc IERC2330 + function extsload(bytes32 slot) external view returns (bytes32 value) { + /// @solidity memory-safe-assembly + assembly { + value := sload(slot) + } + } + + /// @inheritdoc IERC2330 + function extsload(bytes32 startSlot, uint256 nSlots) external view returns (bytes memory value) { + value = new bytes(32 * nSlots); + + /// @solidity memory-safe-assembly + assembly { + for { let i := 0 } lt(i, nSlots) { i := add(i, 1) } { + mstore(add(value, mul(add(i, 1), 32)), sload(add(startSlot, i))) + } + } + } +} diff --git a/src/ERC3156xFlashBorrower.sol b/src/ERC3156xFlashBorrower.sol new file mode 100644 index 0000000..37cb5ea --- /dev/null +++ b/src/ERC3156xFlashBorrower.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IERC3156xFlashLender} from "./interfaces/IERC3156xFlashLender.sol"; +import {IERC3156xFlashBorrower} from "./interfaces/IERC3156xFlashBorrower.sol"; + +import {SafeTransferLib, ERC20} from "@solmate/utils/SafeTransferLib.sol"; + +import {FLASH_BORROWER_SUCCESS_HASH} from "./ERC3156xFlashLender.sol"; + +contract ERC3156xFlashBorrower is IERC3156xFlashBorrower { + using SafeTransferLib for ERC20; + + IERC3156xFlashLender private immutable _LENDER; + + constructor(IERC3156xFlashLender lender) { + _LENDER = lender; + } + + /* PUBLIC */ + + /// @inheritdoc IERC3156xFlashBorrower + function onFlashLoan(address initiator, address token, uint256 amount, uint256 fee, bytes calldata data) + public + virtual + returns (bytes32 successHash, bytes memory returnData) + { + _checkFlashLoan(initiator); + + (successHash, returnData) = _onFlashLoan(initiator, token, amount, fee, data); + + ERC20(token).safeApprove(address(_LENDER), amount + fee); + } + + /* INTERNAL */ + + function _checkFlashLoan(address initiator) internal view virtual { + if (msg.sender != address(_LENDER)) revert UnauthorizedLender(); + if (initiator != address(this)) revert UnauthorizedInitiator(); + } + + function _flashLoan(address token, uint256 amount, bytes calldata data) internal virtual returns (bytes memory) { + return _LENDER.flashLoan(this, token, amount, data); + } + + function _onFlashLoan(address, address, uint256, uint256, bytes calldata) + public + virtual + returns (bytes32, bytes memory) + { + return (FLASH_BORROWER_SUCCESS_HASH, bytes("")); + } +} diff --git a/src/ERC3156xFlashLender.sol b/src/ERC3156xFlashLender.sol new file mode 100644 index 0000000..77f6cb1 --- /dev/null +++ b/src/ERC3156xFlashLender.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IERC3156xFlashLender} from "./interfaces/IERC3156xFlashLender.sol"; +import {IERC3156xFlashBorrower} from "./interfaces/IERC3156xFlashBorrower.sol"; + +import {SafeTransferLib, ERC20} from "@solmate/utils/SafeTransferLib.sol"; + +/// @dev The expected success hash returned by the FlashBorrower. +bytes32 constant FLASH_BORROWER_SUCCESS_HASH = keccak256("ERC3156xFlashBorrower.onFlashLoan"); + +contract ERC3156xFlashLender is IERC3156xFlashLender { + using SafeTransferLib for ERC20; + + /* PUBLIC */ + + /// @inheritdoc IERC3156xFlashLender + function maxFlashLoan(address token) public view virtual returns (uint256) { + return ERC20(token).balanceOf(address(this)); + } + + /// @inheritdoc IERC3156xFlashLender + function flashFee(address, uint256) public pure virtual returns (uint256) { + return 0; + } + + /// @inheritdoc IERC3156xFlashLender + function flashLoan(IERC3156xFlashBorrower receiver, address token, uint256 amount, bytes calldata data) + public + virtual + returns (bytes memory returnData) + { + uint256 max = maxFlashLoan(token); + if (amount > max) revert FlashLoanTooLarge(max); + + ERC20(token).safeTransfer(address(receiver), amount); + + uint256 fee = flashFee(token, amount); + + bytes32 successHash; + (successHash, returnData) = receiver.onFlashLoan(msg.sender, token, amount, fee, data); + if (successHash != FLASH_BORROWER_SUCCESS_HASH) revert InvalidSuccessHash(successHash); + + _accrueFee(token, amount, fee); + + ERC20(token).safeTransferFrom(address(receiver), address(this), amount + fee); + } + + /* INTERNAL */ + + function _accrueFee(address token, uint256 amount, uint256 fee) internal virtual {} +} diff --git a/src/ERC712.sol b/src/ERC712.sol new file mode 100644 index 0000000..0106e70 --- /dev/null +++ b/src/ERC712.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IERC712} from "./interfaces/IERC712.sol"; + +/// @dev The prefix used for EIP-712 signature. +string constant ERC712_MSG_PREFIX = "\x19\x01"; + +/// @dev The domain typehash used for the EIP-712 signature. +bytes32 constant ERC712_DOMAIN_TYPEHASH = + keccak256("ERC712Domain(string name,uint256 chainId,address verifyingContract)"); + +/// @dev The highest valid value for s in an ECDSA signature pair (0 < s < secp256k1n ÷ 2 + 1). +uint256 constant MAX_VALID_ECDSA_S = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0; + +/// @notice ERC712 helpers. +/// @dev Maintains cross-chain replay protection in the event of a fork. +/// @dev Reference: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ERC712.sol +contract ERC712 is IERC712 { + /// @dev The reference chainid. Used to check whether the chain forked and offer replay protection. + uint256 private immutable _CACHED_CHAIN_ID; + + /// @dev The cached domain separator to use if chainid didnt change. + bytes32 private immutable _CACHED_DOMAIN_SEPARATOR; + + /// @dev The name used for EIP-712 signature. + bytes32 private immutable _NAMEHASH; + + /// @dev The nonce used inside by signers to offer signature replay protection. + mapping(address => uint256) private _nonces; + + constructor(string memory name) { + _NAMEHASH = keccak256(bytes(name)); + + _CACHED_CHAIN_ID = block.chainid; + _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(); + } + + /* PUBLIC */ + + /// @inheritdoc IERC712 + function DOMAIN_SEPARATOR() public view virtual returns (bytes32) { + return block.chainid == _CACHED_CHAIN_ID ? _CACHED_DOMAIN_SEPARATOR : _buildDomainSeparator(); + } + + /// @inheritdoc IERC712 + function nonce(address user) public view virtual returns (uint256) { + return _nonces[user]; + } + + /* INTERNAL */ + + /// @dev Verifies a signature components against the provided data hash, nonce, deadline and signer. + /// @param signature The signature to verify. + /// @param dataHash The ERC712 message hash the signature should correspond to. + /// @param signedNonce The nonce used along with the provided signature. Must not be an end-user input and must be proven to be signed by the signer. + /// @param deadline The signature's maximum valid timestamp. Must not be an end-user input and must be proven to be signed by the signer. + /// @param signer The expected signature's signer. + function _verify( + Signature calldata signature, + bytes32 dataHash, + uint256 signedNonce, + uint256 deadline, + address signer + ) internal virtual { + if (block.timestamp > deadline) revert SignatureExpired(); + if (uint256(signature.s) > MAX_VALID_ECDSA_S) revert InvalidValueS(); + // v ∈ {27, 28} (source: https://ethereum.github.io/yellowpaper/paper.pdf #308) + if (signature.v != 27 && signature.v != 28) revert InvalidValueV(); + + bytes32 digest = _hashTypedData(dataHash); + address recovered = ecrecover(digest, signature.v, signature.r, signature.s); + + if (recovered == address(0) || signer != recovered) revert InvalidSignature(recovered); + + uint256 usedNonce = _useNonce(signer); + if (signedNonce != usedNonce) revert InvalidNonce(usedNonce); + } + + /// @dev Increments and returns the nonce that should have been used in the corresponding signature. + function _useNonce(address signer) internal virtual returns (uint256 usedNonce) { + usedNonce = _nonces[signer]++; + + emit NonceUsed(msg.sender, signer, usedNonce); + } + + /* PRIVATE */ + + /// @notice Builds a domain separator using the current chainId and contract address. + function _buildDomainSeparator() private view returns (bytes32) { + return keccak256(abi.encode(ERC712_DOMAIN_TYPEHASH, _NAMEHASH, block.chainid, address(this))); + } + + /// @notice Creates an EIP-712 typed data hash + function _hashTypedData(bytes32 dataHash) private view returns (bytes32) { + return keccak256(abi.encodePacked(ERC712_MSG_PREFIX, DOMAIN_SEPARATOR(), dataHash)); + } +} diff --git a/src/access/Ownable.sol b/src/access/Ownable.sol new file mode 100644 index 0000000..d7052ed --- /dev/null +++ b/src/access/Ownable.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IOwnable} from "../interfaces/access/IOwnable.sol"; + +/// @notice Gas-optimized Ownable helpers. +/// @dev Reference: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable.sol +contract Ownable is IOwnable { + address private _owner; + + /// @dev Initializes the contract setting the deployer as the initial owner. + constructor(address initialOwner) { + _transferOwnership(initialOwner); + } + + /// @dev Throws if called by any account other than the owner. + modifier onlyOwner() { + _checkOwner(); + + _; + } + + /* PUBLIC */ + + /// @inheritdoc IOwnable + function owner() public view virtual returns (address) { + return _owner; + } + + /// @inheritdoc IOwnable + function transferOwnership(address newOwner) public virtual onlyOwner { + _transferOwnership(newOwner); + } + + /* INTERNAL */ + + /// @dev Throws if the sender is not the owner. + function _checkOwner() internal view virtual { + address currentOwner = owner(); + + if (currentOwner != msg.sender) revert OwnershipRequired(currentOwner); + } + + /// @dev Transfers ownership of the contract to a new account (`newOwner`). Internal function without access restriction. + function _transferOwnership(address newOwner) internal virtual { + address oldOwner = owner(); + + _owner = newOwner; + + emit OwnershipTransferred(oldOwner, newOwner); + } +} diff --git a/src/access/Ownable2Step.sol b/src/access/Ownable2Step.sol new file mode 100644 index 0000000..b2c34d5 --- /dev/null +++ b/src/access/Ownable2Step.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IOwnable2Step} from "../interfaces/access/IOwnable2Step.sol"; + +import {Ownable} from "./Ownable.sol"; + +/// @notice Gas-optimized Ownable2Step helpers. +/// @dev Reference: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable2Step.sol +contract Ownable2Step is IOwnable2Step, Ownable { + address private _pendingOwner; + + /// @dev Initializes the contract setting the deployer as the initial owner. + constructor(address initialOwner) Ownable(initialOwner) {} + + /* PUBLIC */ + + /// @inheritdoc IOwnable2Step + function pendingOwner() public view virtual returns (address) { + return _pendingOwner; + } + + /// @inheritdoc IOwnable2Step + function acceptOwnership() public virtual { + address sender = msg.sender; + + address pending = pendingOwner(); + if (pending != sender) revert PendingOwnershipRequired(pending); + + _transferOwnership(sender); + } + + /// @inheritdoc IOwnable2Step + function transferOwnership(address newOwner) public virtual override(IOwnable2Step, Ownable) onlyOwner { + _pendingOwner = newOwner; + + emit OwnershipTransferStarted(owner(), newOwner); + } + + /* INTERNAL */ + + /// @dev Transfers ownership of the contract to a new account (`newOwner`) and deletes any pending owner. Internal function without access restriction. + function _transferOwnership(address newOwner) internal virtual override { + delete _pendingOwner; + + super._transferOwnership(newOwner); + } +} diff --git a/src/interfaces/IERC2330.sol b/src/interfaces/IERC2330.sol new file mode 100644 index 0000000..4facb6e --- /dev/null +++ b/src/interfaces/IERC2330.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +interface IERC2330 { + /* FUNCTIONS */ + + /// @dev Returns the 32-bytes value stored in this contract, at the given storage slot. + function extsload(bytes32 slot) external view returns (bytes32 value); + + /// @dev Returns the `nSlots` 32-bytes values stored in this contract, starting from the given start slot. + function extsload(bytes32 startSlot, uint256 nSlots) external view returns (bytes memory value); +} diff --git a/src/interfaces/IERC3156xFlashBorrower.sol b/src/interfaces/IERC3156xFlashBorrower.sol new file mode 100644 index 0000000..b270e84 --- /dev/null +++ b/src/interfaces/IERC3156xFlashBorrower.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +/// @dev Interface of the ERC3156x FlashBorrower, inspired by https://eips.ethereum.org/EIPS/eip-3156. +/// The FlashLender's `flashLoan` function now returns the FlashBorrower's return data. +interface IERC3156xFlashBorrower { + /* ERRORS */ + + /// @dev Thrown when the caller of the FlashBorrower's callback is not authorized. + error UnauthorizedLender(); + + /// @dev Thrown when the intiiator of the flash loan is not authorized. + error UnauthorizedInitiator(); + + /* FUNCTIONS */ + + /// @dev Receive a flash loan. + /// @param initiator The initiator of the loan. + /// @param token The loan currency. + /// @param amount The amount of tokens lent. + /// @param fee The additional amount of tokens to repay. + /// @param data Arbitrary data structure, intended to contain user-defined parameters. + /// @return The keccak256 hash of "IERC3156xFlashBorrower.onFlashLoan" and any additional arbitrary data. + function onFlashLoan(address initiator, address token, uint256 amount, uint256 fee, bytes calldata data) + external + returns (bytes32, bytes memory); +} diff --git a/src/interfaces/IERC3156xFlashLender.sol b/src/interfaces/IERC3156xFlashLender.sol new file mode 100644 index 0000000..1d30254 --- /dev/null +++ b/src/interfaces/IERC3156xFlashLender.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (interfaces/IERC3156FlashLender.sol) + +pragma solidity ^0.8.0; + +import {IERC3156xFlashBorrower} from "./IERC3156xFlashBorrower.sol"; + +/// @dev Interface of the ERC3156x FlashLender, inspired by https://eips.ethereum.org/EIPS/eip-3156. +/// The FlashLender's `flashLoan` function now returns the FlashBorrower's return data. +interface IERC3156xFlashLender { + /* EVENTS */ + + /// @dev Emitted when a flash loan is initiated. + event FlashLoan(address indexed initiator, address indexed receiver, address indexed token, uint256 amount); + + /* ERRORS */ + + /// @dev Thrown when the requested flash loan amount is larger than the maximum flash loan allowed. + error FlashLoanTooLarge(uint256 maxFlashLoan); + + /// @dev Thrown when the FlashBorrower's success hash is invalid. + error InvalidSuccessHash(bytes32 successHash); + + /* FUNCTIONS */ + + /// @dev The amount of currency available to be lended. + /// @param token The loan currency. + /// @return The amount of `token` that can be borrowed. + function maxFlashLoan(address token) external view returns (uint256); + + /// @dev The fee to be charged for a given loan. + /// @param token The loan currency. + /// @param amount The amount of tokens lent. + /// @return The amount of `token` to be charged for the loan, on top of the returned principal. + function flashFee(address token, uint256 amount) external view returns (uint256); + + /// @dev Initiate a flash loan. + /// @param receiver The receiver of the tokens in the loan, and the receiver of the callback. + /// @param token The loan currency. + /// @param amount The amount of tokens lent. + /// @param data Arbitrary data structure, intended to contain user-defined parameters. + function flashLoan(IERC3156xFlashBorrower receiver, address token, uint256 amount, bytes calldata data) + external + returns (bytes memory); +} diff --git a/src/interfaces/IERC712.sol b/src/interfaces/IERC712.sol new file mode 100644 index 0000000..0343796 --- /dev/null +++ b/src/interfaces/IERC712.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +interface IERC712 { + /* STRUCTS */ + + struct Signature { + uint8 v; + bytes32 r; + bytes32 s; + } + + /* EVENTS */ + + /// @dev Emitted when a signer's nonce is incremented. + event NonceUsed(address indexed caller, address indexed signer, uint256 usedNonce); + + /* ERRORS */ + + /// @notice Thrown when the s part of the ECDSA signature is invalid. + error InvalidValueS(); + + /// @notice Thrown when the v part of the ECDSA signature is invalid. + error InvalidValueV(); + + /// @notice Thrown when the signer of the ECDSA signature is invalid. + error InvalidSignature(address recovered); + + /// @notice Thrown when the nonce is invalid. + error InvalidNonce(uint256 nonce); + + /// @notice Thrown when the signature deadline is expired. + error SignatureExpired(); + + /* FUNCTIONS */ + + /// @notice Returns the domain separator for the current chain. + /// @dev Uses cached version if chainid and address are unchanged from construction. + function DOMAIN_SEPARATOR() external view returns (bytes32); + + /// @notice Returns the given signer's nonce to be used in the next ERC712 signature. + function nonce(address signer) external view returns (uint256); +} diff --git a/src/interfaces/access/IOwnable.sol b/src/interfaces/access/IOwnable.sol new file mode 100644 index 0000000..61bca1a --- /dev/null +++ b/src/interfaces/access/IOwnable.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +interface IOwnable { + /* EVENTS */ + + /// @dev Emitted when owner changes from `previousOwner` to `newOwner`. + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + /* ERRORS */ + + /// @dev Thrown when ownership is required to perform an action. + error OwnershipRequired(address owner); + + /* FUNCTIONS */ + + /// @dev Returns the address of the current owner. + function owner() external view returns (address); + + /// @notice Transfers ownership of the contract to a new account (`newOwner`). Can only be called by the current owner. + /// @dev Ownership can be renounced by transferring ownership to `address(0)`. + /// Renouncing ownership will leave the contract without an owner, thereby removing any functionality that is only available to the owner. + function transferOwnership(address newOwner) external; +} diff --git a/src/interfaces/access/IOwnable2Step.sol b/src/interfaces/access/IOwnable2Step.sol new file mode 100644 index 0000000..22fd8a3 --- /dev/null +++ b/src/interfaces/access/IOwnable2Step.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IOwnable} from "./IOwnable.sol"; + +interface IOwnable2Step is IOwnable { + /* EVENTS */ + + /// @dev Emitted when ownership transfer process is initiated. + event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); + + /* ERRORS */ + + /// @dev Thrown when pending ownership is required to accept ownership. + error PendingOwnershipRequired(address pendingOwner); + + /* FUNCTIONS */ + + /// @dev Returns the address of the pending owner. + function pendingOwner() external view returns (address); + + /// @dev The new owner accepts the ownership transfer. + function acceptOwnership() external; + + /// @dev Starts the ownership transfer of the contract to a new account. + /// Replaces the pending transfer if there is one. Can only be called by the current owner. + function transferOwnership(address newOwner) external; +} diff --git a/test/TestERC2330.sol b/test/TestERC2330.sol new file mode 100644 index 0000000..65009c1 --- /dev/null +++ b/test/TestERC2330.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IERC2330} from "src/interfaces/IERC2330.sol"; + +import {ERC2330Mock} from "test/mocks/ERC2330Mock.sol"; + +import "forge-std/Test.sol"; + +contract TestERC2330 is Test { + ERC2330Mock mock; + + function setUp() public { + mock = new ERC2330Mock(); + } + + function testExtsload() public { + assertEq( + mock.extsload(0x0000000000000000000000000000000000000000000000000000000000000000), + 0x0000000000000000000000000000000100000000000000000000000000000001 + ); + assertEq( + mock.extsload(0x0000000000000000000000000000000000000000000000000000000000000001), + 0x0000000000000000000000000000000000000000000000000000000000000002 + ); + } + + function testExtsloadMultiple() public { + (bytes32 var1, bytes32 var2) = abi.decode( + mock.extsload(0x0000000000000000000000000000000000000000000000000000000000000000, 2), (bytes32, bytes32) + ); + + assertEq(var1, 0x0000000000000000000000000000000100000000000000000000000000000001); + assertEq(var2, 0x0000000000000000000000000000000000000000000000000000000000000002); + } +} diff --git a/test/TestERC3156xFlashBorrower.sol b/test/TestERC3156xFlashBorrower.sol new file mode 100644 index 0000000..94730ca --- /dev/null +++ b/test/TestERC3156xFlashBorrower.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IERC3156xFlashLender} from "src/interfaces/IERC3156xFlashLender.sol"; +import {IERC3156xFlashBorrower} from "src/interfaces/IERC3156xFlashBorrower.sol"; + +import {ERC20Mock} from "test/mocks/ERC20Mock.sol"; +import {ERC3156xFlashLenderMock} from "test/mocks/ERC3156xFlashLenderMock.sol"; +import {ERC3156xFlashBorrowerMock} from "test/mocks/ERC3156xFlashBorrowerMock.sol"; + +import "forge-std/Test.sol"; + +contract TestERC3156xFlashBorrower is Test { + ERC20Mock token1; + ERC20Mock token2; + IERC3156xFlashLender lender; + ERC3156xFlashBorrowerMock borrower; + + function setUp() public { + token1 = new ERC20Mock("Test Token 1", "TT1"); + token2 = new ERC20Mock("Test Token 2", "TT2"); + lender = new ERC3156xFlashLenderMock(); + borrower = new ERC3156xFlashBorrowerMock(lender); + + deal(address(token1), address(lender), type(uint256).max); + } + + function testFlashLoan(uint256 amount) public { + borrower.flashLoan(address(token1), amount, bytes("")); + } + + function testFlashLoanUnauthorizedLender(address _lender, address token, uint256 amount, uint256 fee) public { + vm.assume(_lender != address(lender)); + + vm.prank(_lender); + vm.expectRevert(IERC3156xFlashBorrower.UnauthorizedLender.selector); + borrower.onFlashLoan(address(borrower), token, amount, fee, bytes("")); + } + + function testFlashLoanUnauthorizedInitiator(address initiator, uint256 amount) public { + vm.assume(initiator != address(borrower)); + + vm.prank(initiator); + vm.expectRevert(IERC3156xFlashBorrower.UnauthorizedInitiator.selector); + lender.flashLoan(borrower, address(token1), amount, bytes("")); + } +} diff --git a/test/TestERC3156xFlashLender.sol b/test/TestERC3156xFlashLender.sol new file mode 100644 index 0000000..2c46749 --- /dev/null +++ b/test/TestERC3156xFlashLender.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IERC3156xFlashLender} from "src/interfaces/IERC3156xFlashLender.sol"; +import {IERC3156xFlashBorrower} from "src/interfaces/IERC3156xFlashBorrower.sol"; + +import {FLASH_BORROWER_SUCCESS_HASH} from "src/ERC3156xFlashLender.sol"; +import {SafeTransferLib, ERC20} from "@solmate/utils/SafeTransferLib.sol"; + +import {ERC20Mock} from "test/mocks/ERC20Mock.sol"; +import {ERC3156xFlashLenderMock} from "test/mocks/ERC3156xFlashLenderMock.sol"; + +import "forge-std/Test.sol"; + +contract TestERC3156xFlashLenderBase is Test, IERC3156xFlashBorrower { + ERC20Mock token1; + ERC20Mock token2; + IERC3156xFlashLender lender; + + function setUp() public { + token1 = new ERC20Mock("Test Token 1", "TT1"); + token2 = new ERC20Mock("Test Token 2", "TT2"); + lender = new ERC3156xFlashLenderMock(); + + deal(address(token1), address(lender), type(uint256).max); + } + + function onFlashLoan(address, address, uint256, uint256, bytes calldata) + public + virtual + returns (bytes32, bytes memory) + { + return (FLASH_BORROWER_SUCCESS_HASH, bytes("")); + } +} + +contract TestERC3156xFlashLender is TestERC3156xFlashLenderBase { + using SafeTransferLib for ERC20; + + function testFlashLoanTooLarge(address initiator, uint256 amount) public { + amount = bound(amount, 1, type(uint256).max); + + vm.prank(initiator); + vm.expectRevert(abi.encodeWithSelector(IERC3156xFlashLender.FlashLoanTooLarge.selector, 0)); + lender.flashLoan(this, address(token2), amount, bytes("")); + } +} + +contract TestERC3156xFlashLenderSuccess is TestERC3156xFlashLenderBase { + using SafeTransferLib for ERC20; + + address expectedInitiator; + uint256 expectedAmount; + uint256 expectedFee; + bytes expectedData; + + function testFlashLoan(address initiator, uint256 amount) public { + expectedInitiator = initiator; + expectedAmount = amount; + expectedFee = lender.flashFee(address(token1), amount); + expectedData = bytes("Hello"); + + vm.prank(initiator); + lender.flashLoan(this, address(token1), amount, expectedData); + } + + function onFlashLoan(address initiator, address token, uint256 amount, uint256 fee, bytes calldata data) + public + virtual + override + returns (bytes32, bytes memory) + { + assertEq(initiator, expectedInitiator, "initiator"); + assertEq(token, address(token1), "token"); + assertEq(amount, expectedAmount, "amount"); + assertEq(fee, expectedFee, "fee"); + assertEq(data, expectedData, "data"); + + assertEq(ERC20(token).balanceOf(address(this)), amount, "balanceOf"); + + ERC20(token).safeApprove(msg.sender, amount + fee); + + return (FLASH_BORROWER_SUCCESS_HASH, bytes("")); + } +} + +contract TestERC3156xFlashLenderFailure is TestERC3156xFlashLenderBase { + using SafeTransferLib for ERC20; + + function testFlashLoanInvalidSuccessHash(address initiator, uint256 amount, bytes32 successHash) public { + vm.assume(successHash != FLASH_BORROWER_SUCCESS_HASH); + + vm.prank(initiator); + vm.expectRevert(abi.encodeWithSelector(IERC3156xFlashLender.InvalidSuccessHash.selector, successHash)); + lender.flashLoan(this, address(token1), amount, abi.encode(successHash)); + } + + function onFlashLoan(address, address token, uint256 amount, uint256 fee, bytes calldata data) + public + virtual + override + returns (bytes32, bytes memory) + { + ERC20(token).safeApprove(msg.sender, amount + fee); + + return (abi.decode(data, (bytes32)), bytes("")); + } +} diff --git a/test/TestERC712.sol b/test/TestERC712.sol new file mode 100644 index 0000000..f904ff9 --- /dev/null +++ b/test/TestERC712.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IERC712} from "src/interfaces/IERC712.sol"; + +import {ERC712_MSG_PREFIX, ERC712_DOMAIN_TYPEHASH, MAX_VALID_ECDSA_S} from "src/ERC712.sol"; +import {ERC712Mock} from "test/mocks/ERC712Mock.sol"; + +import "forge-std/Test.sol"; + +uint256 constant SECP256K1_CURVE_ORDER = 115792089237316195423570985008687907852837564279074904382605163141518161494337; + +contract TestERC712 is Test { + event NonceUsed(address indexed caller, address indexed signer, uint256 usedNonce); + + string constant NAME = "Test"; + + ERC712Mock mock; + + function setUp() public { + mock = new ERC712Mock(NAME); + } + + function testDomainSeparator(uint64 chainId) public { + vm.chainId(chainId); + + assertEq( + mock.DOMAIN_SEPARATOR(), + keccak256(abi.encode(ERC712_DOMAIN_TYPEHASH, keccak256(bytes(NAME)), chainId, address(mock))) + ); + } + + function testVerify(bytes32 dataHash, uint256 deadline, uint256 privateKey) public { + deadline = bound(deadline, block.timestamp, type(uint256).max); + privateKey = bound(privateKey, 1, SECP256K1_CURVE_ORDER - 1); + + address signer = vm.addr(privateKey); + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(privateKey, keccak256(abi.encodePacked(ERC712_MSG_PREFIX, mock.DOMAIN_SEPARATOR(), dataHash))); + + vm.expectEmit(true, true, true, true, address(mock)); + emit NonceUsed(address(this), signer, 0); + + mock.verify(IERC712.Signature({r: r, s: s, v: v}), dataHash, 0, deadline, signer); + + assertEq(mock.nonce(signer), 1); + } + + function testVerifySignatureExpired(bytes32 dataHash, bytes32 r, uint256 s, uint256 deadline, address signer) + public + { + s = bound(s, 0, MAX_VALID_ECDSA_S); + deadline = bound(deadline, 0, block.timestamp - 1); + + vm.expectRevert(IERC712.SignatureExpired.selector); + mock.verify(IERC712.Signature({r: r, s: bytes32(s), v: 27}), dataHash, 0, deadline, signer); + + vm.expectRevert(IERC712.SignatureExpired.selector); + mock.verify(IERC712.Signature({r: r, s: bytes32(s), v: 28}), dataHash, 0, deadline, signer); + } + + function testVerifyInvalidValueS(bytes32 dataHash, bytes32 r, uint256 s, address signer) public { + s = bound(s, MAX_VALID_ECDSA_S + 1, type(uint256).max); + + vm.expectRevert(IERC712.InvalidValueS.selector); + mock.verify(IERC712.Signature({r: r, s: bytes32(s), v: 27}), dataHash, 0, block.timestamp, signer); + + vm.expectRevert(IERC712.InvalidValueS.selector); + mock.verify(IERC712.Signature({r: r, s: bytes32(s), v: 28}), dataHash, 0, block.timestamp, signer); + } + + function testVerifyInvalidValueV(bytes32 dataHash, bytes32 r, uint256 s, uint8 v, address signer) public { + s = bound(s, 0, MAX_VALID_ECDSA_S); + vm.assume(v != 27 && v != 28); + + vm.expectRevert(IERC712.InvalidValueV.selector); + mock.verify(IERC712.Signature({r: r, s: bytes32(s), v: v}), dataHash, 0, block.timestamp, signer); + } + + function testVerifyInvalidNonce(bytes32 dataHash, uint256 nonce, uint256 privateKey) public { + nonce = bound(nonce, 1, type(uint256).max); + privateKey = bound(privateKey, 1, SECP256K1_CURVE_ORDER - 1); + + (address signer, IERC712.Signature memory signature) = _sign(privateKey, dataHash); + + vm.expectRevert(abi.encodeWithSelector(IERC712.InvalidNonce.selector, 0)); + mock.verify(signature, dataHash, nonce, block.timestamp, signer); + } + + function testVerifyInvalidSignature(bytes32 dataHash, uint256 s, uint256 deadline, address signer) public { + deadline = bound(deadline, block.timestamp, type(uint256).max); + s = bound(s, 0, MAX_VALID_ECDSA_S); + + vm.expectRevert(abi.encodeWithSelector(IERC712.InvalidSignature.selector, address(0))); + mock.verify(IERC712.Signature({r: 0, s: bytes32(s), v: 27}), dataHash, 0, block.timestamp, signer); + + vm.expectRevert(abi.encodeWithSelector(IERC712.InvalidSignature.selector, address(0))); + mock.verify(IERC712.Signature({r: 0, s: bytes32(s), v: 28}), dataHash, 0, block.timestamp, signer); + } + + function _sign(uint256 privateKey, bytes32 dataHash) internal returns (address, IERC712.Signature memory) { + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(privateKey, keccak256(abi.encodePacked(ERC712_MSG_PREFIX, mock.DOMAIN_SEPARATOR(), dataHash))); + + return (vm.addr(privateKey), IERC712.Signature({r: r, s: s, v: v})); + } +} diff --git a/test/access/TestOwnable.sol b/test/access/TestOwnable.sol new file mode 100644 index 0000000..9051ae2 --- /dev/null +++ b/test/access/TestOwnable.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IOwnable} from "src/interfaces/access/IOwnable.sol"; + +import {OwnableMock} from "test/mocks/OwnableMock.sol"; + +import "forge-std/Test.sol"; + +contract TestOwnable is Test { + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + IOwnable mock; + + function setUp() public { + mock = new OwnableMock(address(this)); + } + + function testConstructorOwner(address initialOwner) public { + mock = new OwnableMock(initialOwner); + + assertEq(mock.owner(), initialOwner); + } + + function testTransferOwnership(address newOwner) public { + vm.expectEmit(true, true, true, true, address(mock)); + emit OwnershipTransferred(address(this), newOwner); + + mock.transferOwnership(newOwner); + + assertEq(mock.owner(), newOwner); + } + + function testTransferOwnershipOnlyOwner(address pranked) public { + vm.assume(pranked != address(this)); + + vm.prank(pranked); + vm.expectRevert(abi.encodeWithSelector(IOwnable.OwnershipRequired.selector, address(this))); + mock.transferOwnership(pranked); + } +} diff --git a/test/access/TestOwnable2Step.sol b/test/access/TestOwnable2Step.sol new file mode 100644 index 0000000..4f275a4 --- /dev/null +++ b/test/access/TestOwnable2Step.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IOwnable} from "src/interfaces/access/IOwnable.sol"; +import {IOwnable2Step} from "src/interfaces/access/IOwnable2Step.sol"; + +import {Ownable2StepMock} from "test/mocks/Ownable2StepMock.sol"; + +import "forge-std/Test.sol"; + +contract TestOwnable2Step is Test { + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); + + IOwnable2Step mock; + + function setUp() public { + mock = new Ownable2StepMock(address(this)); + } + + function testConstructorOwner(address initialOwner) public { + mock = new Ownable2StepMock(initialOwner); + + assertEq(mock.owner(), initialOwner); + } + + function testTransferOwnership(address newOwner) public { + vm.expectEmit(true, true, true, true, address(mock)); + emit OwnershipTransferStarted(address(this), newOwner); + + mock.transferOwnership(newOwner); + + assertEq(mock.owner(), address(this), "owner != address(this)"); + assertEq(mock.pendingOwner(), newOwner, "pendingOwner != newOwner"); + + vm.prank(newOwner); + mock.acceptOwnership(); + + assertEq(mock.owner(), newOwner, "owner != newOwner"); + assertEq(mock.pendingOwner(), address(0), "pendingOwner != address(0)"); + } + + function testTransferOwnershipOnlyOwner(address pranked) public { + vm.assume(pranked != address(this)); + + vm.prank(pranked); + vm.expectRevert(abi.encodeWithSelector(IOwnable.OwnershipRequired.selector, address(this))); + mock.transferOwnership(pranked); + } + + function testAcceptOwnershipOnlyOwner(address pendingOwner, address pranked) public { + vm.assume(pranked != pendingOwner); + + mock.transferOwnership(pendingOwner); + + vm.prank(pranked); + vm.expectRevert(abi.encodeWithSelector(IOwnable2Step.PendingOwnershipRequired.selector, pendingOwner)); + mock.acceptOwnership(); + } +} diff --git a/test/mocks/ERC20Mock.sol b/test/mocks/ERC20Mock.sol new file mode 100644 index 0000000..b68e49b --- /dev/null +++ b/test/mocks/ERC20Mock.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {ERC20} from "@solmate/tokens/ERC20.sol"; + +contract ERC20Mock is ERC20 { + constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol, 18) {} +} diff --git a/test/mocks/ERC2330Mock.sol b/test/mocks/ERC2330Mock.sol new file mode 100644 index 0000000..984e31c --- /dev/null +++ b/test/mocks/ERC2330Mock.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {ERC2330} from "src/ERC2330.sol"; + +contract ERC2330Mock is ERC2330 { + uint128 private _var11; + uint128 private _var12; + uint256 private _var2; + + constructor() { + _var11 = 1; + _var12 = 1; + _var2 = 2; + } +} diff --git a/test/mocks/ERC3156xFlashBorrowerMock.sol b/test/mocks/ERC3156xFlashBorrowerMock.sol new file mode 100644 index 0000000..23a9bde --- /dev/null +++ b/test/mocks/ERC3156xFlashBorrowerMock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {IERC3156xFlashLender} from "src/interfaces/IERC3156xFlashLender.sol"; + +import {ERC3156xFlashBorrower, FLASH_BORROWER_SUCCESS_HASH} from "src/ERC3156xFlashBorrower.sol"; + +contract ERC3156xFlashBorrowerMock is ERC3156xFlashBorrower { + constructor(IERC3156xFlashLender lender) ERC3156xFlashBorrower(lender) {} + + function flashLoan(address token, uint256 amount, bytes calldata data) public returns (bytes memory) { + return _flashLoan(token, amount, data); + } +} diff --git a/test/mocks/ERC3156xFlashLenderMock.sol b/test/mocks/ERC3156xFlashLenderMock.sol new file mode 100644 index 0000000..2253c6c --- /dev/null +++ b/test/mocks/ERC3156xFlashLenderMock.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {ERC3156xFlashLender} from "src/ERC3156xFlashLender.sol"; + +contract ERC3156xFlashLenderMock is ERC3156xFlashLender {} diff --git a/test/mocks/ERC712Mock.sol b/test/mocks/ERC712Mock.sol new file mode 100644 index 0000000..20cd90c --- /dev/null +++ b/test/mocks/ERC712Mock.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {ERC712} from "src/ERC712.sol"; + +contract ERC712Mock is ERC712 { + constructor(string memory name) ERC712(name) {} + + function verify( + Signature calldata signature, + bytes32 dataHash, + uint256 signedNonce, + uint256 deadline, + address signer + ) external { + _verify(signature, dataHash, signedNonce, deadline, signer); + } +} diff --git a/test/mocks/Ownable2StepMock.sol b/test/mocks/Ownable2StepMock.sol new file mode 100644 index 0000000..3c336c0 --- /dev/null +++ b/test/mocks/Ownable2StepMock.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {Ownable2Step, Ownable} from "src/access/Ownable2Step.sol"; + +contract Ownable2StepMock is Ownable2Step { + constructor(address initialOwner) Ownable2Step(initialOwner) {} +} diff --git a/test/mocks/OwnableMock.sol b/test/mocks/OwnableMock.sol new file mode 100644 index 0000000..830cee9 --- /dev/null +++ b/test/mocks/OwnableMock.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import {Ownable} from "src/access/Ownable.sol"; + +contract OwnableMock is Ownable { + constructor(address initialOwner) Ownable(initialOwner) {} +}