Vesting contracts are among the most value-dense contracts in any protocol ecosystem. They lock tokens for months or years on behalf of team members, investors, and contributors. Because the locked amounts are large and the time windows are long, any exploitable surface compounds in severity. A single miscalculation in schedule logic, a weak access control on revocation, or a mishandled beneficiary address can drain the entire allocation.

This article dissects the mechanics and security surfaces of vesting contracts from first principles, progressing from basic schedule implementations through advanced governance interactions, and closes with an end-to-end audit methodology.


How Vesting Contracts Work

A vesting contract encodes a promise: a beneficiary receives tokens according to a schedule rather than all at once. The contract holds the tokens and releases them progressively as time passes and conditions are met. The core state a vesting contract must track includes:

  • The total allocated amount
  • The start time of the vesting period
  • The duration of the vesting period
  • How much has already been released
  • Whether the grant has been revoked

A minimal single-beneficiary vesting skeleton looks like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SimpleVesting is Ownable {
    IERC20 public immutable token;
    address public beneficiary;

    uint256 public start;
    uint256 public duration;
    uint256 public totalAllocation;
    uint256 public released;
    bool public revoked;

    event TokensReleased(address indexed beneficiary, uint256 amount);
    event VestingRevoked(address indexed beneficiary);

    constructor(
        IERC20 _token,
        address _beneficiary,
        uint256 _start,
        uint256 _duration,
        uint256 _totalAllocation
    ) Ownable(msg.sender) {
        require(_beneficiary != address(0), "zero address");
        require(_duration > 0, "zero duration");
        require(_totalAllocation > 0, "zero allocation");

        token = _token;
        beneficiary = _beneficiary;
        start = _start;
        duration = _duration;
        totalAllocation = _totalAllocation;
    }

    function vestedAmount() public view returns (uint256) {
        if (revoked) return released;
        if (block.timestamp < start) return 0;
        if (block.timestamp >= start + duration) return totalAllocation;
        return (totalAllocation * (block.timestamp - start)) / duration;
    }

    function releasable() public view returns (uint256) {
        return vestedAmount() - released;
    }

    function release() external {
        uint256 amount = releasable();
        require(amount > 0, "nothing to release");
        released += amount;
        emit TokensReleased(beneficiary, amount);
        token.transfer(beneficiary, amount);
    }
}

This is the canonical linear vesting pattern. Its simplicity is deceptive. Each of the fields above — block.timestamp, beneficiary, revoked, and the arithmetic itself — represents a distinct attack surface.


block.timestamp Manipulation for Vesting Acceleration

Ethereum validators can manipulate block.timestamp within a bounded window. The Ethereum specification allows a block’s timestamp to be at most a few seconds ahead of the previous block’s timestamp and must be greater than the parent’s. In practice, validators can skew timestamps forward by up to roughly 12 seconds per block without violating consensus rules. Across multiple consecutive blocks, a colluding validator or validator cartel can accumulate meaningful drift.

Why This Matters for Vesting

For most financial applications a 12-second skew is irrelevant. For vesting contracts it matters in two scenarios:

  1. Cliff boundary manipulation: A cliff is a discrete threshold. If block.timestamp >= cliffEnd unlocks a lump sum, a validator can trigger that condition exactly one block early.
  2. Final unlock boundary: The last seconds of a vesting schedule follow the same logic. A validator beneficiary can self-release slightly before the mathematically correct moment.

The more dangerous historical pattern was pre-Merge, where proof-of-work miners had greater timestamp latitude. Post-Merge, the window is tighter but non-zero. The threat model shifts from opportunistic miners to coordinated validators with a financial interest in a specific vesting contract.

Vulnerable Cliff Check

// VULNERABLE: exact timestamp boundary is manipulable
function cliffVestedAmount() public view returns (uint256) {
    if (block.timestamp < cliffEnd) return 0;
    // Entire cliff tranche unlocks at this exact boundary
    return cliffAllocation;
}

Mitigations

