FenruaFENcontracts/presale/FenruaFEN.solSource
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import {ERC20Burnable} from '@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol';
import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol';
/// @dev Minimal immutable presale binding used to validate claims and launch readiness.
interface IFENPresaleLaunchGate {
function fen() external view returns (address);
function treasury() external view returns (address);
function launchReady() external view returns (bool);
}
/// @notice Fixed-supply FEN for chain 978 with a one-way genesis ceremony and treasury launch gate.
contract FenruaFEN is ERC20, ERC20Burnable, Ownable {
error ZeroAddress();
error WrongFenchain(uint256 actualChainId, uint256 requiredChainId);
error NotContract(address account);
error PresaleControllerAlreadySet(address controller);
error InvalidPresaleFEN(address configuredFen);
error InvalidPresaleTreasury(address configuredTreasury);
error PresaleControllerNotSet();
error GenesisBalanceNotAllocated(address distributor, uint256 remainingBalance);
error GenesisTransferAllowanceStillActive(address distributor);
error CeremonyOwnerBalanceNotZero(address ceremonyOwner, uint256 remainingBalance);
error CeremonyOwnerTransferAllowanceActive(address ceremonyOwner);
error GenesisCeremonyNotSealed();
error PresaleNotLaunchReady(address controller);
error NotLaunchController(address caller);
error PreLaunchTransferAllowanceNotSet(address account);
error TransferLockedBeforeLaunch(address from, address to);
uint256 public constant FENCHAIN_CHAIN_ID = 978;
uint256 public constant GENESIS_SUPPLY = 1_000_000_000 ether;
address public immutable launchController; // Treasury wallet; cannot be changed.
address public immutable genesisDistributor; // Temporary ceremony account.
address public presaleController; // One-time-bound P-FEN claim distributor and launch gate.
bool public deploymentSealed; // One-way proof that temporary genesis authority exited.
bool public launched;
mapping(address account => bool allowed) public preLaunchTransferAllowed;
event PresaleControllerSet(address indexed controller);
event GenesisDeploymentSealed(address indexed genesisDistributor);
event PreLaunchTransferAllowed(address indexed account, bool allowed);
event FENLaunched(address indexed controller, uint256 launchedAt);
constructor(address initialOwner, address launchController_)
ERC20('Fenrua FEN', 'FEN')
Ownable(initialOwner)
{
if (block.chainid != FENCHAIN_CHAIN_ID) {
revert WrongFenchain(block.chainid, FENCHAIN_CHAIN_ID);
}
if (launchController_ == address(0)) revert ZeroAddress();
launchController = launchController_;
genesisDistributor = initialOwner;
preLaunchTransferAllowed[initialOwner] = true;
_mint(initialOwner, GENESIS_SUPPLY);
emit PreLaunchTransferAllowed(initialOwner, true);
}
/// @notice Binds the production presale once and grants only it pre-launch claim transfers.
function setPresaleController(address controller) external onlyOwner {
if (controller == address(0)) revert ZeroAddress();
if (controller.code.length == 0) revert NotContract(controller);
if (presaleController != address(0)) {
revert PresaleControllerAlreadySet(presaleController);
}
address configuredFen = IFENPresaleLaunchGate(controller).fen();
if (configuredFen != address(this)) revert InvalidPresaleFEN(configuredFen);
address configuredTreasury = IFENPresaleLaunchGate(controller).treasury();
if (configuredTreasury != launchController) {
revert InvalidPresaleTreasury(configuredTreasury);
}
presaleController = controller;
preLaunchTransferAllowed[controller] = true;
emit PresaleControllerSet(controller);
emit PreLaunchTransferAllowed(controller, true);
}
/// @notice Enables ordinary FEN transfers after the treasury and presale gates both approve.
function activateLaunch() external {
if (msg.sender != launchController) revert NotLaunchController(msg.sender);
if (!deploymentSealed) revert GenesisCeremonyNotSealed();
address controller = presaleController;
if (controller == address(0)) revert PresaleControllerNotSet();
if (!IFENPresaleLaunchGate(controller).launchReady()) {
revert PresaleNotLaunchReady(controller);
}
if (!launched) {
launched = true;
emit FENLaunched(msg.sender, block.timestamp);
}
}
function ceremonySealed() public view returns (bool) {
return deploymentSealed;
}
/// @notice Seals genesis only after all FEN and transfer authority have left the distributor.
function renounceOwnership() public override onlyOwner {
if (presaleController == address(0)) revert PresaleControllerNotSet();
uint256 distributorBalance = balanceOf(genesisDistributor);
if (distributorBalance != 0) {
revert GenesisBalanceNotAllocated(genesisDistributor, distributorBalance);
}
if (preLaunchTransferAllowed[genesisDistributor]) {
revert GenesisTransferAllowanceStillActive(genesisDistributor);
}
address ceremonyOwner = owner();
uint256 ceremonyOwnerBalance = balanceOf(ceremonyOwner);
if (ceremonyOwnerBalance != 0) {
revert CeremonyOwnerBalanceNotZero(ceremonyOwner, ceremonyOwnerBalance);
}
if (preLaunchTransferAllowed[ceremonyOwner]) {
revert CeremonyOwnerTransferAllowanceActive(ceremonyOwner);
}
deploymentSealed = true;
emit GenesisDeploymentSealed(genesisDistributor);
super.renounceOwnership();
}
function renouncePreLaunchTransferAllowance() external {
if (!preLaunchTransferAllowed[msg.sender]) {
revert PreLaunchTransferAllowanceNotSet(msg.sender);
}
preLaunchTransferAllowed[msg.sender] = false;
emit PreLaunchTransferAllowed(msg.sender, false);
}
function _update(address from, address to, uint256 value) internal override {
if (!launched && from != address(0) && to != address(0)) {
if (!preLaunchTransferAllowed[from]) {
revert TransferLockedBeforeLaunch(from, to);
}
}
super._update(from, to, value);
}
}
PFENPresalecontracts/presale/PFENPresale.solSource
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol';
import {IERC20Metadata} from '@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol';
import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
import {ReentrancyGuard} from '@openzeppelin/contracts/utils/ReentrancyGuard.sol';
interface IPresaleFEN is IERC20 {
function burn(uint256 amount) external;
function launched() external view returns (bool);
function launchController() external view returns (address);
function presaleController() external view returns (address);
function ceremonySealed() external view returns (bool);
}
/// @notice Non-transferable P-FEN receipts backed 1:1 by FEN held under a deterministic claim clock.
contract PFENPresale is ERC20, ReentrancyGuard {
using SafeERC20 for IERC20;
using SafeERC20 for IPresaleFEN;
error ZeroAddress();
error NotTreasury(address caller);
error WrongFenchain(uint256 actualChainId, uint256 requiredChainId);
error ZeroAmount();
error BadStablecoinDecimals(uint8 decimals_);
error InvalidLaunchController(address actual, address required);
error InvalidPresaleController(address actual, address required);
error GenesisCeremonyNotSealed();
error FENAlreadyLaunched();
error PresaleAlreadyOpened();
error PresaleNotOpen();
error PresaleAlreadyFinalized();
error PresaleStillActive(uint256 saleEnd);
error PresaleClosed(uint256 saleEnd);
error ReserveNotLocked(uint256 actualReserve, uint256 requiredReserve);
error TreasuryPaymentShortfall(uint256 requiredAmount, uint256 receivedAmount);
error PurchaseExpired(uint256 deadline, uint256 currentTimestamp);
error StageChanged(uint256 expectedStage, uint256 actualStage);
error PaymentExceedsMaximum(uint256 requiredAmount, uint256 maximumAmount);
error StageOutOfRange(uint256 stageIndex);
error StageAllocationExceeded(uint256 stageIndex, uint256 requested, uint256 remaining);
error PFENNonTransferable();
error NoClaimableFEN(address account);
error UnsoldFENAlreadyResolved();
error ReserveInvariantBroken(uint256 actualReserve, uint256 requiredReserve);
struct Stage {
uint256 allocation;
uint256 sold;
uint256 price;
}
uint256 public constant FENCHAIN_CHAIN_ID = 978;
uint256 public constant PRESALE_ALLOCATION = 200_000_000 ether;
uint256 public constant SALE_DURATION = 360 days;
uint256 public constant CLAIM_DELAY = 120 days;
uint256 public constant RELEASE_INTERVAL = 30 days;
uint256 public constant RELEASE_BPS = 300;
uint256 public constant FINAL_RELEASE_PERIOD = 33;
uint256 public constant BPS = 10_000;
uint256 public constant STAGE_COUNT = 15;
IPresaleFEN public immutable fen;
IERC20 public immutable paymentToken;
address public immutable treasury;
uint8 public immutable paymentTokenDecimals;
Stage[15] private _stages;
bool public opened;
bool public deadlineCheckpointRecorded;
bool public unsoldFENResolved;
uint256 public saleStart;
uint256 public saleEnd;
uint256 public soldOutAt;
uint256 public currentStage;
uint256 public totalSoldPFEN;
uint256 public totalClaimedFEN;
uint256 public unsoldFENBurned;
uint256 public totalStablecoinRaised;
mapping(address account => uint256 amount) public purchasedPFEN;
mapping(address account => uint256 amount) public claimedFEN;
event PresaleOpened(uint256 indexed saleStart, uint256 indexed saleEnd);
event PFENPurchased(
address indexed buyer,
uint256 indexed stageIndex,
uint256 pfenAmount,
uint256 stablecoinPaid,
address indexed treasury
);
event PaymentForwarded(
address indexed buyer,
address indexed paymentToken,
uint256 requiredAmount,
uint256 receivedAmount,
address indexed treasury
);
event UnsoldPFENCapacityCancelled(uint256 amount);
event UnsoldFENBurned(address indexed treasury, uint256 amount);
event PresaleFinalized(uint256 indexed presaleEnd, uint256 indexed claimStart, uint256 soldPFEN);
event DeadlineCheckpointRecorded(
uint256 indexed presaleEnd,
uint256 indexed claimStart,
uint256 soldPFEN,
uint256 unsoldPFEN
);
event FENClaimed(address indexed account, uint256 pfenBurned, uint256 fenClaimed);
constructor(
IPresaleFEN fen_,
IERC20Metadata paymentToken_,
address treasury_
) ERC20('Presale FEN Receipt', 'P-FEN') {
if (block.chainid != FENCHAIN_CHAIN_ID) {
revert WrongFenchain(block.chainid, FENCHAIN_CHAIN_ID);
}
if (address(fen_) == address(0) || address(paymentToken_) == address(0)) revert ZeroAddress();
if (treasury_ == address(0)) revert ZeroAddress();
address configuredLaunchController = fen_.launchController();
if (configuredLaunchController != treasury_) {
revert InvalidLaunchController(configuredLaunchController, treasury_);
}
uint8 decimals_ = paymentToken_.decimals();
if (decimals_ < 2 || decimals_ > 30) revert BadStablecoinDecimals(decimals_);
fen = fen_;
paymentToken = IERC20(address(paymentToken_));
treasury = treasury_;
paymentTokenDecimals = decimals_;
uint256 unit = 10 ** uint256(decimals_);
_stages[0] = Stage({allocation: 2_500_000 ether, sold: 0, price: unit / 100});
_stages[1] = Stage({allocation: 5_000_000 ether, sold: 0, price: (unit * 2) / 100});
_stages[2] = Stage({allocation: 7_500_000 ether, sold: 0, price: (unit * 3) / 100});
for (uint256 i = 3; i < 14; i++) {
_stages[i] = Stage({allocation: 15_000_000 ether, sold: 0, price: (unit * (i + 1)) / 100});
}
_stages[14] = Stage({allocation: 20_000_000 ether, sold: 0, price: (unit * 15) / 100});
uint256 totalAllocation = 0;
for (uint256 i = 0; i < STAGE_COUNT; i++) {
totalAllocation += _stages[i].allocation;
}
if (totalAllocation != PRESALE_ALLOCATION) {
revert ReserveInvariantBroken(totalAllocation, PRESALE_ALLOCATION);
}
}
modifier onlyTreasury() {
if (msg.sender != treasury) revert NotTreasury(msg.sender);
_;
}
/// @notice Opens the sale only after the FEN genesis ceremony is irreversibly sealed.
function openSale() external onlyTreasury {
if (opened) revert PresaleAlreadyOpened();
if (fen.launched()) revert FENAlreadyLaunched();
if (!fen.ceremonySealed()) revert GenesisCeremonyNotSealed();
address configuredPresaleController = fen.presaleController();
if (configuredPresaleController != address(this)) {
revert InvalidPresaleController(configuredPresaleController, address(this));
}
_requireOpeningReserveLocked();
opened = true;
saleStart = block.timestamp;
saleEnd = block.timestamp + SALE_DURATION;
emit PresaleOpened(saleStart, saleEnd);
}
/// @notice Atomically proves the treasury receipt before minting the requested P-FEN.
/// @param expectedStage Stage authorized by the buyer's quote.
/// @param maxStablecoinAmount Maximum configured stablecoin debit authorized by the buyer.
/// @param deadline Last timestamp at which the buyer permits execution.
function buyPFEN(
uint256 pfenAmount,
uint256 expectedStage,
uint256 maxStablecoinAmount,
uint256 deadline
) external nonReentrant {
if (!opened) revert PresaleNotOpen();
if (soldOutAt != 0) revert PresaleAlreadyFinalized();
// slither-disable-next-line timestamp
if (block.timestamp >= saleEnd) revert PresaleClosed(saleEnd);
// slither-disable-next-line timestamp
if (block.timestamp > deadline) revert PurchaseExpired(deadline, block.timestamp);
if (pfenAmount == 0) revert ZeroAmount();
_requireActiveReserve();
uint256 stageIndex = currentStage;
if (stageIndex != expectedStage) revert StageChanged(expectedStage, stageIndex);
Stage storage activeStage = _stages[stageIndex];
uint256 remaining = activeStage.allocation - activeStage.sold;
if (pfenAmount > remaining) {
revert StageAllocationExceeded(stageIndex, pfenAmount, remaining);
}
uint256 stablecoinAmount = quotePFEN(stageIndex, pfenAmount);
if (stablecoinAmount > maxStablecoinAmount) {
revert PaymentExceedsMaximum(stablecoinAmount, maxStablecoinAmount);
}
uint256 treasuryBalanceBefore = paymentToken.balanceOf(treasury);
paymentToken.safeTransferFrom(msg.sender, treasury, stablecoinAmount);
uint256 treasuryBalanceAfter = paymentToken.balanceOf(treasury);
uint256 stablecoinReceived =
treasuryBalanceAfter >= treasuryBalanceBefore ? treasuryBalanceAfter - treasuryBalanceBefore : 0;
if (stablecoinReceived < stablecoinAmount) {
revert TreasuryPaymentShortfall(stablecoinAmount, stablecoinReceived);
}
activeStage.sold += pfenAmount;
totalSoldPFEN += pfenAmount;
totalStablecoinRaised += stablecoinReceived;
purchasedPFEN[msg.sender] += pfenAmount;
emit PaymentForwarded(
msg.sender,
address(paymentToken),
stablecoinAmount,
stablecoinReceived,
treasury
);
_mint(msg.sender, pfenAmount);
emit PFENPurchased(msg.sender, stageIndex, pfenAmount, stablecoinReceived, treasury);
_advanceStage();
if (totalSoldPFEN == PRESALE_ALLOCATION) {
_recordSellout(block.timestamp);
}
}
/// @notice Optional indexing checkpoint; claim eligibility does not depend on this transaction.
function recordDeadlineCheckpoint() external nonReentrant {
if (!opened) revert PresaleNotOpen();
if (soldOutAt != 0) revert PresaleAlreadyFinalized();
if (deadlineCheckpointRecorded) revert PresaleAlreadyFinalized();
// slither-disable-next-line timestamp
if (block.timestamp < saleEnd) revert PresaleStillActive(saleEnd);
deadlineCheckpointRecorded = true;
uint256 unsoldCapacity = PRESALE_ALLOCATION - totalSoldPFEN;
emit DeadlineCheckpointRecorded(
saleEnd,
saleEnd + CLAIM_DELAY,
totalSoldPFEN,
unsoldCapacity
);
}
/// @notice Burns only the computed unsold reserve; it cannot burn holder backing.
function burnUnsoldFEN() external nonReentrant onlyTreasury returns (uint256 amount) {
if (!saleEnded()) revert PresaleStillActive(saleEnd);
if (unsoldFENResolved) revert UnsoldFENAlreadyResolved();
amount = PRESALE_ALLOCATION - totalSoldPFEN;
uint256 expectedBeforeBurn = requiredFENReserve();
uint256 reserveBeforeBurn = fen.balanceOf(address(this));
if (reserveBeforeBurn < expectedBeforeBurn) {
revert ReserveInvariantBroken(reserveBeforeBurn, expectedBeforeBurn);
}
unsoldFENResolved = true;
unsoldFENBurned = amount;
fen.burn(amount);
uint256 expectedAfterBurn = requiredFENReserve();
uint256 reserveAfterBurn = fen.balanceOf(address(this));
if (reserveAfterBurn < expectedAfterBurn) {
revert ReserveInvariantBroken(reserveAfterBurn, expectedAfterBurn);
}
emit UnsoldPFENCapacityCancelled(amount);
emit UnsoldFENBurned(msg.sender, amount);
}
/// @notice Releases vested FEN even when global FEN launch has not occurred.
function claim() external nonReentrant returns (uint256 amount) {
amount = claimable(msg.sender);
// slither-disable-next-line incorrect-equality,timestamp
if (amount == 0) revert NoClaimableFEN(msg.sender);
uint256 reserveBeforeClaim = fen.balanceOf(address(this));
uint256 requiredBeforeClaim = outstandingFEN();
if (reserveBeforeClaim < requiredBeforeClaim) {
revert ReserveInvariantBroken(reserveBeforeClaim, requiredBeforeClaim);
}
claimedFEN[msg.sender] += amount;
totalClaimedFEN += amount;
_burn(msg.sender, amount);
fen.safeTransfer(msg.sender, amount);
uint256 reserveAfterClaim = fen.balanceOf(address(this));
uint256 requiredAfterClaim = outstandingFEN();
if (reserveAfterClaim < requiredAfterClaim) {
revert ReserveInvariantBroken(reserveAfterClaim, requiredAfterClaim);
}
emit FENClaimed(msg.sender, amount, amount);
}
function reserveReady() public view returns (bool) {
// slither-disable-next-line incorrect-equality
return fen.balanceOf(address(this)) == PRESALE_ALLOCATION;
}
function outstandingFEN() public view returns (uint256) {
return totalSoldPFEN - totalClaimedFEN;
}
function holderReserveReady() public view returns (bool) {
return fen.balanceOf(address(this)) >= outstandingFEN();
}
function requiredFENReserve() public view returns (uint256) {
uint256 required = outstandingFEN();
if (!unsoldFENResolved) {
required += PRESALE_ALLOCATION - totalSoldPFEN;
}
return required;
}
function reserveInvariantReady() public view returns (bool) {
return fen.balanceOf(address(this)) >= requiredFENReserve();
}
function launchReady() external view returns (bool) {
return saleEnded() && unsoldFENResolved;
}
function finalized() public view returns (bool) {
return saleEnded();
}
/// @notice Time/sellout-derived terminal state requiring no keeper transaction.
function saleEnded() public view returns (bool) {
return opened && (soldOutAt != 0 || block.timestamp >= saleEnd);
}
function presaleEnd() public view returns (uint256) {
if (!opened) return 0;
if (soldOutAt != 0) return soldOutAt;
if (block.timestamp >= saleEnd) return saleEnd;
return 0;
}
function claimStart() public view returns (uint256) {
uint256 resolvedPresaleEnd = presaleEnd();
return resolvedPresaleEnd == 0 ? 0 : resolvedPresaleEnd + CLAIM_DELAY;
}
function stage(uint256 stageIndex)
external
view
returns (uint256 allocation, uint256 sold, uint256 price)
{
if (stageIndex >= STAGE_COUNT) revert StageOutOfRange(stageIndex);
Stage memory stageData = _stages[stageIndex];
return (stageData.allocation, stageData.sold, stageData.price);
}
function quotePFEN(uint256 stageIndex, uint256 pfenAmount) public view returns (uint256) {
if (stageIndex >= STAGE_COUNT) revert StageOutOfRange(stageIndex);
if (pfenAmount == 0) revert ZeroAmount();
return _mulDivUp(pfenAmount, _stages[stageIndex].price, 1 ether);
}
// slither-disable-start timestamp
function claimable(address account) public view returns (uint256) {
uint256 resolvedClaimStart = claimStart();
if (resolvedClaimStart == 0 || block.timestamp < resolvedClaimStart) return 0;
uint256 periods = ((block.timestamp - resolvedClaimStart) / RELEASE_INTERVAL) + 1;
uint256 unlockedBasisPoints =
periods >= FINAL_RELEASE_PERIOD ? BPS : periods * RELEASE_BPS;
uint256 unlocked = (purchasedPFEN[account] * unlockedBasisPoints) / BPS;
uint256 alreadyClaimed = claimedFEN[account];
if (unlocked <= alreadyClaimed) return 0;
return unlocked - alreadyClaimed;
}
// slither-disable-end timestamp
// slither-disable-start timestamp
function unlockedBps() external view returns (uint256) {
uint256 resolvedClaimStart = claimStart();
if (resolvedClaimStart == 0 || block.timestamp < resolvedClaimStart) return 0;
uint256 periods = ((block.timestamp - resolvedClaimStart) / RELEASE_INTERVAL) + 1;
return periods >= FINAL_RELEASE_PERIOD ? BPS : periods * RELEASE_BPS;
}
// slither-disable-end timestamp
function approve(address, uint256) public pure override returns (bool) {
revert PFENNonTransferable();
}
function transfer(address, uint256) public pure override returns (bool) {
revert PFENNonTransferable();
}
function transferFrom(address, address, uint256) public pure override returns (bool) {
revert PFENNonTransferable();
}
function _recordSellout(uint256 resolvedPresaleEnd) internal {
_requireActiveReserve();
soldOutAt = resolvedPresaleEnd;
unsoldFENResolved = true;
emit UnsoldPFENCapacityCancelled(0);
emit PresaleFinalized(resolvedPresaleEnd, resolvedPresaleEnd + CLAIM_DELAY, totalSoldPFEN);
}
function _requireOpeningReserveLocked() internal view {
uint256 reserve = fen.balanceOf(address(this));
if (reserve != PRESALE_ALLOCATION) {
revert ReserveNotLocked(reserve, PRESALE_ALLOCATION);
}
}
function _requireActiveReserve() internal view {
uint256 reserve = fen.balanceOf(address(this));
if (reserve < PRESALE_ALLOCATION) {
revert ReserveNotLocked(reserve, PRESALE_ALLOCATION);
}
}
function _advanceStage() internal {
while (currentStage < STAGE_COUNT && _stages[currentStage].sold == _stages[currentStage].allocation) {
currentStage++;
}
}
function _update(address from, address to, uint256 value) internal override {
if (from != address(0) && to != address(0)) revert PFENNonTransferable();
super._update(from, to, value);
}
function _mulDivUp(uint256 a, uint256 b, uint256 denominator) internal pure returns (uint256) {
uint256 product = a * b;
return ((product - 1) / denominator) + 1;
}
}
FENTransparencyRegistrycontracts/presale/FENTransparencyRegistry.solSource
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol';
contract FENTransparencyRegistry is Ownable {
error WrongFenchain(uint256 actualChainId, uint256 requiredChainId);
error ZeroAddress();
error GenesisRecorderIsFinalAuthority();
error EmptyKey();
error EmptyReceiptHash();
error CoreRecordAlreadySet(bytes32 key);
error CoreRecordOutOfRange(uint256 index);
error ReceiptAlreadyRecorded(bytes32 receiptKey);
error ReceiptOutOfRange(uint256 receiptId);
error WrongFinalAuthority(address proposedAuthority, address requiredAuthority);
error AuthorityAlreadyFinalized();
error OwnershipRenunciationForbidden();
struct CoreRecord {
address account;
bytes32 receiptHash;
string uri;
uint256 recordedAt;
}
struct ReceiptRecord {
bytes32 category;
address target;
uint256 amount;
bytes32 receiptHash;
string uri;
uint256 recordedAt;
}
uint256 public constant FENCHAIN_CHAIN_ID = 978;
address public immutable finalAuthority;
bool public authorityFinalized;
mapping(bytes32 key => CoreRecord record) private _coreRecords;
mapping(bytes32 key => bool set) public coreRecordSet;
bytes32[] private _coreKeys;
mapping(bytes32 receiptKey => bool set) public receiptKeySet;
ReceiptRecord[] private _receipts;
event CoreRecordSet(
bytes32 indexed key,
address indexed account,
bytes32 indexed receiptHash,
string uri,
uint256 recordedAt
);
event ReceiptRecorded(
uint256 indexed receiptId,
bytes32 indexed category,
address indexed target,
uint256 amount,
bytes32 receiptHash,
string uri,
uint256 recordedAt
);
constructor(address genesisRecorder_, address finalAuthority_) Ownable(genesisRecorder_) {
if (block.chainid != FENCHAIN_CHAIN_ID) {
revert WrongFenchain(block.chainid, FENCHAIN_CHAIN_ID);
}
if (finalAuthority_ == address(0)) revert ZeroAddress();
if (genesisRecorder_ == finalAuthority_) revert GenesisRecorderIsFinalAuthority();
finalAuthority = finalAuthority_;
}
/// @notice Hands the registry to its immutable production authority exactly once.
/// @dev The temporary genesis recorder cannot redirect ownership, and the final authority
/// cannot later transfer this registry to a different account.
function transferOwnership(address newOwner) public override onlyOwner {
if (authorityFinalized) revert AuthorityAlreadyFinalized();
if (newOwner != finalAuthority) revert WrongFinalAuthority(newOwner, finalAuthority);
authorityFinalized = true;
super.transferOwnership(newOwner);
}
/// @notice Registry control must always remain with the immutable final authority.
function renounceOwnership() public pure override {
revert OwnershipRenunciationForbidden();
}
function setCoreRecord(
bytes32 key,
address account,
bytes32 receiptHash,
string calldata uri
) external onlyOwner {
if (key == bytes32(0)) revert EmptyKey();
if (account == address(0)) revert ZeroAddress();
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
if (coreRecordSet[key]) revert CoreRecordAlreadySet(key);
uint256 recordedAt = block.timestamp;
_coreRecords[key] =
CoreRecord({account: account, receiptHash: receiptHash, uri: uri, recordedAt: recordedAt});
coreRecordSet[key] = true;
_coreKeys.push(key);
emit CoreRecordSet(key, account, receiptHash, uri, recordedAt);
}
function recordReceipt(
bytes32 category,
address target,
uint256 amount,
bytes32 receiptHash,
string calldata uri
) external onlyOwner returns (uint256 receiptId) {
if (category == bytes32(0)) revert EmptyKey();
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
bytes32 key = receiptKey(category, target, amount, receiptHash);
if (receiptKeySet[key]) revert ReceiptAlreadyRecorded(key);
receiptId = _receipts.length;
uint256 recordedAt = block.timestamp;
receiptKeySet[key] = true;
_receipts.push(
ReceiptRecord({
category: category,
target: target,
amount: amount,
receiptHash: receiptHash,
uri: uri,
recordedAt: recordedAt
})
);
emit ReceiptRecorded(receiptId, category, target, amount, receiptHash, uri, recordedAt);
}
/// @notice Computes the identity used to reject a duplicate receipt tuple.
/// @dev The URI is deliberately excluded: changing presentation metadata must not make
/// the same category/target/amount/evidence tuple recordable twice.
function receiptKey(
bytes32 category,
address target,
uint256 amount,
bytes32 receiptHash
) public pure returns (bytes32) {
return keccak256(abi.encode(category, target, amount, receiptHash));
}
function coreRecord(bytes32 key) external view returns (CoreRecord memory record) {
return _coreRecords[key];
}
function coreRecordCount() external view returns (uint256) {
return _coreKeys.length;
}
function coreRecordKey(uint256 index) external view returns (bytes32 key) {
if (index >= _coreKeys.length) revert CoreRecordOutOfRange(index);
return _coreKeys[index];
}
function receiptCount() external view returns (uint256) {
return _receipts.length;
}
function receipt(uint256 receiptId) external view returns (ReceiptRecord memory record) {
if (receiptId >= _receipts.length) revert ReceiptOutOfRange(receiptId);
return _receipts[receiptId];
}
}
FENAllocationVaultcontracts/presale/FENAllocationVault.solSource
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol';
import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol';
import {ReentrancyGuard} from '@openzeppelin/contracts/utils/ReentrancyGuard.sol';
contract FENAllocationVault is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
error WrongFenchain(uint256 actualChainId, uint256 requiredChainId);
error ZeroAddress();
error ZeroAmount();
error EmptyPurpose();
error EmptyReceiptHash();
error PurposeNotAllowed(bytes32 purpose);
error DestinationNotApproved(address destination);
error SelfDestinationForbidden();
error AllocationCapExceeded(uint256 requestedReleased, uint256 allocationCap);
error FENReleaseBalanceMismatch(uint256 balanceBefore, uint256 balanceAfter, uint256 expectedDecrease);
error FENRescueForbidden();
error AuthorityImmutable();
uint256 public constant FENCHAIN_CHAIN_ID = 978;
IERC20 public immutable fen;
bytes32 public immutable vaultPurpose;
uint256 public immutable allocationCap;
string public vaultName;
uint256 public releasedFEN;
mapping(bytes32 purpose => bool allowed) public allowedPurpose;
mapping(bytes32 purpose => mapping(address destination => bool approved)) public approvedDestinationForPurpose;
event PurposeAllowed(bytes32 indexed purpose, bool allowed, bytes32 indexed receiptHash, string uri);
event DestinationApproved(
address indexed destination,
bool approved,
bytes32 indexed purpose,
bytes32 receiptHash,
string uri
);
event FENReleased(
address indexed destination,
uint256 amount,
bytes32 indexed purpose,
bytes32 indexed receiptHash,
string uri
);
event NonFENRescued(
address indexed token,
address indexed destination,
uint256 amount,
bytes32 indexed receiptHash,
string uri
);
constructor(
IERC20 fen_,
address owner_,
string memory vaultName_,
bytes32 vaultPurpose_,
uint256 allocationCap_
) Ownable(owner_) {
if (block.chainid != FENCHAIN_CHAIN_ID) {
revert WrongFenchain(block.chainid, FENCHAIN_CHAIN_ID);
}
if (address(fen_) == address(0)) revert ZeroAddress();
if (vaultPurpose_ == bytes32(0)) revert EmptyPurpose();
if (allocationCap_ == 0) revert ZeroAmount();
fen = fen_;
vaultName = vaultName_;
vaultPurpose = vaultPurpose_;
allocationCap = allocationCap_;
}
/// @notice Department authority is permanently bound to the genesis multisig.
function transferOwnership(address) public view override onlyOwner {
revert AuthorityImmutable();
}
/// @notice Department authority cannot be abandoned after genesis.
function renounceOwnership() public view override onlyOwner {
revert AuthorityImmutable();
}
function allowPurpose(
bytes32 purpose,
bool allowed,
bytes32 receiptHash,
string calldata uri
) external onlyOwner {
if (purpose == bytes32(0)) revert EmptyPurpose();
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
allowedPurpose[purpose] = allowed;
emit PurposeAllowed(purpose, allowed, receiptHash, uri);
}
function approveDestination(
address destination,
bool approved,
bytes32 purpose,
bytes32 receiptHash,
string calldata uri
) external onlyOwner {
if (destination == address(0)) revert ZeroAddress();
if (destination == address(this)) revert SelfDestinationForbidden();
if (approved && !allowedPurpose[purpose]) revert PurposeNotAllowed(purpose);
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
approvedDestinationForPurpose[purpose][destination] = approved;
emit DestinationApproved(destination, approved, purpose, receiptHash, uri);
}
function releaseFEN(
address destination,
uint256 amount,
bytes32 purpose,
bytes32 receiptHash,
string calldata uri
) external nonReentrant onlyOwner {
if (destination == address(0)) revert ZeroAddress();
if (destination == address(this)) revert SelfDestinationForbidden();
if (amount == 0) revert ZeroAmount();
if (!allowedPurpose[purpose]) revert PurposeNotAllowed(purpose);
if (!approvedDestinationForPurpose[purpose][destination]) revert DestinationNotApproved(destination);
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
if (amount > allocationCap - releasedFEN) {
uint256 requestedReleased;
unchecked {
requestedReleased = releasedFEN + amount;
}
if (requestedReleased < releasedFEN) requestedReleased = type(uint256).max;
revert AllocationCapExceeded(requestedReleased, allocationCap);
}
uint256 nextReleased = releasedFEN + amount;
releasedFEN = nextReleased;
uint256 balanceBefore = fen.balanceOf(address(this));
fen.safeTransfer(destination, amount);
uint256 balanceAfter = fen.balanceOf(address(this));
if (balanceAfter > balanceBefore || balanceBefore - balanceAfter != amount) {
revert FENReleaseBalanceMismatch(balanceBefore, balanceAfter, amount);
}
emit FENReleased(destination, amount, purpose, receiptHash, uri);
}
function rescueNonFEN(
IERC20 token,
address destination,
uint256 amount,
bytes32 receiptHash,
string calldata uri
) external nonReentrant onlyOwner {
if (address(token) == address(0) || destination == address(0)) revert ZeroAddress();
if (destination == address(this)) revert SelfDestinationForbidden();
if (address(token) == address(fen)) revert FENRescueForbidden();
if (amount == 0) revert ZeroAmount();
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
token.safeTransfer(destination, amount);
emit NonFENRescued(address(token), destination, amount, receiptHash, uri);
}
function currentFENBalance() external view returns (uint256) {
return fen.balanceOf(address(this));
}
}
FENStakingPoolcontracts/presale/FENStakingPool.solSource
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol';
import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol';
import {ReentrancyGuard} from '@openzeppelin/contracts/utils/ReentrancyGuard.sol';
contract FENStakingPool is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
error WrongFenchain(uint256 actualChainId, uint256 requiredChainId);
error ZeroAddress();
error ZeroAmount();
error EmptyReceiptHash();
error BadPlan(uint8 planId);
error WalletStakeCapExceeded(address account, uint256 requestedStake, uint256 walletStakeCap);
error EpochNotComplete(uint256 epochId, uint256 epochEnd);
error EpochAlreadyRecorded(uint256 epochId);
error EpochOutOfOrder(uint256 requestedEpoch, uint256 expectedEpoch);
error NoEligibleStake(uint256 epochId);
error PositionOutOfRange(uint256 positionId);
error NotPositionOwner(address caller, address positionOwner);
error NothingClaimable(uint256 positionId);
error StakeLocked(uint256 positionId, uint256 unlockTime);
error PositionWithdrawn(uint256 positionId);
error EpochRollInvariant(uint256 requestedEpoch, uint256 lastRolledEpoch);
error EpochWeightInvariant(uint256 epochId, uint256 availableWeight, uint256 deactivatingWeight);
error SelfDestinationForbidden();
error FENRescueForbidden();
error AuthorityImmutable();
struct Plan {
uint256 lockDuration;
uint256 weightBps;
bool active;
}
struct Position {
address owner;
uint8 planId;
uint256 amount;
uint256 weight;
uint256 startTime;
uint256 activationEpoch;
uint256 unlockTime;
uint256 claimedRewards;
bool withdrawn;
}
struct EpochReceipt {
uint256 rewardAmount;
uint256 totalEligibleWeightedStake;
bytes32 receiptHash;
string uri;
uint256 recordedAt;
}
uint256 public constant FENCHAIN_CHAIN_ID = 978;
uint256 public constant BPS = 10_000;
uint256 public constant ACC_REWARD_PRECISION = 1e24;
uint256 public constant EPOCH_DURATION = 30 days;
uint256 public constant PLAN_COUNT = 3;
IERC20 public immutable fen;
uint256 public immutable epochZeroStart;
Plan[3] private _plans;
Position[] private _positions;
uint256 public walletStakeCap;
uint256 public totalPrincipalStaked;
uint256 public totalActiveWeightedStake;
uint256 public totalRewardsRecorded;
uint256 public totalRewardsClaimed;
uint256 public accRewardPerWeightedStake;
uint256 public lastRolledEpoch;
uint256 public nextEpochToRecord;
mapping(address account => uint256 amount) public walletPrincipalStaked;
mapping(uint256 epochId => uint256 weight) public pendingActivationWeight;
mapping(uint256 epochId => uint256 weight) public pendingDeactivationWeight;
mapping(uint256 positionId => uint256 epochId) public positionDeactivationEpoch;
// The accumulator at an epoch boundary anchors both positions activating at
// that epoch and positions whose eligibility ended before that epoch.
mapping(uint256 epochId => uint256 accReward) public activationAccRewardPerWeightedStake;
mapping(uint256 epochId => bool recorded) public epochRecorded;
mapping(uint256 epochId => EpochReceipt receipt) private _epochReceipts;
event Staked(
uint256 indexed positionId,
address indexed account,
uint8 indexed planId,
uint256 amount,
uint256 weight,
uint256 activationEpoch,
uint256 unlockTime,
bytes32 receiptHash,
string uri
);
event EpochRewardRecorded(
uint256 indexed epochId,
uint256 rewardAmount,
uint256 totalEligibleWeightedStake,
bytes32 indexed receiptHash,
string uri
);
event RewardClaimed(uint256 indexed positionId, address indexed account, uint256 amount);
event PositionDeactivationScheduled(
uint256 indexed positionId,
address indexed account,
uint256 indexed deactivationEpoch,
uint256 weight
);
event StakeWithdrawn(uint256 indexed positionId, address indexed account, uint256 amount);
event WalletStakeCapUpdated(uint256 oldCap, uint256 newCap, bytes32 indexed receiptHash, string uri);
event NonFENRescued(
address indexed token,
address indexed destination,
uint256 amount,
bytes32 indexed receiptHash,
string uri
);
constructor(IERC20 fen_, address owner_, uint256 walletStakeCap_) Ownable(owner_) {
if (block.chainid != FENCHAIN_CHAIN_ID) {
revert WrongFenchain(block.chainid, FENCHAIN_CHAIN_ID);
}
if (address(fen_) == address(0)) revert ZeroAddress();
if (walletStakeCap_ == 0) revert ZeroAmount();
fen = fen_;
walletStakeCap = walletStakeCap_;
epochZeroStart = block.timestamp;
_plans[0] = Plan({lockDuration: 180 days, weightBps: 10_000, active: true});
_plans[1] = Plan({lockDuration: 360 days, weightBps: 12_500, active: true});
_plans[2] = Plan({lockDuration: 540 days, weightBps: 15_000, active: true});
}
/// @notice Pool authority is permanently bound to the genesis multisig.
function transferOwnership(address) public view override onlyOwner {
revert AuthorityImmutable();
}
/// @notice Pool authority cannot be abandoned after genesis.
function renounceOwnership() public view override onlyOwner {
revert AuthorityImmutable();
}
function stake(
uint8 planId,
uint256 amount,
bytes32 receiptHash,
string calldata uri
) external nonReentrant returns (uint256 positionId) {
Plan memory selectedPlan = _plan(planId);
if (amount == 0) revert ZeroAmount();
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
uint256 nextWalletStake = walletPrincipalStaked[msg.sender] + amount;
if (nextWalletStake > walletStakeCap) {
revert WalletStakeCapExceeded(msg.sender, nextWalletStake, walletStakeCap);
}
uint256 weight = (amount * selectedPlan.weightBps) / BPS;
uint256 activationEpoch = currentEpoch() + 1;
uint256 unlockTime = block.timestamp + selectedPlan.lockDuration;
walletPrincipalStaked[msg.sender] = nextWalletStake;
totalPrincipalStaked += amount;
pendingActivationWeight[activationEpoch] += weight;
positionId = _positions.length;
_positions.push(
Position({
owner: msg.sender,
planId: planId,
amount: amount,
weight: weight,
startTime: block.timestamp,
activationEpoch: activationEpoch,
unlockTime: unlockTime,
claimedRewards: 0,
withdrawn: false
})
);
fen.safeTransferFrom(msg.sender, address(this), amount);
emit Staked(positionId, msg.sender, planId, amount, weight, activationEpoch, unlockTime, receiptHash, uri);
}
function recordEpochReward(
uint256 epochId,
uint256 rewardAmount,
bytes32 receiptHash,
string calldata uri
) external nonReentrant onlyOwner {
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
if (epochRecorded[epochId]) revert EpochAlreadyRecorded(epochId);
if (epochId != nextEpochToRecord) revert EpochOutOfOrder(epochId, nextEpochToRecord);
uint256 epochEnd_ = epochEnd(epochId);
if (block.timestamp < epochEnd_) revert EpochNotComplete(epochId, epochEnd_);
_rollToEpoch(epochId);
if (rewardAmount > 0) {
if (totalActiveWeightedStake == 0) revert NoEligibleStake(epochId);
totalRewardsRecorded += rewardAmount;
accRewardPerWeightedStake += (rewardAmount * ACC_REWARD_PRECISION) / totalActiveWeightedStake;
fen.safeTransferFrom(msg.sender, address(this), rewardAmount);
}
epochRecorded[epochId] = true;
nextEpochToRecord = epochId + 1;
_epochReceipts[epochId] = EpochReceipt({
rewardAmount: rewardAmount,
totalEligibleWeightedStake: totalActiveWeightedStake,
receiptHash: receiptHash,
uri: uri,
recordedAt: block.timestamp
});
emit EpochRewardRecorded(epochId, rewardAmount, totalActiveWeightedStake, receiptHash, uri);
}
function claimReward(uint256 positionId) external nonReentrant returns (uint256 amount) {
Position storage stakedPosition = _position(positionId);
_requirePositionOwner(stakedPosition);
amount = _claimable(positionId, stakedPosition);
if (amount == 0) revert NothingClaimable(positionId);
stakedPosition.claimedRewards += amount;
totalRewardsClaimed += amount;
fen.safeTransfer(stakedPosition.owner, amount);
emit RewardClaimed(positionId, stakedPosition.owner, amount);
}
function withdraw(uint256 positionId) external nonReentrant returns (uint256 principal, uint256 reward) {
Position storage stakedPosition = _position(positionId);
_requirePositionOwner(stakedPosition);
if (stakedPosition.withdrawn) revert PositionWithdrawn(positionId);
if (block.timestamp < stakedPosition.unlockTime) revert StakeLocked(positionId, stakedPosition.unlockTime);
reward = _claimable(positionId, stakedPosition);
if (reward > 0) {
stakedPosition.claimedRewards += reward;
totalRewardsClaimed += reward;
fen.safeTransfer(stakedPosition.owner, reward);
emit RewardClaimed(positionId, stakedPosition.owner, reward);
}
principal = stakedPosition.amount;
stakedPosition.withdrawn = true;
walletPrincipalStaked[stakedPosition.owner] -= principal;
totalPrincipalStaked -= principal;
// Eligibility is epoch based: a position must remain staked through an
// epoch's end to earn that epoch. Scheduling the weight change at the
// withdrawal epoch preserves every already-completed epoch even when
// its reward receipt is recorded later. The max branch also makes the
// accounting safe if a future plan permits withdrawal before activation.
uint256 deactivationEpoch = currentEpoch();
if (deactivationEpoch < stakedPosition.activationEpoch) {
deactivationEpoch = stakedPosition.activationEpoch;
}
positionDeactivationEpoch[positionId] = deactivationEpoch;
pendingDeactivationWeight[deactivationEpoch] += stakedPosition.weight;
emit PositionDeactivationScheduled(
positionId,
stakedPosition.owner,
deactivationEpoch,
stakedPosition.weight
);
fen.safeTransfer(stakedPosition.owner, principal);
emit StakeWithdrawn(positionId, stakedPosition.owner, principal);
}
function setWalletStakeCap(
uint256 newWalletStakeCap,
bytes32 receiptHash,
string calldata uri
) external onlyOwner {
if (newWalletStakeCap == 0) revert ZeroAmount();
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
uint256 oldCap = walletStakeCap;
walletStakeCap = newWalletStakeCap;
emit WalletStakeCapUpdated(oldCap, newWalletStakeCap, receiptHash, uri);
}
function rescueNonFEN(
IERC20 token,
address destination,
uint256 amount,
bytes32 receiptHash,
string calldata uri
) external nonReentrant onlyOwner {
if (address(token) == address(0) || destination == address(0)) revert ZeroAddress();
if (destination == address(this)) revert SelfDestinationForbidden();
if (address(token) == address(fen)) revert FENRescueForbidden();
if (amount == 0) revert ZeroAmount();
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
token.safeTransfer(destination, amount);
emit NonFENRescued(address(token), destination, amount, receiptHash, uri);
}
function currentEpoch() public view returns (uint256) {
if (block.timestamp <= epochZeroStart) return 0;
return (block.timestamp - epochZeroStart) / EPOCH_DURATION;
}
function epochEnd(uint256 epochId) public view returns (uint256) {
return epochZeroStart + ((epochId + 1) * EPOCH_DURATION);
}
function positionCount() external view returns (uint256) {
return _positions.length;
}
function position(uint256 positionId) external view returns (Position memory position_) {
return _position(positionId);
}
function plan(uint8 planId) external view returns (Plan memory plan_) {
return _plan(planId);
}
function epochReceipt(uint256 epochId) external view returns (EpochReceipt memory receipt_) {
return _epochReceipts[epochId];
}
function claimable(uint256 positionId) external view returns (uint256) {
Position storage position_ = _position(positionId);
return _claimable(positionId, position_);
}
function currentFENBalance() external view returns (uint256) {
return fen.balanceOf(address(this));
}
function _rollToEpoch(uint256 epochId) private {
if (epochId == 0) return;
if (lastRolledEpoch != epochId - 1) {
revert EpochRollInvariant(epochId, lastRolledEpoch);
}
lastRolledEpoch = epochId;
uint256 activatingWeight = pendingActivationWeight[epochId];
uint256 deactivatingWeight = pendingDeactivationWeight[epochId];
uint256 availableWeight = totalActiveWeightedStake + activatingWeight;
if (deactivatingWeight > availableWeight) {
revert EpochWeightInvariant(epochId, availableWeight, deactivatingWeight);
}
totalActiveWeightedStake = availableWeight - deactivatingWeight;
activationAccRewardPerWeightedStake[epochId] = accRewardPerWeightedStake;
}
function _claimable(uint256 positionId, Position storage position_) private view returns (uint256) {
if (position_.activationEpoch > lastRolledEpoch) return 0;
uint256 activationAcc = activationAccRewardPerWeightedStake[position_.activationEpoch];
uint256 terminalAcc = accRewardPerWeightedStake;
uint256 deactivationEpoch = positionDeactivationEpoch[positionId];
if (deactivationEpoch != 0 && deactivationEpoch <= lastRolledEpoch) {
terminalAcc = activationAccRewardPerWeightedStake[deactivationEpoch];
}
if (terminalAcc <= activationAcc) return 0;
uint256 accumulated = (position_.weight * (terminalAcc - activationAcc)) / ACC_REWARD_PRECISION;
if (accumulated <= position_.claimedRewards) return 0;
return accumulated - position_.claimedRewards;
}
function _plan(uint8 planId) private view returns (Plan memory selectedPlan) {
if (planId >= PLAN_COUNT) revert BadPlan(planId);
selectedPlan = _plans[planId];
if (!selectedPlan.active) revert BadPlan(planId);
}
function _position(uint256 positionId) private view returns (Position storage position_) {
if (positionId >= _positions.length) revert PositionOutOfRange(positionId);
return _positions[positionId];
}
function _requirePositionOwner(Position storage position_) private view {
if (msg.sender != position_.owner) revert NotPositionOwner(msg.sender, position_.owner);
}
}
FENVestingVaultcontracts/presale/FENVestingVault.solSource
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol';
import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol';
import {ReentrancyGuard} from '@openzeppelin/contracts/utils/ReentrancyGuard.sol';
contract FENVestingVault is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
error WrongFenchain(uint256 actualChainId, uint256 requiredChainId);
error ZeroAddress();
error ZeroAmount();
error BadSchedule();
error EmptyReceiptHash();
error GrantOutOfRange(uint256 grantId);
error NotBeneficiary(address caller, address beneficiary);
error NothingClaimable(uint256 grantId);
error AllocationCapExceeded(uint256 requestedGranted, uint256 allocationCap);
error GrantReserveNotBacked(uint256 actualReserve, uint256 requiredReserve);
error FENRescueForbidden();
struct Grant {
address beneficiary;
uint256 totalAmount;
uint256 start;
uint256 cliff;
uint256 interval;
uint256 releaseBps;
uint256 initialBps;
uint256 claimed;
bytes32 receiptHash;
string uri;
}
uint256 public constant FENCHAIN_CHAIN_ID = 978;
uint256 public constant BPS = 10_000;
uint256 public constant MIN_CLIFF = 180 days;
uint256 public constant STANDARD_INTERVAL = 30 days;
uint256 public constant STANDARD_MONTHLY_BPS = 50;
IERC20 public immutable fen;
uint256 public immutable allocationCap;
string public vaultName;
uint256 public totalGranted;
uint256 public totalClaimed;
Grant[] private _grants;
event GrantCreated(
uint256 indexed grantId,
address indexed beneficiary,
uint256 totalAmount,
bytes32 indexed receiptHash,
string uri
);
event GrantClaimed(uint256 indexed grantId, address indexed beneficiary, uint256 amount);
event NonFENRescued(
address indexed token,
address indexed destination,
uint256 amount,
bytes32 indexed receiptHash,
string uri
);
constructor(
IERC20 fen_,
address owner_,
string memory vaultName_,
uint256 allocationCap_
) Ownable(owner_) {
if (block.chainid != FENCHAIN_CHAIN_ID) {
revert WrongFenchain(block.chainid, FENCHAIN_CHAIN_ID);
}
if (address(fen_) == address(0)) revert ZeroAddress();
if (allocationCap_ == 0) revert ZeroAmount();
fen = fen_;
vaultName = vaultName_;
allocationCap = allocationCap_;
}
function createGrant(
address beneficiary,
uint256 totalAmount,
uint256 start,
uint256 cliff,
uint256 interval,
uint256 releaseBps,
uint256 initialBps,
bytes32 receiptHash,
string calldata uri
) external onlyOwner returns (uint256 grantId) {
grantId = _createGrant(
beneficiary,
totalAmount,
start,
cliff,
interval,
releaseBps,
initialBps,
receiptHash,
uri
);
}
function createStandardGrant(
address beneficiary,
uint256 totalAmount,
uint256 start,
bytes32 receiptHash,
string calldata uri
) external onlyOwner returns (uint256 grantId) {
return _createGrant(
beneficiary,
totalAmount,
start,
MIN_CLIFF,
STANDARD_INTERVAL,
STANDARD_MONTHLY_BPS,
0,
receiptHash,
uri
);
}
function claim(uint256 grantId) external nonReentrant returns (uint256 amount) {
Grant storage grant_ = _grant(grantId);
if (msg.sender != grant_.beneficiary) revert NotBeneficiary(msg.sender, grant_.beneficiary);
amount = claimable(grantId);
if (amount == 0) revert NothingClaimable(grantId);
grant_.claimed += amount;
totalClaimed += amount;
fen.safeTransfer(grant_.beneficiary, amount);
emit GrantClaimed(grantId, grant_.beneficiary, amount);
}
function rescueNonFEN(
IERC20 token,
address destination,
uint256 amount,
bytes32 receiptHash,
string calldata uri
) external onlyOwner nonReentrant {
if (address(token) == address(0) || destination == address(0)) revert ZeroAddress();
if (address(token) == address(fen)) revert FENRescueForbidden();
if (amount == 0) revert ZeroAmount();
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
token.safeTransfer(destination, amount);
emit NonFENRescued(address(token), destination, amount, receiptHash, uri);
}
function grantCount() external view returns (uint256) {
return _grants.length;
}
function grant(uint256 grantId) external view returns (Grant memory grant_) {
return _grant(grantId);
}
function vested(uint256 grantId) public view returns (uint256) {
Grant storage grant_ = _grant(grantId);
if (block.timestamp < grant_.start) return 0;
uint256 vestedBps = grant_.initialBps;
uint256 cliffEnd = grant_.start + grant_.cliff;
if (block.timestamp >= cliffEnd) {
uint256 periods = ((block.timestamp - cliffEnd) / grant_.interval) + 1;
vestedBps += periods * grant_.releaseBps;
}
if (vestedBps > BPS) vestedBps = BPS;
return (grant_.totalAmount * vestedBps) / BPS;
}
function claimable(uint256 grantId) public view returns (uint256) {
Grant storage grant_ = _grant(grantId);
uint256 vestedAmount = vested(grantId);
if (vestedAmount <= grant_.claimed) return 0;
return vestedAmount - grant_.claimed;
}
function currentFENBalance() external view returns (uint256) {
return fen.balanceOf(address(this));
}
function _grant(uint256 grantId) private view returns (Grant storage grant_) {
if (grantId >= _grants.length) revert GrantOutOfRange(grantId);
return _grants[grantId];
}
function _createGrant(
address beneficiary,
uint256 totalAmount,
uint256 start,
uint256 cliff,
uint256 interval,
uint256 releaseBps,
uint256 initialBps,
bytes32 receiptHash,
string calldata uri
) private returns (uint256 grantId) {
if (beneficiary == address(0)) revert ZeroAddress();
if (totalAmount == 0) revert ZeroAmount();
if (receiptHash == bytes32(0)) revert EmptyReceiptHash();
if (cliff < MIN_CLIFF || interval == 0 || releaseBps == 0 || initialBps > BPS) {
revert BadSchedule();
}
if (releaseBps > BPS || initialBps + releaseBps > BPS) revert BadSchedule();
uint256 nextGranted = totalGranted + totalAmount;
if (nextGranted > allocationCap) {
revert AllocationCapExceeded(nextGranted, allocationCap);
}
uint256 requiredReserve = nextGranted - totalClaimed;
uint256 actualReserve = fen.balanceOf(address(this));
if (actualReserve < requiredReserve) {
revert GrantReserveNotBacked(actualReserve, requiredReserve);
}
grantId = _grants.length;
totalGranted = nextGranted;
_grants.push();
Grant storage grant_ = _grants[grantId];
grant_.beneficiary = beneficiary;
grant_.totalAmount = totalAmount;
grant_.start = start;
grant_.cliff = cliff;
grant_.interval = interval;
grant_.releaseBps = releaseBps;
grant_.initialBps = initialBps;
grant_.receiptHash = receiptHash;
grant_.uri = uri;
emit GrantCreated(grantId, beneficiary, totalAmount, receiptHash, uri);
}
}
MockStablecoincontracts/mocks/MockStablecoin.solSource
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol';
contract MockStablecoin is ERC20 {
uint8 private immutable _customDecimals;
constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) {
_customDecimals = decimals_;
}
function decimals() public view override returns (uint8) {
return _customDecimals;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}