Skip to content

Commit

Permalink
ERC20Minter with ETH Fee (#372)
Browse files Browse the repository at this point in the history
* feat: wip of erc20 minter with new fees

* fix: events

* fix: function type

* feat: add _ to internal functions

* fix: checks

* fix: tests

* feat: clean up internal functions

* feat: add ERC20MinterConfig struct

* feat: add init check

* Updates for ownership handling (#377)

* fix: tests

* feat: add changeset

* feat: remove ERC20MinterInitialized event

* feat: add detailed changeset

* fix: event test

* fix: remove eth reward not equal to zero

* feat: add zora sepolia erc20 minter v2 deployment address

* fix: changelog

* fix: vm.assume

* feat: add erc20 minter to abi script

* fix: file import

* fix: skip testPremintERC20

---------

Co-authored-by: Iain Nash <me@iain.in>
  • Loading branch information
IsabellaSmallcombe and iainnash authored May 15, 2024
1 parent 16deff0 commit cd6c636
Show file tree
Hide file tree
Showing 10 changed files with 302 additions and 97 deletions.
19 changes: 19 additions & 0 deletions .changeset/funny-grapes-impress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@zoralabs/zora-1155-contracts": patch
---

ERC20 Minter V2 Changes:
* Adds a flat ETH fee that goes to Zora (currently this fee is 0.000111 ETH but the contract owner can change this fee at any time)
* Reward recipients will still receive ERC20 rewards however this percentage can now be changed at any time by the contract owner
* Adds an `ERC20MinterConfig` struct which contains `zoraRewardRecipientAddress`, `rewardRecipientPercentage`, and `ethReward`
* Zora Reward Recipient Address can now be changed at any time by the contract owner as well
* `mint` function is now payable
* New functions:
* `function ethRewardAmount() external view returns (uint256)`
* `function setERC20MinterConfig(ERC20MinterConfig memory config) external`
* `function getERC20MinterConfig() external view returns (ERC20MinterConfig memory)`
* New events:
* `event ERC20MinterConfigSet(ERC20MinterConfig config)`
* Removed events:
* `event ZoraRewardsRecipientSet(address indexed prevRecipient, address indexed newRecipient)`
* `event ERC20MinterInitialized(uint256 rewardPercentage)`
47 changes: 34 additions & 13 deletions packages/1155-contracts/src/interfaces/IERC20Minter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ interface IERC20Minter {
address currency;
}

struct ERC20MinterConfig {
/// @notice The address of the Zora rewards recipient
address zoraRewardRecipientAddress;
/// @notice The reward recipient percentage
uint256 rewardRecipientPercentage;
/// @notice The ETH reward amount
uint256 ethReward;
}

/// @notice Rewards Deposit Event
/// @param createReferral Creator referral address
/// @param mintReferral Mint referral address
Expand All @@ -54,10 +63,6 @@ interface IERC20Minter {
uint256 zoraReward
);

/// @notice ERC20MinterInitialized Event
/// @param rewardPercentage The reward percentage
event ERC20MinterInitialized(uint256 rewardPercentage);

/// @notice MintComment Event
/// @param sender The sender of the comment
/// @param tokenContract The token contract address
Expand All @@ -72,10 +77,9 @@ interface IERC20Minter {
/// @param salesConfig The sales configuration
event SaleSet(address indexed mediaContract, uint256 indexed tokenId, SalesConfig salesConfig);

/// @notice ZoraRewardsRecipientSet Event
/// @param prevRecipient The previous recipient address
/// @param newRecipient The new recipient address
event ZoraRewardsRecipientSet(address indexed prevRecipient, address indexed newRecipient);
/// @notice ERC20MinterConfigSet Event
/// @param config The ERC20MinterConfig
event ERC20MinterConfigSet(ERC20MinterConfig config);

/// @notice Cannot set address to zero
error AddressZero();
Expand All @@ -101,11 +105,11 @@ interface IERC20Minter {
/// @notice ERC20 transfer slippage
error ERC20TransferSlippage();

/// @notice ERC20Minter is already initialized
error AlreadyInitialized();
/// @notice Failed to send ETH reward
error FailedToSendEthReward();

/// @notice Only the Zora rewards recipient can call this function
error OnlyZoraRewardsRecipient();
/// @notice Invalid value
error InvalidValue();

/// @notice Mints a token using an ERC20 currency, note the total value must have been approved prior to calling this function
/// @param mintTo The address to mint the token to
Expand All @@ -125,11 +129,28 @@ interface IERC20Minter {
address currency,
address mintReferral,
string calldata comment
) external;
) external payable;

/// @notice Sets the sale config for a given token
/// @param tokenId The ID of the token to set the sale config for
/// @param salesConfig The sale config to set
function setSale(uint256 tokenId, SalesConfig memory salesConfig) external;

/// @notice Returns the sale config for a given token
/// @param tokenContract The TokenContract address
/// @param tokenId The ID of the token to get the sale config for
function sale(address tokenContract, uint256 tokenId) external view returns (SalesConfig memory);

/// @notice Returns the reward recipient percentage
function totalRewardPct() external view returns (uint256);

/// @notice Returns the ETH reward amount
function ethRewardAmount() external view returns (uint256);

/// @notice Sets the ERC20MinterConfig
/// @param config The ERC20MinterConfig to set
function setERC20MinterConfig(ERC20MinterConfig memory config) external;

/// @notice Gets the ERC20MinterConfig
function getERC20MinterConfig() external view returns (ERC20MinterConfig memory);
}
113 changes: 71 additions & 42 deletions packages/1155-contracts/src/minters/erc20/ERC20Minter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {SaleStrategy} from "../../minters/SaleStrategy.sol";
import {ICreatorCommands} from "../../interfaces/ICreatorCommands.sol";
import {ERC20MinterRewards} from "./ERC20MinterRewards.sol";
import {IZora1155} from "./IZora1155.sol";
import {TransferHelperUtils} from "../../utils/TransferHelperUtils.sol";
import {Initializable} from "@zoralabs/openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol";
import {Ownable2StepUpgradeable} from "../../utils/ownable/Ownable2StepUpgradeable.sol";

/*
Expand Down Expand Up @@ -41,36 +44,29 @@ import {IZora1155} from "./IZora1155.sol";
/// @notice Allows for ZoraCreator Mints to be purchased using ERC20 tokens
/// @dev While this contract _looks_ like a minter, we need to be able to directly manage ERC20 tokens. Therefore, we need to establish minter permissions but instead of using the `requestMint` flow we directly request tokens to be minted in order to safely handle the incoming ERC20 tokens.
/// @author @isabellasmallcombe
contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMintPerAddress, ERC20MinterRewards {
contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMintPerAddress, ERC20MinterRewards, Initializable, Ownable2StepUpgradeable {
using SafeERC20 for IERC20;

/// @notice The address of the Zora rewards recipient
address public zoraRewardRecipientAddress;
/// @notice The ERC20 minter configuration
ERC20MinterConfig public minterConfig;

/// @notice The ERC20 sale configuration for a given 1155 token
/// @dev 1155 token address => 1155 token id => SalesConfig
mapping(address => mapping(uint256 => SalesConfig)) internal salesConfigs;

/// @notice Initializes the contract with a Zora rewards recipient address
/// @dev Allows deterministic contract address, called on deploy
function initialize(address _zoraRewardRecipientAddress) external {
if (_zoraRewardRecipientAddress == address(0)) {
revert AddressZero();
}

if (zoraRewardRecipientAddress != address(0)) {
revert AlreadyInitialized();
}

zoraRewardRecipientAddress = _zoraRewardRecipientAddress;

emit ERC20MinterInitialized(TOTAL_REWARD_PCT);
function initialize(address _zoraRewardRecipientAddress, address owner, uint256 _rewardPct, uint256 _ethReward) external initializer {
__Ownable_init(owner);
_setERC20MinterConfig(
ERC20MinterConfig({zoraRewardRecipientAddress: _zoraRewardRecipientAddress, rewardRecipientPercentage: _rewardPct, ethReward: _ethReward})
);
}

/// @notice Computes the total reward value for a given amount of ERC20 tokens
/// @param totalValue The total number of ERC20 tokens
function computeTotalReward(uint256 totalValue) public pure returns (uint256) {
return (totalValue * TOTAL_REWARD_PCT) / BPS_TO_PERCENT_2_DECIMAL_PERCISION;
function computeTotalReward(uint256 totalValue) public view returns (uint256) {
return (totalValue * minterConfig.rewardRecipientPercentage) / BPS_TO_PERCENT_2_DECIMAL_PERCISION;
}

/// @notice Computes the rewards value given an amount and a reward percentage
Expand Down Expand Up @@ -106,7 +102,7 @@ contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMint
} catch {}

if (createReferral == address(0)) {
createReferral = zoraRewardRecipientAddress;
createReferral = minterConfig.zoraRewardRecipientAddress;
}
}

Expand All @@ -121,10 +117,15 @@ contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMint
firstMinter = IZora1155(tokenContract).getCreatorRewardRecipient(tokenId);
}
} catch {
firstMinter = zoraRewardRecipientAddress;
firstMinter = minterConfig.zoraRewardRecipientAddress;
}
}

/// @notice Gets the ERC20MinterConfig
function getERC20MinterConfig() external view returns (ERC20MinterConfig memory) {
return minterConfig;
}

/// @notice Handles the incoming transfer of ERC20 tokens
/// @param currency The address of the currency to use for the mint
/// @param totalValue The total value of the mint
Expand All @@ -151,19 +152,19 @@ contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMint
address firstMinter = getFirstMinter(tokenAddress, tokenId);

if (mintReferral == address(0)) {
mintReferral = zoraRewardRecipientAddress;
mintReferral = minterConfig.zoraRewardRecipientAddress;
}

IERC20(currency).safeTransfer(createReferral, settings.createReferralReward);
IERC20(currency).safeTransfer(firstMinter, settings.firstMinterReward);
IERC20(currency).safeTransfer(mintReferral, settings.mintReferralReward);
IERC20(currency).safeTransfer(zoraRewardRecipientAddress, settings.zoraReward);
IERC20(currency).safeTransfer(minterConfig.zoraRewardRecipientAddress, settings.zoraReward);

emit ERC20RewardsDeposit(
createReferral,
mintReferral,
firstMinter,
zoraRewardRecipientAddress,
minterConfig.zoraRewardRecipientAddress,
tokenAddress,
currency,
tokenId,
Expand All @@ -174,6 +175,14 @@ contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMint
);
}

/// @notice Distributes the ETH rewards to the Zora rewards recipient
/// @param ethSent The amount of ETH to distribute
function _distributeEthRewards(uint256 ethSent) private {
if (!TransferHelperUtils.safeSendETH(minterConfig.zoraRewardRecipientAddress, ethSent, TransferHelperUtils.FUNDS_SEND_NORMAL_GAS_LIMIT)) {
revert FailedToSendEthReward();
}
}

/// @notice Mints a token using an ERC20 currency, note the total value must have been approved prior to calling this function
/// @param mintTo The address to mint the token to
/// @param quantity The quantity of tokens to mint
Expand All @@ -192,7 +201,11 @@ contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMint
address currency,
address mintReferral,
string calldata comment
) external nonReentrant {
) external payable nonReentrant {
if (msg.value != minterConfig.ethReward) {
revert InvalidValue();
}

SalesConfig storage config = salesConfigs[tokenAddress][tokenId];

if (config.currency == address(0) || config.currency != currency) {
Expand Down Expand Up @@ -223,6 +236,8 @@ contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMint

_distributeRewards(totalReward, currency, tokenId, tokenAddress, mintReferral);

_distributeEthRewards(msg.value);

IERC20(config.currency).safeTransfer(config.fundsRecipient, totalValue - totalReward);

if (bytes(comment).length > 0) {
Expand All @@ -231,8 +246,13 @@ contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMint
}

/// @notice The percentage of the total value that is distributed as rewards
function totalRewardPct() external pure returns (uint256) {
return TOTAL_REWARD_PCT;
function totalRewardPct() external view returns (uint256) {
return minterConfig.rewardRecipientPercentage;
}

/// @notice The amount of ETH distributed as rewards
function ethRewardAmount() external view returns (uint256) {
return minterConfig.ethReward;
}

/// @notice The URI of the contract
Expand All @@ -247,20 +267,19 @@ contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMint

/// @notice The version of the contract
function contractVersion() external pure returns (string memory) {
return "1.0.0";
return "2.0.0";
}

/// @notice Sets the sale config for a given token
/// @param tokenId The ID of the token to set the sale config for
/// @param salesConfig The sale config to set
function setSale(uint256 tokenId, SalesConfig memory salesConfig) external {
_requireNotAddressZero(salesConfig.currency);
_requireNotAddressZero(salesConfig.fundsRecipient);

if (salesConfig.pricePerToken < MIN_PRICE_PER_TOKEN) {
revert PricePerTokenTooLow();
}
if (salesConfig.currency == address(0)) {
revert AddressZero();
}
if (salesConfig.fundsRecipient == address(0)) {
revert AddressZero();
}

salesConfigs[msg.sender][tokenId] = salesConfig;

Expand All @@ -269,6 +288,7 @@ contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMint
}

/// @notice Deletes the sale config for a given token
/// @param tokenId The ID of the token to reset the sale config for
function resetSale(uint256 tokenId) external override {
delete salesConfigs[msg.sender][tokenId];

Expand All @@ -277,6 +297,8 @@ contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMint
}

/// @notice Returns the sale config for a given token
/// @param tokenContract The TokenContract address
/// @param tokenId The ID of the token to get the sale config for
function sale(address tokenContract, uint256 tokenId) external view returns (SalesConfig memory) {
return salesConfigs[tokenContract][tokenId];
}
Expand All @@ -291,19 +313,26 @@ contract ERC20Minter is ReentrancyGuard, IERC20Minter, SaleStrategy, LimitedMint
revert RequestMintInvalidUseMint();
}

/// @notice Set the Zora rewards recipient address
/// @param recipient The new recipient address
function setZoraRewardsRecipient(address recipient) external {
if (msg.sender != zoraRewardRecipientAddress) {
revert OnlyZoraRewardsRecipient();
}
function _setERC20MinterConfig(ERC20MinterConfig memory _config) internal {
_requireNotAddressZero(_config.zoraRewardRecipientAddress);

if (recipient == address(0)) {
revert AddressZero();
if (_config.rewardRecipientPercentage > 100) {
revert InvalidValue();
}

emit ZoraRewardsRecipientSet(zoraRewardRecipientAddress, recipient);
minterConfig = _config;
emit ERC20MinterConfigSet(_config);
}

zoraRewardRecipientAddress = recipient;
/// @notice Sets the ERC20MinterConfig
/// @param config The ERC20MinterConfig to set
function setERC20MinterConfig(ERC20MinterConfig memory config) external onlyOwner {
_setERC20MinterConfig(config);
}

function _requireNotAddressZero(address _address) internal {
if (_address == address(0)) {
revert AddressZero();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ pragma solidity ^0.8.17;
/// @notice ERC20Minter Helper contract template
abstract contract ERC20MinterRewards {
uint256 internal constant MIN_PRICE_PER_TOKEN = 10_000;
uint256 internal constant TOTAL_REWARD_PCT = 5; // 5%
uint256 internal constant BPS_TO_PERCENT_2_DECIMAL_PERCISION = 100;
uint256 internal constant BPS_TO_PERCENT_8_DECIMAL_PERCISION = 100_000_000;
uint256 internal constant CREATE_REFERRAL_PAID_MINT_REWARD_PCT = 28_571400; // 28.5714%, roughly 0.000222 ETH at a 0.000777 value
Expand Down
Loading

0 comments on commit cd6c636

Please sign in to comment.