The primary mitigation is not to rely on block.timestamp for high-value discrete thresholds where a few seconds matter. Instead:

  • Use block numbers for short-duration cliffs where each block represents a meaningful unit.
  • Accept that for multi-month vesting schedules, a 12-second skew is economically negligible and not worth architectural complexity.
  • For governance-critical unlocks, require an explicit admin call that validates elapsed time server-side before executing on-chain.
// SAFER: use block number for cliff where precision matters
uint256 public immutable cliffBlock;

function isCliffReached() public view returns (bool) {
    return block.number >= cliffBlock;
}

Block numbers are not perfectly reliable either — network congestion affects block production rate — but they are not directly manipulable by a single validator.


Linear vs. Cliff Vesting: Mechanics and Attack Surfaces

Linear Vesting

Linear vesting releases tokens continuously as a function of elapsed time. The release rate is constant: totalAllocation / duration tokens per second.

function linearVested(
    uint256 total,
    uint256 start,
    uint256 duration
) internal view returns (uint256) {
    if (block.timestamp <= start) return 0;
    uint256 elapsed = block.timestamp - start;
    if (elapsed >= duration) return total;
    return (total * elapsed) / duration;
}

Attack surface of linear vesting:

  • Precision loss: Integer division truncates. If total is small relative to duration, early calls to release() yield zero even after non-trivial time has passed. This is usually not exploitable for gain but can cause grief.
  • Timestamp skew: Continuous, so each block contributes a marginal extra release. Not catastrophic but measurable over validator-controlled blocks.
  • Re-entrancy on release: If the token is ERC-777 or has hooks, a malicious beneficiary can re-enter release(). The fix is to update released before transferring.

Cliff Vesting

Cliff vesting introduces a discrete threshold before which nothing vests. After the cliff, either a lump sum is released (pure cliff) or the linear schedule resumes from the start (cliff + linear hybrid).

contract CliffLinearVesting {
    uint256 public immutable start;
    uint256 public immutable cliff;      // seconds after start
    uint256 public immutable duration;   // total vesting duration
    uint256 public immutable total;
    uint256 public released;

    function vestedAmount() public view returns (uint256) {
        uint256 cliffTime = start + cliff;
        if (block.timestamp < cliffTime) return 0;

        // After cliff, linear from start (not from cliff)
        uint256 elapsed = block.timestamp - start;
        if (elapsed >= duration) return total;
        return (total * elapsed) / duration;
    }
}

Note the subtle design decision: the linear schedule starts from start, not from cliffTime. This means that at the moment the cliff is passed, the beneficiary can immediately claim a chunk proportional to the cliff duration. This is the intended behavior in most token grant programs (e.g., a 1-year cliff on a 4-year schedule releases 25% immediately at the cliff).

Attack surface of cliff vesting:

  • Timestamp manipulation at cliff boundary: The discrete jump at cliffTime makes this a high-value target for a block producer with a financial interest.
  • Off-by-one in cliff definition: cliff is sometimes defined as an absolute timestamp rather than a duration. Mixed conventions across a codebase cause boundary errors.
  • Double-cliff accounting: Multi-tranche schemes where the cliff condition is checked independently per tranche can allow a beneficiary to claim the same cliff multiple times if released is not updated atomically.

Beneficiary Address Confusion and Transferability Risks

The beneficiary address in a vesting contract is surprisingly complex. Consider what it controls:

  1. Who receives tokens on release()
  2. Who can call permissioned functions (if gated by msg.sender == beneficiary)
  3. Who inherits unclaimed tokens if the contract has a sweep mechanism

The Immutable Beneficiary Problem

If beneficiary is set at construction and never changeable, the beneficiary loses access to tokens if:

  • They lose their private key
  • The beneficiary is a multisig that is later sunset
  • The beneficiary is a contract that is later upgraded or selfdestructed

Many teams address this by making the beneficiary address transferable. But this introduces its own risks.

Transferable Beneficiary Attack

// VULNERABLE: beneficiary can transfer to an attacker's controlled address
function transferBeneficiary(address newBeneficiary) external {
    require(msg.sender == beneficiary, "not beneficiary");
    beneficiary = newBeneficiary;
}

If the beneficiary’s key is phished or compromised, an attacker calls transferBeneficiary to redirect all future releases. There is no recovery path.

