Skip to content

Commit 04bf11e

Browse files
authored
Merge pull request #269 from graceful-tech12/feat/issue-223-token-vesting
feat: add token vesting schedule with cliff and linear release
2 parents 6088d63 + 01f47c7 commit 04bf11e

1 file changed

Lines changed: 173 additions & 107 deletions

File tree

src/contracts/token.rs

Lines changed: 173 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! using Soroban smart contracts.
55
66
use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec};
7-
use crate::types::token::{TokenInfo, TokenMetadata};
7+
use crate::types::token::{TokenInfo, TokenMetadata, VestingSchedule};
88
use crate::utils::StellarClient;
99
use std::collections::HashMap;
1010

@@ -25,6 +25,10 @@ pub struct TokenContract {
2525
balances: HashMap<String, u64>,
2626
/// Allowances: owner -> spender -> amount
2727
allowances: HashMap<String, HashMap<String, u64>>,
28+
/// Vesting schedules: id -> VestingSchedule
29+
vesting_schedules: HashMap<u64, VestingSchedule>,
30+
/// Next vesting schedule ID
31+
next_vesting_id: u64,
2832
}
2933

3034
impl TokenContract {
@@ -38,6 +42,8 @@ impl TokenContract {
3842
address: None,
3943
balances: HashMap::new(),
4044
allowances: HashMap::new(),
45+
vesting_schedules: HashMap::new(),
46+
next_vesting_id: 1,
4147
}
4248
}
4349

@@ -222,6 +228,68 @@ impl TokenContract {
222228
Ok(())
223229
}
224230

231+
// ─── Token Vesting (#223) ──────────────────────────────────────────
232+
233+
/// Create a vesting schedule with cliff and linear release.
234+
/// Tokens are deducted from the caller's balance.
235+
pub fn create_vesting_schedule(
236+
&mut self, caller: Address, beneficiary: Address, amount: u64,
237+
start_time: u64, cliff_duration: u64, total_duration: u64,
238+
) -> Result<u64, String> {
239+
if amount == 0 { return Err("Vesting amount must be greater than 0".to_string()); }
240+
if total_duration == 0 { return Err("Total duration must be greater than 0".to_string()); }
241+
if cliff_duration > total_duration { return Err("Cliff duration cannot exceed total duration".to_string()); }
242+
let caller_key = caller.to_string();
243+
let caller_balance = self.balances.get(&caller_key).copied().unwrap_or(0);
244+
if caller_balance < amount {
245+
return Err(format!("Insufficient balance: caller has {}, vesting requires {}", caller_balance, amount));
246+
}
247+
*self.balances.entry(caller_key).or_insert(0) -= amount;
248+
let id = self.next_vesting_id;
249+
self.next_vesting_id = self.next_vesting_id.checked_add(1).ok_or("Vesting ID overflow")?;
250+
let schedule = VestingSchedule::new(id, beneficiary.to_string(), amount, start_time, cliff_duration, total_duration, None);
251+
self.vesting_schedules.insert(id, schedule);
252+
println!("[Event] VestingScheduleCreated {{ id: {}, beneficiary: {}, amount: {}, cliff: {}s, duration: {}s }}",
253+
id, beneficiary.to_string(), amount, cliff_duration, total_duration);
254+
Ok(id)
255+
}
256+
257+
/// Get the claimable vested amount for a schedule
258+
pub fn get_claimable_amount(&self, schedule_id: u64, current_time: u64) -> Result<u64, String> {
259+
let schedule = self.vesting_schedules.get(&schedule_id).ok_or("Vesting schedule not found")?;
260+
Ok(schedule.claimable_amount(current_time))
261+
}
262+
263+
/// Claim vested tokens from a schedule
264+
pub fn claim_vested(&mut self, schedule_id: u64, current_time: u64) -> Result<u64, String> {
265+
let schedule = self.vesting_schedules.get_mut(&schedule_id).ok_or("Vesting schedule not found")?;
266+
let claimable = schedule.claim(current_time)?;
267+
let beneficiary_key = schedule.beneficiary.clone();
268+
let entry = self.balances.entry(beneficiary_key.clone()).or_insert(0);
269+
*entry = entry.checked_add(claimable).ok_or("Overflow: balance exceeded u64::MAX")?;
270+
println!("[Event] VestingClaimed {{ id: {}, beneficiary: {}, amount: {} }}", schedule_id, beneficiary_key, claimable);
271+
Ok(claimable)
272+
}
273+
274+
/// Revoke a vesting schedule
275+
pub fn revoke_vesting(&mut self, schedule_id: u64, current_time: u64) -> Result<u64, String> {
276+
let schedule = self.vesting_schedules.get_mut(&schedule_id).ok_or("Vesting schedule not found")?;
277+
let unvested = schedule.revoke(current_time);
278+
println!("[Event] VestingRevoked {{ id: {}, unvested_amount: {} }}", schedule_id, unvested);
279+
Ok(unvested)
280+
}
281+
282+
/// Get a vesting schedule by ID
283+
pub fn get_vesting_schedule(&self, schedule_id: u64) -> Option<&VestingSchedule> {
284+
self.vesting_schedules.get(&schedule_id)
285+
}
286+
287+
/// Check if a vesting schedule is fully vested
288+
pub fn is_vesting_complete(&self, schedule_id: u64, current_time: u64) -> Result<bool, String> {
289+
let schedule = self.vesting_schedules.get(&schedule_id).ok_or("Vesting schedule not found")?;
290+
Ok(schedule.is_fully_vested(current_time))
291+
}
292+
225293
// -------------------------------------------------------------------------
226294
// Internal event helpers
227295
// -------------------------------------------------------------------------
@@ -312,144 +380,142 @@ mod tests {
312380
}
313381

