Skip to content

Commit fc9b37a

Browse files
committed
feat: add missing test coverage for issues #479, #484, #485, #486
Issue #479 - transfer_lp_position tests: - Test transfer rejects non-funded and partially funded invoices - Test transfer succeeds for fully funded invoices - Test rejection for same LP transfer - Test funders list and LP index updates - Test event emission on transfer Issue #484 - set_max_oracle_age tests: - Test admin can set max oracle age - Test setting affects fund_invoice oracle check - Test unauthorized access rejection Issue #485 - get_contract_stats multi-token tests: - Test stats with USDC, XLM, and EURC volumes independently - Test total_volume_usd_normalized calculation with price oracle - Test stats without price oracle returns zero normalized - Test multi-token aggregation across all three tokens Issue #486 - LP priority queue edge cases: - Test tie-breaking with three LPs (first-come-first-served) - Test empty queue rejection - Test queue join rejected after resolution - Test queue join rejected for funded, paid, cancelled, expired invoices - Test non-approved LP cannot fund with 4-arg signature - Test queue lifecycle with different reputation scores
1 parent 9da12b1 commit fc9b37a

4 files changed

Lines changed: 588 additions & 0 deletions

File tree

contracts/invoice_liquidity/src/tests_benchmarks.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,182 @@ fn benchmark_all_functions_summary() {
140140
std::println!("| {name:<14} | {cpu:>16} | {mem:>14} |");
141141
}
142142
}
143+
144+
// ================================================================
145+
// Tests for get_contract_stats with multiple tokens (Issue #485)
146+
// ================================================================
147+
148+
use soroban_sdk::{contract, contractimpl, token::Client as TokenClient};
149+
150+
#[contract]
151+
struct MockPriceOracle;
152+
153+
#[contractimpl]
154+
impl MockPriceOracle {
155+
pub fn get_price(_env: soroban_sdk::Env, _token: Address) -> i128 {
156+
20_000
157+
}
158+
}
159+
160+
struct MultiTokenBenchEnv {
161+
env: Env,
162+
contract: InvoiceLiquidityContractClient<'static>,
163+
usdc: Address,
164+
usdc_client: TokenClient<'static>,
165+
xlm: Address,
166+
eurc: Address,
167+
freelancer: Address,
168+
payer: Address,
169+
lp: Address,
170+
admin: Address,
171+
}
172+
173+
fn setup_multi_token() -> MultiTokenBenchEnv {
174+
let env = Env::default();
175+
env.mock_all_auths();
176+
177+
let mut ledger = env.ledger().get();
178+
ledger.timestamp = 1_700_000_000;
179+
env.ledger().set(ledger);
180+
181+
let usdc_admin = Address::generate(&env);
182+
let usdc = env.register_stellar_asset_contract_v2(usdc_admin.clone());
183+
let xlm_admin = Address::generate(&env);
184+
let xlm = env.register_stellar_asset_contract_v2(xlm_admin);
185+
let eurc_admin = Address::generate(&env);
186+
let eurc = env.register_stellar_asset_contract_v2(eurc_admin);
187+
188+
let admin = usdc_admin.clone();
189+
let contract_id = env.register_contract(None, InvoiceLiquidityContract);
190+
let contract = InvoiceLiquidityContractClient::new(&env, &contract_id);
191+
contract.initialize(&admin, &usdc.address(), &eurc.address(), &xlm.address());
192+
193+
let freelancer = Address::generate(&env);
194+
let payer = Address::generate(&env);
195+
let lp = Address::generate(&env);
196+
197+
let usdc_client = StellarAssetClient::new(&env, &usdc.address());
198+
let usdc_token = TokenClient::new(&env, &usdc.address());
199+
let xlm_client = StellarAssetClient::new(&env, &xlm.address());
200+
let eurc_client = StellarAssetClient::new(&env, &eurc.address());
201+
202+
usdc_client.mint(&lp, &10_000_000_000_000);
203+
usdc_client.mint(&payer, &10_000_000_000_000);
204+
xlm_client.mint(&lp, &10_000_000_000_000);
205+
xlm_client.mint(&payer, &10_000_000_000_000);
206+
eurc_client.mint(&lp, &10_000_000_000_000);
207+
eurc_client.mint(&payer, &10_000_000_000_000);
208+
209+
MultiTokenBenchEnv {
210+
env,
211+
contract,
212+
usdc: usdc.address(),
213+
usdc_client,
214+
xlm: xlm.address(),
215+
eurc: eurc.address(),
216+
freelancer,
217+
payer,
218+
lp,
219+
admin,
220+
}
221+
}
222+
223+
fn submit_and_fund(
224+
t: &MultiTokenBenchEnv,
225+
token: &Address,
226+
amount: i128,
227+
) -> u64 {
228+
let due_date = t.env.ledger().timestamp() + 86_400 * 30;
229+
let id = t.contract.submit_invoice(
230+
&t.freelancer,
231+
&t.payer,
232+
&amount,
233+
&due_date,
234+
&300u32,
235+
token,
236+
&ReferralCode::None,
237+
);
238+
t.contract.fund_invoice(&t.lp, &id, &amount, &false);
239+
t.contract.mark_paid(&id, &amount);
240+
id
241+
}
242+
243+
#[test]
244+
fn test_stats_tracks_usdc_volume() {
245+
let t = setup_multi_token();
246+
submit_and_fund(&t, &t.usdc, 500_000_000);
247+
248+
let stats = t.contract.get_contract_stats();
249+
assert_eq!(stats.total_volume_usdc, 500_000_000);
250+
assert_eq!(stats.total_invoices, 1);
251+
assert_eq!(stats.total_funded, 1);
252+
assert_eq!(stats.total_paid, 1);
253+
}
254+
255+
#[test]
256+
fn test_stats_tracks_xlm_volume() {
257+
let t = setup_multi_token();
258+
submit_and_fund(&t, &t.xlm, 2_000_000_000);
259+
260+
let stats = t.contract.get_contract_stats();
261+
assert_eq!(stats.total_volume_xlm, 2_000_000_000);
262+
assert_eq!(stats.total_volume_usdc, 0);
263+
}
264+
265+
#[test]
266+
fn test_stats_tracks_eurc_volume() {
267+
let t = setup_multi_token();
268+
submit_and_fund(&t, &t.eurc, 750_000_000);
269+
270+
let stats = t.contract.get_contract_stats();
271+
assert_eq!(stats.total_volume_eurc, 750_000_000);
272+
assert_eq!(stats.total_volume_usdc, 0);
273+
assert_eq!(stats.total_volume_xlm, 0);
274+
}
275+
276+
#[test]
277+
fn test_stats_multi_token_aggregation() {
278+
let t = setup_multi_token();
279+
submit_and_fund(&t, &t.usdc, 1_000_000_000);
280+
submit_and_fund(&t, &t.xlm, 2_000_000_000);
281+
submit_and_fund(&t, &t.eurc, 3_000_000_000);
282+
283+
let stats = t.contract.get_contract_stats();
284+
assert_eq!(stats.total_invoices, 3);
285+
assert_eq!(stats.total_funded, 3);
286+
assert_eq!(stats.total_paid, 3);
287+
assert_eq!(stats.total_volume_usdc, 1_000_000_000);
288+
assert_eq!(stats.total_volume_xlm, 2_000_000_000);
289+
assert_eq!(stats.total_volume_eurc, 3_000_000_000);
290+
}
291+
292+
#[test]
293+
fn test_stats_usd_normalized_with_price_oracle() {
294+
let t = setup_multi_token();
295+
submit_and_fund(&t, &t.usdc, 1_000_000_000);
296+
297+
// Install a mock price oracle that returns a fixed price.
298+
let oracle_id = t.env.register_contract(None, MockPriceOracle);
299+
t.env.as_contract(&t.contract.address, || {
300+
let mut config = crate::storage::get_config(&t.env).unwrap();
301+
config.price_oracle = Some(oracle_id.clone());
302+
crate::storage::set_config(&t.env, &config);
303+
});
304+
305+
let stats = t.contract.get_contract_stats();
306+
// MockPriceOracle returns 20_000; normalized = 1_000_000_000 * 20_000 / 10_000
307+
assert_eq!(
308+
stats.total_volume_usd_normalized,
309+
1_000_000_000 * 20_000 / 10_000
310+
);
311+
}
312+
313+
#[test]
314+
fn test_stats_without_price_oracle_returns_zero_normalized() {
315+
let t = setup_multi_token();
316+
submit_and_fund(&t, &t.usdc, 1_000_000_000);
317+
318+
// No oracle configured — should return 0 for normalized volume.
319+
let stats = t.contract.get_contract_stats();
320+
assert_eq!(stats.total_volume_usd_normalized, 0);
321+
}

