A non-custodial, multi-grant token vesting vault. A single deployment can hold many
independent grants, each with its own beneficiary, ERC-20 token, cliff + linear release
schedule, and optional revocability. This is not OpenZeppelin's single-beneficiary
VestingWallet: one contract manages an unbounded set of unrelated grants that never
cross-fund each other.
Each grant vests on the standard OpenZeppelin linear curve, gated by a cliff:
vested(t) = 0 for t < cliff
total * (t - start) / duration for cliff <= t < start + duration
total for t >= start + duration
startanchors the linear math;durationis measured fromstart.cliffmust lie within[start, start + duration]. Nothing is releasable before it, but at the cliff the beneficiary can immediately claim everything that has linearly accrued betweenstartandcliff(a standard "cliff catch-up").- At or after
start + durationthe grant is fully vested and the beneficiary can withdraw exactlytotal.
release(grantId) transfers vested(now) - released to the beneficiary. Anyone may call
it; the funds always go to the grant's beneficiary regardless of who pays the gas.
A grant created with revocable = true may be revoked by its grantor (the address
that funded it), exactly once. On revoke:
- The vested-so-far amount (
vested(now) - released) is paid to the beneficiary. - The unvested remainder (
total - vested(now)) is returned to the grantor. - Vesting is frozen at the revoke timestamp; the grant can never vest further.
These two transfers sum to the grant's entire remaining balance in the vault
(total - released): nothing is minted, nothing is stranded. After revoke, releasable
is 0 and any later release call is a no-op. Non-revocable grants can never be revoked;
a second revoke reverts.
createGrant pulls total tokens from the funder with SafeERC20 and records the
actually received balance delta as the grant total. For a fee-on-transfer token the
recorded total therefore equals what the vault holds, so a grant can never be under-funded
relative to its own accounting and the solvency invariant holds.
- The contract is non-custodial except for the single defined path: the grantor of a revocable grant can revoke it, which claws back only the still-unvested portion. The already-vested portion always belongs to the beneficiary and is paid out on revoke.
- Non-revocable grants have no path for anyone to divert funds from the beneficiary.
- There is no owner, admin, pause, or upgrade path.
ReentrancyGuardprotectscreateGrant,release, andrevoke. - Choosing a well-behaved ERC-20 is the deployer's responsibility. FoT tokens are handled safely for funding; the beneficiary of an FoT grant still pays that token's transfer fee on withdrawal, as with any transfer of such a token.
released <= vestedAmount(now) <= totalfor every grant, at all times.- Solvency: the vault's balance of a token is at least the sum of
total - releasedover all live (unrevoked) grants of that token. - Revoke conservation:
paidToBeneficiary + returnedToGrantor == total - released. vestedAmountis non-decreasing in time up to revoke, then frozen.
A stateful invariant suite drives ~12,800 random createGrant / warp / release / revoke
calls and asserts solvency and ordering after every call.
src/TokenVesting.sol the vault
test/TokenVesting.t.sol unit tests (cliff, linear, revoke, FoT, reentrancy)
test/TokenVesting.invariant.t.sol stateful invariants
test/handlers/VestingHandler.sol invariant handler
test/mocks/ plain, fee-on-transfer, and reentrant ERC-20 mocks
script/Deploy.s.sol deployment script
forge build --sizes
forge test
forge snapshot
Toolchain: Solidity 0.8.26, via_ir, EVM cancun, OpenZeppelin Contracts v5.0.2.