A decentralized voting escrow system for HEMI tokens implementing time-locked staking with voting power and incentive distribution.
veHemi consists of the following contracts:
- VeHemi - Main voting escrow contract (V2) handling token locking, stake weight calculation, NFT transfers, and parallel locked/forfeitable subcurve tracking
- VeHemiVoteDelegation - Delegation system for voting power with hourly epoch checkpoints and auto-delegation support
- VeHemiAragonAdapter - Stateless adapter that presents veHEMI through the IVotes + ERC20-like metadata interface (
balanceOf,totalSupply,decimals,name,symbol) expected by Aragon's TokenVoting plugin - VeHemiStorageV2 - Storage extension appending locked/forfeitable subcurve state after the V1 storage layout
veHemi uses a linear decay system where both voting power and incentives are calculated using the same formula:
- Formula:
locked_amount * (lock_end_time - current_time) / max_lock_duration - Voting power: Determines governance voting weight
- Incentive distribution: Determines reward allocation proportion
- Longer locks = more weight: Users locking for longer durations get proportionally more voting power and incentives
Consider two users in veHemi:
- User A: Locks 100 HEMI for 4 years (single lock)
- User B: Locks 100 HEMI for 2 years, then relocks for 2 more years
User A gets more weight because:
- Single 4-year lock has higher average voting power over the entire period
- Linear decay curve favors longer initial lock durations
- More consistent incentive distribution throughout the lock period
- Duration: Up to 4 years maximum
- NFT representation: Each lock is a unique NFT
- Transferability: Default transferable, can be non-transferable
- Extensions: Only NFT owner can extend lock duration
- Amount increases: Anyone can increase locked amount
- Minimum amount: All lock creation (
createLockandcreateLockFor) requires at least 10 HEMI (MIN_LOCK_AMOUNT) to prevent dust lock griefing
- Default: Transferable by default
- Non-transferable: Created with
transferable = false(e.g., protocol distributions) - Auto-transferable: Non-transferable NFTs become transferable after first lock duration ends
- Delegation on transfer: Transfers re-delegate the lock to the recipient — or to the recipient's auto-delegate target if they set one via the Aragon adapter
- Epoch-based: Delegations take effect at the next hourly epoch boundary
- Per-token: Each veHEMI NFT can be delegated independently
- Flexible: Delegate to any address or self
- Revocable: Change or revoke at any time
- Auto-delegate: When set via the Aragon adapter, future locks and transfers automatically delegate to the chosen address
VeHemi V2 maintains parallel subcurves alongside the global supply curve to track non-transferable and forfeitable stake weight independently.
Three supply curves:
- Global (
totalVeHemiSupply): All positions, decaying linearly to zero atlock.end - Locked (
nonTransferableTotalVeHemiSupply): Non-transferable positions only, bounded bytransferableAfter - Forfeitable (
forfeitableTotalVeHemiSupply): Forfeitable subset of locked positions, also bounded bytransferableAfter
Invariant: forfeitable <= locked <= total always holds.
Subcurve transition: When block.timestamp >= transferableAfter, a position exits both the locked and forfeitable subcurves while retaining its full global voting power until lock.end. This means:
- A non-transferable position extended past its original
transferableAfterbecomes transferable at the originally promised time increaseUnlockTimedoes NOT extendtransferableAfter— the user's transferability promise is preserved- The forfeit window is bounded by
transferableAfter— once a position becomes transferable, it can no longer be forfeited
Seeding: The subcurves are initialized via a 3-phase on-chain enumeration: markSeedingStarted() opens the window and snapshots nextTokenId into seedingTargetId; seedBatch(maxIterations) (callable many times) iterates token IDs and accumulates per-position slope/bias deltas; finalizeSeeding() materializes the aggregate LockedPoints for both subcurves and flips lockedSeedingFinalized. All three phases must execute in a single block (block.timestamp == seedingStartedAt), so the deploy script bundles them into one Gnosis Safe MultiSend. The on-chain scan replaces an earlier caller-supplied tokenIds array, eliminating both the operator-drift footgun and the adversarial front-run vector where someone could mint a non-transferable position into the gap between off-chain list derivation and Safe execution.
Combined supply view: supplyBreakdown() returns (total, locked, forfeitable, transferable) in a single call with defensive caps enforcing the ordering invariant.
- Forfeitable positions: Created with
forfeitable = trueviacreateLockFor - Forfeit admin: A privileged address (set by the contract owner) that can claw back forfeitable positions
- Forfeit window: Only valid when
block.timestamp < transferableAfter— once the position becomes transferable, it can no longer be forfeited - Token destination: Forfeited HEMI is transferred to the forfeit admin (
msg.sender), not the position owner - Cleanup: Forfeit burns the NFT and cleans up all associated storage (locked balance, transferableAfter, forfeitable flag, provider) AND the per-token delegation cache in
VeHemiVoteDelegation.delegations[id]
The V2.1 implementation upgrade fixes a stale-cache bug in the forfeit path:
- Bug: when
forfeit(id)ran within the last hour of a lock's lifetime (block.timestamp + 1h > lock.end), the outer near-expiry guard short-circuited thevoteDelegation.delegate(id, address(0))cleanup call, leavingdelegations[id]populated forever after the NFT was burned. Vote tallies were unaffected (slope-change roll-off still handled by the checkpoint walk), butdelegation(burnedId)returned stale data for indexers and frontends. - Fix: two-part —
VeHemi._delegatenow letsdelegatee_ == address(0)cleanup calls pass through the guard unconditionally, andVeHemiVoteDelegation._delegateinvokes_getNormalizedLockedInfoonly in the new-delegatee branch so cleanup tolerates at-or-near-expiry locks. - Indexer impact: post-upgrade, near-expiry forfeits emit
DelegateChanged(id, prev, address(0))andDelegateVotesChangedevents from the Aragon adapter that pre-upgrade were silently dropped. Event signatures are unchanged; consumers that already handle the regular forfeit path will absorb the new cases automatically. No back-fill events are emitted for pre-upgrade stale records (they remain frozen and benign — the NFT is burned, soownerOfreverts and downstream reads can gate on that). - Tests: regression coverage lives in
test/DelegationBehavior.t.sol(3 boundary tests including a sub-hour sweep at{1, 60, 3599, 3600, 3601}seconds before lock end),test/VeHemiForfeitableCurve.t.sol::test_Forfeit_JointAccountingDecrement(per-accumulator exact-delta pin), andtest/adapter/VeHemiAragonAdapter.t.sol::test_relay_onForfeit_nearExpiry(adapter relay liveness on the previously-buggy path). Fuzzed byinvariant_pastVotesImmutabilityandinvariant_adapterRelayParityintest/Invariant.t.sol(20 invariants × 1024 runs × 128 depth).
The V2.1 implementation also addresses audit finding HIGH-2: VeHemi.updateVoteDelegation is a single-SSTORE pointer swap that performs zero state migration. Calling it on a freshly-deployed VeHemiVoteDelegation proxy silently zeros every voter's stake, breaks in-flight Aragon proposals, and severs the event-relay path until each user manually re-delegates. The mitigation has three parts:
- Strong NatSpec warning on
VeHemi.updateVoteDelegationdocumenting the silent-orphaning failure mode, the in-flight-proposal brick risk, and the mandatory operator runbook. Operators reading the source see the exact pre-flight checklist (deploy new VVD → snapshot → import → seal → swap). - State-migration tooling on
VeHemiVoteDelegation(newVeHemiDelegationStorageV3chain at slot 50):importDelegationsFromLegacy(legacy, tokenIds)— owner-only (VeHemi owner). Readsdelegation(id)from the legacy VVD and replays it via_delegateso checkpoint history begins building forward in the new contract. Idempotent. EmitsLegacyDelegationImported.importAutoDelegatesFromLegacy(legacy, owners)— same pattern forautoDelegatemappings. EmitsAutoDelegateSet+LegacyAutoDelegateImported.finalizeMigration()— one-way latch sealing the import path. After this call, the import functions revert permanently withMigrationFinalizedError, preventing any post-migration state injection that would corrupt futuregetPastVoteshistory.
- Mandatory Safe MultiSend operator runbook (encoded in NatSpec on
updateVoteDelegation): the new VVD'simportDelegationsFromLegacy+importAutoDelegatesFromLegacy+setTrustedAdapter+finalizeMigrationmust all execute in the same Safe MultiSend as the finalveHemi.updateVoteDelegation(newVVD)call. This makes the migration atomic from chain-state's perspective — no window where the new VVD is empty and live. - Tests: end-to-end runbook coverage in
test/HIGH2_LegacyImport.t.sol(28 tests), including:test_endToEndMigrationRunbook_preservesVotes— full operator flow → delegate votes preserved post-swaptest_withoutImport_swapZerosVotes_documentedBug— proves the original HIGH-2 bug AND that operators who skip the import path lose state- Idempotency, access control (only VeHemi owner), legacy-address validation, finalize semantics, post-finalize reverts
- Bytecode impact: zero on VeHemi (NatSpec only). VeHemiVoteDelegation grew from 14,185B to 16,364B (+2,179B), leaving an 8,212B EIP-170 margin (down from 10,391B pre-fix).
- Limitation (out-of-scope for V2): historical
getPastVotesqueries for blocks BEFORE the migration return zero on the new contract — the import replays delegations via_delegatewhich builds NEW checkpoints starting at the next epoch boundary. Operators MUST schedule the migration during a governance freeze (no in-flight proposals) to avoid bricking proposals whose snapshot block precedes the swap. The audit's strongest fix (full historical checkpoint copy) is deferred to V3 alongside the planned library extraction.
The VeHemiAragonAdapter enables veHEMI to be used as the voting token for Aragon's TokenVoting plugin. The adapter is a stateless, immutable contract that translates veHEMI's per-NFT delegation model into the standard IVotes interface that Aragon expects.
How it works:
- IVotes compliance: Implements
getVotes,getPastVotes,getPastTotalSupply,delegates,delegate, anddelegateBySig.delegateBySigreverts with a message pointing toVeHemiVoteDelegation.delegateBySig, because the IVotes address-based signature is incompatible with veHEMI's per-tokenId delegation scheme and cannot be transparently forwarded - ERC-6372: Reports
clock()asblock.timestampwithCLOCK_MODE = "mode=timestamp" - Bulk delegation:
adapter.delegate(delegatee)delegates ALL of the caller's veHEMI positions to a single address viadelegateAllFor, matching Aragon's one-click delegation UX - Event relay: Delegation events (
DelegateVotesChanged,DelegateChanged) are relayed from the delegation contract to the adapter address so Aragon's subgraph indexes them correctly - Subgraph sync:
refreshVotingPower/refreshVotingPowerBatchre-emit events with current decayed voting power for keeper-driven subgraph updates - Balance display:
balanceOfreturns total locked HEMI across all positions (not NFT count), providing meaningful data for the Aragon member detail page delegates(account)semantics: Returns the delegatee only when ALL of an account's veHEMI positions are delegated to the same address. Returnsaddress(0)if the account has no positions or if positions are split across different delegatees (a consequence of veHEMI's per-NFT delegation model, where there isn't always a single account-wide delegatee)- MED-7 mitigation (2026-05-04 audit):
DelegateChangedis only emitted when the post-change state is account-wide consistent (all NFTs delegated to the same address, includingaddress(0)). Mixed-state per-tokenId calls from multi-token owners are silently skipped, andadapter.delegate(addr)emits exactly one consolidated event after the batch (using an EIP-1153 transient flag to suppress the per-token relay). This keeps the Aragon subgraph in lock-step withdelegates(account)and preservesdelegateAllFor's O(N) gas profile
Hourly checkpoints: Delegations activate at the next hourly epoch boundary (up to 1 hour delay). This provides anti-flash-delegation protection while keeping the governance experience responsive. The keeper should call refreshVotingPowerBatch periodically to keep the Aragon subgraph in sync with naturally decaying voting power.
// Transferable self-lock at maximum duration (4 years)
uint256 tokenId = veHemi.createLock(amount, 4 * 365.25 days);
// Non-transferable, non-forfeitable lock for another account
// Args: amount, lockDuration, recipient, transferable, forfeitable
// transferable=false → recipient cannot transfer until first unlock time
// forfeitable=false → forfeitAdmin cannot claw back this lock
uint256 tokenId = veHemi.createLockFor(amount, 4 * 365.25 days, recipient, false, false);// Per-token delegation (via delegation contract directly)
veHemiVoteDelegation.delegate(tokenId, delegateeAddress);
// Bulk delegation via Aragon adapter (delegates ALL positions + sets auto-delegate)
veHemiAragonAdapter.delegate(delegateeAddress);
// Clear auto-delegate (future positions will self-delegate by default)
veHemiVoteDelegation.clearAutoDelegate();
// Check voting power
uint256 votes = veHemiVoteDelegation.getVotes(accountAddress);// Check transferability
bool isTransferable = veHemi.isTransferable(tokenId);
// Transfer NFT
veHemi.transferFrom(from, to, tokenId);Individual supply functions:
// Global aggregate stake weight
uint256 totalSupply = veHemi.totalVeHemiSupply();
// Non-transferable stake weight (locked subcurve)
uint256 lockedSupply = veHemi.nonTransferableTotalVeHemiSupply();
// Forfeitable stake weight (subset of locked)
uint256 forfeitableSupply = veHemi.forfeitableTotalVeHemiSupply();All four values at once with defensive caps enforcing forfeitable <= locked <= total:
(uint256 total, uint256 locked, uint256 forfeitable, uint256 transferable) = veHemi.supplyBreakdown();Per-position queries:
// Stake weight: linearly-decaying bias (NOT the deposited HEMI amount)
uint256 weight = veHemi.balanceOfNFT(tokenId);
// Deposited HEMI amount (constant until withdraw)
int128 amount = veHemi.getLockedBalance(tokenId).amount;This repo uses both foundry and hardhat frameworks, but npm manages all dependencies (foundry libs included). Foundry commands will only work after installing dependencies with npm:
npm i # install dependencies (including foundry libs)
forge build # build contracts
forge test # run testsforge coverage at default settings takes ~15 minutes on this codebase because
it disables the Solidity optimizer (for accurate source-line mapping) and then
runs all 131,072 invariant mutations × 6 invariants × unoptimized bytecode,
plus 1,000-run fuzz tests. Fork tests are NOT a cause — they self-skip
in microseconds when RPC URLs are unset.
Fast coverage (~5 seconds, smoke-test quality) — override the fuzz/invariant run counts via env vars:
FOUNDRY_FUZZ_RUNS=1 FOUNDRY_INVARIANT_RUNS=1 FOUNDRY_INVARIANT_DEPTH=1 \
forge coverage --report summary --report lcovProduces per-contract coverage of:
src/VeHemi.sol: 97.89% lines, 81.38% branches, 95.56% functionssrc/VeHemiVoteDelegation.sol: 98.10% lines, 88.64% branches, 100% functionssrc/adapter/VeHemiAragonAdapter.sol: 100% lines, branches, functionssrc/utils/PositionFactory.sol: 100% lines, branches, functions
The reduced run counts affect input-space exploration depth but not which
branches of production code get hit (the deterministic test suite already
reaches them all). Full fuzz/invariant exploration remains available by
running forge test separately at default settings.
Full coverage (~15 minutes, default config):
forge coverage --report summary --report lcov--ir-minimum does NOT work on this codebase — fails with a Yul
stack-too-deep error on VeHemi.sol's curve math. --via-ir is silently
ignored by forge coverage (Foundry issue #6592).
Additional test-quality signals beyond line coverage:
- Shadow-accounting stress tests (
test/VeHemiStressTest.t.sol) prove algebraic equivalence between contract aggregates and an independent calculator at every mutation. - Invariant suite (
test/Invariant.t.sol) with 6 invariants:invariant_tokenConservation,invariant_veHemiSupply,invariant_votingPower,invariant_subcurveOrdering,invariant_supplyBreakdownConsistency, andinvariant_epochMonotonicity. Runs 131,072 mutations per invariant per run. - Fork tests — 92 in
test/ForkUpgradeLockedCurve.t.sol(against Hemi mainnet state viaHEMI_RPC_URL) and 14 intest/adapter/VeHemiAragonAdapterFork.t.sol(against Ethereum mainnet Aragon OSx viaETH_RPC_URL). - 100% declared-error coverage — every custom error is exercised by at
least one
expectReverttest. - Fuzz tests (32
testFuzz_*across the suite) with 1,000 runs per test.
Before any deployment/upgrade it's recommended to run scripts against local fork chain:
Make sure that the .env file has correct params and then run:
./scripts/start-forked-node.sh
./scripts/test-next-deployment-on-fork.shMake sure that the .env file has correct params and then run:
npx hardhat deploy --network hemiThe V2 upgrade introduces parallel locked/forfeitable subcurves, hourly delegation epochs, and the Aragon adapter. Two deploy scripts handle this:
-
deploy/04_upgrade_vehemi_v2.ts— bundles4 + Ntransactions into a single Gnosis Safe MultiSend (executed atomically; all shareblock.timestamp):upgrade(VeHemiVoteDelegation proxy, new delegation impl)— upgrades the delegation contract to add hourly checkpoints,autoDelegate/delegateAllFor/clearAutoDelegate, and the trusted-adapter hook consumed by the Aragon adapter.upgrade(VeHemi proxy, new VeHemi V2 impl)— upgrades VeHemi to the V2 implementation. The V2 locked-curve logic is gated bylockedSeedingFinalized, so the contract behaves identically to V1 until seeding completes.markSeedingStarted()— opens the seeding window, snapshotsnextTokenIdintoseedingTargetId, and freezesseedingStartedAt = block.timestamp. While the window is open,_createLockrejects new non-transferable mints andforfeit/increaseAmount/increaseUnlockTimereject mutations of existing non-transferable positions, so the seeded set cannot drift mid-flow.seedBatch(SEED_BATCH_SIZE)×N—N = ceil(nextTokenId / SEED_BATCH_SIZE) + 1. Each call iterates a chunk of token IDs in[lastProcessedId+1, seedingTargetId), skipping burned/transferable/expired entries and accumulating slope-change writes for the locked + forfeitable subcurves. The on-chain scan replaces the prior caller-supplied list. The+1slack absorbs any mints that race the off-chainnextTokenIdsnapshot — extra calls past the cursor are structural no-ops.finalizeSeeding()— requires the cursor to have reachedseedingTargetId - 1, then advances the global epoch, writes the aggregateLockedPoints for both subcurves, flipslockedSeedingFinalized, and clears the accumulator.
The two
upgrade()calls use the bare form (notupgradeAndCall) — no initializer is called because theinitializermodifier would revert on already-initialized proxies. -
deploy/05_aragon_adapter.ts— deploys the immutableVeHemiAragonAdapterand callssetTrustedAdapter(adapter)onVeHemiVoteDelegation(owner-only, batched for the Gnosis Safe). Includes pre-flight checks (voteDelegation() != address(0),totalVeHemiSupply() > 0) and post-deploy ERC-165 verification (IVotes,ERC165,ERC6372).
markSeedingStarted sets a latch that is never cleared and finalizeSeeding sets lockedSeedingFinalized = true; both latches are monotonic. The on-chain scan removes the operator-drift and front-run risks of the prior caller-supplied list, but the atomicity guard is load-bearing: seedBatch and finalizeSeeding revert with SeedingInProgress if block.timestamp != seedingStartedAt. Cross-block execution would leave slope-change entries at past subEnd values as dead storage (the forward-only _checkpoint catchup never visits past timestamps).
npx hardhat --network hemi deploy without a --tags filter so the full 00→07→99 sequence executes in one invocation; both scripts append to the same multisig.batch.tmp.json and script 99 (runAtTheEnd: true) proposes the accumulated batch as a single Safe MultiSend. Splitting them (e.g., --tags VeHemiV2Upgrade followed days later by --tags VeHemiAragonAdapter) creates an adapter-bootstrap window during which trustedAdapter == address(0) on the upgraded VVD: every _delegate notify hook is silently skipped (Aragon's subgraph sees zero DelegateChanged/DelegateVotesChanged events), and delegateAllFor reverts with NotTrustedAdapter, bricking adapter.delegate(X) entirely. Audit LOW-10 (2026-05-04). Script 05's pre-flight aborts if VVD is still V1 on-chain and the Safe batch file is empty; helpers/safe.ts auto-cleans any stale batch file from a prior aborted run at process start so a phantom-non-empty file can't bypass the gate.
markSeedingStarted outside the same transaction as seedBatch/finalizeSeeding. The Safe MultiSend guarantees same-tx execution by construction — DO NOT manually decompose the bundle into separate proposals. If markSeedingStarted lands in a different block from the follow-ups, the contract enters a permanent stuck state: the seedingStarted latch can't be re-armed, the cross-block atomicity guard rejects every subsequent seedBatch/finalizeSeeding, and new non-transferable position mints stay blocked until an implementation upgrade migrates the latch state. Existing positions and transferable operations remain unaffected, but the V2 subcurve features and new locked-mint flow are bricked. Recovery requires a fresh proxy upgrade with a custom storage migration — high-friction emergency. Both the contract NatSpec on markSeedingStarted and the deploy script structure document this; verify Safe calldata before signing.
Pre-execution checklist:
- Leftover Safe batch staging file is auto-cleaned at process start by
helpers/safe.tsat module-load time (the firstimportof the helper, before any deploy script body runs) (LOW-10 stale-batch defense). A loud[LOW-10 safety]warning prints if a stalemultisig.batch.tmp.jsonis detected and removed. Belt-and-suspenders: manuallyrm -f multisig.batch.tmp.jsonbefore re-running if you want to silence the warning or eliminate any doubt. - Run
./scripts/check-storage-layouts.shto confirm the new implementation's storage layout matches the committed golden fixtures. - Run
forge test --match-path test/ForkUpgradeLockedCurve.t.sol --fork-url $HEMI_RPC_URLto validate the upgrade against mainnet state. - Fork-simulate the full MultiSend (e.g., via
./scripts/test-next-deployment-on-fork.sh) and verify eachseedBatchsub-call's gas estimate stays under ~25M. If any batch approaches 30M, lowerSEED_BATCH_SIZEindeploy/04_upgrade_vehemi_v2.ts— the+1slack means smaller batches just produce a few more no-op calls. - Verify Gnosis Safe calldata against the script-generated batch before signing. Expected transaction count is
4 + NwhereN = ceil(nextTokenId / SEED_BATCH_SIZE) + 1— a different count indicates either stale-file contamination, a script bug, or an unexpected mid-scriptnextTokenIddrift.
After both scripts complete, configure the Aragon TokenVoting plugin to use the deployed adapter address as its voting token.
npx hardhat etherscan-verify --network hemiMIT License