QIE ID Gated Allowlist
An allowlist only holders of a QIE ID (.qie) name can claim a place on, with an optional cap and an onchain member record.
QieIdGatedAllowlist.sol
// SPDX-License-Identifier: MITpragma solidity ^0.8.20; interface IERC721Balance { function balanceOf(address owner) external view returns (uint256);} /// @title QieIdGatedAllowlist/// @notice Access control gated on holding a QIE ID (.qie) name, with an/// onchain record of who claimed a place and when./// @dev The gate is deliberately `balanceOf(user) > 0` and nothing else./// QIE ID is an ERC-721 of .qie names: verified onchain: it answers/// supportsInterface(0x80ac58cd) and balanceOf(), but ownerOf() reverts/// for sequential ids (ids are not sequential), tokenURI is empty, and/// the ENS-style addr()/name() resolver calls revert. So "holds at least/// one name" is the only sound onchain check; do not add ownerOf or/// name-resolution logic on top of it, it will revert.////// The registry address is a constructor parameter rather than a/// constant so this deploys on any network: pass the QIE ID address on/// QIE Mainnet, or any ERC-721 you want to gate on elsewhere.contract QieIdGatedAllowlist { IERC721Balance public immutable gateToken; address public owner; uint256 public claimed; uint256 public maxClaims; // 0 = unlimited bool public claimingOpen; mapping(address => uint64) public claimedAt; // 0 = has not claimed address[] private _members; event Claimed(address indexed member, uint256 position); event ClaimingToggled(bool open); event MaxClaimsChanged(uint256 maxClaims); event OwnerChanged(address indexed previousOwner, address indexed newOwner); error NotOwner(); error NotAContract(); error ZeroAddress(); error ClaimingClosed(); error NoQieId(); error AlreadyClaimed(); error AllocationFull(); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } /// @param gateToken_ ERC-721 whose holders may claim (QIE ID on QIE Mainnet). /// @param initialOwner Can open/close claiming and change the cap. /// @param maxClaims_ Maximum places; 0 for unlimited. constructor(address gateToken_, address initialOwner, uint256 maxClaims_) { if (gateToken_ == address(0) || initialOwner == address(0)) revert ZeroAddress(); // gateToken is immutable, so a wrong address here bricks the contract // permanently, every read reverts and nobody can ever claim. Reject // anything without code at deploy time rather than after the fact. if (gateToken_.code.length == 0) revert NotAContract(); gateToken = IERC721Balance(gateToken_); owner = initialOwner; maxClaims = maxClaims_; claimingOpen = true; emit OwnerChanged(address(0), initialOwner); } /// @notice True when `user` holds at least one gate-token name. /// @dev Deliberately a low-level staticcall rather than a direct call. A /// plain external call bubbles up any revert, which would make /// canClaim() -- a view the UI calls to decide whether to show a /// claim button -- throw instead of returning false. A gate that /// cannot answer is treated as "not a holder", never as an error. function holdsQieId(address user) public view returns (bool) { (bool ok, bytes memory data) = address(gateToken).staticcall( abi.encodeWithSelector(IERC721Balance.balanceOf.selector, user) ); if (!ok || data.length < 32) return false; return abi.decode(data, (uint256)) > 0; } /// @notice Whether `user` could claim a place right now. function canClaim(address user) external view returns (bool) { if (!claimingOpen) return false; if (claimedAt[user] != 0) return false; if (maxClaims != 0 && claimed >= maxClaims) return false; return holdsQieId(user); } /// @notice Claim a place. Requires a QIE ID and one claim per address. function claim() external { if (!claimingOpen) revert ClaimingClosed(); if (claimedAt[msg.sender] != 0) revert AlreadyClaimed(); if (maxClaims != 0 && claimed >= maxClaims) revert AllocationFull(); if (!holdsQieId(msg.sender)) revert NoQieId(); claimedAt[msg.sender] = uint64(block.timestamp); _members.push(msg.sender); claimed += 1; emit Claimed(msg.sender, claimed); } function setClaimingOpen(bool open) external onlyOwner { claimingOpen = open; emit ClaimingToggled(open); } function setMaxClaims(uint256 maxClaims_) external onlyOwner { maxClaims = maxClaims_; emit MaxClaimsChanged(maxClaims_); } function transferOwnership(address newOwner) external onlyOwner { if (newOwner == address(0)) revert ZeroAddress(); emit OwnerChanged(owner, newOwner); owner = newOwner; } function memberCount() external view returns (uint256) { return _members.length; } /// @notice Paged member list. Deliberately paged: an unbounded getter /// grows until it reverts out-of-gas for every caller. function membersPage(uint256 offset, uint256 limit) external view returns (address[] memory page) { uint256 total = _members.length; if (offset >= total) return new address[](0); uint256 end = offset + limit; if (end > total) end = total; page = new address[](end - offset); for (uint256 i = offset; i < end; i++) { page[i - offset] = _members[i]; } }}