// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "./interfaces/ISeason1.sol"; /** * @title AereQuestAttestor — EIP-712 one-shot quest ticket redeemer * @notice Clones the AereFaucetV2 one-shot-ticket pattern (EIP-712 flavoured): * an authorized off-chain signer issues a signed ticket * (account, questId, nonce, validUntil). Redeeming the ticket credits * the points ledger with the quest's weight EXACTLY ONCE per * (account, questId, nonce). Replays revert. * * Design notes / why it is safe: * - The ticket does NOT carry an amount. The credited amount is the quest * `weight` read LIVE from the registry, so a leaked signer key cannot * over-credit beyond registry config, and the Foundation can retune * weights without re-issuing tickets. * - The quest WINDOW and PER-USER CAP are enforced on-chain from the * registry, independent of what the signer attests. * - `requiresVerifiedHuman` quests additionally require the (settable) * humanity oracle to return true for `account`. * - Replay key = keccak256(account, questId, nonce). One-shot. * - Anyone may submit the redeem tx (relayer / paymaster friendly); the * signature, not msg.sender, authorizes the credit. * * This contract must be added as an authorized attestor on the ledger by the * Foundation post-deploy. Owner = Foundation multisig. */ contract AereQuestAttestor is Ownable, ReentrancyGuard, EIP712, IAereQuestAttestor { using ECDSA for bytes32; // keccak256("QuestTicket(address account,bytes32 questId,uint256 nonce,uint64 validUntil)") bytes32 public constant TICKET_TYPEHASH = keccak256("QuestTicket(address account,bytes32 questId,uint256 nonce,uint64 validUntil)"); IAerePointsLedger public immutable LEDGER; IAereQuestRegistry public immutable REGISTRY; /// @notice Foundation-managed set of authorized ticket signers. mapping(address => bool) public isSigner; /// @notice Settable humanity oracle for `requiresVerifiedHuman` quests. IHumanityOracle public humanityOracle; /// @notice One-shot replay guard. keccak256(account, questId, nonce) => used. mapping(bytes32 => bool) public ticketUsed; /// @notice Cumulative points an account has earned from a given quest /// (for per-user-cap enforcement). mapping(address => mapping(bytes32 => uint256)) public earned; /// @notice Number of DISTINCT quests an account has been credited for at /// least once (read by the referral registry for activation). mapping(address => uint256) public distinctQuestCount; event SignerSet(address indexed signer, bool allowed); event HumanityOracleSet(address indexed oracle); event QuestAttested( address indexed account, bytes32 indexed questId, uint256 nonce, uint256 weight ); error BadSigner(); error TicketExpired(); error TicketAlreadyUsed(); error QuestClosed(); error PerUserCapReached(); error NotVerifiedHuman(); error HumanityOracleUnset(); constructor(address ledger, address registry, address initialSigner) EIP712("AereQuestAttestor", "1") { require(ledger != address(0) && registry != address(0), "zero-ref"); require(initialSigner != address(0), "zero-signer"); LEDGER = IAerePointsLedger(ledger); REGISTRY = IAereQuestRegistry(registry); isSigner[initialSigner] = true; emit SignerSet(initialSigner, true); } /* ------------------------------- admin ---------------------------------- */ function setSigner(address signer, bool allowed) external onlyOwner { require(signer != address(0), "zero-signer"); isSigner[signer] = allowed; emit SignerSet(signer, allowed); } function setHumanityOracle(address oracle) external onlyOwner { humanityOracle = IHumanityOracle(oracle); emit HumanityOracleSet(oracle); } /* ------------------------------- redeem --------------------------------- */ /// @notice Redeem a one-shot quest ticket, crediting `account` with the /// quest weight. Callable by anyone (relayer/paymaster friendly). function redeem( address account, bytes32 questId, uint256 nonce, uint64 validUntil, bytes calldata signature ) external nonReentrant { if (block.timestamp > validUntil) revert TicketExpired(); bytes32 replayKey = keccak256(abi.encodePacked(account, questId, nonce)); if (ticketUsed[replayKey]) revert TicketAlreadyUsed(); // Verify EIP-712 signature by an authorized signer. bytes32 structHash = keccak256(abi.encode(TICKET_TYPEHASH, account, questId, nonce, validUntil)); address recovered = _hashTypedDataV4(structHash).recover(signature); if (!isSigner[recovered]) revert BadSigner(); // Read live quest config. IAereQuestRegistry.Quest memory q = REGISTRY.getQuest(questId); if (!q.active) revert QuestClosed(); if (block.timestamp < q.startTime) revert QuestClosed(); if (q.endTime != 0 && block.timestamp > q.endTime) revert QuestClosed(); if (q.requiresVerifiedHuman) { if (address(humanityOracle) == address(0)) revert HumanityOracleUnset(); if (!humanityOracle.isVerifiedHuman(account)) revert NotVerifiedHuman(); } // Enforce per-user cap. uint256 already = earned[account][questId]; if (already + q.weight > q.perUserCap) revert PerUserCapReached(); // Effects: burn the ticket, update accounting. ticketUsed[replayKey] = true; if (already == 0) { distinctQuestCount[account] += 1; } earned[account][questId] = already + q.weight; // Interaction: credit the ledger. LEDGER.credit(account, q.weight, questId); emit QuestAttested(account, questId, nonce, q.weight); } /* --------------------------------- views -------------------------------- */ function domainSeparator() external view returns (bytes32) { return _domainSeparatorV4(); } function isTicketUsed(address account, bytes32 questId, uint256 nonce) external view returns (bool) { return ticketUsed[keccak256(abi.encodePacked(account, questId, nonce))]; } }