A safer pattern uses a two-step transfer with a time delay:

address public pendingBeneficiary;
uint256 public transferRequestTime;
uint256 public constant TRANSFER_DELAY = 2 days;

function requestBeneficiaryTransfer(address newBeneficiary) external {
    require(msg.sender == beneficiary, "not beneficiary");
    require(newBeneficiary != address(0), "zero address");
    pendingBeneficiary = newBeneficiary;
    transferRequestTime = block.timestamp;
}

function acceptBeneficiaryTransfer() external {
    require(msg.sender == pendingBeneficiary, "not pending");
    require(
        block.timestamp >= transferRequestTime + TRANSFER_DELAY,
        "delay not elapsed"
    );
    beneficiary = pendingBeneficiary;
    pendingBeneficiary = address(0);
}

This gives the owner (or a monitoring system) a window to revoke the grant before an attacker can complete a transfer under a compromised key.

Smart Contract Beneficiaries

When the beneficiary is a contract, release() must use safeTransfer and the beneficiary contract must be able to receive the token. More critically, if the beneficiary is an upgradeable proxy, the proxy admin can redirect token receipt to an arbitrary logic contract. The vesting contract’s owner must validate that contract beneficiaries are either immutable or under governance control.


Multi-Beneficiary Vesting Contract Accounting Errors

Factory patterns and multi-beneficiary contracts manage many grants in one deployment. This introduces accounting hazards not present in single-beneficiary contracts.

Shared Token Pool Errors

// VULNERABLE: over-allocation is possible
mapping(address => VestingGrant) public grants;
uint256 public totalAllocated;

function addGrant(address beneficiary, uint256 amount) external onlyOwner {
    // Missing: check that token balance covers totalAllocated + amount
    totalAllocated += amount;
    grants[beneficiary] = VestingGrant({
        total: amount,
        released: 0,
        start: block.timestamp,
        duration: 4 * 365 days
    });
}

If totalAllocated can exceed the actual token balance of the contract, later beneficiaries cannot be paid. The fix is straightforward:

function addGrant(address beneficiary, uint256 amount) external onlyOwner {
    uint256 available = token.balanceOf(address(this)) - totalAllocated;
    require(available >= amount, "insufficient unallocated tokens");
    totalAllocated += amount;
    // ...
}

Per-Grant released Isolation

Every grant must maintain its own released counter. A common error in refactored code is promoting released to a single contract-level variable when adding a second beneficiary. This causes accounting drift where one beneficiary’s releases are counted against another’s allocation.

struct VestingGrant {
    uint256 total;
    uint256 released;   // MUST be per-grant, never shared
    uint256 start;
    uint256 cliff;
    uint256 duration;
    bool revoked;
}

Integer Overflow in Aggregate Accounting

When totalAllocated is decremented on revocation, the unvested remainder must be computed carefully:

function revoke(address beneficiary) external onlyOwner {
    VestingGrant storage grant = grants[beneficiary];
    require(!grant.revoked, "already revoked");

    uint256 vested = _vestedAmount(grant);
    uint256 unvested = grant.total - vested;

    grant.revoked = true;
    totalAllocated -= unvested;  // Reduce pool by unvested portion only

    // Return unvested tokens to owner
    token.transfer(owner(), unvested);
}

Failing to subtract only unvested (instead subtracting grant.total) causes totalAllocated to undercount, potentially allowing over-allocation in subsequent grants.


The Revocation Mechanism and Access Control Requirements

Revocation is one of the most dangerous functions in a vesting contract. It allows the owner to claw back unvested tokens. Correct implementation requires:

  1. Only unvested tokens are returned; already-vested tokens remain claimable by the beneficiary.
  2. The beneficiary can still call release() after revocation to claim their vested-but-unclaimed amount.
  3. The revocation is irreversible.
  4. The access control is properly timelocked or governed.

Correct Revocation Implementation

