// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; 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 {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; interface INativeFeeEscrow { function claim() external; function balanceOf(address recipient) external view returns (uint256); } /// @notice ETH-only treasury for a reviewed, non-mintable, standard 18-decimal ERC20. /// @dev No owner or upgrade. External token/escrow/chain assumptions still require review. contract FoldTreasury is ReentrancyGuard { using SafeERC20 for IERC20; address public constant DEAD = 0x000000000000000000000000000000000000dEaD; uint256 public constant EXPECTED_SUPPLY = 1_000_000_000 ether; uint256 public constant EXIT_FEE_BPS = 200; uint256 public constant BPS = 10_000; uint256 public constant COLLECT_GAS_LIMIT = 150_000; IERC20 public immutable token; address public immutable feeEscrow; uint256 public totalFeesCollected; uint256 public totalRedeemed; uint256 public totalEthPaid; uint256 public totalEthRetained; error InvalidToken(); error InvalidEscrow(); error SupplyChanged(); error InvalidAmount(); error InvalidRecipient(); error Expired(); error ZeroPayout(); error MinimumNotMet(uint256 actual, uint256 minimum); error TransferMismatch(); error EthTransferFailed(); event Received(address indexed sender, uint256 amount); event Collected(uint256 amount); event CollectionFailed(); event StrayBurned(uint256 amount); event Redeemed(address indexed holder, address indexed recipient, uint256 burned, uint256 paid, uint256 retained); constructor(address token_, address feeEscrow_) { if (token_.code.length == 0) revert InvalidToken(); uint256 supply = IERC20(token_).totalSupply(); // Pons V2 permits voluntary burns, including before the treasury is deployed. // The reviewed token must have no post-genesis mint path; the cap alone cannot prove this. if (IERC20Metadata(token_).decimals() != 18 || supply == 0 || supply > EXPECTED_SUPPLY) revert InvalidToken(); if (feeEscrow_ != address(0) && feeEscrow_.code.length == 0) revert InvalidEscrow(); token = IERC20(token_); feeEscrow = feeEscrow_; } receive() external payable { emit Received(msg.sender, msg.value); } function treasury() public view returns (uint256) { return address(this).balance; } /// @dev Stray tokens are forfeited, not a deposit. They may be burned by anyone. function effectiveSupply() public view returns (uint256) { uint256 supply = token.totalSupply(); if (supply > EXPECTED_SUPPLY) revert SupplyChanged(); return supply - token.balanceOf(DEAD) - token.balanceOf(address(this)); } function backing() external view returns (uint256) { uint256 supply = effectiveSupply(); return supply == 0 ? 0 : Math.mulDiv(treasury(), 1 ether, supply); } /// @notice Quote uses ETH already held, excluding unclaimed and unswept fees. function quoteRedeem(uint256 amount) public view returns (uint256 gross, uint256 retained, uint256 net) { uint256 supply = effectiveSupply(); if (amount > supply) revert InvalidAmount(); if (amount == 0 || supply == 0) return (0, 0, 0); gross = Math.mulDiv(amount, treasury(), supply); net = Math.mulDiv(gross, BPS - EXIT_FEE_BPS, BPS); retained = gross - net; } function collect() external nonReentrant returns (uint256) { return _collect(); } /// @dev An escrow failure (including gas/returndata grief) cannot spend the pot. function _collect() private returns (uint256 received) { address escrow = feeEscrow; if (escrow == address(0)) return 0; uint256 beforeBalance = treasury(); bytes memory data = abi.encodeCall(INativeFeeEscrow.claim, ()); bool ok; assembly ("memory-safe") { ok := call(150000, escrow, 0, add(data, 32), mload(data), 0, 0) } if (!ok) { emit CollectionFailed(); return 0; } received = treasury() - beforeBalance; totalFeesCollected += received; if (received != 0) emit Collected(received); } function burnStray() external nonReentrant { uint256 amount = token.balanceOf(address(this)); if (amount == 0) return; uint256 beforeDead = token.balanceOf(DEAD); token.safeTransfer(DEAD, amount); if (token.balanceOf(DEAD) - beforeDead != amount || token.balanceOf(address(this)) != 0) revert TransferMismatch(); emit StrayBurned(amount); } /// @notice Approve exactly `amount` first. Burns irreversibly; no request queue. /// @dev If the final outstanding token is redeemed, retained ETH stays forever. function redeem(uint256 amount, uint256 minOut, address payable recipient, uint256 deadline) external nonReentrant returns (uint256 paid) { if (block.timestamp > deadline) revert Expired(); if (recipient == address(0) || recipient == address(this) || recipient == DEAD) revert InvalidRecipient(); if (amount == 0 || amount > token.balanceOf(msg.sender)) revert InvalidAmount(); _collect(); (, uint256 retained, uint256 net) = quoteRedeem(amount); if (net == 0) revert ZeroPayout(); if (net < minOut) revert MinimumNotMet(net, minOut); uint256 beforeDead = token.balanceOf(DEAD); uint256 beforeSupply = token.totalSupply(); token.safeTransferFrom(msg.sender, DEAD, amount); if (token.balanceOf(DEAD) - beforeDead != amount || token.totalSupply() != beforeSupply) revert TransferMismatch(); totalRedeemed += amount; totalEthPaid += net; totalEthRetained += retained; (bool ok,) = recipient.call{value: net}(""); if (!ok) revert EthTransferFailed(); emit Redeemed(msg.sender, recipient, amount, net, retained); return net; } }