Skip to content

ADR-004: Timestamp-Based Vesting Curve

Vesting schedules come in three common shapes:

  1. Pure linear — tokens vest continuously from start to end
  2. Cliff-then-linear — no tokens until cliff, then linear from cliff to end
  3. Pure cliff — 100% of tokens unlock at a single moment

The initial architecture documentation proposed a VestingType enum with two variants (Cliff and Linear) to distinguish between curve types. This is the common approach in Solana vesting programs.

Use three timestamps (start_time, cliff_time, end_time) to implicitly define the vesting curve, without an explicit VestingType enum.

All three real-world vesting curves are naturally expressed by three timestamps:

  • cliff_time == 0 → pure linear from start_time to end_time
  • cliff_time > start_time, end_time > cliff_time → cliff-then-linear
  • cliff_time > start_time, end_time == cliff_time → pure cliff (100% at a single point)

A single formula handles all three cases without branching on a type tag:

if clock < cliff_time:
vested = 0
else:
elapsed = clock - start_time
duration = end_time - start_time
vested = min(amount * elapsed / duration, amount)

This is smaller, simpler, and cannot desync — the curve is the timestamps. A stored enum adds a byte, requires validation that the enum matches the timestamps, and creates a desync risk if the curve type is set incorrectly at creation time. For the pure cliff case, setting end_time == cliff_time makes elapsed / duration == 1 exactly when clock >= cliff_time, vesting 100% without special-casing.

ApproachProsCons
VestingType enumExplicit curve type in account data; easy frontend displayDesync risk (enum != timestamps); extra byte; branches in instruction logic
Timestamps only (chosen)Single formula; no desync possible; supports all three curvesCurve type implicit; frontends must derive display label
  • One formula for all curves: No branching on curve type in the program. The same elapsed / duration math works for linear, cliff-then-linear, and pure cliff.
  • Smaller account: Saves 1+ byte per stream (no enum discriminant).
  • Impossible to misconfigure: You can’t accidentally set VestingType::Linear with cliff_time > start_time because there is no type field to desync.
  • Implicit curve type: The curve shape is not directly visible in account data. Frontends must derive the display label from timestamps (e.g., “Cliff + Linear” when cliff_time > start_time). The SDK’s getVestedPercent() handles this transparently.
  • Pure cliff edge case: Setting end_time == cliff_time for a pure cliff is not immediately intuitive. Documentation must explicitly call out this pattern.