diff --git a/contracts/GOV_Token.sol b/contracts/GOV_Token.sol new file mode 100644 index 00000000..01bf3479 --- /dev/null +++ b/contracts/GOV_Token.sol @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title GOV_Token + * @dev BOXMKT — BOXMEOUT governance ERC-20 token. + * + * Tokenomics: + * - Max supply: 100 000 000 BOXMKT + * - Minting: owner + authorised minters, capped at MAX_SUPPLY + * - Vesting: linear release schedule per beneficiary + * - Emissions: weekly mint to a designated reward pool (cooldown enforced) + */ +contract GOV_Token { + // ── ERC-20 metadata ────────────────────────────────────────────────────── + string public constant name = "BOXMEOUT Governance Token"; + string public constant symbol = "BOXMKT"; + uint8 public constant decimals = 18; + + uint256 public constant MAX_SUPPLY = 100_000_000 * 1e18; + + // ── Supply / balances ──────────────────────────────────────────────────── + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + // ── Access control ─────────────────────────────────────────────────────── + address public owner; + mapping(address => bool) public isMinter; + + // ── Vesting ────────────────────────────────────────────────────────────── + struct VestingSchedule { + uint256 totalAmount; + uint256 released; + uint64 startTime; + uint64 duration; + bool revocable; + bool revoked; + } + mapping(address => VestingSchedule) public vestingSchedules; + + // ── Emissions ──────────────────────────────────────────────────────────── + address public emissionReceiver; + uint256 public emissionPerCycle; + uint64 public emissionCooldown; + uint64 public lastEmissionAt; + + // ── Events ─────────────────────────────────────────────────────────────── + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner_, address indexed spender, uint256 value); + event Minted(address indexed to, uint256 amount); + event Burned(address indexed from, uint256 amount); + event MinterGranted(address indexed minter); + event MinterRevoked(address indexed minter); + event VestingCreated(address indexed beneficiary, uint256 totalAmount, uint64 startTime, uint64 duration); + event VestingReleased(address indexed beneficiary, uint256 amount); + event VestingRevoked(address indexed beneficiary, uint256 amountBurned); + event EmissionMinted(address indexed receiver, uint256 amount); + event EmissionParamsUpdated(address receiver, uint256 perCycle, uint64 cooldown); + + // ── Modifiers ──────────────────────────────────────────────────────────── + modifier onlyOwner() { + require(msg.sender == owner, "BOXMKT: not owner"); + _; + } + modifier onlyMinter() { + require(isMinter[msg.sender] || msg.sender == owner, "BOXMKT: not minter"); + _; + } + + // ── Constructor ────────────────────────────────────────────────────────── + constructor(address _emissionReceiver, uint256 _emissionPerCycle, uint256 _initialSupply) { + require(_emissionReceiver != address(0), "BOXMKT: zero emission receiver"); + require(_initialSupply <= MAX_SUPPLY, "BOXMKT: exceeds max supply"); + + owner = msg.sender; + emissionReceiver = _emissionReceiver; + emissionPerCycle = _emissionPerCycle; + emissionCooldown = 7 days; + lastEmissionAt = uint64(block.timestamp); + + if (_initialSupply > 0) _mint(msg.sender, _initialSupply); + } + + // ── ERC-20 core ────────────────────────────────────────────────────────── + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= amount, "BOXMKT: insufficient allowance"); + allowance[from][msg.sender] = allowed - amount; + } + _transfer(from, to, amount); + return true; + } + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + function increaseAllowance(address spender, uint256 added) external returns (bool) { + allowance[msg.sender][spender] += added; + emit Approval(msg.sender, spender, allowance[msg.sender][spender]); + return true; + } + function decreaseAllowance(address spender, uint256 sub) external returns (bool) { + uint256 cur = allowance[msg.sender][spender]; + require(cur >= sub, "BOXMKT: decreased below zero"); + allowance[msg.sender][spender] = cur - sub; + emit Approval(msg.sender, spender, allowance[msg.sender][spender]); + return true; + } + + // ── Minting & burning ──────────────────────────────────────────────────── + function mint(address to, uint256 amount) external onlyMinter { + _mint(to, amount); + emit Minted(to, amount); + } + function burn(uint256 amount) external { + require(balanceOf[msg.sender] >= amount, "BOXMKT: burn exceeds balance"); + balanceOf[msg.sender] -= amount; + totalSupply -= amount; + emit Transfer(msg.sender, address(0), amount); + emit Burned(msg.sender, amount); + } + function grantMinter(address m) external onlyOwner { + isMinter[m] = true; + emit MinterGranted(m); + } + function revokeMinter(address m) external onlyOwner { + isMinter[m] = false; + emit MinterRevoked(m); + } + + // ── Vesting ────────────────────────────────────────────────────────────── + /** + * @notice Create a linear vesting schedule. Mints tokens into escrow. + */ + function createVesting( + address beneficiary, + uint256 totalAmount, + uint64 startTime, + uint64 duration, + bool revocable + ) external onlyOwner { + require(beneficiary != address(0), "BOXMKT: zero beneficiary"); + require(duration > 0 && totalAmount > 0, "BOXMKT: invalid params"); + require(vestingSchedules[beneficiary].totalAmount == 0, "BOXMKT: schedule exists"); + + _mint(address(this), totalAmount); // escrowed in contract + + vestingSchedules[beneficiary] = VestingSchedule({ + totalAmount: totalAmount, + released: 0, + startTime: startTime, + duration: duration, + revocable: revocable, + revoked: false + }); + + emit VestingCreated(beneficiary, totalAmount, startTime, duration); + } + + /// @notice Returns tokens currently claimable by beneficiary. + function vestedAmount(address beneficiary) public view returns (uint256) { + VestingSchedule storage s = vestingSchedules[beneficiary]; + if (s.totalAmount == 0 || s.revoked) return 0; + + uint64 ts = uint64(block.timestamp); + if (ts < s.startTime) return 0; + + uint64 elapsed = ts - s.startTime; + uint256 vested = elapsed >= s.duration + ? s.totalAmount + : (s.totalAmount * elapsed) / s.duration; + + return vested > s.released ? vested - s.released : 0; + } + + /// @notice Beneficiary claims their unlocked tokens. + function releaseVested() external { + uint256 amount = vestedAmount(msg.sender); + require(amount > 0, "BOXMKT: nothing to release"); + vestingSchedules[msg.sender].released += amount; + _transfer(address(this), msg.sender, amount); + emit VestingReleased(msg.sender, amount); + } + + /// @notice Owner revokes a revocable schedule; unvested tokens are burned. + function revokeVesting(address beneficiary) external onlyOwner { + VestingSchedule storage s = vestingSchedules[beneficiary]; + require(s.revocable && !s.revoked, "BOXMKT: not revocable or already revoked"); + + // Let beneficiary claim already-vested portion + uint256 claimable = vestedAmount(beneficiary); + if (claimable > 0) { + s.released += claimable; + _transfer(address(this), beneficiary, claimable); + emit VestingReleased(beneficiary, claimable); + } + + uint256 unvested = s.totalAmount - s.released; + s.revoked = true; + + if (unvested > 0) { + balanceOf[address(this)] -= unvested; + totalSupply -= unvested; + emit Transfer(address(this), address(0), unvested); + } + + emit VestingRevoked(beneficiary, unvested); + } + + // ── Emissions ──────────────────────────────────────────────────────────── + /// @notice Mint one emission cycle to the receiver (callable by anyone once cooldown passes). + function emitTokens() external { + require( + block.timestamp >= uint256(lastEmissionAt) + uint256(emissionCooldown), + "BOXMKT: cooldown active" + ); + require(emissionReceiver != address(0) && emissionPerCycle > 0, "BOXMKT: emission not configured"); + + lastEmissionAt = uint64(block.timestamp); + _mint(emissionReceiver, emissionPerCycle); + emit EmissionMinted(emissionReceiver, emissionPerCycle); + } + + function setEmissionParams(address receiver, uint256 perCycle, uint64 cooldown) external onlyOwner { + require(receiver != address(0) && cooldown > 0, "BOXMKT: invalid params"); + emissionReceiver = receiver; + emissionPerCycle = perCycle; + emissionCooldown = cooldown; + emit EmissionParamsUpdated(receiver, perCycle, cooldown); + } + + // ── Admin ──────────────────────────────────────────────────────────────── + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), "BOXMKT: zero address"); + owner = newOwner; + } + + // ── Internal ───────────────────────────────────────────────────────────── + function _transfer(address from, address to, uint256 amount) internal { + require(to != address(0), "BOXMKT: transfer to zero"); + require(balanceOf[from] >= amount, "BOXMKT: insufficient balance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } + function _mint(address to, uint256 amount) internal { + require(to != address(0), "BOXMKT: mint to zero"); + require(totalSupply + amount <= MAX_SUPPLY, "BOXMKT: exceeds max supply"); + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } +} diff --git a/contracts/Timelock.sol b/contracts/Timelock.sol new file mode 100644 index 00000000..b64cd156 --- /dev/null +++ b/contracts/Timelock.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title Timelock + * @dev 2-day timelock executor for BOXMEOUT governance proposals. + * + * Flow: + * 1. Voting contract calls queue() once a proposal passes. + * 2. After MIN_DELAY (2 days) the executor calls execute(). + * 3. Owner or proposer can cancel() before execution. + * + * Security: + * - CEI: status → Executed BEFORE external call in execute(). + * - Each txHash executes at most once. + * - Execution window: MIN_DELAY … MIN_DELAY + GRACE_PERIOD (14 days). + */ +contract Timelock { + uint256 public constant MIN_DELAY = 2 days; + uint256 public constant MAX_DELAY = 30 days; + uint256 public constant GRACE_PERIOD = 14 days; + + address public owner; + address public proposer; + address public executor; + uint256 public delay; + + enum TxStatus { Pending, Queued, Executed, Cancelled } + + struct QueuedTx { + address target; + uint256 value; + bytes data; + uint256 eta; + TxStatus status; + } + + mapping(bytes32 => QueuedTx) public queuedTxs; + + event TransactionQueued(bytes32 indexed txHash, address indexed target, uint256 value, bytes data, uint256 eta); + event TransactionExecuted(bytes32 indexed txHash, address indexed target, uint256 value, bytes data); + event TransactionCancelled(bytes32 indexed txHash); + event DelayUpdated(uint256 oldDelay, uint256 newDelay); + event ProposerUpdated(address indexed oldProposer, address indexed newProposer); + event ExecutorUpdated(address indexed oldExecutor, address indexed newExecutor); + + modifier onlyOwner() { + require(msg.sender == owner, "Timelock: not owner"); + _; + } + modifier onlyProposer() { + require(msg.sender == proposer || msg.sender == owner, "Timelock: not proposer"); + _; + } + modifier onlyExecutor() { + require(msg.sender == executor || msg.sender == owner, "Timelock: not executor"); + _; + } + + constructor(address _proposer, address _executor, uint256 _delay) { + require(_proposer != address(0), "Timelock: zero proposer"); + require(_executor != address(0), "Timelock: zero executor"); + require(_delay >= MIN_DELAY && _delay <= MAX_DELAY, "Timelock: invalid delay"); + owner = msg.sender; + proposer = _proposer; + executor = _executor; + delay = _delay; + } + + /** + * @notice Queue a transaction for delayed execution. + * @param eta Desired execution timestamp (must be >= block.timestamp + delay). + */ + function queue( + address target, + uint256 value, + bytes calldata data, + uint256 eta + ) external onlyProposer returns (bytes32 txHash) { + require(target != address(0), "Timelock: zero target"); + require(eta >= block.timestamp + delay, "Timelock: eta too early"); + require(eta <= block.timestamp + delay + GRACE_PERIOD,"Timelock: eta too late"); + + txHash = _txHash(target, value, data, eta); + require(queuedTxs[txHash].status != TxStatus.Queued, "Timelock: already queued"); + + queuedTxs[txHash] = QueuedTx({ + target: target, + value: value, + data: data, + eta: eta, + status: TxStatus.Queued + }); + + emit TransactionQueued(txHash, target, value, data, eta); + } + + /** + * @notice Execute a queued transaction after its delay has elapsed. + * @dev CEI: state updated to Executed BEFORE the external call. + */ + function execute(bytes32 txHash) + external payable onlyExecutor + returns (bytes memory returnData) + { + QueuedTx storage tx_ = queuedTxs[txHash]; + require(tx_.status == TxStatus.Queued, "Timelock: not queued"); + require(block.timestamp >= tx_.eta, "Timelock: delay not elapsed"); + require(block.timestamp <= tx_.eta + GRACE_PERIOD, "Timelock: tx expired"); + + // CEI — update state before external call + tx_.status = TxStatus.Executed; + + bool success; + (success, returnData) = tx_.target.call{value: tx_.value}(tx_.data); + require(success, "Timelock: execution failed"); + + emit TransactionExecuted(txHash, tx_.target, tx_.value, tx_.data); + } + + /// @notice Cancel a queued transaction before it executes. + function cancel(bytes32 txHash) external onlyProposer { + QueuedTx storage tx_ = queuedTxs[txHash]; + require(tx_.status == TxStatus.Queued, "Timelock: not queued"); + tx_.status = TxStatus.Cancelled; + emit TransactionCancelled(txHash); + } + + /// @notice Returns true when txHash is past its eta and ready to execute. + function isReady(bytes32 txHash) external view returns (bool) { + QueuedTx storage tx_ = queuedTxs[txHash]; + return tx_.status == TxStatus.Queued && block.timestamp >= tx_.eta; + } + + /// @notice Compute the deterministic hash for a transaction tuple. + function hashTransaction(address target, uint256 value, bytes calldata data, uint256 eta) + external pure returns (bytes32) + { + return _txHash(target, value, data, eta); + } + + // Admin + function setDelay(uint256 newDelay) external onlyOwner { + require(newDelay >= MIN_DELAY && newDelay <= MAX_DELAY, "Timelock: invalid delay"); + emit DelayUpdated(delay, newDelay); + delay = newDelay; + } + function setProposer(address p) external onlyOwner { + require(p != address(0), "Timelock: zero address"); + emit ProposerUpdated(proposer, p); + proposer = p; + } + function setExecutor(address e) external onlyOwner { + require(e != address(0), "Timelock: zero address"); + emit ExecutorUpdated(executor, e); + executor = e; + } + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), "Timelock: zero address"); + owner = newOwner; + } + + receive() external payable {} + + function _txHash(address target, uint256 value, bytes memory data, uint256 eta) + internal pure returns (bytes32) + { + return keccak256(abi.encode(target, value, data, eta)); + } +} diff --git a/contracts/Voting.sol b/contracts/Voting.sol new file mode 100644 index 00000000..09cb3ee7 --- /dev/null +++ b/contracts/Voting.sol @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./GOV_Token.sol"; +import "./Timelock.sol"; + +/** + * @title Voting + * @dev veToken vote-escrow governance for BOXMEOUT. + * + * ## veToken mechanics + * - Holders lock BOXMKT for 1–4 years. + * - Voting power = lockedAmount * lockYears (1x–4x multiplier). + * - Locked tokens cannot be transferred until the lock expires. + * - Expired locks can be withdrawn at any time. + * + * ## Proposal lifecycle + * - Any holder with >= PROPOSAL_THRESHOLD veTokens can submit a proposal. + * - Voting period: 5 days minimum (configurable). + * - Quorum: 4% of total veToken supply must vote YES. + * - A passing proposal is queued in the Timelock; executed after 2-day delay. + * - All votes are stored immutably on-chain for audit purposes. + * + * ## Security + * - require_auth equivalent: only lock owner can vote / unlock. + * - CEI in queueProposal: status updated before Timelock.queue() call. + */ +contract Voting { + // ── Constants ──────────────────────────────────────────────────────────── + uint256 public constant MIN_LOCK_SECS = 365 days; // 1 year + uint256 public constant MAX_LOCK_SECS = 4 * 365 days; // 4 years + uint256 public constant VOTING_PERIOD = 5 days; + uint256 public constant PROPOSAL_THRESHOLD = 1_000 * 1e18; // 1 000 BOXMKT locked + uint256 public constant QUORUM_BPS = 400; // 4% of total veSupply + + // ── State ──────────────────────────────────────────────────────────────── + GOV_Token public govToken; + Timelock public timelock; + address public owner; + + // veToken tracking + struct Lock { + uint256 amount; // BOXMKT locked + uint64 unlockTime; // unix timestamp + uint256 votingPower; // cached at lock time: amount * years + } + mapping(address => Lock) public locks; + uint256 public totalVeSupply; // sum of all active voting powers + + // Proposals + enum ProposalStatus { Pending, Active, Passed, Failed, Queued, Executed, Cancelled } + + struct Proposal { + uint256 id; + address proposer; + string description; + address target; // contract to call on execution + bytes callData; // encoded function call + uint256 value; // ETH to forward + uint64 startTime; + uint64 endTime; + uint256 yesVotes; + uint256 noVotes; + ProposalStatus status; + bytes32 timelockTxHash; // set after queueing + } + + uint256 public proposalCount; + mapping(uint256 => Proposal) public proposals; + + // Vote record: proposalId → voter → voted + mapping(uint256 => mapping(address => bool)) public hasVoted; + + // Historical vote log for audit trail + struct VoteRecord { + uint256 proposalId; + address voter; + bool support; + uint256 weight; + uint64 timestamp; + } + VoteRecord[] public voteHistory; + + // ── Events ─────────────────────────────────────────────────────────────── + event Locked(address indexed user, uint256 amount, uint64 unlockTime, uint256 votingPower); + event Unlocked(address indexed user, uint256 amount); + event ProposalCreated(uint256 indexed id, address indexed proposer, string description, uint64 endTime); + event VoteCast(uint256 indexed proposalId, address indexed voter, bool support, uint256 weight); + event ProposalQueued(uint256 indexed id, bytes32 timelockTxHash, uint256 eta); + event ProposalExecuted(uint256 indexed id); + event ProposalCancelled(uint256 indexed id); + + // ── Modifiers ──────────────────────────────────────────────────────────── + modifier onlyOwner() { + require(msg.sender == owner, "Voting: not owner"); + _; + } + + // ── Constructor ────────────────────────────────────────────────────────── + constructor(address _govToken, address _timelock) { + require(_govToken != address(0), "Voting: zero govToken"); + require(_timelock != address(0), "Voting: zero timelock"); + owner = msg.sender; + govToken = GOV_Token(_govToken); + timelock = Timelock(_timelock); + } + + // ── veToken: lock / unlock ──────────────────────────────────────────────── + /** + * @notice Lock `amount` BOXMKT for `lockSecs` seconds (1–4 years). + * @dev Caller must approve this contract to spend `amount` BOXMKT first. + */ + function lock(uint256 amount, uint256 lockSecs) external { + require(amount > 0, "Voting: zero amount"); + require(lockSecs >= MIN_LOCK_SECS, "Voting: lock too short"); + require(lockSecs <= MAX_LOCK_SECS, "Voting: lock too long"); + require(locks[msg.sender].amount == 0, "Voting: already locked — use extend"); + + govToken.transferFrom(msg.sender, address(this), amount); + + // Voting power: amount * (lockYears) where lockYears = lockSecs / 1 year (1–4) + uint256 lockYears = lockSecs / 365 days; // integer 1–4 + if (lockYears == 0) lockYears = 1; + uint256 votingPower = amount * lockYears; + + uint64 unlockTime = uint64(block.timestamp + lockSecs); + locks[msg.sender] = Lock({ amount: amount, unlockTime: unlockTime, votingPower: votingPower }); + totalVeSupply += votingPower; + + emit Locked(msg.sender, amount, unlockTime, votingPower); + } + + /** + * @notice Extend an existing lock to increase voting power. + * @dev New `lockSecs` must result in an unlockTime later than the current one. + */ + function extendLock(uint256 lockSecs) external { + Lock storage l = locks[msg.sender]; + require(l.amount > 0, "Voting: no existing lock"); + require(lockSecs >= MIN_LOCK_SECS && lockSecs <= MAX_LOCK_SECS, "Voting: invalid duration"); + + uint64 newUnlock = uint64(block.timestamp + lockSecs); + require(newUnlock > l.unlockTime, "Voting: must be later than current unlock"); + + uint256 lockYears = lockSecs / 365 days; + if (lockYears == 0) lockYears = 1; + uint256 newPower = l.amount * lockYears; + + totalVeSupply = totalVeSupply - l.votingPower + newPower; + l.unlockTime = newUnlock; + l.votingPower = newPower; + + emit Locked(msg.sender, l.amount, newUnlock, newPower); + } + + /** + * @notice Withdraw locked tokens after the lock period expires. + */ + function unlock() external { + Lock storage l = locks[msg.sender]; + require(l.amount > 0, "Voting: nothing locked"); + require(block.timestamp >= l.unlockTime, "Voting: lock not expired"); + + uint256 amount = l.amount; + totalVeSupply -= l.votingPower; + delete locks[msg.sender]; + + govToken.transfer(msg.sender, amount); + emit Unlocked(msg.sender, amount); + } + + // ── Proposals ──────────────────────────────────────────────────────────── + /** + * @notice Submit a governance proposal. + * @param description Human-readable description + * @param target Contract address to call on execution + * @param callData ABI-encoded function call + * @param value ETH to forward on execution + */ + function propose( + string calldata description, + address target, + bytes calldata callData, + uint256 value + ) external returns (uint256 id) { + require(locks[msg.sender].votingPower >= PROPOSAL_THRESHOLD, "Voting: insufficient voting power"); + require(target != address(0), "Voting: zero target"); + + id = ++proposalCount; + uint64 startTime = uint64(block.timestamp); + uint64 endTime = uint64(block.timestamp + VOTING_PERIOD); + + proposals[id] = Proposal({ + id: id, + proposer: msg.sender, + description: description, + target: target, + callData: callData, + value: value, + startTime: startTime, + endTime: endTime, + yesVotes: 0, + noVotes: 0, + status: ProposalStatus.Active, + timelockTxHash: bytes32(0) + }); + + emit ProposalCreated(id, msg.sender, description, endTime); + } + + /** + * @notice Cast a vote on an active proposal. + * @param proposalId Proposal to vote on + * @param support true = YES, false = NO + */ + function vote(uint256 proposalId, bool support) external { + Proposal storage p = proposals[proposalId]; + require(p.status == ProposalStatus.Active, "Voting: proposal not active"); + require(block.timestamp <= p.endTime, "Voting: voting period ended"); + require(!hasVoted[proposalId][msg.sender], "Voting: already voted"); + require(locks[msg.sender].amount > 0, "Voting: no locked tokens"); + + uint256 weight = locks[msg.sender].votingPower; + hasVoted[proposalId][msg.sender] = true; + + if (support) { + p.yesVotes += weight; + } else { + p.noVotes += weight; + } + + // Immutable audit trail + voteHistory.push(VoteRecord({ + proposalId: proposalId, + voter: msg.sender, + support: support, + weight: weight, + timestamp: uint64(block.timestamp) + })); + + emit VoteCast(proposalId, msg.sender, support, weight); + } + + /** + * @notice Finalise a proposal after its voting period and queue it if it passed. + * @dev Anyone can call once endTime has elapsed. + * CEI: status updated BEFORE calling timelock.queue(). + */ + function finalise(uint256 proposalId) external { + Proposal storage p = proposals[proposalId]; + require(p.status == ProposalStatus.Active, "Voting: not active"); + require(block.timestamp > p.endTime, "Voting: period not ended"); + + // Quorum check: yesVotes must represent >= QUORUM_BPS of total veSupply + bool quorumMet = totalVeSupply == 0 + ? false + : (p.yesVotes * 10_000) / totalVeSupply >= QUORUM_BPS; + + if (!quorumMet || p.yesVotes <= p.noVotes) { + p.status = ProposalStatus.Failed; + return; + } + + // CEI: mark Queued before external call + p.status = ProposalStatus.Queued; + + uint256 eta = block.timestamp + timelock.delay(); + bytes32 txHash = timelock.queue(p.target, p.value, p.callData, eta); + p.timelockTxHash = txHash; + + emit ProposalQueued(proposalId, txHash, eta); + } + + /** + * @notice Execute a queued proposal via the Timelock. + */ + function execute(uint256 proposalId) external payable { + Proposal storage p = proposals[proposalId]; + require(p.status == ProposalStatus.Queued, "Voting: not queued"); + + p.status = ProposalStatus.Executed; + timelock.execute{value: p.value}(p.timelockTxHash); + + emit ProposalExecuted(proposalId); + } + + /** + * @notice Cancel an active or queued proposal. Proposer or owner only. + */ + function cancel(uint256 proposalId) external { + Proposal storage p = proposals[proposalId]; + require( + msg.sender == p.proposer || msg.sender == owner, + "Voting: not proposer or owner" + ); + require( + p.status == ProposalStatus.Active || p.status == ProposalStatus.Queued, + "Voting: cannot cancel" + ); + + if (p.status == ProposalStatus.Queued) { + timelock.cancel(p.timelockTxHash); + } + + p.status = ProposalStatus.Cancelled; + emit ProposalCancelled(proposalId); + } + + // ── View helpers ───────────────────────────────────────────────────────── + /// @notice Returns the voting power of `user` (0 if no active lock). + function votingPowerOf(address user) external view returns (uint256) { + return locks[user].votingPower; + } + + /// @notice Returns the length of the immutable vote history array. + function voteHistoryLength() external view returns (uint256) { + return voteHistory.length; + } + + /// @notice Returns a slice of vote records [from, to). + function getVoteHistory(uint256 from, uint256 to) + external view + returns (VoteRecord[] memory records) + { + require(to <= voteHistory.length && from <= to, "Voting: invalid range"); + records = new VoteRecord[](to - from); + for (uint256 i = from; i < to; i++) { + records[i - from] = voteHistory[i]; + } + } + + // ── Admin ──────────────────────────────────────────────────────────────── + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), "Voting: zero address"); + owner = newOwner; + } +}