Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6,416 changes: 3,268 additions & 3,148 deletions contracts/claims-processor/src/lib.rs

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions contracts/claims-processor/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ fn create_crop_product(w: &World) -> u128 {
name: symbol_short!("crop_kism"),
category: symbol_short!("crop"),
oracle_key: symbol_short!("kis2606"),
expected_token: w.usdc.clone(),
trigger_type: TriggerType::Threshold,
oracle_data_type: symbol_short!("weather"),
trigger_threshold: 50_000_000,
Expand All @@ -118,7 +119,7 @@ fn buy_crop_policy(w: &World, buyer: &Address, product_id: u128) -> u128 {
StellarAssetClient::new(&w.env, &w.usdc).mint(&w.policy_id, &10_000_000_000i128);

let policy_id = PolicyEngineClient::new(&w.env, &w.policy_id)
.buy_policy(buyer, &product_id, &COVERAGE, &30u32, &symbol_short!("kis2606"));
.buy_policy(buyer, &product_id, &COVERAGE, &30u32, &symbol_short!("kis2606"), &w.usdc);

// Lock coverage in the pool for this policy
RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &policy_id, &COVERAGE);
Expand Down Expand Up @@ -1711,7 +1712,7 @@ fn test_payout_delay_within_bounds() {
let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id);
let delay = 365 * 24 * 60 * 60; // Exact max
cp.set_payout_delay(&w.admin, &delay);
assert_eq!(cp.get_payout_delay(), delay);
assert_eq!(cp.get_payout_delay(), delay as u64);
}

#[test]
Expand Down
7 changes: 7 additions & 0 deletions contracts/claims-processor/src/test_advanced.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ fn create_crop_product(w: &World) -> u128 {
name: symbol_short!("crop_kism"),
category: symbol_short!("crop"),
oracle_key: symbol_short!("kis2606"),
expected_token: w.usdc.clone(),
trigger_type: TriggerType::Threshold,
oracle_data_type: symbol_short!("weather"),
trigger_threshold: 50_000_000,
Expand All @@ -140,6 +141,7 @@ fn buy_crop_policy(w: &World, buyer: &Address, product_id: u128) -> u128 {
&COVERAGE,
&30u32,
&symbol_short!("kis2606"),
&w.usdc,
);

// Lock coverage in the pool for this policy
Expand Down Expand Up @@ -297,6 +299,7 @@ fn test_staleness_threshold_boundary() {
name: symbol_short!("crop"),
category: symbol_short!("crop"),
oracle_key: symbol_short!("kis2606"),
expected_token: usdc.clone(),
trigger_type: TriggerType::Threshold,
oracle_data_type: symbol_short!("weather"),
trigger_threshold: 50_000_000,
Expand All @@ -319,6 +322,7 @@ fn test_staleness_threshold_boundary() {
&COVERAGE,
&30u32,
&symbol_short!("kis2606"),
&usdc,
);
RiskPoolClient::new(&env, &pool_id).lock_for_policy(&admin, &pol_id, &COVERAGE);

Expand Down Expand Up @@ -414,6 +418,7 @@ fn test_maximum_coverage_boundary() {
name: symbol_short!("max_cov"),
category: symbol_short!("crop"),
oracle_key: symbol_short!("kis2606"),
expected_token: w.usdc.clone(),
trigger_type: TriggerType::Threshold,
oracle_data_type: symbol_short!("weather"),
trigger_threshold: 50_000_000,
Expand All @@ -437,6 +442,7 @@ fn test_maximum_coverage_boundary() {
&10_000_000_000i128,
&30u32,
&symbol_short!("kis2606"),
&w.usdc,
);
RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &pol_id, &10_000_000_000i128);

Expand Down Expand Up @@ -466,6 +472,7 @@ fn test_minimum_coverage_boundary() {
&100_000_000i128,
&30u32,
&symbol_short!("kis2606"),
&w.usdc,
);
RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &pol_id, &100_000_000i128);

Expand Down
2 changes: 2 additions & 0 deletions contracts/claims-processor/src/test_claim_uniqueness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ fn create_crop_product(w: &World) -> u128 {
name: symbol_short!("uniq_cism"),
category: symbol_short!("crop"),
oracle_key: symbol_short!("kis2606"),
expected_token: w.usdc.clone(),
trigger_type: TriggerType::Threshold,
oracle_data_type: symbol_short!("weather"),
trigger_threshold: 50_000_000,
Expand Down Expand Up @@ -167,6 +168,7 @@ fn buy_policies(w: &mut World, buyer: &Address, n: usize) -> std::vec::Vec<u128>
&COVERAGE,
&30u32,
&oracle_key,
&w.usdc,
);
RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &policy_id, &COVERAGE);
out.push(policy_id);
Expand Down
127 changes: 127 additions & 0 deletions contracts/claims-processor/src/test_dust.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//! Tests for multi-recipient payout rounding and dust distribution (Issue #772).
//!
//! Multi-recipient payouts truncate upon integer division. To prevent dust from
//! accumulating in escrow, the protocol dust policy dictates that any remainder
//! (total_payout % num_recipients) is distributed in full to the last recipient,
//! ensuring that the sum of distributed payouts matches the total payout exactly
//! with zero escrow dust left behind.
#![cfg(test)]

