aere-contracts/contracts/faucet/AereFaucetV2.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

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