Skip to content

Instantly share code, notes, and snippets.

@0xm1kr
Created September 23, 2025 18:30
Show Gist options
  • Select an option

  • Save 0xm1kr/048f78d43145473d575fcce7a6b4c683 to your computer and use it in GitHub Desktop.

Select an option

Save 0xm1kr/048f78d43145473d575fcce7a6b4c683 to your computer and use it in GitHub Desktop.
// 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