44//! using Soroban smart contracts.
55
66use soroban_sdk:: { contract, contractimpl, Address , Env , Symbol , Vec } ;
7- use crate :: types:: token:: { TokenInfo , TokenMetadata } ;
7+ use crate :: types:: token:: { TokenInfo , TokenMetadata , VestingSchedule } ;
88use crate :: utils:: StellarClient ;
99use 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
3034impl 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