contracts/invoice_liquidity/src/tests_lp_priority_queue.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,158 @@ fn test_full_queue_lifecycle_with_payout() {
293293
let invoice = t.contract.get_invoice(&id);
294294
assert_eq!(invoice.status, InvoiceStatus::Paid);
295295
}
296+
297+
// ================================================================
298+
// Tests for LP priority queue edge cases (Issue #486)
299+
// ================================================================
300+
301+
#[test]
302+
fn test_tie_breaking_three_lps_first_come_wins() {
303+
let t = setup_queue();
304+
let id = submit_invoice(&t);
305+
306+
// All three LPs have default score 50.
307+
t.contract.join_fund_queue(&t.lp_a, &id); // 1st
308+
t.contract.join_fund_queue(&t.lp_b, &id); // 2nd
309+
t.contract.join_fund_queue(&t.lp_c, &id); // 3rd
310+
311+
let winner = t.contract.resolve_fund_queue(&id);
312+
assert_eq!(winner, t.lp_a, "first LP should win on three-way tie");
313+
}
314+
315+
#[test]
316+
fn test_resolve_empty_queue_rejected() {
317+
let t = setup_queue();
318+
let id = submit_invoice(&t);
319+
320+
let result = t.contract.try_resolve_fund_queue(&id);
321+
assert_eq!(result, Err(Ok(ContractError::NotFunded)));
322+
}
323+
324+
#[test]
325+
fn test_join_queue_after_invoice_resolved_rejected() {
326+
let t = setup_queue();
327+
let id = submit_invoice(&t);
328+
329+
t.contract.join_fund_queue(&t.lp_a, &id);
330+
t.contract.resolve_fund_queue(&id);
331+
332+
let result = t.contract.try_join_fund_queue(&t.lp_c, &id);
333+
assert_eq!(result, Err(Ok(ContractError::NotApprovedFunder)));
334+
}
335+
336+
#[test]
337+
fn test_join_queue_for_funded_invoice_rejected() {
338+
let t = setup_queue();
339+
let id = submit_invoice(&t);
340+
341+
t.contract
342+
.fund_invoice(&t.lp_a, &id, &INVOICE_AMOUNT, &false);
343+
344+
let result = t.contract.try_join_fund_queue(&t.lp_b, &id);
345+
assert_eq!(result, Err(Ok(ContractError::AlreadyFunded)));
346+
}
347+
348+
#[test]
349+
fn test_join_queue_for_paid_invoice_rejected() {
350+
let t = setup_queue();
351+
let id = submit_invoice(&t);
352+
353+
t.contract
354+
.fund_invoice(&t.lp_a, &id, &INVOICE_AMOUNT, &false);
355+
t.contract.mark_paid(&id, &INVOICE_AMOUNT);
356+
357+
let result = t.contract.try_join_fund_queue(&t.lp_b, &id);
358+
assert_eq!(result, Err(Ok(ContractError::AlreadyPaid)));
359+
}
360+
361+
#[test]
362+
fn test_join_queue_for_cancelled_invoice_rejected() {
363+
let t = setup_queue();
364+
let id = submit_invoice(&t);
365+
366+
t.contract.cancel_invoice(&id);
367+
368+
let result = t.contract.try_join_fund_queue(&t.lp_a, &id);
369+
assert_eq!(result, Err(Ok(ContractError::AlreadyCancelled)));
370+
}
371+
372+
#[test]
373+
fn test_join_queue_for_expired_invoice_rejected() {
374+
let t = setup_queue();
375+
let id = submit_invoice(&t);
376+
377+
// Advance time past due date to make it expirable.
378+
let mut ledger = t.env.ledger().get();
379+
ledger.timestamp += DUE_DATE_OFFSET + 1;
380+
t.env.ledger().set(ledger);
381+
382+
t.contract.expire_invoice(&id);
383+
384+
let result = t.contract.try_join_fund_queue(&t.lp_a, &id);
385+
assert_eq!(result, Err(Ok(ContractError::InvoiceExpired)));
386+
}
387+
388+
#[test]
389+
fn test_non_approved_lp_cannot_fund_with_4_arg_signature() {
390+
let t = setup_queue();
391+
let id = submit_invoice(&t);
392+
393+
t.contract.join_fund_queue(&t.lp_a, &id);
394+
t.contract.resolve_fund_queue(&id);
395+
396+
// lp_b is not the approved LP — should fail with 4-arg fund_invoice.
397+
let result = t.contract.try_fund_invoice(&t.lp_b, &id, &INVOICE_AMOUNT, &false);
398+
assert_eq!(result, Err(Ok(ContractError::NotApprovedFunder)));
399+
}
400+
401+
#[test]
402+
fn test_queue_lifecycle_with_different_scores() {
403+
let t = setup_queue();
404+
let id = submit_invoice(&t);
405+
406+
// Boost lp_c's score by having it fund several invoices.
407+
for _ in 0..5u32 {
408+
let extra_id = submit_invoice(&t);
409+
t.contract
410+
.fund_invoice(&t.lp_c, &extra_id, &INVOICE_AMOUNT, &false);
411+
}
412+
// lp_c score is now 55, lp_a=50, lp_b=50.
413+
414+
t.contract.join_fund_queue(&t.lp_a, &id);
415+
t.contract.join_fund_queue(&t.lp_b, &id);
416+
t.contract.join_fund_queue(&t.lp_c, &id);
417+
418+
let winner = t.contract.resolve_fund_queue(&id);
419+
assert_eq!(winner, t.lp_c, "highest score should win regardless of join order");
420+
421+
t.contract
422+
.fund_invoice(&t.lp_c, &id, &INVOICE_AMOUNT, &false);
423+
t.contract.mark_paid(&id, &INVOICE_AMOUNT);
424+
425+
let invoice = t.contract.get_invoice(&id);
426+
assert_eq!(invoice.status, InvoiceStatus::Paid);
427+
}
428+
429+
#[test]
430+
fn test_resolve_queue_only_once_stored() {
431+
let t = setup_queue();
432+
let id = submit_invoice(&t);
433+
434+
t.contract.join_fund_queue(&t.lp_a, &id);
435+
let first_winner = t.contract.resolve_fund_queue(&id);
436+
437+
// Adding a new LP with higher score after resolution doesn't change result.
438+
let boosted_id = submit_invoice(&t);
439+
for _ in 0..10u32 {
440+
let extra = submit_invoice(&t);
441+
t.contract
442+
.fund_invoice(&t.lp_b, &extra, &INVOICE_AMOUNT, &false);
443+
}
444+
t.contract
445+
.fund_invoice(&t.lp_b, &boosted_id, &INVOICE_AMOUNT, &false);
446+
447+
// Can't join after resolution anyway, but resolve is idempotent.
448+
let second_winner = t.contract.resolve_fund_queue(&id);
449+
assert_eq!(first_winner, second_winner);
450+
}

0 commit comments

Comments
 (0)