Skip to content

Commit 7d5dc86

Browse files
authored
Merge pull request #191 from chigozirim007/feature/frontloaded-vesting-curves
Add front-loaded exponential decay vesting curve
2 parents db9a82f + e8bb16b commit 7d5dc86

3 files changed

Lines changed: 130 additions & 15 deletions

File tree

contracts/vesting_curves/README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
---
22

33
### Summary
4-
Introduces the `VestingCurve` enum to support both **Linear** and **Exponential** vesting schedules in the Vesting Vault contract. This enhancement allows flexible release patterns, enabling either steady vesting or a slow-start/accelerated curve, while ensuring correctness through unit and integration tests.
4+
Introduces the `VestingCurve` enum to support **Linear**, **Exponential**, and **ExponentialDecay** vesting schedules in the Vesting Vault contract. This enhancement allows flexible release patterns, enabling steady vesting, a slow-start/accelerated curve, or a front-loaded decay curve while ensuring correctness through unit and integration tests.
55

66
### Key Features
77
* **VestingCurve Enum**:
88
- `Linear`: vested = total × elapsed ÷ duration
99
- `Exponential`: vested = total × elapsed² ÷ duration²
10+
- `ExponentialDecay`: vested = total - (total × remaining² ÷ duration²)
1011
* **Function Dispatch**: `vested_amount`, `claim`, and `status` now branch on curve type.
1112
* **Mathematical Behavior**:
1213
- Linear: proportional vesting (50% time → 50% tokens).
1314
- Exponential: slower start, faster finish (50% time → 25% tokens).
15+
- ExponentialDecay: faster start, slower finish (50% time → 75% tokens).
1416
* **Immutable Curve**: Curve set at `initialize()` and cannot be changed mid-schedule.
1517
* **Incremental Claim Guard**: Ensures multiple claims sum correctly regardless of curve.
1618
* **Testing**: 11 unit + integration tests validating math, claims, and curve behavior.
@@ -20,18 +22,18 @@ Introduces the `VestingCurve` enum to support both **Linear** and **Exponential*
2022
- ✅ All 11 tests should pass, covering both Linear and Exponential curves.
2123
2. Build the WASM binary: `stellar contract build`.
2224
3. Deploy to Stellar Testnet with `stellar contract deploy`.
23-
4. Initialize vaults with `--curve '{"Linear": {}}'` and `--curve '{"Exponential": {}}'`.
25+
4. Initialize vaults with `--curve '{"Linear": {}}'`, `--curve '{"Exponential": {}}'`, or `--curve '{"ExponentialDecay": {}}'`.
2426
5. Invoke `get_curve` to confirm correct variant.
25-
6. Check `vested_amount` at 50% elapsed: Linear → 50%, Exponential → 25%.
27+
6. Check `vested_amount` at 50% elapsed: Linear → 50%, Exponential → 25%, ExponentialDecay → 75%.
2628
7. Use `claim` to verify incremental transfers.
2729

2830
### Checklist
29-
- [x] Add `VestingCurve` enum with Linear & Exponential variants
31+
- [x] Add `VestingCurve` enum with Linear, Exponential, & ExponentialDecay variants
3032
- [x] Update `vested_amount`, `claim`, and `status` to dispatch on curve
3133
- [x] Ensure integer-only math with `u128` intermediates
3234
- [x] Enforce immutable curve at initialization
3335
- [x] Implement incremental claim logic
34-
- [x] Write 11 unit/integration tests for both curves
36+
- [x] Write unit/integration tests for all curve variants
3537
- [x] Build & deploy WASM contract to Stellar Testnet
3638

3739
---

contracts/vesting_curves/contracts/vesting-vault/src/lib.rs

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const MAX_DURATION: u64 = 315_360_000;
2424
pub enum VestingCurve {
2525
Linear,
2626
Exponential,
27+
ExponentialDecay,
2728
}
2829

2930
// ---------------------------------------------------------------------------
@@ -100,21 +101,46 @@ impl VestingVault {
100101
return total; // fully vested
101102
}
102103

