Created
September 23, 2025 18:30
-
-
Save 0xm1kr/048f78d43145473d575fcce7a6b4c683 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // SPDX-License-Identifier: MIT | |
| pragma solidity 0.8.20; | |
| import "@openzeppelin/contracts/access/Ownable.sol"; | |
| import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; | |
| import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; | |
| import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; | |
| import "@openzeppelin/contracts/utils/Pausable.sol"; | |
| /** | |
| * @title Presale | |
| */ | |
| contract Presale is Ownable, ReentrancyGuard, Pausable, EIP712 { | |
| using ECDSA for bytes32; | |
| /// @notice Maximum amount that can be purchased per wallet | |
| uint256 public maxAmountPerWallet; | |
| /// @notice Price per unit for all purchases | |
| uint256 public pricePerUnit; | |
| /// @notice Total amount sold so far | |
| uint256 public totalSold; | |
| /// @notice Mapping of buyer address to their total purchase amount | |
| mapping(address => uint256) public purchases; | |
| /// @notice Mapping to track used nonces to prevent replay attacks | |
| mapping(bytes32 => bool) public usedNonces; | |
| /// @notice Mapping of address to custom max amount per wallet (0 means use default) | |
| mapping(address => uint256) public customMaxAmountPerWallet; | |
| /// @notice Event emitted when a presale purchase is made | |
| event PresalePurchase( | |
| address indexed buyer, | |
| address indexed recipient, | |
| uint256 quantity, | |
| uint256 totalPrice | |
| ); | |
| /// @notice Event emitted when maxAmountPerWallet is updated | |
| event MaxAmountPerWalletUpdated(uint256 newMaxAmountPerWallet); | |
| /// @notice Event emitted when price per unit is updated | |
| event PricePerUnitUpdated(uint256 newPricePerUnit); | |
| /// @notice Event emitted when custom max amount per wallet is set for an address | |
| event CustomMaxAmountSet(address indexed wallet, uint256 customMaxAmount); | |
| /// @notice Event emitted when custom max amount per wallet is removed for an address | |
| event CustomMaxAmountRemoved(address indexed wallet); | |
| /// @notice Event emitted when authorized signer is updated | |
| event AuthorizedSignerUpdated(address indexed newSigner); | |
| /// @notice Error thrown when signature verification fails | |
| error InvalidSignature(); | |
| /// @notice Error thrown when nonce has already been used | |
| error NonceAlreadyUsed(); | |
| /// @notice Error thrown when max amount per wallet would be exceeded | |
| error MaxAmountExceeded(); | |
| /// @notice Error thrown when insufficient payment is sent | |
| error InvalidPayment(); | |
| /// @notice Error thrown when zero quantity is requested | |
| error ZeroQuantity(); | |
| /// @notice Error thrown when buyer address is invalid | |
| error InvalidBuyer(); | |
| /// @notice Error thrown when caller is not the buyer | |
| error InvalidCaller(); | |
| /// @notice Error thrown when recipient address is invalid | |
| error InvalidRecipient(); | |
| /// @notice EIP-712 type hash for the purchase struct | |
| bytes32 private constant PURCHASE_TYPEHASH = keccak256( | |
| "Purchase(address buyer,uint256 quantity,address recipient,bytes32 nonce)" | |
| ); | |
| /// @notice Struct for EIP-712 typed data signing | |
| struct Purchase { | |
| address buyer; | |
| uint256 quantity; | |
| address recipient; | |
| bytes32 nonce; | |
| } | |
| /// @notice Authorized signer address for validating whitelist purchases | |
| address public authorizedSigner; | |
| /** | |
| * @notice Constructor to initialize the presale contract | |
| * @param _owner Address of the contract owner | |
| * @param _authorizedSigner Address of the authorized signer | |
| * @param _maxAmountPerWallet Maximum amount that can be purchased per wallet | |
| * @param _pricePerUnit Price per unit for all purchases | |
| */ | |
| constructor( | |
| address _owner, | |
| address _authorizedSigner, | |
| uint256 _maxAmountPerWallet, | |
| uint256 _pricePerUnit | |
| ) Ownable(_owner) EIP712("OPPresale", "1") { | |
| require(_owner != address(0), "Invalid owner address"); | |
| require(_authorizedSigner != address(0), "Invalid signer address"); | |
| require(_maxAmountPerWallet > 0, "Max amount per wallet must be greater than zero"); | |
| require(_pricePerUnit > 0, "Price per unit must be greater than zero"); | |
| authorizedSigner = _authorizedSigner; | |
| maxAmountPerWallet = _maxAmountPerWallet; | |
| pricePerUnit = _pricePerUnit; | |
| } | |
| /** | |
| * @notice Purchase function with signature validation | |
| * @param signature The signature proving authorization for this purchase | |
| * @param buyer The whitelisted buyer | |
| * @param quantity Amount to purchase | |
| * @param recipient Address to receive the purchased items | |
| * @param nonce Unique nonce to prevent replay attacks | |
| */ | |
| function purchase( | |
| bytes memory signature, | |
| address buyer, | |
| uint256 quantity, | |
| address recipient, | |
| bytes32 nonce | |
| ) external payable nonReentrant whenNotPaused { | |
| if (buyer == address(0)) revert InvalidBuyer(); | |
| if (msg.sender != buyer) revert InvalidCaller(); | |
| if (recipient == address(0)) revert InvalidRecipient(); | |
| if (quantity == 0) revert ZeroQuantity(); | |
| if (usedNonces[nonce]) revert NonceAlreadyUsed(); | |
| // Check if buyer has a custom max amount, otherwise use default | |
| uint256 effectiveMaxAmount = customMaxAmountPerWallet[buyer]; | |
| if (effectiveMaxAmount == 0) { | |
| effectiveMaxAmount = maxAmountPerWallet; | |
| } | |
| if (purchases[buyer] + quantity > effectiveMaxAmount) revert MaxAmountExceeded(); | |
| uint256 totalPrice = pricePerUnit * quantity; | |
| if (msg.value != totalPrice) revert InvalidPayment(); | |
| // Verify the signature using EIP-712 - signature must come from authorized signer | |
| Purchase memory purchaseData = Purchase({ | |
| buyer: buyer, | |
| quantity: quantity, | |
| recipient: recipient, | |
| nonce: nonce | |
| }); | |
| bytes32 structHash = keccak256( | |
| abi.encode( | |
| PURCHASE_TYPEHASH, | |
| purchaseData.buyer, | |
| purchaseData.quantity, | |
| purchaseData.recipient, | |
| purchaseData.nonce | |
| ) | |
| ); | |
| bytes32 hash = _hashTypedDataV4(structHash); | |
| address signer = hash.recover(signature); | |
| if (signer != authorizedSigner) revert InvalidSignature(); | |
| usedNonces[nonce] = true; | |
| purchases[buyer] += quantity; | |
| totalSold += quantity; | |
| emit PresalePurchase(buyer, recipient, quantity, totalPrice); | |
| } | |
| /** | |
| * @notice Get the current purchase amount for a specific buyer | |
| * @param buyer Address of the buyer | |
| * @return Current purchase amount for the buyer | |
| */ | |
| function getBuyerPurchaseAmount(address buyer) external view returns (uint256) { | |
| return purchases[buyer]; | |
| } | |
| /** | |
| * @notice Get remaining amount available for purchase by a specific buyer | |
| * @param buyer Address of the buyer to check | |
| * @return Remaining amount that can still be purchased by this buyer | |
| */ | |
| function getRemainingAmountForWallet(address buyer) external view returns (uint256) { | |
| uint256 currentPurchases = purchases[buyer]; | |
| // Check if buyer has a custom max amount, otherwise use default | |
| uint256 effectiveMaxAmount = customMaxAmountPerWallet[buyer]; | |
| if (effectiveMaxAmount == 0) { | |
| effectiveMaxAmount = maxAmountPerWallet; | |
| } | |
| if (currentPurchases >= effectiveMaxAmount) { | |
| return 0; | |
| } | |
| return effectiveMaxAmount - currentPurchases; | |
| } | |
| /** | |
| * @notice Check if a nonce has been used | |
| * @param nonce The nonce to check | |
| * @return True if the nonce has been used, false otherwise | |
| */ | |
| function isNonceUsed(bytes32 nonce) external view returns (bool) { | |
| return usedNonces[nonce]; | |
| } | |
| /** | |
| * @notice Get the EIP-712 domain separator | |
| * @return The domain separator for typed data signing | |
| */ | |
| function getDomainSeparator() external view returns (bytes32) { | |
| return _domainSeparatorV4(); | |
| } | |
| /** | |
| * @notice Update the maximum amount that can be purchased per wallet (owner only) | |
| * @param _maxAmountPerWallet New maximum amount per wallet | |
| */ | |
| function setMaxAmountPerWallet(uint256 _maxAmountPerWallet) external onlyOwner { | |
| require(_maxAmountPerWallet > 0, "Max amount per wallet must be greater than zero"); | |
| maxAmountPerWallet = _maxAmountPerWallet; | |
| emit MaxAmountPerWalletUpdated(_maxAmountPerWallet); | |
| } | |
| /** | |
| * @notice Update the price per unit (owner only) | |
| * @param _pricePerUnit New price per unit for all purchases | |
| */ | |
| function setPricePerUnit(uint256 _pricePerUnit) external onlyOwner { | |
| require(_pricePerUnit > 0, "Price per unit must be greater than zero"); | |
| pricePerUnit = _pricePerUnit; | |
| emit PricePerUnitUpdated(_pricePerUnit); | |
| } | |
| /** | |
| * @notice Update the authorized signer address (owner only) | |
| * @param _authorizedSigner New authorized signer address | |
| */ | |
| function setAuthorizedSigner(address _authorizedSigner) external onlyOwner { | |
| require(_authorizedSigner != address(0), "Invalid signer address"); | |
| authorizedSigner = _authorizedSigner; | |
| emit AuthorizedSignerUpdated(_authorizedSigner); | |
| } | |
| /** | |
| * @notice Set a custom max amount per wallet for a specific address (owner only) | |
| * @param wallet Address to set custom max amount for | |
| * @param customMaxAmount Custom max amount (0 to remove custom override) | |
| */ | |
| function setCustomMaxAmountPerWallet(address wallet, uint256 customMaxAmount) external onlyOwner { | |
| require(wallet != address(0), "Invalid wallet address"); | |
| require(customMaxAmount <= 10000, "Custom max amount too high"); // Reasonable upper bound | |
| if (customMaxAmount == 0) { | |
| delete customMaxAmountPerWallet[wallet]; | |
| emit CustomMaxAmountRemoved(wallet); | |
| } else { | |
| customMaxAmountPerWallet[wallet] = customMaxAmount; | |
| emit CustomMaxAmountSet(wallet, customMaxAmount); | |
| } | |
| } | |
| /** | |
| * @notice Get the effective max amount per wallet for a specific buyer | |
| * @param buyer Address to check | |
| * @return Effective max amount (custom override or default) | |
| */ | |
| function getEffectiveMaxAmountPerWallet(address buyer) external view returns (uint256) { | |
| uint256 customMax = customMaxAmountPerWallet[buyer]; | |
| return customMax == 0 ? maxAmountPerWallet : customMax; | |
| } | |
| /** | |
| * @notice Pause the contract to prevent purchases (owner only) | |
| */ | |
| function pause() external onlyOwner { | |
| _pause(); | |
| } | |
| /** | |
| * @notice Unpause the contract to allow purchases (owner only) | |
| */ | |
| function unpause() external onlyOwner { | |
| _unpause(); | |
| } | |
| /** | |
| * @notice Withdraw contract balance to owner | |
| */ | |
| function withdraw() external { | |
| uint256 balance = address(this).balance; | |
| require(balance > 0, "No funds to withdraw"); | |
| (bool success, ) = payable(owner()).call{value: balance}(""); | |
| if (!success) { | |
| revert("Withdrawal failed"); | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment