Skip to content
Merged
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
12 changes: 8 additions & 4 deletions campaign/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ impl CampaignContract {
// Calculate pro-rata refund: (donor_amount * refund_numerator) / refund_denominator
// PR #21: anti-dust floor via calculate_refund_amount helper.
let refund_amount = calculate_refund_amount(
&env,
donor_asset_amount,
refund_numerator,
refund_denominator,
Expand Down Expand Up @@ -809,20 +810,23 @@ mod test {
}
}

fn calculate_refund_amount(
pub(crate) fn calculate_refund_amount(
env: &Env,
donor_asset_amount: i128,
refund_numerator: i128,
refund_denominator: i128,
) -> i128 {
debug_assert!(refund_denominator > 0, "refund_denominator must be nonzero");
if refund_denominator <= 0 {
panic_with_error(env, Error::Overflow);
}

let numerator = donor_asset_amount
.checked_mul(refund_numerator)
.expect("overflow in refund numerator");
.unwrap_or_else(|| panic_with_error(env, Error::Overflow));

let refund = numerator / refund_denominator;

// Anti-dust floor: if the donor is entitled to something nonzero but
// PR #21: anti-dust floor if the donor is entitled to something nonzero but
// floor division rounded it all the way down to 0, bump to 1 unit
// rather than letting them lose their entire refund to rounding.
if refund == 0 && numerator > 0 {
Expand Down
133 changes: 132 additions & 1 deletion campaign/src/test/claim_refund_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use soroban_sdk::{log, vec, Address, Env, Vec};
use super::with_contract;
use crate::storage::{set_campaign, set_donor, set_milestone};
use crate::types::{
AssetInfo, CampaignData, CampaignStatus, DonorRecord, MilestoneData, MilestoneStatus,
AssetInfo, CampaignData, CampaignStatus, DataKey, DonorRecord, MilestoneData, MilestoneStatus,
StellarAsset,
};
use crate::{CampaignContract, CampaignContractClient};
Expand Down Expand Up @@ -100,6 +100,137 @@ fn create_test_donor(env: &Env, donor: &Address, total_donated: i128, refund_cla
set_donor(env, donor, &donor_record);
}

// ─── calculate_refund_amount typed-error tests (issue #33) ───────────────────

/// Zero refund denominator must panic with typed `Error::Overflow`, not a WASM trap.
#[test]
#[should_panic(expected = "Error(Contract, #17)")]
fn test_calculate_refund_amount_zero_denominator() {
let env = make_env();
with_contract(&env, || {
crate::calculate_refund_amount(&env, 100, 50, 0);
});
}

/// Negative refund denominator must also panic with typed `Error::Overflow`.
#[test]
#[should_panic(expected = "Error(Contract, #17)")]
fn test_calculate_refund_amount_negative_denominator() {
let env = make_env();
with_contract(&env, || {
crate::calculate_refund_amount(&env, 100, 50, -1);
});
}

/// Integer overflow in refund numerator must panic with typed `Error::Overflow`.
#[test]
#[should_panic(expected = "Error(Contract, #17)")]
fn test_calculate_refund_amount_overflow() {
let env = make_env();
with_contract(&env, || {
crate::calculate_refund_amount(&env, i128::MAX, 2, 1);
});
}

/// PR #21 anti-dust floor: tiny pro-rata share rounds up to 1 unit.
#[test]
fn test_calculate_refund_amount_anti_dust_floor() {
let env = make_env();
with_contract(&env, || {
// (1 * 1) / 1000 = 0 in floor division, but numerator > 0 → bump to 1
let refund = crate::calculate_refund_amount(&env, 1, 1, 1000);
assert_eq!(refund, 1);
});
}

/// `claim_refund` must surface typed `Error::Overflow` when refund math overflows.
#[test]
#[should_panic(expected = "Error(Contract, #17)")]
fn test_claim_refund_refund_amount_overflow() {
let env = make_env();
env.ledger().set_timestamp(BASE);
env.mock_all_auths();
with_contract(&env, || {
let token_issuer = Address::generate(&env);
let end_time = env.ledger().timestamp() + 1000;
let campaign = CampaignData {
creator: Address::generate(&env),
goal_amount: 1000,
raised_amount: 2,
end_time,
status: CampaignStatus::Cancelled,
accepted_assets: {
let mut assets = soroban_sdk::Vec::new(&env);
assets.push_back(StellarAsset {
asset_code: soroban_sdk::String::from_str(&env, "TST"),
issuer: Some(token_issuer.clone()),
});
assets
},
milestone_count: 1,
min_donation_amount: 0,
created_at_ledger: 0,
created_at_time: 0,
concluded_at_ledger: None,
};
set_campaign(&env, &campaign);
create_test_milestone(&env, 0, 1000, MilestoneStatus::Locked);

let donor = Address::generate(&env);
create_test_donor(&env, &donor, 100, false);
env.storage().persistent().set(
&DataKey::DonorAssetDonation(donor.clone(), token_issuer.clone()),
&i128::MAX,
);

CampaignContract::claim_refund(env.clone(), donor);
});
}

/// `claim_refund` must surface typed `Error::Overflow` when raised_amount is zero.
#[test]
#[should_panic(expected = "Error(Contract, #17)")]
fn test_claim_refund_zero_denominator() {
let env = make_env();
env.ledger().set_timestamp(BASE);
env.mock_all_auths();
with_contract(&env, || {
let token_issuer = Address::generate(&env);
let end_time = env.ledger().timestamp() + 1000;
let campaign = CampaignData {
creator: Address::generate(&env),
goal_amount: 1000,
raised_amount: 0,
end_time,
status: CampaignStatus::Cancelled,
accepted_assets: {
let mut assets = soroban_sdk::Vec::new(&env);
assets.push_back(StellarAsset {
asset_code: soroban_sdk::String::from_str(&env, "TST"),
issuer: Some(token_issuer.clone()),
});
assets
},
milestone_count: 1,
min_donation_amount: 0,
created_at_ledger: 0,
created_at_time: 0,
concluded_at_ledger: None,
};
set_campaign(&env, &campaign);
create_test_milestone(&env, 0, 1000, MilestoneStatus::Locked);

let donor = Address::generate(&env);
create_test_donor(&env, &donor, 100, false);
env.storage().persistent().set(
&DataKey::DonorAssetDonation(donor.clone(), token_issuer.clone()),
&100i128,
);

CampaignContract::claim_refund(env.clone(), donor);
});
}

// ─── Error path tests ────────────────────────────────────────────────────────

/// Claiming a refund when no campaign has been initialized should panic.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"generators": {
"address": 1,
"nonce": 0,
"mux_id": 0
},
"auth": [
[],
[]
],
"ledger": {
"protocol_version": 26,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
"base_reserve": 0,
"min_persistent_entry_ttl": 4096,
"min_temp_entry_ttl": 16,
"max_entry_ttl": 6312000,
"ledger_entries": [
{
"entry": {
"last_modified_ledger_seq": 0,
"data": {
"contract_data": {
"ext": "v0",
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
"key": "ledger_key_contract_instance",
"durability": "persistent",
"val": {
"contract_instance": {
"executable": {
"wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"storage": null
}
}
}
},
"ext": "v0"
},
"live_until": 4095
},
{
"entry": {
"last_modified_ledger_seq": 0,
"data": {
"contract_code": {
"ext": "v0",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"code": ""
}
},
"ext": "v0"
},
"live_until": 4095
}
]
},
"events": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"generators": {
"address": 1,
"nonce": 0,
"mux_id": 0
},
"auth": [
[]
],
"ledger": {
"protocol_version": 26,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
"base_reserve": 0,
"min_persistent_entry_ttl": 4096,
"min_temp_entry_ttl": 16,
"max_entry_ttl": 6312000,
"ledger_entries": [
{
"entry": {
"last_modified_ledger_seq": 0,
"data": {
"contract_data": {
"ext": "v0",
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
"key": "ledger_key_contract_instance",
"durability": "persistent",
"val": {
"contract_instance": {
"executable": {
"wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"storage": null
}
}
}
},
"ext": "v0"
},
"live_until": 4095
},
{
"entry": {
"last_modified_ledger_seq": 0,
"data": {
"contract_code": {
"ext": "v0",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"code": ""
}
},
"ext": "v0"
},
"live_until": 4095
}
]
},
"events": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"generators": {
"address": 1,
"nonce": 0,
"mux_id": 0
},
"auth": [
[]
],
"ledger": {
"protocol_version": 26,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
"base_reserve": 0,
"min_persistent_entry_ttl": 4096,
"min_temp_entry_ttl": 16,
"max_entry_ttl": 6312000,
"ledger_entries": [
{
"entry": {
"last_modified_ledger_seq": 0,
"data": {
"contract_data": {
"ext": "v0",
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
"key": "ledger_key_contract_instance",
"durability": "persistent",
"val": {
"contract_instance": {
"executable": {
"wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"storage": null
}
}
}
},
"ext": "v0"
},
"live_until": 4095
},
{
"entry": {
"last_modified_ledger_seq": 0,
"data": {
"contract_code": {
"ext": "v0",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"code": ""
}
},
"ext": "v0"
},
"live_until": 4095
}
]
},
"events": []
}
Loading
Loading