The local vote can be finalized before the upstream voting window ends:
function canVote(uint256 id) public view returns(bool _canVote, uint32 _createdAt) {
require(!executed[id], "Executed");
(,uint32 createdAt,,bool processed,) = voter.proposalData(id);
uint256 period = voter.votingPeriod();
_createdAt = createdAt;
if(_createdAt + period > block.timestamp && !processed) {
_canVote = true;
} else {
_canVote = false;
}
}
function vote(uint256 id, uint256 pctYes, uint256 pctNo) external {
...
(bool _canVote, uint32 _createdAt) = canVote(id);
require(_canVote, "!ended");
...
VoteData storage totals = voteTotals[id];
totals.yes += weightYes;
totals.no += weightNo;
...
if(_createdAt + executionDelay < block.timestamp) {
try magicStaker.castVote(id, totals.yes, totals.no) {
executed[id] = true;
emit VoteCommitted(id);
} catch {
...
}
}
}
function commitVote(uint256 id) external {
(bool _canVote, uint32 _createdAt) = canVote(id);
require(_canVote, "!ended");
require(_createdAt + executionDelay < block.timestamp, "!time");
VoteData storage totals = voteTotals[id];
magicStaker.castVote(id, totals.yes, totals.no);
executed[id] = true;
emit VoteCommitted(id);
}
canVote() keeps local voting open until the upstream voting period ends, but both vote() and commitVote() can irreversibly commit the wrapper's upstream vote earlier, immediately after executionDelay.
The finalization is irreversible at both layers:
function castVote(uint256 id, uint256 totalYes, uint256 totalNo) external {
require(msg.sender == magicVoter, "!voter");
uint256 total = totalYes + totalNo;
require((totalSupply * 2000) / DENOM <= total, "!quorum");
uint256 weightYes = (totalYes * DENOM) / total;
uint256 weightNo = DENOM - weightYes;
voter.voteForProposal(address(this), id, weightYes, weightNo);
emit VoteCast(id, weightYes, weightNo);
}
castVote() does not forward the raw local turnout. It only checks that local turnout reached 20%, then rescales the current yes/no split into a full 100% upstream vote for the wrapper account.
That means the bug is not that 20% always governs the wrapper. The bug is that once 20% has voted and executionDelay has passed, that 20% can lock the wrapper's full upstream vote if the remaining 80% has not participated yet. If the rest of the voting power already voted before commit, the early faction cannot override them. The exploitability comes from the ability to finalize early while upstream voting is still open.
function _voteForProposal(address account, uint256 id, uint256 pctYes, uint256 pctNo) internal {
require(id < proposalData.length, "Invalid ID");
Vote memory vote = accountVoteWeights[account][id];
require(vote.weightYes + vote.weightNo == 0, "Already voted");
Proposal memory proposal = proposalData[id];
require(!proposal.processed, "Proposal already processed");
require(proposal.createdAt + VOTING_PERIOD > block.timestamp, "Voting period has closed");
uint256 accountWeight = staker.getAccountWeightAt(account, proposal.epoch) / 10 ** TOKEN_DECIMALS;
require(accountWeight > 0, "Account weight is zero");
vote.weightYes = uint40(accountWeight * pctYes / MAX_PCT);
vote.weightNo = uint40(accountWeight * pctNo / MAX_PCT);
accountVoteWeights[account][id] = vote;
...
}
Upstream Voter allows each account to vote only once per proposal. Because the wrapper votes as address(this), the first successful commit consumes the wrapper's single upstream vote while the upstream proposal may still be open.
Concrete consequence:
- A proposal opens upstream and local users begin voting.
- After
executionDelay, a faction representing just over the 20% local quorum threshold causes magicVoter to call magicStaker.castVote() before the rest of the users have participated.
magicStaker scales that faction's current yes/no split into the wrapper's full upstream vote.
- The upstream voter records the wrapper as having voted and will reject any later correction.
- Users who intended to vote later within the still-open upstream voting period are permanently excluded from the wrapper's final decision.
This is not merely an abstention model. The local contract continues to key liveness to the upstream voting period in canVote(), but the wrapper's effective decision point is whichever transaction first commits after executionDelay. In practice, that means a whale with roughly 20% of wrapper supply can lock the wrapper's entire vote if they act first in that early-commit window, even though the remaining voting power would otherwise still be eligible to participate.