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.
158 lines
6.4 KiB
Solidity
158 lines
6.4 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity 0.8.23;
|
|
|
|
import "@openzeppelin/contracts/access/Ownable.sol";
|
|
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
|
|
|
|
/**
|
|
* @title AereFaucetV2 — testnet faucet with optional anonymous claim path
|
|
* @notice Two claim paths:
|
|
*
|
|
* 1. claim() — per-address cooldown (existing AereFaucet pattern,
|
|
* preserved for compatibility).
|
|
*
|
|
* 2. claimAnonymous(commitment, recipient, validUntil, sig) —
|
|
* Foundation off-chain issues a one-time claim ticket. The
|
|
* ticket commits to a fresh commitment value generated by
|
|
* the user; the on-chain side burns the commitment after
|
|
* first use. Different (commitment, recipient) pairs are
|
|
* independent — a user can claim N times by getting N
|
|
* tickets, but each ticket is one-shot.
|
|
*
|
|
* The anonymous path is useful for:
|
|
* - developer testing with throwaway addresses
|
|
* - integration tests in CI
|
|
* - hackathon onboarding where the founder doesn't want
|
|
* to expose participant wallet trails
|
|
*
|
|
* CIRCULATION CONTROL: anonymous claim drains the same balance
|
|
* as regular claim; both share `dripAmount`. The Foundation gates
|
|
* issuance volume by deciding how many tickets to mint off-chain.
|
|
*
|
|
* IMMUTABILITY: SIGNER is set at deploy and cannot be replaced.
|
|
* To rotate, redeploy. (Faucets are cheap; live ones can be
|
|
* retired by setting dripAmount to 0.)
|
|
*
|
|
* GOTCHAS:
|
|
* - Tickets are ECDSA-signed (EIP-191) by SIGNER.
|
|
* - validUntil prevents indefinite ticket hoarding.
|
|
* - Commitment is the unique replay key; collisions are
|
|
* improbable with 32-byte randomness.
|
|
* - recipient is bound in the signed message — a ticket cannot
|
|
* be redirected after signing.
|
|
*/
|
|
contract AereFaucetV2 is Ownable, ReentrancyGuard {
|
|
|
|
/* ================================ config ================================ */
|
|
|
|
/// @notice Off-chain ticket signer for anonymous-mode claims.
|
|
address public immutable SIGNER;
|
|
|
|
uint256 public dripAmount = 0.05 ether;
|
|
uint64 public cooldown = 1 days;
|
|
|
|
/* ================================= state ================================ */
|
|
|
|
mapping(address => uint64) public lastClaim;
|
|
/// @notice commitment → spent. One-shot per commitment.
|
|
mapping(bytes32 => bool) public spentCommitments;
|
|
|
|
/* ================================= events ================================ */
|
|
|
|
event Dripped(address indexed to, uint256 amount);
|
|
event AnonymousDripped(address indexed to, bytes32 indexed commitment, uint256 amount);
|
|
event Funded(address indexed from, uint256 amount);
|
|
event ConfigUpdated(uint256 dripAmount, uint64 cooldown);
|
|
|
|
/* ================================= errors ================================ */
|
|
|
|
error InCooldown();
|
|
error Dry();
|
|
error TransferFailed();
|
|
error TicketExpired();
|
|
error TicketSignerMismatch();
|
|
error CommitmentSpent();
|
|
error BadParams();
|
|
|
|
/* ============================== constructor ============================= */
|
|
|
|
constructor(address signer_) {
|
|
require(signer_ != address(0), "zero-signer");
|
|
SIGNER = signer_;
|
|
}
|
|
|
|
receive() external payable { emit Funded(msg.sender, msg.value); }
|
|
|
|
/* ============================== regular claim ============================ */
|
|
|
|
function claim() external nonReentrant {
|
|
if (block.timestamp - lastClaim[msg.sender] < cooldown) revert InCooldown();
|
|
if (address(this).balance < dripAmount) revert Dry();
|
|
lastClaim[msg.sender] = uint64(block.timestamp);
|
|
(bool ok, ) = msg.sender.call{value: dripAmount}("");
|
|
if (!ok) revert TransferFailed();
|
|
emit Dripped(msg.sender, dripAmount);
|
|
}
|
|
|
|
/* =========================== anonymous claim ============================= */
|
|
|
|
/// @notice Burn a one-time ticket and drip to `recipient`. The ticket
|
|
/// is an EIP-191 signature by SIGNER over
|
|
/// keccak256(abi.encode("AereFaucetV2", chainid, address(this),
|
|
/// commitment, recipient, validUntil))
|
|
/// msg.sender doesn't matter for permission — anyone can submit
|
|
/// (typical relayer pattern). Useful for gasless onboarding.
|
|
function claimAnonymous(
|
|
bytes32 commitment,
|
|
address recipient,
|
|
uint64 validUntil,
|
|
bytes calldata sig
|
|
) external nonReentrant {
|
|
if (recipient == address(0) || commitment == bytes32(0)) revert BadParams();
|
|
if (block.timestamp > validUntil) revert TicketExpired();
|
|
if (spentCommitments[commitment]) revert CommitmentSpent();
|
|
if (address(this).balance < dripAmount) revert Dry();
|
|
|
|
bytes32 inner = keccak256(abi.encode(
|
|
"AereFaucetV2", block.chainid, address(this), commitment, recipient, validUntil
|
|
));
|
|
bytes32 ethSigned = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", inner));
|
|
address recovered = _recover(ethSigned, sig);
|
|
if (recovered != SIGNER) revert TicketSignerMismatch();
|
|
|
|
spentCommitments[commitment] = true;
|
|
(bool ok, ) = recipient.call{value: dripAmount}("");
|
|
if (!ok) revert TransferFailed();
|
|
emit AnonymousDripped(recipient, commitment, dripAmount);
|
|
}
|
|
|
|
/* ============================== owner ops ============================== */
|
|
|
|
function setConfig(uint256 _drip, uint64 _cooldown) external onlyOwner {
|
|
if (_cooldown < 1 hours) revert BadParams();
|
|
dripAmount = _drip;
|
|
cooldown = _cooldown;
|
|
emit ConfigUpdated(_drip, _cooldown);
|
|
}
|
|
|
|
/* ================================ views ================================ */
|
|
|
|
function nextClaimAt(address user) external view returns (uint64) {
|
|
return lastClaim[user] + cooldown;
|
|
}
|
|
|
|
/* ================================ internal ============================== */
|
|
|
|
function _recover(bytes32 hash, bytes calldata sig) internal pure returns (address) {
|
|
if (sig.length != 65) return address(0);
|
|
bytes32 r; bytes32 s; uint8 v;
|
|
assembly {
|
|
r := calldataload(sig.offset)
|
|
s := calldataload(add(sig.offset, 0x20))
|
|
v := byte(0, calldataload(add(sig.offset, 0x40)))
|
|
}
|
|
if (v < 27) v += 27;
|
|
return ecrecover(hash, v, r, s);
|
|
}
|
|
}
|