function revoke(address beneficiary) external onlyOwner {
    VestingGrant storage grant = grants[beneficiary];
    require(!grant.revoked, "already revoked");

    // Compute vested amount at this moment
    uint256 vested = _vestedAmount(grant);

    // Mark as revoked; vestedAmount() will now return `vested` as a cap
    grant.revoked = true;
    grant.revokedAt = block.timestamp;
    grant.vestedAtRevocation = vested;

    // Reclaim only what has not yet vested
    uint256 toReturn = grant.total - vested;
    totalAllocated -= toReturn;
    token.transfer(owner(), toReturn);

    emit VestingRevoked(beneficiary, toReturn);
}

Post-revocation, _vestedAmount must respect the cap:

function _vestedAmount(VestingGrant storage grant) internal view returns (uint256) {
    if (grant.revoked) return grant.vestedAtRevocation;
    // ... normal schedule logic
}

Access Control Anti-Patterns

Unprotected revoke: revoke with no modifier — anyone can revoke any grant.

EOA-only owner with no timelock: A single private key controls revocation. Key compromise means arbitrary revocation of all grants.

Missing event: Revocation without an event makes monitoring impossible.

Revoke resets released: A bug where grant.released is zeroed on revocation allows a beneficiary to re-claim already-distributed tokens.

The recommended pattern is to place revoke behind a timelock with a multi-signature requirement. The timelock delay gives beneficiaries time to observe and respond (e.g., legally) before revocation executes.


Vesting Contracts Interacting with Governance (Locked Token Voting)

Many protocols allow locked tokens to participate in governance. This creates a meaningful interaction surface between the vesting contract and the governance system.

Delegation Patterns

The simplest pattern is a delegate function on the vesting contract that calls token.delegate(delegatee) on behalf of the beneficiary:

function delegate(address delegatee) external {
    require(msg.sender == beneficiary, "not beneficiary");
    IVotes(address(token)).delegate(delegatee);
}

This gives the beneficiary voting power proportional to their total allocation even before it vests.

Attack Surface: Governance Power Without Economic Skin

If vesting contracts delegate full allocation (including unvested tokens) to governance, a beneficiary can vote with tokens they may never receive (if their grant is later revoked). This inflates their governance power relative to their actual economic stake.

Mitigation: delegate only vested amounts.

function currentVotingPower() public view returns (uint256) {
    // Only vested tokens contribute to votes
    return _vestedAmount(grants[beneficiary]);
}

This requires a custom governance adapter because standard ERC20Votes uses token balance, not a vesting schedule.

Snapshot Manipulation

Governance systems often snapshot voting power at a proposal creation block. If a beneficiary can accelerate their vesting (via timestamp manipulation) before the snapshot block, they gain disproportionate voting power. The attack:

  1. Attacker is a validator.
  2. They craft a block that skews block.timestamp forward past a cliff boundary.
  3. They call delegate on the vesting contract, capturing the cliff tranche as voting power.
  4. They create a governance proposal in the same block.
  5. The snapshot records inflated voting power.

Mitigation: use block numbers rather than timestamps for snapshot-sensitive vesting state, and add a cooldown between delegation changes and snapshot eligibility.

Re-entrancy via Governance Callbacks

Some governance frameworks invoke callbacks on token receipt or delegation. If the vesting contract calls token.delegate() and the token’s delegation logic triggers an external callback, re-entrancy into release() or revoke() is possible. Always follow checks-effects-interactions and consider using ReentrancyGuard.

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract GovernanceVesting is ReentrancyGuard {
    function release() external nonReentrant {
        // ...
    }

    function delegate(address delegatee) external nonReentrant {
        require(msg.sender == beneficiary, "not beneficiary");
        IVotes(address(token)).delegate(delegatee);
    }
}

Complete Hardened Vesting Contract

The following contract incorporates the mitigations discussed:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

interface IVotes {
    function delegate(address delegatee) external;
}

