aere-contracts/contracts/season1/AereQuestAttestor.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

161 lines
6.5 KiB
Solidity

// 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))];
}
}