104+
let elapsed_u128 = elapsed as u128;
105+
let duration_u128 = duration as u128;
106+
let duration_sq = duration_u128 * duration_u128;
107+
103108
match curve {
104109
VestingCurve::Linear => (total * elapsed as i128) / duration as i128,
105110
VestingCurve::Exponential => {
106-
let elapsed_u128 = elapsed as u128;
107-
let duration_u128 = duration as u128;
108-
let total_u128 = total as u128;
109-
110-
let numerator = total_u128 * elapsed_u128 * elapsed_u128;
111-
let denominator = duration_u128 * duration_u128;
112-
113-
(numerator / denominator) as i128
111+
let elapsed_sq = elapsed_u128 * elapsed_u128;
112+
Self::mul_fraction(total, elapsed_sq, duration_sq)
113+
}
114+
VestingCurve::ExponentialDecay => {
115+
let remaining = duration - elapsed;
116+
let remaining_u128 = remaining as u128;
117+
let remaining_sq = remaining_u128 * remaining_u128;
118+
119+
// Front-loaded vesting: the locked portion decays quadratically,
120+
// so the initial release rate is high and slows over time.
121+
total - Self::mul_fraction(total, remaining_sq, duration_sq)
114122
}
115123
}
116124
}
117125

126+
fn mul_fraction(total: i128, numerator: u128, denominator: u128) -> i128 {
127+
assert!(denominator > 0, "denominator must be positive");
128+
assert!(numerator <= denominator, "fraction must be <= 1");
129+
130+
let total_u128 = total as u128;
131+
132+
// For the vesting curves here numerator <= denominator, which lets us
133+
// decompose the multiplication without overflowing u128 while still
134+
// preserving floor(total * numerator / denominator) exactly.
135+
let quotient = total_u128 / denominator;
136+
let remainder = total_u128 % denominator;
137+
138+
let whole = quotient * numerator;
139+
let fractional = (remainder * numerator) / denominator;
140+
141+
(whole + fractional) as i128
142+
}
143+
118144
// -----------------------------------------------------------------------
119145
// Claim
120146
// -----------------------------------------------------------------------

contracts/vesting_curves/contracts/vesting-vault/src/test.rs

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,52 @@ fn e6_expo_after_end_capped_at_full() {
148148
assert_eq!(vested_at(&s.env, &s.vault, START + DURATION + 5000), TOTAL);
149149
}
150150

151+
// ── Exponential Decay ───────────────────────────────────────────────────────
152+
153+
#[test]
154+
fn d1_decay_at_start_is_zero() {
155+
let s = create_setup(VestingCurve::ExponentialDecay);
156+
assert_eq!(vested_at(&s.env, &s.vault, START), 0);
157+
}
158+
159+
#[test]
160+
fn d2_decay_at_quarter_is_43_75_percent() {
161+
let s = create_setup(VestingCurve::ExponentialDecay);
162+
let elapsed = DURATION / 4;
163+
let remaining = DURATION - elapsed;
164+
let expected = TOTAL
165+
- TOTAL * (remaining as i128 * remaining as i128)
166+
/ (DURATION as i128 * DURATION as i128);
167+
let actual = vested_at(&s.env, &s.vault, START + elapsed);
168+
assert_eq!(actual, expected, "decay 25% elapsed failed: got {actual}");
169+
}
170+
171+
#[test]
172+
fn d3_decay_at_half_is_seventy_five_percent() {
173+
let s = create_setup(VestingCurve::ExponentialDecay);
174+
let expected = (TOTAL * 3) / 4; // 1 - 0.5^2 = 0.75
175+
let actual = vested_at(&s.env, &s.vault, START + DURATION / 2);
176+
assert_eq!(actual, expected, "decay 50% elapsed failed: got {actual}");
177+
}
178+
179+
#[test]
180+
fn d4_decay_at_three_quarters_is_93_75_percent() {
181+
let s = create_setup(VestingCurve::ExponentialDecay);
182+
let elapsed = (DURATION * 3) / 4;
183+
let remaining = DURATION - elapsed;
184+
let expected = TOTAL
185+
- TOTAL * (remaining as i128 * remaining as i128)
186+
/ (DURATION as i128 * DURATION as i128);
187+
let actual = vested_at(&s.env, &s.vault, START + elapsed);
188+
assert_eq!(actual, expected, "decay 75% elapsed failed: got {actual}");
189+
}
190+
191+
#[test]
192+
fn d5_decay_at_end_is_full() {
193+
let s = create_setup(VestingCurve::ExponentialDecay);
194+
assert_eq!(vested_at(&s.env, &s.vault, START + DURATION), TOTAL);
195+
}
196+
151197
// ── Comparison ──────────────────────────────────────────────────────────────
152198

