Skip to content

Commit 5b4583c

Browse files
authored
Merge pull request #616 from KorexOnchain/fix/reputation-decay-601-unbounded-loop
2 parents db871af + 1ee23d8 commit 5b4583c

7 files changed

Lines changed: 5193 additions & 25 deletions

contracts/invoice_liquidity/src/constants.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ pub const UPGRADE_COOLDOWN_LEDGERS: u64 = 1440;
2525
/// Rate limit cooldown for economic parameters — 30 minutes (360 ledgers).
2626
pub const ECONOMIC_PARAM_COOLDOWN_LEDGERS: u64 = 360;
2727

28+
// ----------------------------------------------------------------
29+
// Reputation Decay Bounds (Issue #601)
30+
// ----------------------------------------------------------------
31+
32+
/// Maximum number of decay periods `get_payer_score` will iterate before
33+
/// short-circuiting the score to zero. See invoice.rs for full rationale.
34+
pub const MAX_REPUTATION_DECAY_PERIODS: u64 = 1000;
35+
2836
/// Minimum number of ledgers that must elapse between the first LP joining the
2937
/// fund queue and `resolve_fund_queue` being callable. At ~5 s per ledger,
3038
/// 120 ledgers ≈ 10 minutes, giving other LPs a fair window to join.

contracts/invoice_liquidity/src/invoice.rs

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -481,16 +481,23 @@ pub fn get_payer_score(env: &Env, payer: &Address) -> u32 {
481481
u64::from(ledgers_since_activity) / decay_config.decay_period_ledgers;
482482

483483
// Apply decay: score = score * (1 - decay_rate/10000)^periods
484-
let mut decayed_score = rep.score as u64;
485-
for _ in 0..periods_passed {
486-
// Decay: subtract decay_rate_bps basis points (min 1 point)
487-
let mut decay_amount =
488-
(decayed_score * decay_config.decay_rate_bps as u64) / 10_000;
489-
if decay_amount == 0 && decayed_score > 0 {
490-
decay_amount = 1;
484+
// Issue #601: periods_passed is unbounded (governance-
485+
// configurable decay_period_ledgers can be set to 1),
486+
// so cap iteration and short-circuit to 0 beyond that.
487+
let decayed_score: u64 = if periods_passed > crate::constants::MAX_REPUTATION_DECAY_PERIODS {
488+
0
489+
} else {
490+
let mut decayed_score = rep.score as u64;
491+
for _ in 0..periods_passed {
492+
let mut decay_amount =
493+
(decayed_score * decay_config.decay_rate_bps as u64) / 10_000;
494+
if decay_amount == 0 && decayed_score > 0 {
495+
decay_amount = 1;
496+
}
497+
decayed_score = decayed_score.saturating_sub(decay_amount);
491498
}
492-
decayed_score = decayed_score.saturating_sub(decay_amount);
493-
}
499+
decayed_score
500+
};
494501

495502
let new_score = (decayed_score.min(100)) as u32;
496503
if new_score != rep.score {

contracts/invoice_liquidity/src/test.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,84 @@ fn test_reputation_score_never_goes_below_zero() {
12071207
assert_eq!(score, 0, "Score should floor at 0, not go negative");
12081208
}
12091209

1210+
// ----------------------------------------------------------------
1211+
// Regression tests — issue #601: bound reputation decay loop
1212+
// ----------------------------------------------------------------
1213+
1214+
#[test]
1215+
fn test_reputation_decay_bounded_for_extremely_long_inactivity() {
1216+
let t = setup();
1217+
1218+
t.env.as_contract(&t.contract.address, || {
1219+
invoice::set_payer_score(&t.env, &t.payer, 80);
1220+
});
1221+
1222+
let config = Config {
1223+
high_rep_threshold: 80,
1224+
bonus_bps: 200,
1225+
min_discount_rate_bps: 100,
1226+
decay_rate_bps: 100,
1227+
decay_period_ledgers: 2,
1228+
dispute_timeout_ledgers: 100,
1229+
xlm_sac_address: Address::generate(&t.env),
1230+
usdc_sac_address: Address::generate(&t.env),
1231+
eurc_sac_address: Address::generate(&t.env),
1232+
price_oracle: None,
1233+
max_oracle_age_ledgers: 17280,
1234+
};
1235+
t.env.as_contract(&t.contract.address, || {
1236+
crate::storage::set_config(&t.env, &config);
1237+
t.env.storage().instance().extend_ttl(1_000_000, 2_000_000);
1238+
});
1239+
1240+
// periods_passed = 2,500 / 2 = 1,250 (1.25x the cap) — sustained
1241+
// long-term inactivity under a normal (non-griefing) decay period.
1242+
let mut ledger = t.env.ledger().get();
1243+
ledger.sequence_number += 2_500;
1244+
t.env.ledger().set(ledger);
1245+
1246+
let score = t.contract.payer_score(&t.payer);
1247+
1248+
assert_eq!(score, 0, "Score for a long-inactive payer should floor at 0, not hang or panic");
1249+
}
1250+
1251+
#[test]
1252+
fn test_reputation_decay_bounded_when_decay_period_is_one_ledger() {
1253+
let t = setup();
1254+
1255+
t.env.as_contract(&t.contract.address, || {
1256+
invoice::set_payer_score(&t.env, &t.payer, 80);
1257+
});
1258+
1259+
// The exact griefing scenario from issue #601: decay_period_ledgers=1.
1260+
let config = Config {
1261+
high_rep_threshold: 80,
1262+
bonus_bps: 200,
1263+
min_discount_rate_bps: 100,
1264+
decay_rate_bps: 100,
1265+
decay_period_ledgers: 1,
1266+
dispute_timeout_ledgers: 100,
1267+
xlm_sac_address: Address::generate(&t.env),
1268+
usdc_sac_address: Address::generate(&t.env),
1269+
eurc_sac_address: Address::generate(&t.env),
1270+
price_oracle: None,
1271+
max_oracle_age_ledgers: 17280,
1272+
};
1273+
t.env.as_contract(&t.contract.address, || {
1274+
crate::storage::set_config(&t.env, &config);
1275+
t.env.storage().instance().extend_ttl(1_000_000, 2_000_000);
1276+
});
1277+
1278+
// periods_passed = 1,500 (1.5x the cap) with decay_period_ledgers=1.
1279+
let mut ledger = t.env.ledger().get();
1280+
ledger.sequence_number += 1_500;
1281+
t.env.ledger().set(ledger);
1282+
1283+
let score = t.contract.payer_score(&t.payer);
1284+
1285+
assert_eq!(score, 0, "decay_period_ledgers=1 with a large gap should floor at 0, not hang or panic");
1286+
}
1287+
12101288
#[test]
12111289
fn test_reputation_score_never_exceeds_100() {
12121290
let t = setup();

contracts/invoice_liquidity/src/tests_new_features.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ fn test_batch_submit_all_valid_invoices() {
580580
let result = t.contract.try_submit_invoices_batch(&batch);
581581
assert!(result.is_ok());
582582

583-
let ids = result.unwrap();
583+
let ids = result.unwrap().unwrap();
584584
assert_eq!(ids.len(), 5);
585585

586586
// Verify all invoices were created with sequential IDs
@@ -673,7 +673,7 @@ fn test_batch_submit_referral_tracking() {
673673
let result = t.contract.try_submit_invoices_batch(&batch);
674674
assert!(result.is_ok());
675675

676-
let ids = result.unwrap();
676+
let ids = result.unwrap().unwrap();
677677
assert_eq!(ids.len(), 3);
678678

679679
// Verify referral count was incremented
@@ -708,7 +708,7 @@ fn test_batch_submit_exact_10_invoices_succeeds() {
708708
let result = t.contract.try_submit_invoices_batch(&batch);
709709
assert!(result.is_ok());
710710

711-
let ids = result.unwrap();
711+
let ids = result.unwrap().unwrap();
712712
assert_eq!(ids.len(), 10);
713713
}
714714

contracts/invoice_liquidity/src/tests_storage_layout.rs

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@
22
mod tests {
33
use crate::invoice::{InvoiceCore, InvoiceMetadata, Invoice, InvoiceStatus, ReferralCode};
44
use soroban_sdk::testutils::Address as TestAddress;
5-
use soroban_sdk::Address;
5+
use soroban_sdk::{Address, Env};
66

77
#[test]
88
fn test_invoice_to_core_split() {
9+
let env = Env::default();
910
// Create a full invoice
1011
let invoice = Invoice {
1112
id: 123,
12-
freelancer: TestAddress::random(),
13-
payer: TestAddress::random(),
14-
token: TestAddress::random(),
13+
freelancer: Address::generate(&env),
14+
payer: Address::generate(&env),
15+
token: Address::generate(&env),
1516
amount: 1_000_000,
1617
due_date: 1234567890,
1718
discount_rate: 300,
1819
status: InvoiceStatus::Pending,
19-
funder: Some(TestAddress::random()),
20+
funder: Some(Address::generate(&env)),
2021
funded_at: Some(1234567800),
2122
amount_funded: 0,
2223
amount_paid: 0,
@@ -43,11 +44,12 @@ mod tests {
4344

4445
#[test]
4546
fn test_invoice_core_with_metadata_roundtrip() {
47+
let env = Env::default();
4648
// Create core and metadata
47-
let freelancer = TestAddress::random();
48-
let payer = TestAddress::random();
49-
let token = TestAddress::random();
50-
let funder = TestAddress::random();
49+
let freelancer = Address::generate(&env);
50+
let payer = Address::generate(&env);
51+
let token = Address::generate(&env);
52+
let funder = Address::generate(&env);
5153

5254
let core = InvoiceCore {
5355
id: 456,
@@ -94,14 +96,15 @@ mod tests {
9496

9597
#[test]
9698
fn test_invoice_hot_cold_separation_consistency() {
99+
let env = Env::default();
97100
// Test that extracting hot/cold and recombining gives same result
98101
let invoice = Invoice {
99102
id: 789,
100-
freelancer: TestAddress::random(),
101-
payer: TestAddress::random(),
102-
token: TestAddress::random(),
103+
freelancer: Address::generate(&env),
104+
payer: Address::generate(&env),
105+
token: Address::generate(&env),
103106
amount: 5_000_000,
104-
due_date: 9876543210,
107+
due_date: 987654321,
105108
discount_rate: 100,
106109
status: InvoiceStatus::PartiallyFunded,
107110
funder: None,

0 commit comments

Comments
 (0)