contract HardenedMultiVesting is Ownable2Step, ReentrancyGuard {
    using SafeERC20 for IERC20;

    IERC20 public immutable token;

    struct Grant {
        uint256 total;
        uint256 released;
        uint256 start;
        uint256 cliff;       // duration in seconds before cliff
        uint256 duration;    // total duration in seconds
        bool revoked;
        uint256 vestedAtRevocation;
        address pendingBeneficiary;
        uint256 transferRequestTime;
    }

    uint256 public constant TRANSFER_DELAY = 2 days;
    uint256 public totalAllocated;

    mapping(address => Grant) public grants;

    event GrantAdded(address indexed beneficiary, uint256 total);
    event TokensReleased(address indexed beneficiary, uint256 amount);
    event GrantRevoked(address indexed beneficiary, uint256 returned);
    event BeneficiaryTransferRequested(address indexed from, address indexed to);
    event BeneficiaryTransferred(address indexed from, address indexed to);

    constructor(IERC20 _token) Ownable(msg.sender) {
        token = _token;
    }

    // ─── Grant Management ────────────────────────────────────────────────────

    function addGrant(
        address beneficiary,
        uint256 total,
        uint256 start,
        uint256 cliff,
        uint256 duration
    ) external onlyOwner {
        require(beneficiary != address(0), "zero beneficiary");
        require(total > 0, "zero total");
        require(duration > 0, "zero duration");
        require(cliff <= duration, "cliff exceeds duration");
        require(grants[beneficiary].total == 0, "grant exists");

        uint256 available = token.balanceOf(address(this)) - totalAllocated;
        require(available >= total, "insufficient unallocated");

        totalAllocated += total;
        grants[beneficiary] = Grant({
            total: total,
            released: 0,
            start: start,
            cliff: cliff,
            duration: duration,
            revoked: false,
            vestedAtRevocation: 0,
            pendingBeneficiary: address(0),
            transferRequestTime: 0
        });

        emit GrantAdded(beneficiary, total);
    }

    // ─── Vesting Schedule ────────────────────────────────────────────────────

    function vestedAmount(address beneficiary) public view returns (uint256) {
        Grant storage g = grants[beneficiary];
        if (g.revoked) return g.vestedAtRevocation;

        uint256 cliffTime = g.start + g.cliff;
        if (block.timestamp < cliffTime) return 0;

        uint256 elapsed = block.timestamp - g.start;
        if (elapsed >= g.duration) return g.total;

        return (g.total * elapsed) / g.duration;
    }

    function releasable(address beneficiary) public view returns (uint256) {
        return vestedAmount(beneficiary) - grants[beneficiary].released;
    }

    // ─── Release ─────────────────────────────────────────────────────────────

    function release(address beneficiary) external nonReentrant {
        Grant storage g = grants[beneficiary];
        uint256 amount = releasable(beneficiary);
        require(amount > 0, "nothing releasable");

        // Effects before interactions
        g.released += amount;
        totalAllocated -= amount;

        emit TokensReleased(beneficiary, amount);
        token.safeTransfer(beneficiary, amount);
    }

    // ─── Revocation ──────────────────────────────────────────────────────────

    function revoke(address beneficiary) external onlyOwner nonReentrant {
        Grant storage g = grants[beneficiary];
        require(!g.revoked, "already revoked");

        uint256 vested = vestedAmount(beneficiary);
        uint256 toReturn = g.total - vested;

        g.revoked = true;
        g.vestedAtRevocation = vested;
        totalAllocated -= toReturn;

        emit GrantRevoked(beneficiary, toReturn);
        token.safeTransfer(owner(), toReturn);
    }

    // ─── Beneficiary Transfer (Two-Step with Delay) ───────────────────────────

    function requestBeneficiaryTransfer(
        address from,
        address to
    ) external {
        require(msg.sender == from, "not beneficiary");
        require(to != address(0), "zero address");
        Grant storage g = grants[from];
        require(g.total > 0 && !g.revoked, "no active grant");

        g.pendingBeneficiary = to;
        g.transferRequestTime = block.timestamp;

        emit BeneficiaryTransferRequested(from, to);
    }

    function acceptBeneficiaryTransfer(address from) external nonReentrant {
        Grant storage g = grants[from];
        require(msg.sender == g.pendingBeneficiary, "not pending");
        require(
            block.timestamp >= g.transferRequestTime + TRANSFER_DELAY,
            "delay not elapsed"
        );

        address to = g.pendingBeneficiary;
        grants[to] = g;
        grants[to].pendingBeneficiary = address(0);
        grants[to].transferRequestTime = 0;
        delete grants[from];

        emit BeneficiaryTransferred(from, to);
    }

    // ─── Governance Delegation ────────────────────────────────────────────────

    function delegate(address beneficiary, address delegatee) external nonReentrant {
        require(msg.sender == beneficiary, "not beneficiary");
        Grant storage g = grants[beneficiary];
        require(g.total > 0, "no grant");
        // Delegates only; voting adapter reads vestedAmount() for power calculation
        IVotes(address(token)).delegate(delegatee);
    }
}

