# Delegated votes are locked when owner lock is expired

VotingEscrow.delegate requires the target lock to be unexpired, so a user who delegated and let their lock expire can never undelegate or withdraw, permanently locking tokens.

- Payout: Contest reward
- Severity: high
- Program: Canto
- Credited: ltyu
- Disclosed: 2023-10-11
- Write-up quality: 6/10
- Original: https://github.com/code-423n4/2023-08-verwa-findings/issues/268
- Library page: https://bugbountylibrary.com/r/c4%3A2023-08-verwa%3AH-04
- Tags: domain:web3 impact:funds-loss kind:contest platform:code4rena sev:high tech:evm tech:solidity technique:source-review vuln:accounting-error vuln:business-logic

**Target:** VotingEscrow.sol delegate/withdraw
**Root cause:** delegate() enforces `toLocked.end > block.timestamp` even on the undelegate path where toLocked is the caller's own lock; once that lock expires the check always reverts, and withdraw() requires delegatee == msg.sender.
**Steps:**
1. Alice locks tokens and calls `delegate(bob)` so locked[alice].delegatee = bob.
2. Time passes beyond lock end (vm.warp(ve.LOCKTIME() + 1 days)).
3. Alice calls `delegate(alice)` to undelegate; branch `_addr == msg.sender` sets toLocked = locked[alice].
4. require(toLocked.end > block.timestamp) reverts with 'Delegatee lock expired'.
5. withdraw() also reverts on `require(locked_.delegatee == msg.sender, "Lock delegated")`, so tokens are stuck.
**How found:** Code reading of delegate()/withdraw() paths: noticed the undelegate branch reuses the same expiry require as delegation, and that withdraw() gates on delegatee == msg.sender, making expiry a permanent lock.
**Insight:** When a function has symmetric delegate/undelegate branches, check whether shared requires (expiry, balance, cooldown) are valid on the reverse path; exit functions gated on state that can expire are a classic permanent-lock pattern.
