Skip to content

Commit 30a42b2

Browse files
authored
Merge pull request #41 from K1NGD4VID/fuzz_target_1
Update lending pool fuzz target for LP-share model
2 parents e009622 + 6b4209e commit 30a42b2

5 files changed

Lines changed: 1495 additions & 64 deletions

File tree

fuzz/Cargo.toml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,6 @@ path = "../remittance_nft"
2424
[dependencies.multisig_governance]
2525
path = "../multisig_governance"
2626

27-
[[bin]]
28-
name = "fuzz_target_1"
29-
path = "fuzz_targets/fuzz_target_1.rs"
30-
test = false
31-
doc = false
32-
bench = false
33-
3427
[[bin]]
3528
name = "lending_pool_fuzz"
3629
path = "fuzz_targets/lending_pool_fuzz.rs"

fuzz/fuzz_targets/fuzz_target_1.rs

Lines changed: 0 additions & 7 deletions
This file was deleted.

fuzz/fuzz_targets/lending_pool_fuzz.rs

Lines changed: 214 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ macro_rules! rcall {
1818
};
1919
}
2020

21-
#[derive(Arbitrary, Debug)]
21+
#[derive(Arbitrary, Debug, Clone)]
2222
enum 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)]
3030
struct 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

Comments
 (0)