use super::*;
use soroban_sdk::Env;

#[test]
fn test_multi_recipient_even_split() {
let env = Env::default();
let total = 100i128;
let count = 2u32;
let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count);
assert_eq!(dist.len(), 2);
assert_eq!(dist.get(0).unwrap(), 50);
assert_eq!(dist.get(1).unwrap(), 50);

let sum: i128 = dist.iter().sum();
assert_eq!(sum, total);
}

#[test]
fn test_multi_recipient_rounding_dust_distributed_to_last() {
let env = Env::default();
let total = 100i128;
let count = 3u32;
// 100 / 3 = 33 base with 1 remainder
let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count);
assert_eq!(dist.len(), 3);
assert_eq!(dist.get(0).unwrap(), 33);
assert_eq!(dist.get(1).unwrap(), 33);
assert_eq!(dist.get(2).unwrap(), 34); // 33 + 1 dust remainder

let sum: i128 = dist.iter().sum();
assert_eq!(sum, total);
let dust = total - sum;
assert_eq!(dust, 0, "Escrow dust must be exactly zero");
}

#[test]
fn test_multi_recipient_large_remainder() {
let env = Env::default();
let total = 10i128;
let count = 4u32;
// 10 / 4 = 2 base with 2 remainder
let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count);
assert_eq!(dist.len(), 4);
assert_eq!(dist.get(0).unwrap(), 2);
assert_eq!(dist.get(1).unwrap(), 2);
assert_eq!(dist.get(2).unwrap(), 2);
assert_eq!(dist.get(3).unwrap(), 4); // 2 + 2 dust remainder

let sum: i128 = dist.iter().sum();
assert_eq!(sum, total);
let dust = total - sum;
assert_eq!(dust, 0);
}

#[test]
fn test_multi_recipient_single_recipient() {
let env = Env::default();
let total = 1_000_000i128;
let count = 1u32;
let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count);
assert_eq!(dist.len(), 1);
assert_eq!(dist.get(0).unwrap(), 1_000_000);

let sum: i128 = dist.iter().sum();
assert_eq!(sum, total);
}

#[test]
fn test_multi_recipient_zero_amount() {
let env = Env::default();
let total = 0i128;
let count = 5u32;
let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count);
assert_eq!(dist.len(), 5);
for i in 0..5 {
assert_eq!(dist.get(i).unwrap(), 0);
}
let sum: i128 = dist.iter().sum();
assert_eq!(sum, 0);
}

#[test]
fn test_multi_recipient_more_recipients_than_amount() {
let env = Env::default();
let total = 2i128;
let count = 5u32;
// 2 / 5 = 0 base with 2 remainder
let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count);
assert_eq!(dist.len(), 5);
assert_eq!(dist.get(0).unwrap(), 0);
assert_eq!(dist.get(1).unwrap(), 0);
assert_eq!(dist.get(2).unwrap(), 0);
assert_eq!(dist.get(3).unwrap(), 0);
assert_eq!(dist.get(4).unwrap(), 2); // 0 + 2 dust remainder

