// SPDX-License-Identifier: MIT pragma solidity 0.8.30; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IArbSysBlockNumber { function arbBlockNumber() external view returns (uint256); } interface IV2BuybackRouter { function WETH() external view returns (address); function swapExactETHForTokensSupportingFeeOnTransferTokens( uint256 amountOutMin, address[] calldata path, address to, uint256 deadline ) external payable; } /// @notice Fully funded daily ETH allocations; reporter-trusted accounting and snapshots. /// @dev No token issuance, custody of holder tokens, upgrade proxy, or reserve withdrawal. contract VellinDistributor is Ownable2Step, ReentrancyGuard, Pausable { uint256 public constant HOLDER_PERCENT = 30; uint256 public constant BURN_PERCENT = 30; uint256 public constant MAX_BATCH = 50; uint256 public constant PUSH_GAS = 50_000; uint256 public constant AUTO_RETRY_DELAY = 1 hours; address public constant BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD; IERC20 public immutable token; IV2BuybackRouter public immutable router; address public immutable team; bytes32 public immutable policyHash; address public keeper; address[] private buybackPath; struct Epoch { bytes32 root; bytes32 reportHash; bytes32 snapshotHash; uint64 snapshotBlock; bool funded; uint256 netProfit; uint256 holderPool; uint256 remaining; } struct Report { uint64 day; uint64 snapshotBlock; bytes32 snapshotHash; bytes32 root; bytes32 reportHash; int256 realisedPnl; uint256 tradingFees; uint256 infrastructureCosts; } struct Payment { address account; uint256 amount; bytes32[] proof; } mapping(uint64 => Epoch) public epochs; mapping(uint64 => mapping(address => bool)) public processed; mapping(address => uint256) public credit; mapping(address => uint256) public lastPaymentAttempt; uint64 public lastDay; uint64 public lastSnapshotBlock; bool public hasEpoch; uint256 public holderReserve; uint256 public buybackReserve; uint256 public creditReserve; error InvalidConfiguration(); error InvalidEpoch(); error InvalidFunding(); error InvalidProof(); error AlreadyProcessed(); error InvalidBatch(); error InvalidPayment(); error UnauthorizedKeeper(); error InvalidBuyback(); error DirectTransferDisabled(); error RenounceDisabled(); event EpochFunded( uint64 indexed day, bytes32 root, uint64 snapshotBlock, bytes32 snapshotHash, bytes32 reportHash, int256 realisedPnl, uint256 tradingFees, uint256 infrastructureCosts, uint256 netProfit, uint256 holders, uint256 buyback, uint256 teamAmount ); event HolderProcessed(uint64 indexed day, address indexed account, uint256 amount); event PaymentSent(address indexed beneficiary, address indexed destination, uint256 amount); event PaymentDeferred(address indexed beneficiary, uint256 amount); event BuybackBurned(uint256 ethSpent, uint256 tokensSentToBurn, uint256 minimumOut); event KeeperUpdated(address indexed keeper); constructor( address initialOwner, address token_, address router_, address team_, address keeper_, address[] memory path_, bytes32 policyHash_ ) Ownable(initialOwner) { if ( token_.code.length == 0 || router_.code.length == 0 || team_ == address(0) || team_ == BURN_ADDRESS || keeper_ == address(0) || policyHash_ == bytes32(0) || path_.length < 2 || path_.length > 5 || path_[path_.length - 1] != token_ || path_[0] != IV2BuybackRouter(router_).WETH() ) revert InvalidConfiguration(); for (uint256 i; i < path_.length; ++i) { if (path_[i] == address(0)) revert InvalidConfiguration(); } token = IERC20(token_); router = IV2BuybackRouter(router_); team = team_; keeper = keeper_; buybackPath = path_; policyHash = policyHash_; } function path() external view returns (address[] memory) { return buybackPath; } function split(int256 realisedPnl, uint256 tradingFees, uint256 infrastructureCosts) public pure returns (uint256 net, uint256 holders, uint256 burn, uint256 teamAmount) { uint256 costs = tradingFees + infrastructureCosts; if (realisedPnl <= 0 || uint256(realisedPnl) <= costs) return (0, 0, 0, 0); net = uint256(realisedPnl) - costs; holders = Math.mulDiv(net, HOLDER_PERCENT, 100); burn = Math.mulDiv(net, BURN_PERCENT, 100); teamAmount = net - holders - burn; // At most two wei of split rounding. } /// @dev Nitro's NUMBER opcode is a parent-chain height; snapshots use L2 RPC block numbers. function _currentChainBlock() private view returns (uint256) { if (block.chainid == 4663 || block.chainid == 46630) { // Fail closed if the required Robinhood/Arbitrum system precompile is unavailable. return IArbSysBlockNumber(address(100)).arbBlockNumber(); } return block.number; } /// @notice Developer deposits exactly the positive, approved daily net result. /// @dev Report contents and end-of-day snapshot accuracy remain operator-attested. function fundEpoch(Report calldata r) external payable nonReentrant onlyOwner whenNotPaused { if ( r.day >= block.timestamp / 1 days || (hasEpoch && r.day <= lastDay) || epochs[r.day].funded || r.snapshotBlock >= _currentChainBlock() || r.snapshotBlock == 0 || (hasEpoch && r.snapshotBlock <= lastSnapshotBlock) || r.snapshotHash == bytes32(0) || r.reportHash == bytes32(0) ) revert InvalidEpoch(); (uint256 net, uint256 holders, uint256 burn, uint256 teamAmount) = split(r.realisedPnl, r.tradingFees, r.infrastructureCosts); if (msg.value != net || (holders == 0) != (r.root == bytes32(0))) revert InvalidFunding(); epochs[r.day] = Epoch(r.root, r.reportHash, r.snapshotHash, r.snapshotBlock, true, net, holders, holders); lastDay = r.day; lastSnapshotBlock = r.snapshotBlock; hasEpoch = true; holderReserve += holders; buybackReserve += burn; credit[team] += teamAmount; creditReserve += teamAmount; emit EpochFunded( r.day, r.root, r.snapshotBlock, r.snapshotHash, r.reportHash, r.realisedPnl, r.tradingFees, r.infrastructureCosts, net, holders, burn, teamAmount ); _pushCredit(team); } /// @notice Anyone can deliver valid payments to their fixed beneficiaries. Replays skip. function distribute(uint64 day, Payment[] calldata payments) external nonReentrant { if (payments.length == 0 || payments.length > MAX_BATCH) revert InvalidBatch(); for (uint256 i; i < payments.length; ++i) { Payment calldata p = payments[i]; if (processed[day][p.account]) continue; _creditHolder(day, p.account, p.amount, p.proof); _pushCredit(p.account); } } /// @notice Holder fallback, including contracts that cannot accept the bounded push. function claim(uint64 day, uint256 amount, bytes32[] calldata proof, address payable destination) external nonReentrant { if (processed[day][msg.sender]) revert AlreadyProcessed(); _creditHolder(day, msg.sender, amount, proof); _withdrawCredit(msg.sender, destination); } function retryPayment(address account) external nonReentrant { _pushCredit(account); } /// @notice Bounded keeper recovery; persistently rejecting wallets are retried at most hourly. /// @dev Holder withdrawals and individual caller-funded retries remain immediately accessible. function retryPayments(address[] calldata accounts) external nonReentrant { if (accounts.length == 0 || accounts.length > MAX_BATCH) revert InvalidBatch(); for (uint256 i; i < accounts.length; ++i) { address account = accounts[i]; if (credit[account] != 0 && block.timestamp >= lastPaymentAttempt[account] + AUTO_RETRY_DELAY) { _pushCredit(account); } } } function withdrawCredit(address payable destination) external nonReentrant { _withdrawCredit(msg.sender, destination); } function _creditHolder(uint64 day, address account, uint256 amount, bytes32[] calldata proof) private { Epoch storage e = epochs[day]; if ( !e.funded || account == address(0) || account == BURN_ADDRESS || account == address(this) || amount == 0 || amount > e.remaining || !MerkleProof.verifyCalldata(proof, e.root, leaf(day, account, amount)) ) revert InvalidProof(); processed[day][account] = true; e.remaining -= amount; holderReserve -= amount; credit[account] += amount; creditReserve += amount; emit HolderProcessed(day, account, amount); } function leaf(uint64 day, address account, uint256 amount) public view returns (bytes32) { // OpenZeppelin StandardMerkleTree double-hashed ABI encoding; domain-bound. return keccak256(bytes.concat(keccak256(abi.encode(block.chainid, address(this), day, account, amount)))); } function _pushCredit(address account) private { uint256 amount = credit[account]; if (amount == 0) return; lastPaymentAttempt[account] = block.timestamp; credit[account] = 0; creditReserve -= amount; bool sent; // No returndata copy: even a hostile receiver cannot return-bomb the batch. assembly ("memory-safe") { sent := call(50000, account, amount, 0, 0, 0, 0) } if (sent) { emit PaymentSent(account, account, amount); } else { credit[account] = amount; creditReserve += amount; emit PaymentDeferred(account, amount); } } function _withdrawCredit(address account, address payable destination) private { uint256 amount = credit[account]; if (amount == 0 || destination == address(0) || destination == BURN_ADDRESS || destination == address(this)) { revert InvalidPayment(); } credit[account] = 0; creditReserve -= amount; (bool sent,) = destination.call{value: amount}(""); if (!sent) revert InvalidPayment(); emit PaymentSent(account, destination, amount); } /// @notice Uses only the 30% burn reserve; swap failure leaves holder payments available. /// @dev Target V2 router/path must be verified for the actual launch market before deployment. function executeBuyback(uint256 ethAmount, uint256 minimumTokens, uint256 deadline) external nonReentrant whenNotPaused { if (msg.sender != keeper && msg.sender != owner()) revert UnauthorizedKeeper(); if ( ethAmount == 0 || ethAmount > buybackReserve || minimumTokens == 0 || deadline < block.timestamp || deadline > block.timestamp + 10 minutes ) revert InvalidBuyback(); buybackReserve -= ethAmount; uint256 beforeBurn = token.balanceOf(BURN_ADDRESS); router.swapExactETHForTokensSupportingFeeOnTransferTokens{value: ethAmount}( minimumTokens, buybackPath, BURN_ADDRESS, deadline ); uint256 burned = token.balanceOf(BURN_ADDRESS) - beforeBurn; if (burned < minimumTokens) revert InvalidBuyback(); emit BuybackBurned(ethAmount, burned, minimumTokens); } function reserved() public view returns (uint256) { return holderReserve + buybackReserve + creditReserve; } function setKeeper(address next) external onlyOwner { if (next == address(0)) revert InvalidConfiguration(); keeper = next; emit KeeperUpdated(next); } // Pause new reports and buybacks; funded holder claims/credits remain accessible. function pause() external onlyOwner { _pause(); } function unpause() external onlyOwner { _unpause(); } function renounceOwnership() public view override onlyOwner { revert RenounceDisabled(); } receive() external payable { revert DirectTransferDisabled(); } }