153199
#[test]
@@ -165,6 +211,21 @@ fn c1_at_midpoint_exponential_less_than_linear() {
165211
);
166212
}
167213

214+
#[test]
215+
fn c2_at_midpoint_decay_greater_than_linear() {
216+
let sl = create_setup(VestingCurve::Linear);
217+
let sd = create_setup(VestingCurve::ExponentialDecay);
218+
let mid = START + DURATION / 2;
219+
220+
let linear_mid = vested_at(&sl.env, &sl.vault, mid);
221+
let decay_mid = vested_at(&sd.env, &sd.vault, mid);
222+
223+
assert!(
224+
decay_mid > linear_mid,
225+
"Expected decay ({decay_mid}) > linear ({linear_mid}) at midpoint"
226+
);
227+
}
228+
168229
// ── Integration tests ────────────────────────────────────────────────────────
169230

170231
#[test]
@@ -203,9 +264,11 @@ fn i2_exponential_claim_at_three_quarters() {
203264
fn i3_get_curve_returns_correct_variant() {
204265
let sl = create_setup(VestingCurve::Linear);
205266
let se = create_setup(VestingCurve::Exponential);
267+
let sd = create_setup(VestingCurve::ExponentialDecay);
206268

207269
assert_eq!(sl.vault.get_curve(), VestingCurve::Linear);
208270
assert_eq!(se.vault.get_curve(), VestingCurve::Exponential);
271+
assert_eq!(sd.vault.get_curve(), VestingCurve::ExponentialDecay);
209272
}
210273

211274
#[test]
@@ -255,6 +318,30 @@ fn i6_double_claim_only_yields_incremental_amount() {
255318
assert_eq!(bal, TOTAL);
256319
}
257320

321+
#[test]
322+
fn i7_decay_double_claim_only_yields_incremental_amount() {
323+
let s = create_setup(VestingCurve::ExponentialDecay);
324+
325+
// First claim at 25 %
326+
let elapsed = DURATION / 4;
327+
s.env.ledger().with_mut(|l| l.timestamp = START + elapsed);
328+
let first_claim = s.vault.claim();
329+
assert_eq!(first_claim, 437_500_000);
330+
331+
// Advance to 50 %
332+
s.env.ledger().with_mut(|l| l.timestamp = START + DURATION / 2);
333+
let second_claim = s.vault.claim();
334+
assert_eq!(second_claim, 312_500_000);
335+
336+
// Advance to 100 %
337+
s.env.ledger().with_mut(|l| l.timestamp = START + DURATION);
338+
let third_claim = s.vault.claim();
339+
assert_eq!(third_claim, 250_000_000);
340+
341+
let bal = TokenClient::new(&s.env, &s.token).balance(&s.beneficiary);
342+
assert_eq!(bal, TOTAL);
343+
}
344+
258345
// ── Zero-duration / zero-amount edge cases (Issue #41) ──────────────────────
259346

260347
#[test]
@@ -349,7 +436,7 @@ fn z3_zero_duration_exponential_panics() {
349436
// ── Duration cap (Issue #44) ────────────────────────────────────────────────
350437

351438
#[test]
352-
fn i7_initialize_allows_max_duration() {
439+
fn i8_initialize_allows_max_duration() {
353440
let env = Env::default();
354441
env.mock_all_auths();
355442

@@ -382,7 +469,7 @@ fn i7_initialize_allows_max_duration() {
382469

383470
#[test]
384471
#[should_panic(expected = "duration exceeds MAX_DURATION")]
385-
fn i8_initialize_rejects_duration_over_max() {
472+
fn i9_initialize_rejects_duration_over_max() {
386473
let env = Env::default();
387474
env.mock_all_auths();
388475

0 commit comments

Comments
 (0)