How to Audit a Vesting Contract End to End

Auditing a vesting contract requires both mathematical verification and adversarial thinking. The following methodology covers the full surface.

Step 1: Understand the Business Logic

Before reading code, collect the specification:

  • What is the vesting schedule (linear, cliff, stepped, milestone)?
  • Who are the beneficiaries (EOAs, multisigs, contracts)?
  • Is revocation intended? Who can revoke?
  • Can beneficiaries transfer their grants?
  • Do locked tokens participate in governance?

Discrepancies between the spec and the code are the most common source of material bugs.

Step 2: Map All State Variables and Their Invariants

For every state variable, write its invariant:

VariableInvariant
totalAllocated≤ token.balanceOf(address(this)) at all times
grant.released≤ grant.total at all times
grant.released≤ vestedAmount(beneficiary) at all times
grant.totalConstant after creation (immutable per grant)

Verify each invariant holds before and after every state-mutating function using symbolic reasoning or a fuzzer.

Step 3: Test the Schedule Arithmetic

For each vesting schedule variant:

  • Verify vestedAmount() returns 0 before start
  • Verify vestedAmount() returns 0 before cliff
  • Verify vestedAmount() returns the cliff tranche immediately after cliff
  • Verify vestedAmount() returns total at or after start + duration
  • Verify monotonicity: vestedAmount(t+1) >= vestedAmount(t) for all t
  • Verify no precision loss causes permanent lockup of small amounts

Use Foundry’s time manipulation cheatcodes:

function testCliffBoundary() public {
    // One second before cliff: nothing vested
    vm.warp(grant.start + grant.cliff - 1);
    assertEq(vesting.vestedAmount(beneficiary), 0);

    // Exactly at cliff: cliff tranche vested
    vm.warp(grant.start + grant.cliff);
    uint256 expectedCliff = (grant.total * grant.cliff) / grant.duration;
    assertEq(vesting.vestedAmount(beneficiary), expectedCliff);
}

Step 4: Fuzz the Release Function

function testFuzz_releaseNeverExceedsTotal(uint256 warpSeconds) public {
    warpSeconds = bound(warpSeconds, 0, 10 * 365 days);
    vm.warp(grant.start + warpSeconds);

    // Release everything available
    if (vesting.releasable(beneficiary) > 0) {
        vm.prank(beneficiary);
        vesting.release(beneficiary);
    }

    assertLe(vesting.grants(beneficiary).released, grant.total);
}

Step 5: Verify Access Control on Every Privileged Function

For each privileged function, test:

  • Correct principal can call it
  • Every other address reverts
  • Post-ownership-transfer, old owner cannot call it
function testRevoke_onlyOwner() public {
    vm.prank(attacker);
    vm.expectRevert();
    vesting.revoke(beneficiary);
}

Step 6: Check Re-entrancy Paths

Identify every external call in the contract. Common calls:

  • token.transfer() / token.safeTransfer()
  • token.delegate()
  • Any callback in governance hooks

Verify that state is updated before the external call (checks-effects-interactions), or that nonReentrant guards are present.

Step 7: Simulate Revocation Edge Cases

  • Revoke immediately after grant creation (no tokens vested): entire allocation returned.
  • Revoke at exactly cliff time: cliff tranche stays with beneficiary, rest returned.
  • Revoke after full vesting: 0 tokens returned, beneficiary can still call release.
  • Call release after revocation: only vestedAtRevocation - released claimable.
  • Double-revoke: must revert.

Step 8: Validate Multi-Grant Accounting

  • Add N grants summing to exactly the contract’s token balance. Attempt to add one more: must revert.
  • Release from grant A. Verify grant B’s released is unaffected.
  • Revoke grant A. Verify totalAllocated decreases by exactly the unvested portion.

