Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The published line and the local line had no common ancestor: the public one carried the redaction pass, the local one carried three weeks of corrections that never shipped. This commit ports the local work onto the public line, keeps every public redaction, and extends the same discretion to seven client mentions that were still named in published comments. Carried: LICENSE year and LICENSING.md; the measured burn figures replacing the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is a share of validator coinbase revenue, which is zero today); 'audited' removed from next to Bouncy Castle; citation paths rewritten to published form with CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and ownership comments brought down to what the code does; the AerePyth repair; the shutter test helper the tests cite; runnable package.json entries; the CI file split into a GitHub/Gitea twin pair with a real measured test-run status; and the .gitignore hardening written after a compiled artifact leaked a local path in a sibling repository. A false '2-of-3 multisig' description of the owner account is corrected to what the chain measures: an externally owned account. The self-audit findings catalog stays unpublished pending an explicit decision.
161 lines
6.6 KiB
Solidity
161 lines
6.6 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 account (single-key EOA today, not a 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))];
|
|
}
|
|
}
|