@@ -18,15 +18,15 @@ macro_rules! rcall {
1818 } ;
1919}
2020
21- #[ derive( Arbitrary , Debug ) ]
21+ #[ derive( Arbitrary , Debug , Clone ) ]
2222enum FuzzAction {
2323 Deposit { user_id : u8 , amount : i128 } ,
2424 Withdraw { user_id : u8 , amount : i128 } ,
2525 GetDeposit { user_id : u8 } ,
2626 MultipleOperations { operations : Vec < Operation > } ,
2727}
2828
29- #[ derive( Arbitrary , Debug ) ]
29+ #[ derive( Arbitrary , Debug , Clone ) ]
3030struct Operation {
3131 user_id : u8 ,
3232 amount : i128 ,
@@ -43,7 +43,36 @@ fn setup_token_contract<'a>(
4343 ( contract_id. address ( ) , stellar_asset_client, token_client)
4444}
4545
46- fuzz_target ! ( |data: FuzzAction | {
46+ fn assert_no_value_creation ( shares : i128 , pool_balance : i128 , total_shares : i128 , redeemable : i128 ) {
47+ if total_shares == 0 {
48+ assert_eq ! ( redeemable, 0 ) ;
49+ return ;
50+ }
51+ if let ( Some ( lhs) , Some ( rhs) ) = ( redeemable. checked_mul ( total_shares) , shares. checked_mul ( pool_balance) ) {
52+ assert ! ( lhs <= rhs, "Value creation from rounding detected" ) ;
53+ } else {
54+ let q = shares / total_shares;
55+ let r = shares % total_shares;
56+ if let Some ( term1) = q. checked_mul ( pool_balance) {
57+ if let Some ( term2_num) = r. checked_mul ( pool_balance) {
58+ let term2 = term2_num / total_shares;
59+ if let Some ( max_redeemable) = term1. checked_add ( term2) {
60+ assert ! ( redeemable <= max_redeemable, "Value creation from rounding detected" ) ;
61+ }
62+ }
63+ }
64+ }
65+ }
66+
67+ macro_rules! safe_call {
68+ ( $expr: expr) => {
69+ std:: panic:: catch_unwind( std:: panic:: AssertUnwindSafe ( || $expr) ) . unwrap_or_else( |e| {
70+ panic!( "Unexpected contract panic: {:?}" , e) ;
71+ } )
72+ } ;
73+ }
74+
75+ pub fn run_fuzz_logic ( data : FuzzAction ) {
4776 let env = Env :: default ( ) ;
4877 env. mock_all_auths ( ) ;
4978
@@ -58,6 +87,9 @@ fuzz_target!(|data: FuzzAction| {
5887 // 3. Initialize LendingPool with Admin (Contract client wrapper has 1 arg)
5988 let pool_admin = Address :: generate ( & env) ;
6089 pool_client. initialize ( & pool_admin) ;
90+
91+ // Disable withdrawal cooldown so we can test deposits and withdrawals in the same sequence
92+ pool_client. set_withdrawal_cooldown ( & 0 ) ;
6193
6294 match data {
6395 FuzzAction :: Deposit { user_id : _, amount } => {
@@ -71,13 +103,30 @@ fuzz_target!(|data: FuzzAction| {
71103 // Mint tokens to user
72104 stellar_asset_client. mint ( & user, & amount) ;
73105
74- let result = rcall!( & env, pool_client, "deposit" , ( user, token_id, amount) ) ;
106+ let result = std:: panic:: catch_unwind ( std:: panic:: AssertUnwindSafe ( || {
107+ rcall ! ( & env, pool_client, "deposit" , ( user, token_id, amount) )
108+ } ) ) ;
109+ let result = match result {
110+ Ok ( res) => res,
111+ Err ( err) => {
112+ panic ! ( "Contract deposit panicked unexpectedly: {:?}" , err) ;
113+ }
114+ } ;
75115
76116 if result. is_ok ( ) {
77- // Verify invariant: deposit should increase user balance
78- let balance = pool_client. get_deposit( & user, & token_id) ;
117+ // Verify invariants:
118+ // 1. Shares minted on deposit must be strictly positive for any positive deposit amount
119+ let shares = safe_call ! ( pool_client. get_shares( & user, & token_id) ) ;
120+ assert ! ( shares > 0 , "Shares minted on deposit must be strictly positive" ) ;
121+
122+ // 2. get_deposit's redeemable asset value must never be negative
123+ let balance = safe_call ! ( pool_client. get_deposit( & user, & token_id) ) ;
79124 assert ! ( balance >= 0 , "Balance should never be negative" ) ;
80- assert_eq!( balance, amount, "Balance should match deposited amount" ) ;
125+
126+ // 3. Redeemable value must never exceed what is mathematically possible (no value creation from rounding)
127+ let cur_total_shares = safe_call ! ( pool_client. get_total_shares( & token_id) ) ;
128+ let pool_balance = token_client. balance ( & pool_id) ;
129+ assert_no_value_creation ( shares, pool_balance, cur_total_shares, balance) ;
81130
82131 // Verify pool token balance
83132 assert_eq ! (
@@ -88,51 +137,75 @@ fuzz_target!(|data: FuzzAction| {
88137 }
89138 }
90139
91- FuzzAction :: Withdraw { user_id: _, amount } => {
140+ FuzzAction :: Withdraw { user_id : _, amount : shares_to_withdraw } => {
92141 let user = Address :: generate ( & env) ;
93142
94143 // Skip invalid amounts
95- if amount <= 0 {
144+ if shares_to_withdraw <= 0 {
96145 return ;
97146 }
98147
99- // First deposit some amount to allow withdrawal
100- let deposit_amount = match amount . checked_mul ( 2 ) {
101- Some ( v ) => v ,
102- None => return ,
103- } ;
148+ // First deposit some assets to mint shares to allow withdrawal.
149+ // Since it's a fresh pool, depositing ` deposit_amount` of assets will mint `deposit_amount` shares.
150+ // The minimum initial deposit is 1,000 assets (which mints 1,000 shares).
151+ // So we need deposit_amount >= 1,000 and deposit_amount >= shares_to_withdraw.
152+ let deposit_amount = std :: cmp :: max ( shares_to_withdraw , 1_000 ) ;
104153 stellar_asset_client. mint ( & user, & deposit_amount) ;
105- pool_client. deposit( & user, & token_id, & deposit_amount) ;
106154
107- let balance_before = pool_client. get_deposit( & user, & token_id) ;
108- let result = rcall!( & env, pool_client, "withdraw" , ( user, amount) ) ;
155+ let dep_result = std:: panic:: catch_unwind ( std:: panic:: AssertUnwindSafe ( || {
156+ pool_client. deposit ( & user, & token_id, & deposit_amount)
157+ } ) ) ;
158+ if dep_result. is_err ( ) {
159+ return ;
160+ }
161+
162+ let shares_before = safe_call ! ( pool_client. get_shares( & user, & token_id) ) ;
163+
164+ // withdraw's parameter is a share count, not an asset amount
165+ let result = std:: panic:: catch_unwind ( std:: panic:: AssertUnwindSafe ( || {
166+ rcall ! ( & env, pool_client, "withdraw" , ( user, token_id, shares_to_withdraw) )
167+ } ) ) ;
168+ let result = match result {
169+ Ok ( res) => res,
170+ Err ( err) => {
171+ panic ! ( "Contract withdraw panicked unexpectedly: {:?}" , err) ;
172+ }
173+ } ;
109174
110175 if result. is_ok ( ) {
111- let balance_after = pool_client. get_deposit( & user, & token_id) ;
176+ let shares_after = safe_call ! ( pool_client. get_shares( & user, & token_id) ) ;
177+ let balance_after = safe_call ! ( pool_client. get_deposit( & user, & token_id) ) ;
112178
113- // Verify invariant: balance should decrease by withdrawal amount
179+ // Verify invariants:
180+ // 1. shares decreased by shares_to_withdraw
114181 assert_eq ! (
115- balance_before - amount ,
116- balance_after ,
117- "Balance should decrease by withdrawal amount "
182+ shares_before - shares_to_withdraw ,
183+ shares_after ,
184+ "Shares should decrease by withdrawal share count "
118185 ) ;
186+
187+ // 2. get_deposit's redeemable asset value must never be negative
119188 assert ! ( balance_after >= 0 , "Balance should never be negative" ) ;
120189
121- // Verify pool token balance
122- assert_eq!(
123- token_client. balance( & pool_id) ,
124- deposit_amount - amount,
125- "Pool token balance mismatch after withdrawal"
126- ) ;
190+ // 3. Redeemable value must never exceed what is mathematically possible (no value creation from rounding)
191+ let cur_total_shares = safe_call ! ( pool_client. get_total_shares( & token_id) ) ;
192+ let pool_balance = token_client. balance ( & pool_id) ;
193+ assert_no_value_creation ( shares_after, pool_balance, cur_total_shares, balance_after) ;
127194 }
128195 }
129196
130197 FuzzAction :: GetDeposit { user_id : _ } => {
131198 let user = Address :: generate ( & env) ;
132- let balance = pool_client. get_deposit( & user, & token_id) ;
199+ let balance = safe_call ! ( pool_client. get_deposit( & user, & token_id) ) ;
133200
134201 // Verify invariant: balance should never be negative
135202 assert ! ( balance >= 0 , "Balance should never be negative" ) ;
203+
204+ // Verify no value creation from rounding
205+ let shares = safe_call ! ( pool_client. get_shares( & user, & token_id) ) ;
206+ let cur_total_shares = safe_call ! ( pool_client. get_total_shares( & token_id) ) ;
207+ let pool_balance = token_client. balance ( & pool_id) ;
208+ assert_no_value_creation ( shares, pool_balance, cur_total_shares, balance) ;
136209 }
137210
138211 FuzzAction :: MultipleOperations { operations } => {
@@ -151,30 +224,83 @@ fuzz_target!(|data: FuzzAction| {
151224 }
152225
153226 stellar_asset_client. mint ( & user_addr, & op. amount ) ;
154- let result = rcall!(
155- & env,
156- pool_client,
157- "deposit" ,
158- ( user_addr, token_id, op. amount)
159- ) ;
227+
228+ let shares_before = safe_call ! ( pool_client. get_shares( & user_addr, & token_id) ) ;
229+
230+ let result = std:: panic:: catch_unwind ( std:: panic:: AssertUnwindSafe ( || {
231+ rcall ! (
232+ & env,
233+ pool_client,
234+ "deposit" ,
235+ ( user_addr, token_id, op. amount)
236+ )
237+ } ) ) ;
238+ let result = match result {
239+ Ok ( res) => res,
240+ Err ( err) => {
241+ panic ! ( "Contract deposit panicked unexpectedly: {:?}" , err) ;
242+ }
243+ } ;
244+
160245 if result. is_ok ( ) {
161246 total_expected_deposits += op. amount ;
247+
248+ // Verify invariants:
249+ // 1. Shares minted on deposit must be strictly positive
250+ let shares_after = safe_call ! ( pool_client. get_shares( & user_addr, & token_id) ) ;
251+ assert ! ( shares_after > shares_before, "Shares minted on deposit must be strictly positive" ) ;
252+
253+ // 2. get_deposit's redeemable asset value must never be negative
254+ let balance = safe_call ! ( pool_client. get_deposit( & user_addr, & token_id) ) ;
255+ assert ! ( balance >= 0 , "Balance should never be negative" ) ;
256+
257+ // 3. Redeemable value must never exceed what is mathematically possible (no value creation from rounding)
258+ let cur_total_shares = safe_call ! ( pool_client. get_total_shares( & token_id) ) ;
259+ let pool_balance = token_client. balance ( & pool_id) ;
260+ assert_no_value_creation ( shares_after, pool_balance, cur_total_shares, balance) ;
162261 }
163262 } else {
164- if op. amount <= 0 {
263+ let shares_to_withdraw = op. amount ;
264+ if shares_to_withdraw <= 0 {
165265 continue ;
166266 }
167267
168- // Attempt withdrawal
169- let result = rcall!( & env, pool_client, "withdraw" , ( user_addr, op. amount) ) ;
170- if result. is_ok( ) {
171- total_expected_deposits -= op. amount;
172- } else {
173- // If it fails, balance should be verified or we just continue
174- let balance = pool_client. get_deposit( & user_addr, & token_id) ;
175- if balance < op. amount {
176- // Expected failure
268+ let shares_before = safe_call ! ( pool_client. get_shares( & user_addr, & token_id) ) ;
269+ let pool_balance_before = token_client. balance ( & pool_id) ;
270+
271+ // withdraw's parameter is a share count, not an asset amount
272+ let result = std:: panic:: catch_unwind ( std:: panic:: AssertUnwindSafe ( || {
273+ rcall ! ( & env, pool_client, "withdraw" , ( user_addr, token_id, shares_to_withdraw) )
274+ } ) ) ;
275+ let result = match result {
276+ Ok ( res) => res,
277+ Err ( err) => {
278+ panic ! ( "Contract withdraw panicked unexpectedly: {:?}" , err) ;
177279 }
280+ } ;
281+
282+ if result. is_ok ( ) {
283+ let pool_balance_after = token_client. balance ( & pool_id) ;
284+ let assets_withdrawn = pool_balance_before - pool_balance_after;
285+ total_expected_deposits -= assets_withdrawn;
286+
287+ let shares_after = safe_call ! ( pool_client. get_shares( & user_addr, & token_id) ) ;
288+ let balance_after = safe_call ! ( pool_client. get_deposit( & user_addr, & token_id) ) ;
289+
290+ // Verify invariants:
291+ // 1. shares decreased by shares_to_withdraw
292+ assert_eq ! (
293+ shares_before - shares_to_withdraw,
294+ shares_after,
295+ "Shares should decrease by withdrawal share count"
296+ ) ;
297+
298+ // 2. get_deposit's redeemable asset value must never be negative
299+ assert ! ( balance_after >= 0 , "Balance should never be negative" ) ;
300+
301+ // 3. Redeemable value must never exceed what is mathematically possible (no value creation from rounding)
302+ let cur_total_shares = safe_call ! ( pool_client. get_total_shares( & token_id) ) ;
303+ assert_no_value_creation ( shares_after, pool_balance_after, cur_total_shares, balance_after) ;
178304 }
179305 }
180306 }
@@ -186,13 +312,51 @@ fuzz_target!(|data: FuzzAction| {
186312 "Total deposits should match pool token balance"
187313 ) ;
188314
189- // Verify all individual balances are non-negative
315+ // Verify all individual balances are non-negative and satisfy the no value creation invariant
190316 for ( _, user_addr) in users {
191- assert!(
192- pool_client. get_deposit( & user_addr, & token_id) >= 0 ,
193- "Individual balance should never be negative"
194- ) ;
317+ let shares = safe_call ! ( pool_client. get_shares( & user_addr, & token_id) ) ;
318+ let balance = safe_call ! ( pool_client. get_deposit( & user_addr, & token_id) ) ;
319+ assert ! ( balance >= 0 , "Individual balance should never be negative" ) ;
320+
321+ let cur_total_shares = safe_call ! ( pool_client. get_total_shares( & token_id) ) ;
322+ let pool_balance = token_client. balance ( & pool_id) ;
323+ assert_no_value_creation ( shares, pool_balance, cur_total_shares, balance) ;
195324 }
196325 }
197326 }
327+ }
328+
329+ fuzz_target ! ( |data: FuzzAction | {
330+ run_fuzz_logic( data) ;
198331} ) ;
332+
333+ #[ cfg( test) ]
334+ mod tests {
335+ use super :: * ;
336+
337+ #[ test]
338+ fn test_fuzz_sanity ( ) {
339+ let test_cases = vec ! [
340+ FuzzAction :: Deposit { user_id: 1 , amount: 500 } , // < MINIMUM_INITIAL_DEPOSIT (should fail)
341+ FuzzAction :: Deposit { user_id: 1 , amount: 2000 } , // OK
342+ FuzzAction :: Deposit { user_id: 2 , amount: 1500 } , // OK
343+ FuzzAction :: Withdraw { user_id: 1 , amount: 500 } , // OK (withdraws 500 shares)
344+ FuzzAction :: Withdraw { user_id: 2 , amount: 3000 } , // exceeds shares (should fail)
345+ FuzzAction :: GetDeposit { user_id: 1 } ,
346+ FuzzAction :: GetDeposit { user_id: 3 } , // new user (0 shares)
347+ FuzzAction :: MultipleOperations {
348+ operations: vec![
349+ Operation { user_id: 1 , amount: 1500 , is_deposit: true } ,
350+ Operation { user_id: 2 , amount: 2500 , is_deposit: true } ,
351+ Operation { user_id: 1 , amount: 500 , is_deposit: false } ,
352+ Operation { user_id: 2 , amount: 1000 , is_deposit: false } ,
353+ Operation { user_id: 3 , amount: 100 , is_deposit: false } , // should fail
354+ ]
355+ }
356+ ] ;
357+
358+ for case in test_cases {
359+ run_fuzz_logic ( case) ;
360+ }
361+ }
362+ }
0 commit comments