314382
#[cfg(test)]
315-
mod tests {
383+
mod soroban_tests {
316384
use super::*;
317-
use soroban_sdk::{testutils::Address as _, Env};
318-
319-
fn setup() -> (Env, soroban_sdk::Address, TokenContractClient<'static>) {
320-
let env = Env::default();
321-
env.mock_all_auths();
322-
let contract_id = env.register_contract(None, TokenContract);
323-
let client = TokenContractClient::new(&env, &contract_id);
324-
let admin = soroban_sdk::Address::generate(&env);
325-
client.initialize(&admin);
326-
// Leak env for 'static lifetime required by Client — acceptable in tests
327-
let env: &'static Env = Box::leak(Box::new(env));
328-
let client = TokenContractClient::new(env, &contract_id);
329-
(env.clone(), admin, client)
330-
}
331385

332386
#[test]
333-
fn test_mint_and_balance() {
334-
let env = Env::default();
335-
env.mock_all_auths();
336-
let contract_id = env.register_contract(None, TokenContract);
337-
let client = TokenContractClient::new(&env, &contract_id);
338-
let admin = soroban_sdk::Address::generate(&env);
339-
client.initialize(&admin);
340-
341-
let user = soroban_sdk::Address::generate(&env);
342-
client.mint(&user, &1_000);
343-
assert_eq!(client.balance_of(&user), 1_000);
344-
}
387+
fn test_transfer_basic() {
388+
let mut token = TokenContract::new("Test Token".to_string(), "TEST".to_string(), 1000000);
389+
let from = Address::generate(&Env::default());
390+
let to = Address::generate(&Env::default());
345391

346-
#[test]
347-
fn test_transfer() {
348-
let env = Env::default();
349-
env.mock_all_auths();
350-
let contract_id = env.register_contract(None, TokenContract);
351-
let client = TokenContractClient::new(&env, &contract_id);
352-
let admin = soroban_sdk::Address::generate(&env);
353-
client.initialize(&admin);
392+
// Mint initial balance to sender
393+
token.mint(from.clone(), 1000).unwrap();
354394

355-
let sender = soroban_sdk::Address::generate(&env);
356-
let receiver = soroban_sdk::Address::generate(&env);
357-
client.mint(&sender, &1_000);
395+
// Perform transfer
396+
token.transfer(from.clone(), to.clone(), 400).unwrap();
358397

359-
client.transfer(&sender, &receiver, &400);
360-
assert_eq!(client.balance_of(&sender), 600);
361-
assert_eq!(client.balance_of(&receiver), 400);
398+
assert_eq!(token.balance_of(from), 600);
399+
assert_eq!(token.balance_of(to), 400);
362400
}
363401

364402
#[test]
365-
#[should_panic(expected = "Insufficient balance")]
366403
fn test_transfer_insufficient_balance() {
367-
let env = Env::default();
368-
env.mock_all_auths();
369-
let contract_id = env.register_contract(None, TokenContract);
370-
let client = TokenContractClient::new(&env, &contract_id);
371-
let admin = soroban_sdk::Address::generate(&env);
372-
client.initialize(&admin);
404+
let mut token = TokenContract::new("Test Token".to_string(), "TEST".to_string(), 1000000);
405+
let from = Address::generate(&Env::default());
406+
let to = Address::generate(&Env::default());
373407

374-
let sender = soroban_sdk::Address::generate(&env);
375-
let receiver = soroban_sdk::Address::generate(&env);
376-
client.mint(&sender, &100);
377-
client.transfer(&sender, &receiver, &500);
408+
token.mint(from.clone(), 100).unwrap();
409+
410+
let result = token.transfer(from, to, 500);
411+
assert!(result.is_err());
412+
assert!(result.unwrap_err().contains("Insufficient balance"));
378413
}
379414

380415
#[test]
381416
fn test_approve_and_allowance() {
382-
let env = Env::default();
383-
env.mock_all_auths();
384-
let contract_id = env.register_contract(None, TokenContract);
385-
let client = TokenContractClient::new(&env, &contract_id);
386-
let admin = soroban_sdk::Address::generate(&env);
387-
client.initialize(&admin);
417+
let mut token = TokenContract::new("Test Token".to_string(), "TEST".to_string(), 1000000);
418+
let owner = Address::generate(&Env::default());
419+
let spender = Address::generate(&Env::default());
388420

389-
let owner = soroban_sdk::Address::generate(&env);
390-
let spender = soroban_sdk::Address::generate(&env);
391-
client.approve(&owner, &spender, &300);
392-
assert_eq!(client.allowance(&owner, &spender), 300);
421+
token.approve(owner.clone(), spender.clone(), 300).unwrap();
422+
assert_eq!(token.allowance(owner, spender), 300);
393423
}
394424

395425
#[test]
396426
fn test_transfer_from_success() {
397-
let env = Env::default();
398-
env.mock_all_auths();
399-
let contract_id = env.register_contract(None, TokenContract);
400-
let client = TokenContractClient::new(&env, &contract_id);
401-
let admin = soroban_sdk::Address::generate(&env);
402-
client.initialize(&admin);
427+
let mut token = TokenContract::new("Test Token".to_string(), "TEST".to_string(), 1000000);
428+
let owner = Address::generate(&Env::default());
429+
let spender = Address::generate(&Env::default());
430+
let receiver = Address::generate(&Env::default());
431+
432+
token.mint(owner.clone(), 1000).unwrap();
433+
token.approve(owner.clone(), spender.clone(), 500).unwrap();
434+
435+
token.transfer_from(spender.clone(), owner.clone(), receiver.clone(), 200).unwrap();
403436

404-
let owner = soroban_sdk::Address::generate(&env);
405-
let spender = soroban_sdk::Address::generate(&env);
406-
let receiver = soroban_sdk::Address::generate(&env);
437+
assert_eq!(token.balance_of(owner.clone()), 800);
438+
assert_eq!(token.balance_of(receiver), 200);
439+
assert_eq!(token.allowance(owner, spender), 300);
440+
}
407441

408-
client.mint(&owner, &1_000);
409-
client.approve(&owner, &spender, &500);
442+
#[test]
443+
fn test_transfer_from_insufficient_allowance() {
444+
let mut token = TokenContract::new("Test Token".to_string(), "TEST".to_string(), 1000000);
445+
let owner = Address::generate(&Env::default());
446+
let spender = Address::generate(&Env::default());
447+
let receiver = Address::generate(&Env::default());
410448

411-
client.transfer_from(&spender, &owner, &receiver, &200);
449+
token.mint(owner.clone(), 1000).unwrap();
450+
token.approve(owner.clone(), spender.clone(), 50).unwrap();
412451

413-
assert_eq!(client.balance_of(&owner), 800);
414-
assert_eq!(client.balance_of(&receiver), 200);
415-
assert_eq!(client.allowance(&owner, &spender), 300);
452+
let result = token.transfer_from(spender, owner, receiver, 200);
453+
assert!(result.is_err());
454+
assert!(result.unwrap_err().contains("Insufficient allowance"));
416455
}
417456

418-
// -------------------------------------------------------------------------
419-
// Issue #17 – TransferFrom tests
420-
// -------------------------------------------------------------------------
457+
// ─── Token Vesting Tests (#223) ──────────────────────
421458

422459
#[test]
423-
#[should_panic(expected = "Insufficient allowance")]
424-
fn test_transfer_from_insufficient_allowance() {
425-
let env = Env::default();
426-
env.mock_all_auths();
427-
let contract_id = env.register_contract(None, TokenContract);
428-
let client = TokenContractClient::new(&env, &contract_id);
429-
let admin = soroban_sdk::Address::generate(&env);
430-
client.initialize(&admin);
460+
fn test_create_vesting_schedule() {
461+
let mut token = TokenContract::new("Test Token".to_string(), "TEST".to_string(), 1000000);
462+
let caller = Address::generate(&Env::default());
463+
let beneficiary = Address::generate(&Env::default());
464+
token.mint(caller.clone(), 20000).unwrap();
465+
let id = token.create_vesting_schedule(caller.clone(), beneficiary.clone(), 10000, 1000, 500, 2000).unwrap();
466+
assert_eq!(id, 1);
467+
assert_eq!(token.balance_of(caller), 10000);
468+
let schedule = token.get_vesting_schedule(1).unwrap();
469+
assert_eq!(schedule.total_amount, 10000);
470+
assert!(schedule.active);
471+
}
472+
473+
#[test]
474+
fn test_vesting_cliff_period_no_claims() {
475+
let mut token = TokenContract::new("Test Token".to_string(), "TEST".to_string(), 1000000);
476+
let caller = Address::generate(&Env::default());
477+
let beneficiary = Address::generate(&Env::default());
478+
token.mint(caller.clone(), 20000).unwrap();
479+
token.create_vesting_schedule(caller, beneficiary.clone(), 10000, 1000, 500, 2000).unwrap();
480+
let claimable = token.get_claimable_amount(1, 1200).unwrap();
481+
assert_eq!(claimable, 0);
482+
}
431483

432-
let owner = soroban_sdk::Address::generate(&env);
433-
let spender = soroban_sdk::Address::generate(&env);
434-
let receiver = soroban_sdk::Address::generate(&env);
484+
#[test]
485+
fn test_vesting_linear_release_after_cliff() {
486+
let mut token = TokenContract::new("Test Token".to_string(), "TEST".to_string(), 1000000);
487+
let caller = Address::generate(&Env::default());
488+
let beneficiary = Address::generate(&Env::default());
489+
token.mint(caller.clone(), 20000).unwrap();
490+
token.create_vesting_schedule(caller, beneficiary.clone(), 10000, 1000, 500, 2000).unwrap();
491+
let claimable = token.get_claimable_amount(1, 1750).unwrap();
492+
assert_eq!(claimable, 5000);
493+
}
435494

436-
client.mint(&owner, &1_000);
437-
client.approve(&owner, &spender, &50);
438-
client.transfer_from(&spender, &owner, &receiver, &200);
495+
#[test]
496+
fn test_vesting_claim_and_balance() {
497+
let mut token = TokenContract::new("Test Token".to_string(), "TEST".to_string(), 1000000);
498+
let caller = Address::generate(&Env::default());
499+
let beneficiary = Address::generate(&Env::default());
500+
token.mint(caller.clone(), 20000).unwrap();
501+
token.create_vesting_schedule(caller, beneficiary.clone(), 10000, 1000, 500, 2000).unwrap();
502+
let claimed = token.claim_vested(1, 2000).unwrap();
503+
assert_eq!(claimed, 10000);
504+
assert_eq!(token.balance_of(beneficiary), 10000);
505+
assert!(token.claim_vested(1, 2000).is_err());
439506
}
440507

441508
#[test]
442-
fn test_burn() {
443-
let env = Env::default();
444-
env.mock_all_auths();
445-
let contract_id = env.register_contract(None, TokenContract);
446-
let client = TokenContractClient::new(&env, &contract_id);
447-
let admin = soroban_sdk::Address::generate(&env);
448-
client.initialize(&admin);
449-
450-
let user = soroban_sdk::Address::generate(&env);
451-
client.mint(&user, &1_000);
452-
client.burn(&user, &400);
453-
assert_eq!(client.balance_of(&user), 600);
509+
fn test_vesting_revoke() {
510+
let mut token = TokenContract::new("Test Token".to_string(), "TEST".to_string(), 1000000);
511+
let caller = Address::generate(&Env::default());
512+
let beneficiary = Address::generate(&Env::default());
513+
token.mint(caller.clone(), 20000).unwrap();
514+
token.create_vesting_schedule(caller, beneficiary.clone(), 10000, 1000, 500, 2000).unwrap();
515+
let claimed = token.claim_vested(1, 1750).unwrap();
516+
assert_eq!(claimed, 5000);
517+
let unvested = token.revoke_vesting(1, 1750).unwrap();
518+
assert_eq!(unvested, 5000);
519+
assert!(token.get_vesting_schedule(1).unwrap().revoked);
454520
}
455521
}

0 commit comments

Comments
 (0)