Step 9: Review Governance Integration

  • Can vestedAmount be manipulated via timestamp to inflate snapshot voting power?
  • Does delegation of unvested tokens create governance power the beneficiary should not have?
  • Is there a cooldown between delegation and snapshot?
  • Can a callback from the governance/token contract re-enter the vesting contract?

Step 10: Static Analysis and Formal Verification

Run Slither across the contract. Key detectors relevant to vesting:

  • tautology: schedule conditions that are always true/false
  • reentrancy-eth / reentrancy-no-eth: re-entrancy via token callbacks
  • incorrect-equality: using == on timestamps instead of >=
  • divide-before-multiply: precision loss in schedule arithmetic

For high-value contracts, consider Certora Prover rules asserting the core invariants formally.


Vesting Contract Audit Checklist

Schedule Logic

  • vestedAmount() returns 0 before start
  • vestedAmount() returns 0 before start + cliff
  • vestedAmount() returns the correct cliff tranche exactly at start + cliff
  • vestedAmount() returns total at and after start + duration
  • vestedAmount() is monotonically non-decreasing
  • No division-before-multiplication precision loss
  • No permanent lockup of dust amounts due to truncation
  • Cliff duration does not exceed total duration

Timestamp Dependence

  • Discrete high-value cliff thresholds assessed for block producer manipulation risk
  • Block numbers used where timestamp precision is critical
  • block.timestamp dependencies documented and accepted in threat model

Beneficiary Management

  • Beneficiary address validated non-zero at construction
  • Beneficiary transfer (if present) uses two-step pattern with delay
  • Contract beneficiaries validated for token receivability
  • Owner cannot unilaterally redirect token receipts mid-grant

Access Control

  • revoke protected by onlyOwner or equivalent
  • Owner is a timelock or multisig, not a bare EOA
  • No function allows arbitrary callers to modify grant state
  • Ownership transfer uses two-step pattern (Ownable2Step)

Revocation Correctness

  • Only unvested tokens are returned on revocation
  • Beneficiary can still call release after revocation for vested-unclaimed amount
  • released counter is not reset on revocation
  • Double-revoke reverts
  • totalAllocated decremented by unvested amount only

Multi-Grant Accounting

  • totalAllocated never exceeds contract token balance
  • Each grant has an isolated released counter and vested balance
  • Cliff logic is correct: no tokens released before cliff, full cliff amount available immediately after
  • Beneficiary address is validated on grant creation (not zero address)
  • Token transfer uses safeTransfer, not raw transfer

Timestamp Security

  • Vesting calculations use block.timestamp, not block numbers (block times vary)
  • No function allows start to be set in the past to create immediately vested tokens
  • Duration cannot be set to zero (would make all tokens immediately vested)

Governance Integration

  • If vesting tokens are used for voting, only vested-and-released tokens count
  • Or: if locked tokens vote, the voting weight decreases as tokens are released
  • No double-counting: a token cannot both vest and be released while still being counted in governance

Operational Security

  • Beneficiary change function requires two-step confirmation (beneficiary must accept)
  • Emergency pause does not prevent beneficiaries from claiming already-vested tokens
  • Vesting contract holds only tokens for active grants (not an open treasury)

A Note on Cliff Timing Attacks

The most underestimated vulnerability in vesting contracts is the cliff timing attack via block.timestamp manipulation. Validators on proof-of-stake chains can adjust block.timestamp by up to 12 seconds (one slot). For a cliff that vests at exactly startTime + cliffDuration, a validator-beneficiary can propose a block with a timestamp that crosses the cliff 12 seconds earlier than real time.

For a vesting schedule with a 12-month cliff on a large token allocation, this is not exploitable in any material way — 12 seconds out of 31 million seconds. But for a very short cliff (say, 5 minutes, which sometimes appears in test configurations that make it to production), this is a 4% timing advantage.

The practical recommendation: vesting cliffs should never be shorter than 24 hours in a production deployment. Below that threshold, timestamp manipulation becomes meaningful, and the contract design should be reconsidered.