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.
161 lines
6.5 KiB
Solidity
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))];
|
|
}
|
|
}
|