// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; /** * @title AereFaucetV2 — testnet faucet with optional anonymous claim path * @notice Two claim paths: * * 1. claim() — per-address cooldown (existing AereFaucet pattern, * preserved for compatibility). * * 2. claimAnonymous(commitment, recipient, validUntil, sig) — * Foundation off-chain issues a one-time claim ticket. The * ticket commits to a fresh commitment value generated by * the user; the on-chain side burns the commitment after * first use. Different (commitment, recipient) pairs are * independent — a user can claim N times by getting N * tickets, but each ticket is one-shot. * * The anonymous path is useful for: * - developer testing with throwaway addresses * - integration tests in CI * - hackathon onboarding where the founder doesn't want * to expose participant wallet trails * * CIRCULATION CONTROL: anonymous claim drains the same balance * as regular claim; both share `dripAmount`. The Foundation gates * issuance volume by deciding how many tickets to mint off-chain. * * IMMUTABILITY: SIGNER is set at deploy and cannot be replaced. * To rotate, redeploy. (Faucets are cheap; live ones can be * retired by setting dripAmount to 0.) * * GOTCHAS: * - Tickets are ECDSA-signed (EIP-191) by SIGNER. * - validUntil prevents indefinite ticket hoarding. * - Commitment is the unique replay key; collisions are * improbable with 32-byte randomness. * - recipient is bound in the signed message — a ticket cannot * be redirected after signing. */ contract AereFaucetV2 is Ownable, ReentrancyGuard { /* ================================ config ================================ */ /// @notice Off-chain ticket signer for anonymous-mode claims. address public immutable SIGNER; uint256 public dripAmount = 0.05 ether; uint64 public cooldown = 1 days; /* ================================= state ================================ */ mapping(address => uint64) public lastClaim; /// @notice commitment → spent. One-shot per commitment. mapping(bytes32 => bool) public spentCommitments; /* ================================= events ================================ */ event Dripped(address indexed to, uint256 amount); event AnonymousDripped(address indexed to, bytes32 indexed commitment, uint256 amount); event Funded(address indexed from, uint256 amount); event ConfigUpdated(uint256 dripAmount, uint64 cooldown); /* ================================= errors ================================ */ error InCooldown(); error Dry(); error TransferFailed(); error TicketExpired(); error TicketSignerMismatch(); error CommitmentSpent(); error BadParams(); /* ============================== constructor ============================= */ constructor(address signer_) { require(signer_ != address(0), "zero-signer"); SIGNER = signer_; } receive() external payable { emit Funded(msg.sender, msg.value); } /* ============================== regular claim ============================ */ function claim() external nonReentrant { if (block.timestamp - lastClaim[msg.sender] < cooldown) revert InCooldown(); if (address(this).balance < dripAmount) revert Dry(); lastClaim[msg.sender] = uint64(block.timestamp); (bool ok, ) = msg.sender.call{value: dripAmount}(""); if (!ok) revert TransferFailed(); emit Dripped(msg.sender, dripAmount); } /* =========================== anonymous claim ============================= */ /// @notice Burn a one-time ticket and drip to `recipient`. The ticket /// is an EIP-191 signature by SIGNER over /// keccak256(abi.encode("AereFaucetV2", chainid, address(this), /// commitment, recipient, validUntil)) /// msg.sender doesn't matter for permission — anyone can submit /// (typical relayer pattern). Useful for gasless onboarding. function claimAnonymous( bytes32 commitment, address recipient, uint64 validUntil, bytes calldata sig ) external nonReentrant { if (recipient == address(0) || commitment == bytes32(0)) revert BadParams(); if (block.timestamp > validUntil) revert TicketExpired(); if (spentCommitments[commitment]) revert CommitmentSpent(); if (address(this).balance < dripAmount) revert Dry(); bytes32 inner = keccak256(abi.encode( "AereFaucetV2", block.chainid, address(this), commitment, recipient, validUntil )); bytes32 ethSigned = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", inner)); address recovered = _recover(ethSigned, sig); if (recovered != SIGNER) revert TicketSignerMismatch(); spentCommitments[commitment] = true; (bool ok, ) = recipient.call{value: dripAmount}(""); if (!ok) revert TransferFailed(); emit AnonymousDripped(recipient, commitment, dripAmount); } /* ============================== owner ops ============================== */ function setConfig(uint256 _drip, uint64 _cooldown) external onlyOwner { if (_cooldown < 1 hours) revert BadParams(); dripAmount = _drip; cooldown = _cooldown; emit ConfigUpdated(_drip, _cooldown); } /* ================================ views ================================ */ function nextClaimAt(address user) external view returns (uint64) { return lastClaim[user] + cooldown; } /* ================================ internal ============================== */ function _recover(bytes32 hash, bytes calldata sig) internal pure returns (address) { if (sig.length != 65) return address(0); bytes32 r; bytes32 s; uint8 v; assembly { r := calldataload(sig.offset) s := calldataload(add(sig.offset, 0x20)) v := byte(0, calldataload(add(sig.offset, 0x40))) } if (v < 27) v += 27; return ecrecover(hash, v, r, s); } }