diff --git a/script/DeployContracts.s.sol b/script/DeployContracts.s.sol index d386443..741d8e8 100644 --- a/script/DeployContracts.s.sol +++ b/script/DeployContracts.s.sol @@ -13,16 +13,25 @@ import { ITreasury, Treasury } from "../src/governance/treasury/Treasury.sol"; import { MetadataRenderer } from "../src/token/metadata/MetadataRenderer.sol"; import { MetadataRendererTypesV1 } from "../src/token/metadata/types/MetadataRendererTypesV1.sol"; import { ERC1967Proxy } from "../src/lib/proxy/ERC1967Proxy.sol"; +import { ProtocolRewards } from "../src/rewards/ProtocolRewards.sol"; contract DeployContracts is Script { using Strings for uint256; + string configFile; + + function _getKey(string memory key) internal returns (address result) { + (result) = abi.decode(vm.parseJson(configFile, key), (address)); + } + function run() public { uint256 chainID = vm.envUint("CHAIN_ID"); uint256 key = vm.envUint("PRIVATE_KEY"); address weth = vm.envAddress("WETH_ADDRESS"); address owner = vm.envAddress("MANAGER_OWNER"); + configFile = vm.readFile(string.concat("./addresses/", Strings.toString(chainID), ".json")); + address deployerAddress = vm.addr(key); console2.log("~~~~~~~~~~ CHAIN ID ~~~~~~~~~~~"); @@ -42,6 +51,8 @@ contract DeployContracts is Script { Manager manager = Manager(address(new ERC1967Proxy(managerImpl0, abi.encodeWithSignature("initialize(address)", owner)))); + ProtocolRewards rewards = new ProtocolRewards(address(manager), _getKey("BuilderDAO")); + // Deploy token implementation address tokenImpl = address(new Token(address(manager))); @@ -49,7 +60,7 @@ contract DeployContracts is Script { address metadataRendererImpl = address(new MetadataRenderer(address(manager))); // Deploy auction house implementation - address auctionImpl = address(new Auction(address(manager), weth)); + address auctionImpl = address(new Auction(address(manager), address(rewards), weth)); // Deploy treasury implementation address treasuryImpl = address(new Treasury(address(manager))); diff --git a/script/DeployVersion1_1.s.sol b/script/DeployVersion1_1.s.sol index 6620dba..1cfb6f4 100644 --- a/script/DeployVersion1_1.s.sol +++ b/script/DeployVersion1_1.s.sol @@ -14,6 +14,7 @@ import { ITreasury, Treasury } from "../src/governance/treasury/Treasury.sol"; import { MetadataRenderer } from "../src/token/metadata/MetadataRenderer.sol"; import { MetadataRendererTypesV1 } from "../src/token/metadata/types/MetadataRendererTypesV1.sol"; import { ERC1967Proxy } from "../src/lib/proxy/ERC1967Proxy.sol"; +import { ProtocolRewards } from "../src/rewards/ProtocolRewards.sol"; contract DeployVersion1_1 is Script { using Strings for uint256; @@ -34,6 +35,7 @@ contract DeployVersion1_1 is Script { address managerProxy = _getKey("Manager"); address weth = _getKey("WETH"); + address builderRewardsRecipent = _getKey("BuilderDAO"); console2.log("~~~~~~~~~~ DEPLOYER ADDRESS ~~~~~~~~~~~"); console2.logAddress(deployerAddress); @@ -49,8 +51,10 @@ contract DeployVersion1_1 is Script { // Get root manager implementation + proxy Manager manager = Manager(managerProxy); + ProtocolRewards rewards = new ProtocolRewards(address(manager), builderRewardsRecipent); + // Deploy auction upgrade implementation - address auctionUpgradeImpl = address(new Auction(managerProxy, weth)); + address auctionUpgradeImpl = address(new Auction(managerProxy, address(rewards), weth)); // Deploy governor upgrade implementation address governorUpgradeImpl = address(new Governor(managerProxy)); // Deploy treasury upgrade implementation diff --git a/src/auction/Auction.sol b/src/auction/Auction.sol index 55527e1..5c6cce7 100644 --- a/src/auction/Auction.sol +++ b/src/auction/Auction.sol @@ -8,21 +8,23 @@ import { Pausable } from "../lib/utils/Pausable.sol"; import { SafeCast } from "../lib/utils/SafeCast.sol"; import { AuctionStorageV1 } from "./storage/AuctionStorageV1.sol"; +import { AuctionStorageV2 } from "./storage/AuctionStorageV2.sol"; import { Token } from "../token/Token.sol"; import { IManager } from "../manager/IManager.sol"; import { IAuction } from "./IAuction.sol"; import { IWETH } from "../lib/interfaces/IWETH.sol"; +import { IProtocolRewards } from "../rewards/interfaces/IProtocolRewards.sol"; import { VersionedContract } from "../VersionedContract.sol"; /// @title Auction -/// @author Rohan Kulkarni +/// @author Rohan Kulkarni & Neokry /// @notice A DAO's auction house /// @custom:repo github.com/ourzora/nouns-protocol /// Modified from: /// - NounsAuctionHouse.sol commit 2cbe6c7 - licensed under the BSD-3-Clause license. /// - Zora V3 ReserveAuctionCoreEth module commit 795aeca - licensed under the GPL-3.0 license. -contract Auction is IAuction, VersionedContract, UUPS, Ownable, ReentrancyGuard, Pausable, AuctionStorageV1 { +contract Auction is IAuction, VersionedContract, UUPS, Ownable, ReentrancyGuard, Pausable, AuctionStorageV1, AuctionStorageV2 { /// /// /// IMMUTABLES /// /// /// @@ -39,15 +41,23 @@ contract Auction is IAuction, VersionedContract, UUPS, Ownable, ReentrancyGuard, /// @notice The contract upgrade manager IManager private immutable manager; + /// @notice The rewards manager + IProtocolRewards private immutable rewards; + /// /// /// CONSTRUCTOR /// /// /// /// @param _manager The contract upgrade manager address /// @param _weth The address of WETH - constructor(address _manager, address _weth) payable initializer { + constructor( + address _manager, + address _rewards, + address _weth + ) payable initializer { manager = IManager(_manager); WETH = _weth; + rewards = IProtocolRewards(_rewards); } /// /// @@ -88,15 +98,33 @@ contract Auction is IAuction, VersionedContract, UUPS, Ownable, ReentrancyGuard, settings.treasury = _treasury; settings.timeBuffer = INITIAL_TIME_BUFFER; settings.minBidIncrement = INITIAL_MIN_BID_INCREMENT_PERCENT; + + // Store the founder rewards recipient + founderRewardsRecipent = _founder; + founderRewardBPS = params.founderRewardBPS; } /// /// /// CREATE BID /// /// /// + /// @notice Creates a bid for the current token + /// @param _tokenId The ERC-721 token id + function createBidWithReferral(uint256 _tokenId, address _referral) external payable nonReentrant { + currentBidReferral = _referral; + _createBid(_tokenId); + } + /// @notice Creates a bid for the current token /// @param _tokenId The ERC-721 token id function createBid(uint256 _tokenId) external payable nonReentrant { + currentBidReferral = address(0); + _createBid(_tokenId); + } + + /// @notice Creates a bid for the current token + /// @param _tokenId The ERC-721 token id + function _createBid(uint256 _tokenId) private { // Ensure the bid is for the current token if (auction.tokenId != _tokenId) { revert INVALID_TOKEN_ID(); @@ -203,8 +231,25 @@ contract Auction is IAuction, VersionedContract, UUPS, Ownable, ReentrancyGuard, // Cache the amount of the highest bid uint256 highestBid = _auction.highestBid; - // If the highest bid included ETH: Transfer it to the DAO treasury - if (highestBid != 0) _handleOutgoingTransfer(settings.treasury, highestBid); + // If the highest bid included ETH: Pay rewards and transfer remaining amount to the DAO treasury + if (highestBid != 0) { + // Calculate rewards + IProtocolRewards.RewardSplits memory split = rewards.computeTotalRewards(highestBid, founderRewardBPS); + + if (split.totalRewards != 0) { + // Deposit rewards + rewards.depositRewards{ value: split.totalRewards }( + founderRewardsRecipent, + split.founderReward, + currentBidReferral, + split.refferalReward, + split.builderReward + ); + } + + // Deposit remaining amount to treasury + _handleOutgoingTransfer(settings.treasury, highestBid - split.totalRewards); + } // Transfer the token to the highest bidder token.transferFrom(address(this), _auction.highestBidder, _auction.tokenId); diff --git a/src/auction/IAuction.sol b/src/auction/IAuction.sol index c5ddfbe..5057f55 100644 --- a/src/auction/IAuction.sol +++ b/src/auction/IAuction.sol @@ -96,6 +96,7 @@ interface IAuction is IUUPS, IOwnable, IPausable { struct AuctionParams { uint256 duration; uint256 reservePrice; + uint256 founderRewardBPS; } /// /// diff --git a/src/auction/storage/AuctionStorageV2.sol b/src/auction/storage/AuctionStorageV2.sol new file mode 100644 index 0000000..2e5407d --- /dev/null +++ b/src/auction/storage/AuctionStorageV2.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.16; + +contract AuctionStorageV2 { + /// @notice The referral for the current auction bid + address public currentBidReferral; + + /// @notice The DAO founder collecting protocol rewards + address public founderRewardsRecipent; + + /// @notice The rewards to be paid to the DAO founder in BPS + uint256 public founderRewardBPS; +} diff --git a/src/lib/utils/ECDSA.sol b/src/lib/utils/ECDSA.sol new file mode 100644 index 0000000..858d47a --- /dev/null +++ b/src/lib/utils/ECDSA.sol @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.9.0) (utils/cryptography/ECDSA.sol) + +pragma solidity ^0.8.8; + +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +/** + * @dev Elliptic Curve Digital Signature Algorithm (ECDSA) operations. + * + * These functions can be used to verify that a message was signed by the holder + * of the private keys of a given address. + */ +library ECDSA { + enum RecoverError { + NoError, + InvalidSignature, + InvalidSignatureLength, + InvalidSignatureS, + InvalidSignatureV // Deprecated in v4.8 + } + + function _throwError(RecoverError error) private pure { + if (error == RecoverError.NoError) { + return; // no error: do nothing + } else if (error == RecoverError.InvalidSignature) { + revert("ECDSA: invalid signature"); + } else if (error == RecoverError.InvalidSignatureLength) { + revert("ECDSA: invalid signature length"); + } else if (error == RecoverError.InvalidSignatureS) { + revert("ECDSA: invalid signature 's' value"); + } + } + + /** + * @dev Returns the address that signed a hashed message (`hash`) with + * `signature` or error string. This address can then be used for verification purposes. + * + * The `ecrecover` EVM opcode allows for malleable (non-unique) signatures: + * this function rejects them by requiring the `s` value to be in the lower + * half order, and the `v` value to be either 27 or 28. + * + * IMPORTANT: `hash` _must_ be the result of a hash operation for the + * verification to be secure: it is possible to craft signatures that + * recover to arbitrary addresses for non-hashed data. A safe way to ensure + * this is by receiving a hash of the original message (which may otherwise + * be too long), and then calling {toEthSignedMessageHash} on it. + * + * Documentation for signature generation: + * - with https://web3js.readthedocs.io/en/v1.3.4/web3-eth-accounts.html#sign[Web3.js] + * - with https://docs.ethers.io/v5/api/signer/#Signer-signMessage[ethers] + * + * _Available since v4.3._ + */ + function tryRecover(bytes32 hash, bytes memory signature) internal pure returns (address, RecoverError) { + if (signature.length == 65) { + bytes32 r; + bytes32 s; + uint8 v; + // ecrecover takes the signature parameters, and the only way to get them + // currently is to use assembly. + /// @solidity memory-safe-assembly + assembly { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } + return tryRecover(hash, v, r, s); + } else { + return (address(0), RecoverError.InvalidSignatureLength); + } + } + + /** + * @dev Returns the address that signed a hashed message (`hash`) with + * `signature`. This address can then be used for verification purposes. + * + * The `ecrecover` EVM opcode allows for malleable (non-unique) signatures: + * this function rejects them by requiring the `s` value to be in the lower + * half order, and the `v` value to be either 27 or 28. + * + * IMPORTANT: `hash` _must_ be the result of a hash operation for the + * verification to be secure: it is possible to craft signatures that + * recover to arbitrary addresses for non-hashed data. A safe way to ensure + * this is by receiving a hash of the original message (which may otherwise + * be too long), and then calling {toEthSignedMessageHash} on it. + */ + function recover(bytes32 hash, bytes memory signature) internal pure returns (address) { + (address recovered, RecoverError error) = tryRecover(hash, signature); + _throwError(error); + return recovered; + } + + /** + * @dev Overload of {ECDSA-tryRecover} that receives the `r` and `vs` short-signature fields separately. + * + * See https://eips.ethereum.org/EIPS/eip-2098[EIP-2098 short signatures] + * + * _Available since v4.3._ + */ + function tryRecover( + bytes32 hash, + bytes32 r, + bytes32 vs + ) internal pure returns (address, RecoverError) { + bytes32 s = vs & bytes32(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff); + uint8 v = uint8((uint256(vs) >> 255) + 27); + return tryRecover(hash, v, r, s); + } + + /** + * @dev Overload of {ECDSA-recover} that receives the `r and `vs` short-signature fields separately. + * + * _Available since v4.2._ + */ + function recover( + bytes32 hash, + bytes32 r, + bytes32 vs + ) internal pure returns (address) { + (address recovered, RecoverError error) = tryRecover(hash, r, vs); + _throwError(error); + return recovered; + } + + /** + * @dev Overload of {ECDSA-tryRecover} that receives the `v`, + * `r` and `s` signature fields separately. + * + * _Available since v4.3._ + */ + function tryRecover( + bytes32 hash, + uint8 v, + bytes32 r, + bytes32 s + ) internal pure returns (address, RecoverError) { + // EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature + // unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines + // the valid range for s in (301): 0 < s < secp256k1n ÷ 2 + 1, and for v in (302): v ∈ {27, 28}. Most + // signatures from current libraries generate a unique signature with an s-value in the lower half order. + // + // If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value + // with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or + // vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept + // these malleable signatures as well. + if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { + return (address(0), RecoverError.InvalidSignatureS); + } + + // If the signature is valid (and not malleable), return the signer address + address signer = ecrecover(hash, v, r, s); + if (signer == address(0)) { + return (address(0), RecoverError.InvalidSignature); + } + + return (signer, RecoverError.NoError); + } + + /** + * @dev Overload of {ECDSA-recover} that receives the `v`, + * `r` and `s` signature fields separately. + */ + function recover( + bytes32 hash, + uint8 v, + bytes32 r, + bytes32 s + ) internal pure returns (address) { + (address recovered, RecoverError error) = tryRecover(hash, v, r, s); + _throwError(error); + return recovered; + } + + /** + * @dev Returns an Ethereum Signed Message, created from a `hash`. This + * produces hash corresponding to the one signed with the + * https://eth.wiki/json-rpc/API#eth_sign[`eth_sign`] + * JSON-RPC method as part of EIP-191. + * + * See {recover}. + */ + function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32 message) { + // 32 is the length in bytes of hash, + // enforced by the type signature above + /// @solidity memory-safe-assembly + assembly { + mstore(0x00, "\x19Ethereum Signed Message:\n32") + mstore(0x1c, hash) + message := keccak256(0x00, 0x3c) + } + } + + /** + * @dev Returns an Ethereum Signed Message, created from `s`. This + * produces hash corresponding to the one signed with the + * https://eth.wiki/json-rpc/API#eth_sign[`eth_sign`] + * JSON-RPC method as part of EIP-191. + * + * See {recover}. + */ + function toEthSignedMessageHash(bytes memory s) internal pure returns (bytes32) { + return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", Strings.toString(s.length), s)); + } + + /** + * @dev Returns an Ethereum Signed Typed Data, created from a + * `domainSeparator` and a `structHash`. This produces hash corresponding + * to the one signed with the + * https://eips.ethereum.org/EIPS/eip-712[`eth_signTypedData`] + * JSON-RPC method as part of EIP-712. + * + * See {recover}. + */ + function toTypedDataHash(bytes32 domainSeparator, bytes32 structHash) internal pure returns (bytes32 data) { + /// @solidity memory-safe-assembly + assembly { + let ptr := mload(0x40) + mstore(ptr, "\x19\x01") + mstore(add(ptr, 0x02), domainSeparator) + mstore(add(ptr, 0x22), structHash) + data := keccak256(ptr, 0x42) + } + } + + /** + * @dev Returns an Ethereum Signed Data with intended validator, created from a + * `validator` and `data` according to the version 0 of EIP-191. + * + * See {recover}. + */ + function toDataWithIntendedValidatorHash(address validator, bytes memory data) internal pure returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x00", validator, data)); + } +} diff --git a/src/rewards/ProtocolRewards.sol b/src/rewards/ProtocolRewards.sol new file mode 100644 index 0000000..6138622 --- /dev/null +++ b/src/rewards/ProtocolRewards.sol @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.16; + +import { EIP712 } from "../lib/utils/EIP712.sol"; +import { ECDSA } from "../lib/utils/ECDSA.sol"; +import { Ownable } from "../lib/utils/Ownable.sol"; +import { IProtocolRewards } from "./interfaces/IProtocolRewards.sol"; + +/// @title ProtocolRewards +/// @notice Manager of deposits & withdrawals for protocol rewards +contract ProtocolRewards is IProtocolRewards, EIP712 { + /// @notice The EIP-712 typehash for gasless withdraws + bytes32 public constant WITHDRAW_TYPEHASH = keccak256("Withdraw(address from,address to,uint256 amount,uint256 nonce,uint256 deadline)"); + + /// @notice An account's balance + mapping(address => uint256) public balanceOf; + + /// @notice Manager contract + address immutable manager; + + /// @notice Configuration for the protocol rewards + RewardConfig public config; + + constructor(address _manager, address _builderRewardRecipient) payable initializer { + manager = _manager; + config.builderRewardRecipient = _builderRewardRecipient; + __EIP712_init("ProtocolRewards", "1"); + } + + /// @notice The total amount of ETH held in the contract + function totalSupply() external view returns (uint256) { + return address(this).balance; + } + + /// @notice Function to set the reward percentages + /// @param referralRewardBPS The reward to be paid to the referrer in BPS + /// @param builderRewardBPS The reward to be paid to Build DAO in BPS + function setRewardPercentages(uint256 referralRewardBPS, uint256 builderRewardBPS) external { + if (msg.sender != Ownable(manager).owner()) { + revert ONLY_MANAGER_OWNER(); + } + + config.referralRewardBPS = referralRewardBPS; + config.builderRewardBPS = builderRewardBPS; + } + + /// @notice Function to set the builder reward recipient + /// @param builderRewardRecipient The address to send Builder DAO rewards to + function setBuilderRewardRecipient(address builderRewardRecipient) external { + if (msg.sender != Ownable(manager).owner()) { + revert ONLY_MANAGER_OWNER(); + } + + config.builderRewardRecipient = builderRewardRecipient; + } + + /// @notice Generic function to deposit ETH for a recipient, with an optional comment + /// @param to Address to deposit to + /// @param to Reason system reason for deposit (used for indexing) + /// @param comment Optional comment as reason for deposit + function deposit( + address to, + bytes4 reason, + string calldata comment + ) external payable { + if (to == address(0)) { + revert ADDRESS_ZERO(); + } + + balanceOf[to] += msg.value; + + emit Deposit(msg.sender, to, reason, msg.value, comment); + } + + /// @notice Generic function to deposit ETH for multiple recipients, with an optional comment + /// @param recipients recipients to send the amount to, array aligns with amounts + /// @param amounts amounts to send to each recipient, array aligns with recipients + /// @param reasons optional bytes4 hash for indexing + /// @param comment Optional comment to include with mint + function depositBatch( + address[] calldata recipients, + uint256[] calldata amounts, + bytes4[] calldata reasons, + string calldata comment + ) external payable { + uint256 numRecipients = recipients.length; + + if (numRecipients != amounts.length || numRecipients != reasons.length) { + revert ARRAY_LENGTH_MISMATCH(); + } + + uint256 expectedTotalValue; + + for (uint256 i; i < numRecipients; ) { + expectedTotalValue += amounts[i]; + + unchecked { + ++i; + } + } + + if (msg.value != expectedTotalValue) { + revert INVALID_DEPOSIT(); + } + + address currentRecipient; + uint256 currentAmount; + + for (uint256 i; i < numRecipients; ) { + currentRecipient = recipients[i]; + currentAmount = amounts[i]; + + if (currentRecipient == address(0)) { + revert ADDRESS_ZERO(); + } + + balanceOf[currentRecipient] += currentAmount; + + emit Deposit(msg.sender, currentRecipient, reasons[i], currentAmount, comment); + + unchecked { + ++i; + } + } + } + + function computeTotalRewards(uint256 finalBidAmount, uint256 founderRewardBPS) external view returns (RewardSplits memory split) { + uint256 referralBPSCached = config.referralRewardBPS; + uint256 builderBPSCached = config.referralRewardBPS; + + uint256 totalBPS = founderRewardBPS + referralBPSCached + builderBPSCached; + + if (totalBPS >= 10_000) { + revert INVALID_PERCENTAGES(); + } + + split.totalRewards = (finalBidAmount * totalBPS) / 10_000; + + split.founderReward = (finalBidAmount * founderRewardBPS) / 10_000; + split.refferalReward = (finalBidAmount * referralBPSCached) / 10_000; + split.builderReward = (finalBidAmount * builderBPSCached) / 10_000; + } + + /// @notice Used by Zora ERC-721 & ERC-1155 contracts to deposit protocol rewards + /// @param founder Creator for NFT rewards + /// @param founderReward Creator for NFT rewards + /// @param referral Creator reward amount + /// @param referralReward Mint referral user + /// @param builderReward Mint referral user + function depositRewards( + address founder, + uint256 founderReward, + address referral, + uint256 referralReward, + uint256 builderReward + ) external payable { + if (msg.value != (founderReward + referralReward + builderReward)) { + revert INVALID_DEPOSIT(); + } + + address cachedBuilderRecipent = config.builderRewardRecipient; + + if (referral == address(0)) { + referral = cachedBuilderRecipent; + } + + unchecked { + if (founder != address(0)) { + balanceOf[founder] += founderReward; + } + if (referral != address(0)) { + balanceOf[referral] += referralReward; + } + if (cachedBuilderRecipent != address(0)) { + balanceOf[cachedBuilderRecipent] += builderReward; + } + } + + emit RewardsDeposit(founder, referral, cachedBuilderRecipent, msg.sender, founderReward, referralReward, builderReward); + } + + /// @notice Withdraw protocol rewards + /// @param to Withdraws from msg.sender to this address + /// @param amount Amount to withdraw (0 for total balance) + function withdraw(address to, uint256 amount) external { + if (to == address(0)) { + revert ADDRESS_ZERO(); + } + + address owner = msg.sender; + + if (amount > balanceOf[owner]) { + revert INVALID_WITHDRAW(); + } + + if (amount == 0) { + amount = balanceOf[owner]; + } + + balanceOf[owner] -= amount; + + emit Withdraw(owner, to, amount); + + (bool success, ) = to.call{ value: amount }(""); + + if (!success) { + revert TRANSFER_FAILED(); + } + } + + /// @notice Withdraw rewards on behalf of an address + /// @param to The address to withdraw for + /// @param amount The amount to withdraw (0 for total balance) + function withdrawFor(address to, uint256 amount) external { + if (to == address(0)) { + revert ADDRESS_ZERO(); + } + + if (amount > balanceOf[to]) { + revert INVALID_WITHDRAW(); + } + + if (amount == 0) { + amount = balanceOf[to]; + } + + balanceOf[to] -= amount; + + emit Withdraw(to, to, amount); + + (bool success, ) = to.call{ value: amount }(""); + + if (!success) { + revert TRANSFER_FAILED(); + } + } + + /// @notice Execute a withdraw of protocol rewards via signature + /// @param from Withdraw from this address + /// @param to Withdraw to this address + /// @param amount Amount to withdraw (0 for total balance) + /// @param deadline Deadline for the signature to be valid + /// @param v V component of signature + /// @param r R component of signature + /// @param s S component of signature + function withdrawWithSig( + address from, + address to, + uint256 amount, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + if (block.timestamp > deadline) { + revert SIGNATURE_DEADLINE_EXPIRED(); + } + + bytes32 withdrawHash; + + unchecked { + withdrawHash = keccak256(abi.encode(WITHDRAW_TYPEHASH, from, to, amount, nonces[from]++, deadline)); + } + + bytes32 digest = ECDSA.toTypedDataHash(DOMAIN_SEPARATOR(), withdrawHash); + + address recoveredAddress = ecrecover(digest, v, r, s); + + if (recoveredAddress == address(0) || recoveredAddress != from) { + revert INVALID_SIGNATURE(); + } + + if (to == address(0)) { + revert ADDRESS_ZERO(); + } + + if (amount > balanceOf[from]) { + revert INVALID_WITHDRAW(); + } + + if (amount == 0) { + amount = balanceOf[from]; + } + + balanceOf[from] -= amount; + + emit Withdraw(from, to, amount); + + (bool success, ) = to.call{ value: amount }(""); + + if (!success) { + revert TRANSFER_FAILED(); + } + } +} diff --git a/src/rewards/interfaces/IProtocolRewards.sol b/src/rewards/interfaces/IProtocolRewards.sol new file mode 100644 index 0000000..2aa0429 --- /dev/null +++ b/src/rewards/interfaces/IProtocolRewards.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.16; + +/// @title IProtocolRewards +/// @notice The interface for deposits & withdrawals for Protocol Rewards +interface IProtocolRewards { + /// @notice Rewards Deposit Event + /// @param founder Creator for NFT rewards + /// @param bidReferral Mint referral user + /// @param builder First minter reward recipient + /// @param from The caller of the deposit + /// @param bidReferralReward Creator reward amount + /// @param builderReward Creator referral reward + event RewardsDeposit( + address indexed founder, + address indexed bidReferral, + address builder, + address from, + uint256 founderReward, + uint256 bidReferralReward, + uint256 builderReward + ); + + /// @notice Deposit Event + /// @param from From user + /// @param to To user (within contract) + /// @param reason Optional bytes4 reason for indexing + /// @param amount Amount of deposit + /// @param comment Optional user comment + event Deposit(address indexed from, address indexed to, bytes4 indexed reason, uint256 amount, string comment); + + /// @notice Withdraw Event + /// @param from From user + /// @param to To user (within contract) + /// @param amount Amount of deposit + event Withdraw(address indexed from, address indexed to, uint256 amount); + + /// @notice Invalid percentages + error INVALID_PERCENTAGES(); + + /// @notice Function argument array length mismatch + error ARRAY_LENGTH_MISMATCH(); + + /// @notice Invalid deposit + error INVALID_DEPOSIT(); + + /// @notice Invalid withdraw + error INVALID_WITHDRAW(); + + /// @notice Signature for withdraw is too old and has expired + error SIGNATURE_DEADLINE_EXPIRED(); + + /// @notice Low-level ETH transfer has failed + error TRANSFER_FAILED(); + + /// @notice Caller is not managers owner + error ONLY_MANAGER_OWNER(); + + /// @notice Config for protocol rewards + struct RewardConfig { + //// @notice Address to send Builder DAO rewards to + address builderRewardRecipient; + //// @notice Percentage of final bid amount in BPS claimable by the bid referral + uint256 referralRewardBPS; + //// @notice Percentage of final bid amount in BPS claimable by BuilderDAO + uint256 builderRewardBPS; + } + + struct RewardSplits { + //// @notice Total rewards amount + uint256 totalRewards; + //// @notice Founder rewards amount + uint256 founderReward; + //// @notice Bid referral rewards amount + uint256 refferalReward; + //// @notice BuilderDAO rewards amount + uint256 builderReward; + } + + /// @notice Generic function to deposit ETH for a recipient, with an optional comment + /// @param to Address to deposit to + /// @param why Reason system reason for deposit (used for indexing) + /// @param comment Optional comment as reason for deposit + function deposit( + address to, + bytes4 why, + string calldata comment + ) external payable; + + /// @notice Generic function to deposit ETH for multiple recipients, with an optional comment + /// @param recipients recipients to send the amount to, array aligns with amounts + /// @param amounts amounts to send to each recipient, array aligns with recipients + /// @param reasons optional bytes4 hash for indexing + /// @param comment Optional comment to include with mint + function depositBatch( + address[] calldata recipients, + uint256[] calldata amounts, + bytes4[] calldata reasons, + string calldata comment + ) external payable; + + /// @notice Computes the total rewards given a bid amount and founders reward percentage + /// @param finalBidAmount Final bid amount + /// @param founderRewardBPS Percentage of final bid amount in BPS claimable by the founder + function computeTotalRewards(uint256 finalBidAmount, uint256 founderRewardBPS) external returns (RewardSplits memory split); + + /// @notice Used by Auction contracts to deposit protocol rewards + /// @param founder Deployer for founder rewards + /// @param founderReward Founder reward amount + /// @param referral Bid referral user + /// @param referralReward Bid referral reward amount + /// @param builderReward BuilderDAO reward amount + function depositRewards( + address founder, + uint256 founderReward, + address referral, + uint256 referralReward, + uint256 builderReward + ) external payable; + + /// @notice Withdraw protocol rewards + /// @param to Withdraws from msg.sender to this address + /// @param amount amount to withdraw + function withdraw(address to, uint256 amount) external; + + /// @notice Execute a withdraw of protocol rewards via signature + /// @param from Withdraw from this address + /// @param to Withdraw to this address + /// @param amount Amount to withdraw + /// @param deadline Deadline for the signature to be valid + /// @param v V component of signature + /// @param r R component of signature + /// @param s S component of signature + function withdrawWithSig( + address from, + address to, + uint256 amount, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; +} diff --git a/test/Auction.t.sol b/test/Auction.t.sol index 5a5fbb4..9924087 100644 --- a/test/Auction.t.sol +++ b/test/Auction.t.sol @@ -25,6 +25,22 @@ contract AuctionTest is NounsBuilderTest { mockImpl = new MockImpl(); } + function deployAltMock(uint256 founderRewardPercent) internal virtual { + setMockFounderParams(); + + setMockTokenParams(); + + setAuctionParams(0.01 ether, 10 minutes, founderRewardPercent); + + setMockGovParams(); + + setImplementationAddresses(); + + deploy(foundersArr, implAddresses, implData); + + setMockMetadata(); + } + function test_AuctionHouseInitialized() public { deployMock(); @@ -586,4 +602,27 @@ contract AuctionTest is NounsBuilderTest { vm.expectRevert(abi.encodeWithSignature("UNPAUSED()")); auction.upgradeTo(address(mockImpl)); } + + function test_FounderRewardSet() public { + // deploy with 5% founder fee + deployAltMock(500); + + vm.prank(founder); + auction.unpause(); + + vm.prank(bidder1); + auction.createBid{ value: 0.420 ether }(2); + + vm.prank(bidder2); + auction.createBid{ value: 1 ether }(2); + + vm.warp(10 minutes + 1 seconds); + + auction.settleCurrentAndCreateNewAuction(); + + assertEq(token.ownerOf(2), bidder2); + assertEq(token.getVotes(bidder2), 1); + + assertEq(address(treasury).balance, 0.95 ether); + } } diff --git a/test/Gov.t.sol b/test/Gov.t.sol index b9cec5d..ad70017 100644 --- a/test/Gov.t.sol +++ b/test/Gov.t.sol @@ -44,7 +44,7 @@ contract GovTest is NounsBuilderTest, GovernorTypesV1 { setMockTokenParams(); - setAuctionParams(0, 1 days); + setAuctionParams(0, 1 days, 0); setGovParams(2 days, 1 days, 1 weeks, 25, 1000, founder); @@ -73,7 +73,7 @@ contract GovTest is NounsBuilderTest, GovernorTypesV1 { setMockTokenParams(); - setAuctionParams(0, 1 days); + setAuctionParams(0, 1 days, 0); setGovParams(2 days, 1 days, 1 weeks, 100, 1000, founder); diff --git a/test/Manager.t.sol b/test/Manager.t.sol index fdc55e0..e433f7b 100644 --- a/test/Manager.t.sol +++ b/test/Manager.t.sol @@ -10,8 +10,6 @@ import { MockImpl } from "./utils/mocks/MockImpl.sol"; contract ManagerTest is NounsBuilderTest { MockImpl internal mockImpl; - address internal builderDAO; - function setUp() public virtual override { super.setUp(); diff --git a/test/utils/NounsBuilderTest.sol b/test/utils/NounsBuilderTest.sol index 5c53d3f..1591e7c 100644 --- a/test/utils/NounsBuilderTest.sol +++ b/test/utils/NounsBuilderTest.sol @@ -15,6 +15,7 @@ import { ERC1967Proxy } from "../../src/lib/proxy/ERC1967Proxy.sol"; import { MockERC721 } from "../utils/mocks/MockERC721.sol"; import { MockERC1155 } from "../utils/mocks/MockERC1155.sol"; import { WETH } from ".././utils/mocks/WETH.sol"; +import { ProtocolRewards } from "../../src/rewards/ProtocolRewards.sol"; contract NounsBuilderTest is Test { /// /// @@ -22,6 +23,7 @@ contract NounsBuilderTest is Test { /// /// Manager internal manager; + ProtocolRewards internal rewards; address internal managerImpl0; address internal managerImpl; @@ -33,6 +35,7 @@ contract NounsBuilderTest is Test { address internal nounsDAO; address internal zoraDAO; + address internal builderDAO; address internal founder; address internal founder2; address internal weth; @@ -48,9 +51,10 @@ contract NounsBuilderTest is Test { nounsDAO = vm.addr(0xA11CE); zoraDAO = vm.addr(0xB0B); + builderDAO = vm.addr(0xCAB); - founder = vm.addr(0xCAB); - founder2 = vm.addr(0xDAD); + founder = vm.addr(0xDAD); + founder2 = vm.addr(0xE1AD); vm.label(zoraDAO, "ZORA_DAO"); vm.label(nounsDAO, "NOUNS_DAO"); @@ -61,9 +65,11 @@ contract NounsBuilderTest is Test { managerImpl0 = address(new Manager()); manager = Manager(address(new ERC1967Proxy(managerImpl0, abi.encodeWithSignature("initialize(address)", zoraDAO)))); + rewards = new ProtocolRewards(address(manager), builderDAO); + tokenImpl = address(new Token(address(manager))); metadataRendererImpl = address(new MetadataRenderer(address(manager))); - auctionImpl = address(new Auction(address(manager), weth)); + auctionImpl = address(new Auction(address(manager), address(rewards), weth)); treasuryImpl = address(new Treasury(address(manager))); governorImpl = address(new Governor(address(manager))); @@ -163,12 +169,16 @@ contract NounsBuilderTest is Test { } function setMockAuctionParams() internal virtual { - setAuctionParams(0.01 ether, 10 minutes); + setAuctionParams(0.01 ether, 10 minutes, 0); } - function setAuctionParams(uint256 _reservePrice, uint256 _duration) internal virtual { + function setAuctionParams( + uint256 _reservePrice, + uint256 _duration, + uint256 _founderRewardBPS + ) internal virtual { implData.push(); - auctionParams = IAuction.AuctionParams({ reservePrice: _reservePrice, duration: _duration }); + auctionParams = IAuction.AuctionParams({ reservePrice: _reservePrice, duration: _duration, founderRewardBPS: _founderRewardBPS }); implData[manager.IMPLEMENTATION_TYPE_AUCTION()] = abi.encode(auctionParams); }