Reproduced Exploit
Hyperstable: Non-perpetual locks gain extra delegation power
Chain
Other
Category
untagged
Date
Jan 1970
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: Hyperstable-security-review_2025-03-19. The historical source/toolchain is unavailable; this entry is documentation only and claims no executable Forge PoC.
Vulnerability classes: vuln/logic · vuln/governance · vuln/accounting
Reproduction: a faithful minimal reproduction of the vulnerable finding — the vulnerable delegation code (
vePeg._delegate/_moveAllDelegates) is reproduced verbatim (marked@>) with faithful minimal doubles; local deploy, no fork.
Root cause#
vePeg._delegate(_from, _to) gates delegation on the source token _from being perpetually locked, intending that only perpetual locks carry delegation power. But delegation is then applied at the address level: _moveAllDelegates copies every tokenId the owner holds — including non-perpetual locks — into the delegatee's checkpoint, so passing one perpetual lock through the gate leaks the voting power of all the owner's non-perpetual locks. The vulnerable lines, reproduced verbatim from the finding:
//File: src/governance/vePeg.sol
function _moveAllDelegates(address owner, address srcRep, address dstRep) internal {
--- SNIPPED
---
if (dstRep != address(0)) {
--- SNIPPED
---
// Plus all that's owned
for (uint256 i = 0; i < ownerTokenCount; i++) {
@> uint256 tId = ownerToNFTokenIdList[owner][i]; //@audit This contains all locks, including non-perpetual locks
dstRepNew.push(tId);
}
--- SNIPPED
---
}
}
The require(currentLock.perpetuallyLocked == true, ...) check in _delegate only validates the single _from lock. _moveAllDelegates then ignores that restriction entirely and iterates ownerToNFTokenIdList[owner], which lists all of the owner's locks regardless of their perpetuallyLocked flag.
Why it's exploitable here#
Following the finding's mechanism with concrete numbers:
- The owner holds three self-owned locks: one perpetual lock of
100e18(tokenId 1) and two non-perpetual locks of50e18each (tokenIds 2 and 3). - The owner calls
delegate(perpId, toId). BecauseperpIdis the perpetual lock, theperpetuallyLocked == truegate passes. _moveAllDelegatescopies all three of the owner's tokenIds into the delegatee's checkpoint — not just the perpetual one.getVotes(delegatee)now returns200e18, but only100e18should have been delegated. The extra100e18comes from the two non-perpetual locks that are supposed to carry no delegation power — inflating the delegatee's governance weight.
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in vePeg…:
- L74 — Owner holds mixed locks: Setup: ownerToNFTokenIdList records every lock an owner holds — perpetual and non-perpetual alike — indexed by position.
- L106 — Votes sum checkpoint locks: getVotes tallies delegation power by summing the balance of every tokenId in the account's latest delegation checkpoint.
- L126 — Perpetual-only delegation gate: _delegate proceeds only when the source lock _from is perpetually locked, meant to ensure only perpetual locks confer delegation power.
- L140 — Select checkpoint slot: _findWhatCheckpointToWrite picks the delegatee's next checkpoint index so the new delegation snapshot can be recorded.
- L154 — Strip old delegate's tokens: For a non-zero previous delegate, the loop rebuilds its checkpoint without any tokenId the owner holds, clearing prior delegation.
- L182 — Carry over delegatee's tokens: The delegatee's existing checkpoint tokenIds are copied forward unchanged before the owner's locks get appended.
- L186 — All owned locks copied in: Root cause: the loop copies every tokenId in ownerToNFTokenIdList into the delegatee, adding the owner's non-perpetual locks despite the perpetual-only gate.
- L204 — Leaked power booked to SINK: The 100e18 of delegation power leaked from two non-perpetual locks is recorded to SINK, quantifying voting power the delegatee should never hold.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 57824-h-03-non-perpetual-locks-gaining-extra-delegation-power-pash_exp && forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: one perpetual lock passes the gate, yet both non-perpetual locks are delegated too, leaking 100e18 of extra delegation power to the delegatee. Both gates are green (registry forge test PASS + Playground _verify-poc VERDICT: PASS).
Sources & further analysis#
Reproductions & code
- No executable Forge reproduction is claimed; the historical source/toolchain was unavailable for this finding.
- AuditVault finding: Hyperstable-security-review_2025-03-19.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Hyperstable: Non-perpetual locks gain extra delegation power".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.