let sum: i128 = dist.iter().sum();
assert_eq!(sum, total);
}

#[test]
#[should_panic(expected = "Error(Contract, #22)")]
fn test_multi_recipient_zero_recipients_fails() {
let env = Env::default();
ClaimsProcessor::calculate_multi_recipient_payout(env, 100, 0);
}

#[test]
fn test_multi_recipient_via_client() {
let env = Env::default();
let contract_id = env.register(ClaimsProcessor, ());
let client = ClaimsProcessorClient::new(&env, &contract_id);

let dist = client.calculate_multi_recipient_payout(&100i128, &3u32);
assert_eq!(dist.len(), 3);
assert_eq!(dist.get(0).unwrap(), 33);
assert_eq!(dist.get(1).unwrap(), 33);
assert_eq!(dist.get(2).unwrap(), 34);
}
10 changes: 10 additions & 0 deletions contracts/claims-processor/src/test_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ fn create_drought_product(te: &TestEnv) -> u128 {
name: symbol_short!("drght"),
category: symbol_short!("crop"),
oracle_key: symbol_short!("kis2606"),
expected_token: te.usdc.clone(),
trigger_type: TriggerType::Threshold,
oracle_data_type: weather(),
trigger_threshold: 500_000_000i128,
Expand Down Expand Up @@ -146,13 +147,15 @@ fn batch_processes_multiple_pending_claims() {
&1_000_0000000i128,
&30u32,
&symbol_short!("kis2606"),
&te.usdc,
);
let p2 = policy_client.buy_policy(
&farmer2,
&prod_id,
&2_000_0000000i128,
&30u32,
&symbol_short!("kis2606"),
&te.usdc,
);

claims_client.submit_claim(&farmer1, &p1);
Expand Down Expand Up @@ -196,6 +199,7 @@ fn batch_skips_non_pending_claims() {
&1_000_0000000i128,
&30u32,
&symbol_short!("kis2606"),
&te.usdc,
);
let _claim_id = claims_client.submit_claim(&farmer, &p1);
// Process it once
Expand Down Expand Up @@ -241,6 +245,7 @@ fn test_stale_oracle_data_rejected() {
&1_000_0000000i128,
&30u32,
&symbol_short!("kis2606"),
&te.usdc,
);

// Advance ledger to more than staleness_threshold (604_800 s) past the data timestamp
Expand Down Expand Up @@ -278,6 +283,7 @@ fn test_oracle_unavailable_queues_claim_then_retries() {
&1_000_0000000i128,
&30u32,
&symbol_short!("kis2606"),
&te.usdc,
);

// Oracle has never received data: verification errors, claim is queued.
Expand Down Expand Up @@ -333,6 +339,7 @@ fn test_fresh_oracle_data_accepted() {
&1_000_0000000i128,
&30u32,
&symbol_short!("kis2606"),
&te.usdc,
);

// Set ledger to within the staleness threshold (data is fresh)
Expand All @@ -357,6 +364,7 @@ fn test_equal_comparison() {
name: symbol_short!("equal"),
category: symbol_short!("flight"),
oracle_key: symbol_short!("flight1"),
expected_token: te.usdc.clone(),
trigger_type: TriggerType::Threshold,
oracle_data_type: weather(),
trigger_threshold: 100_000_000i128,
Expand Down Expand Up @@ -390,6 +398,7 @@ fn test_equal_comparison() {
&1_000_0000000i128,
&30u32,
&symbol_short!("flight1"),
&te.usdc,
);

let fresh_now = data_ts + 3_600;
Expand Down Expand Up @@ -419,6 +428,7 @@ fn dispute_removes_claim_from_pending_queue() {
&1_000_0000000i128,
&30u32,
&symbol_short!("kis2606"),
&te.usdc,
);

// Submit enqueues the claim.
Expand Down
Loading
Loading