Background
Thessaly Governance provides token-weighted voting with delegation and a timelock on Optimism. Voting power is read at a snapshot taken a voting delay after the proposal is created; passed proposals are queued in the Timelock and executed after its delay. A guardian multisig can cancel proposals.
The governance token, THS, is listed on lending markets, so voting power can be borrowed for as long as someone is willing to pay interest.
Architecture
VotesToken checkpoints voting power each block; the Governor counts votes at the snapshot and queues succeeded proposals in the Timelock, which holds the treasury and admin rights over dependent contracts.
Figure 1Thessaly Governance: components and paths involved in this case
Elements:
- Lending market (external party): THS borrowable
- New delegate (adversary): created 40 min earlier
- VotesToken (protocol component): Checkpointed voting power with delegation
- Governor (protocol component, involved in this case): Proposal lifecycle and vote counting
- Timelock (protocol component, involved in this case): Delay and execution of passed proposals
- Guardian multisig (external party): cancel right
- Treasury (external party): held by the Timelock
Connections:
- Lending market to New delegate: borrow 1.9M THS (attack path)
- New delegate to VotesToken: delegate() (attack path)
- VotesToken to Governor: getPastVotes() (data or call flow)
- Governor to Timelock: queue / execute (control path)
- Guardian multisig to Governor: cancel() (control path)
- Timelock to Treasury: controls (control path)
Components in the Thessaly Governance profile
Governor(involved in this case)
Proposal lifecycle and vote counting
VotesToken
Checkpointed voting power with delegation
Timelock(involved in this case)
Delay and execution of passed proposals
Security challenge
Shortly after midnight UTC on 10 June 2026, an address that had held no voting power an hour earlier created Proposal 41. Its payload had two calls: Timelock.updateDelay(0), then a transfer of the whole treasury to the proposer. 96% of the proposer’s voting power came from 1.9 million THS — 4.2% of supply — borrowed and delegated six minutes earlier.
The questions were how borrowed tokens could vote at all, and why a proposal would set the delay to zero before moving funds if the timelock delayed the whole proposal anyway.
Attack surface
The response examined how voting power could be acquired and what a passed proposal could reach.
| Surface | Exposure | Control |
|---|---|---|
| Voting delay | One block between proposal and snapshot | 43,200 blocks (one day) |
| Borrowable THS | Voting power available for the price of a short borrow | Longer voting delay makes borrowing costly; R-07 alerts |
| Delay changes | Applied when queued and read at execution time | Each operation keeps the delay in force when it was queued |
| Delay floor | None; the delay could be set to zero | Minimum of 48 hours |
| Guardian | Cancel right on proposals | Unchanged; used before voting closed |
Technical investigation
The voting snapshot was taken one block after the proposal. The borrower delegated at 23:55, the proposal followed at 00:01, and by the snapshot the borrowed balance was locked in as voting power. The tokens went back to the lending market at 01:20; voting power at the snapshot was unaffected by the repayment.
The payload explained the second question. Thessaly’s Governor applied timelock parameter changes when a proposal was queued, so that a proposal raising the delay would protect its own remaining calls. The Timelock checked readiness against the current delay rather than storing an execution time per operation. A proposal that lowered the delay therefore lowered it for itself: once queued, the treasury transfer would have been executable in the same block.
Monitoring raised a critical alert on the payload the moment the proposal was created, and a high alert on the borrowed voting power. The guardian cancelled the proposal that morning, before voting closed. The table compares the exposure at the time with the configuration after the fix.
Figure 2Path of Proposal 41
- Proposer (adversary) to VotesToken: delegate(self) with 1.9M borrowed THS. 23:55 (attack step)
- Proposer (adversary) to Governor: propose([updateDelay(0), treasury.transfer(100%)]). 00:01 (attack step)
- Governor (on-chain) to Monitoring: ProposalCreated (normal step)
- Monitoring (off-chain) to Guardian: R-17 critical payload · R-07 borrowed votes (defensive step)
- Proposer (adversary) to VotesToken: repay borrowed THS. after snapshot (normal step)
- Guardian (participant) to Governor: cancel(41). before voting closes (defensive step)
| Parameter | At the time | After the fix |
|---|---|---|
| Voting delay | 1 block (2 s) | 43,200 blocks (24 h) |
| Minimum time borrowed tokens must be held to vote | 2 s | 24 h |
| Time the borrowed tokens were actually held | 1 h 26 min | — |
| Timelock delay floor | None | 48 h |
| Delay before a proposal that lowers the delay can move funds | 0 s | ≥ 48 h |
Monitoring record
Open in Security Monitoring| Time (UTC) | Rule | Observation | Measured | Alert |
|---|---|---|---|---|
| 2026-06-09 23:55:12 | R-04Large holder movement | 1.9M THS (4.2% of supply) borrowed and delegated to a new addressMedium | 4.2 (threshold 1 % of supply) | Alert |
| 2026-06-10 00:01:04 | R-07Borrowed voting power | Proposal 41 created with 96% borrowed voting powerHigh | 96 (threshold 10 % of votes acquired in 50 blocks) | Alert |
| 2026-06-10 00:01:04 | R-17High-risk proposal payload | Proposal 41 sets timelock delay to zero and moves the treasuryCritical | Condition matched | Alert |
| 2026-06-10 00:03:20 | R-08Governance lifecycle | Voting snapshot taken one block after proposalMedium | Condition matched | Alert |
| 2026-06-10 01:20:47 | R-04Large holder movement | Borrowed THS returned to the lending marketLow | 4.2 (threshold 1 % of supply) | Alert |
| 2026-06-10 09:30:05 | R-08Governance lifecycle | Guardian cancels Proposal 41Low | Condition matched | Alert |
| 2026-06-11 15:00:00 | R-09Protocol parameter change | Voting delay raised to 43,200 blocks (one day)Medium | Recorded for the timeline | Logged |
| 2026-06-11 15:00:00 | R-09Protocol parameter change | Timelock minimum delay floor of 48 hours enforcedMedium | Recorded for the timeline | Logged |
Timeline
Exploitation
1.9M THS borrowed and delegated to a new address
Detection
Proposal 41 created; critical payload alert
Voting snapshot one block after the proposal
Borrowed THS returned to the lending market
Containment
Guardian cancels Proposal 41
Fix
Voting delay raised; 48-hour delay floor enforced
Verification
Timelock v2 behaviour verified against the Proposal 41 payload
Root cause
Two well-intended design choices combined into a bypass. Applying delay changes at queue time was meant to let a proposal protect itself by raising the delay; checking readiness against the current delay was meant to keep the timelock simple. Together they let a proposal shorten the delay it was subject to, and with no floor, shorten it to zero.
The one-block voting delay supplied the votes: it let tokens borrowed minutes earlier count at the snapshot.
- Class
- Governance manipulation
- Severity
- Critical
- Components involved
- GovernorTimelock
- Origin
- Incident response
// ThessalyGovernor: timelock parameters apply when a proposal is queuedfunction _queueOperations(uint256 id, address[] memory t, uint256[] memory v, bytes[] memory d) internal { for (uint256 i = 0; i < t.length; i++) { if (t[i] == address(timelock) && bytes4(d[i]) == Timelock.updateDelay.selector) { timelock.updateDelay(abi.decode(d[i][4:], (uint256))); // takes effect now continue; } timelock.queue(hashOperation(t[i], v[i], d[i], id), block.timestamp); }} // Timelockfunction updateDelay(uint256 newDelay) external onlyGovernor { delay = newDelay; // no lower bound} function isReady(bytes32 op) public view returns (bool) { return queuedAt[op] != 0 && block.timestamp >= queuedAt[op] + delay; // current delay}Risk evaluation
The bypass is modelled as an open critical finding for the period it existed, and Governance Risk is reduced because borrowed voting power could reach the proposal threshold. The after state is the current profile, which still reflects that THS can be borrowed.
The score moves from 70.1 (BB) to 77.6 (BBB), a change of +7.5 points. The largest single contribution is Code Security: restoring it from 62.0 to 80.0 alone moves the score from 70.1 to 75.5.
| Component | Weight | Range | During case | Today | Score points |
|---|---|---|---|---|---|
| Code Security | 30% | 62.0 | 80.0 | +5.4 | |
| Operational Security | 20% | 76.0 | 76.0 | 0.0 | |
| Governance Risk | 15% | 58.0 | 72.0 | +2.1 | |
| Economic Security | 15% | 76.0 | 76.0 | 0.0 | |
| Infrastructure Resilience | 10% | 78.0 | 78.0 | 0.0 | |
| Transparency | 10% | 84.0 | 84.0 | 0.0 | |
| Quantix Risk Index | 100% | 70.1 | 77.6 | +7.5 |
Scores use the six weighted components of the Quantix Risk Index. Open findings reduce Code Security by a fixed penalty per severity; the current profile is the protocol’s entry in Quantix Intelligence.
Mitigation strategy
Timelock v2 enforces a minimum delay of 48 hours, so no proposal can remove the delay it is subject to. Each operation now stores the execution time computed when it is queued, and delay changes go through the queue like any other call.
The voting delay rose from one block to 43,200 blocks, so borrowed tokens must be held — and paid for — for a day before they count. Both changes went through governance the next day.
- 01Cancel Proposal 41 through the guardian
- 02Raise the voting delay to 43,200 blocks
- 03Enforce a 48-hour minimum timelock delay
- 04Store an execution time per operation; queue delay changes like any call
- 05Alert on borrowed voting power and high-risk payloads at proposal creation
+uint256 public constant MIN_DELAY = 172800; // 48 hours function updateDelay(uint256 newDelay) external onlyGovernor {+ if (newDelay < MIN_DELAY) revert DelayBelowMinimum(newDelay); delay = newDelay; } -function isReady(bytes32 op) public view returns (bool) {- return queuedAt[op] != 0 && block.timestamp >= queuedAt[op] + delay;-}+function queue(bytes32 op) external onlyGovernor {+ eta[op] = block.timestamp + delay; // fixed when queued+}++function isReady(bytes32 op) public view returns (bool) {+ return eta[op] != 0 && block.timestamp >= eta[op];+}Security outcome
Proposal 41 was cancelled before voting closed and no treasury assets moved. The voting delay and the delay floor took effect the next afternoon.
The proposer and borrower addresses are tracked in the monitoring rule set, and both alerts that fired on Proposal 41 remain active for every monitored governor.
- Detection to containment
- 9 h 29 min
- Detection to first fix
- 38 h 59 min
- Detection to verified fix
- 5.5 days
- Risk Index change
- BB → BBB (+7.5)
Lessons learned
- 01
A timelock that a proposal can shorten is not a timelock for that proposal.
- 02
Fix each operation’s execution time when it is queued, and set a floor on the delay.
- 03
Borrowable governance tokens make the voting delay a security parameter.
- 04
Review proposals by their decoded calldata, from the moment they are created.

