From 438ff2f5e63352eae3ff7f10131e9797207e6751 Mon Sep 17 00:00:00 2001 From: Promise Pius Obi Date: Thu, 18 Jun 2026 16:26:31 +0100 Subject: [PATCH 1/4] feat: implemented and remove orphaned errors.rs and unused require_creator function --- campaign/src/errors.rs | 27 --------------------------- campaign/src/lib.rs | 11 ----------- 2 files changed, 38 deletions(-) delete mode 100644 campaign/src/errors.rs diff --git a/campaign/src/errors.rs b/campaign/src/errors.rs deleted file mode 100644 index c785a62..0000000 --- a/campaign/src/errors.rs +++ /dev/null @@ -1,27 +0,0 @@ -use soroban_sdk::contracterror; - -/// Campaign-specific error codes for deadline extension, cancellation, and -/// contract lifecycle operations that fall outside the core campaign flow. -/// -/// This enum complements [`crate::types::Error`] and is used only by the -/// standalone helper functions in `contract.rs`. -/// -/// Each variant has a stable `u32` discriminant — never renumber. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum CampaignError { - /// Caller is not the campaign creator (requires creator auth). - Unauthorized = 1, - /// The new deadline is not strictly later than the current deadline. - InvalidDeadline = 2, - /// The deadline has already been extended the maximum number of times. - ExtensionLimitExceeded = 3, - /// The campaign is not in a state that allows deadline extension. - CampaignNotExtendable = 4, - /// Cannot cancel because the campaign still holds donor funds. - CannotCancelWithFunds = 5, - /// Campaign has already been cancelled — no further cancellation. - CampaignAlreadyCancelled = 6, - /// The campaign deadline has passed; operation is no longer permitted. - CampaignEnded = 7, -} diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index d658f61..e8ceba4 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -559,17 +559,6 @@ impl CampaignContract { } } -/// Issue #175 – assert the current invoker is the campaign creator. -/// -/// Reads the creator address from campaign storage and calls `require_auth()`. -/// Panics with `Error::Unauthorized` if the campaign is not initialized; -/// Soroban's auth framework panics if the invoker is not the creator. -fn require_creator(env: &Env) { - let campaign = - get_campaign(env).unwrap_or_else(|| panic_with_error(env, Error::Unauthorized)); - campaign.creator.require_auth(); -} - /// Validates that `asset` is in the campaign's accepted list and returns the /// token contract address needed to construct a `token::Client`. fn get_token_address_for_asset( From 1ee315c6201fcaf81fa22b0e51e255d198a9ceeb Mon Sep 17 00:00:00 2001 From: Promise Pius Obi Date: Sat, 20 Jun 2026 15:04:50 +0100 Subject: [PATCH 2/4] feat: implemented release_milestone_multi_asset silences asset_raised underflow with .unwrap_or(0).max(0) should panic with typed Error --- campaign/src/multi_asset_release.rs | 75 +- campaign/src/test/scratch_test.rs | 9 + campaign/src/test_token_scratch.rs | 16 + campaign/src/types.rs | 2 + .../test_release_underflow_panics.1.json | 213 +++++ ...ulti_asset_release_underflow_panics.1.json | 822 ++++++++++++++++++ 6 files changed, 1133 insertions(+), 4 deletions(-) create mode 100644 campaign/src/test/scratch_test.rs create mode 100644 campaign/src/test_token_scratch.rs create mode 100644 campaign/test_snapshots/multi_asset_release/tests/test_release_underflow_panics.1.json create mode 100644 campaign/test_snapshots/test/release_milestone_tests/test_multi_asset_release_underflow_panics.1.json diff --git a/campaign/src/multi_asset_release.rs b/campaign/src/multi_asset_release.rs index a6f4899..cfc2e3e 100644 --- a/campaign/src/multi_asset_release.rs +++ b/campaign/src/multi_asset_release.rs @@ -184,8 +184,10 @@ pub fn release_milestone_multi_asset( // Update per-asset accounting let new_asset_raised = asset_raised .checked_sub(clamped_release) - .unwrap_or(0) - .max(0); + .unwrap_or_else(|| panic_with_error!(env, Error::LedgerUnderflow)); + if new_asset_raised < 0 { + panic_with_error!(env, Error::LedgerUnderflow); + } storage_set_asset_raised(env, &token_address, new_asset_raised); total_released = total_released @@ -196,8 +198,10 @@ pub fn release_milestone_multi_asset( // ── 8. Update global total-raised bookkeeping ──────────────────────────── let new_total_raised = total_raised .checked_sub(total_released) - .unwrap_or(0) - .max(0); + .unwrap_or_else(|| panic_with_error!(env, Error::LedgerUnderflow)); + if new_total_raised < 0 { + panic_with_error!(env, Error::LedgerUnderflow); + } storage_set_total_raised(env, new_total_raised); // Issue #242 – Release reentrancy lock @@ -257,4 +261,67 @@ mod tests { let result = compute_asset_release(-100, 1000, 1000); assert_eq!(result, None); } + + #[test] + #[should_panic(expected = "HostError")] + fn test_release_underflow_panics() { + use crate::types::{CampaignData, CampaignStatus, MilestoneData, MilestoneStatus, StellarAsset}; + use crate::storage::{set_campaign, set_milestone, storage_set_asset_raised, storage_set_total_raised}; + use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec}; + use soroban_sdk::token::StellarAssetClient; + + let env = Env::default(); + env.mock_all_auths(); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token_issuer = env.register_stellar_asset_contract(token_admin.clone()); + let token_client = StellarAssetClient::new(&env, &token_issuer); + token_client.mint(&env.current_contract_address(), &5000); + + let mut assets = Vec::new(&env); + assets.push_back(StellarAsset { + asset_code: String::from_str(&env, "USDC"), + issuer: Some(token_issuer.clone()), + }); + + let campaign = CampaignData { + creator, + goal_amount: 3000, + raised_amount: 3000, + end_time: env.ledger().timestamp() + 86400, + status: CampaignStatus::Active, + accepted_assets: assets, + milestone_count: 1, + min_donation_amount: 0, + created_at_ledger: 0, + created_at_time: 0, + concluded_at_ledger: None, + }; + set_campaign(&env, &campaign); + + let milestone = MilestoneData { + index: 0, + target_amount: 3000, // milestone_release = 3000 + released_amount: 0, + description_hash: soroban_sdk::BytesN::from_array(&env, &[0; 32]), + status: MilestoneStatus::Unlocked, + released_at: None, + released_at_ledger: None, + release_tx: None, + released_to: None, + }; + set_milestone(&env, 0, &milestone); + + // Force underflow condition: total_raised = 1000, asset_raised = 500 + // asset_release = 500 * 3000 / 1000 = 1500 + // clamped_release = min(1500, 5000) = 1500 + // new_asset_raised = 500 - 1500 => underflow! + storage_set_total_raised(&env, 1000); + storage_set_asset_raised(&env, &token_issuer, 500); + + release_milestone_multi_asset(&env, 0, recipient); + } } diff --git a/campaign/src/test/scratch_test.rs b/campaign/src/test/scratch_test.rs new file mode 100644 index 0000000..576cfec --- /dev/null +++ b/campaign/src/test/scratch_test.rs @@ -0,0 +1,9 @@ +use soroban_sdk::{Env, Address}; +use crate::test::release_milestone_tests::*; + +#[test] +fn scratch() { + let env = Env::default(); + env.mock_all_auths(); + // ... +} diff --git a/campaign/src/test_token_scratch.rs b/campaign/src/test_token_scratch.rs new file mode 100644 index 0000000..4c990f7 --- /dev/null +++ b/campaign/src/test_token_scratch.rs @@ -0,0 +1,16 @@ +#[cfg(test)] +mod test { + use soroban_sdk::{Env, Address, token::StellarAssetClient, token::Client}; + #[test] + fn test_mint() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let token = env.register_stellar_asset_contract_v2(admin).address(); + let admin_client = StellarAssetClient::new(&env, &token); + let user = Address::generate(&env); + admin_client.mint(&user, &1000); + let client = Client::new(&env, &token); + assert_eq!(client.balance(&user), 1000); + } +} diff --git a/campaign/src/types.rs b/campaign/src/types.rs index d6b010d..d326f96 100644 --- a/campaign/src/types.rs +++ b/campaign/src/types.rs @@ -108,6 +108,8 @@ pub enum Error { // ── Upgrade / freeze ─────────────────────────────────────────────────── 8x /// Contract is frozen; all mutating operations are blocked. ContractFrozen = 80, + /// A ledger decrement operation underflowed, indicating an invariant violation. + LedgerUnderflow = 81, } diff --git a/campaign/test_snapshots/multi_asset_release/tests/test_release_underflow_panics.1.json b/campaign/test_snapshots/multi_asset_release/tests/test_release_underflow_panics.1.json new file mode 100644 index 0000000..d725b01 --- /dev/null +++ b/campaign/test_snapshots/multi_asset_release/tests/test_release_underflow_panics.1.json @@ -0,0 +1,213 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + { + "function": { + "contract_fn": { + "contract_address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ] + ], + "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": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "balance": "0", + "seq_num": "0", + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + "live_until": null + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000004" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 120960 + } + ] + }, + "events": [ + { + "event": { + "ext": "v0", + "contract_id": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "set_admin" + }, + { + "address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + }, + { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + } + ], + "data": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + } + } + }, + "failed_call": false + } + ] +} \ No newline at end of file diff --git a/campaign/test_snapshots/test/release_milestone_tests/test_multi_asset_release_underflow_panics.1.json b/campaign/test_snapshots/test/release_milestone_tests/test_multi_asset_release_underflow_panics.1.json new file mode 100644 index 0000000..da9f409 --- /dev/null +++ b/campaign/test_snapshots/test/release_milestone_tests/test_multi_asset_release_underflow_panics.1.json @@ -0,0 +1,822 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [] + ], + "ledger": { + "protocol_version": 26, + "sequence_number": 0, + "timestamp": 31536000, + "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": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "balance": "0", + "seq_num": "0", + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + "live_until": null + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AssetRaised" + }, + { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + ] + }, + "durability": "persistent", + "val": { + "i128": "1000" + } + } + }, + "ext": "v0" + }, + "live_until": 1036800 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "CampaignData" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "accepted_assets" + }, + "val": { + "vec": [ + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "XLM" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + } + ] + } + ] + } + }, + { + "key": { + "symbol": "concluded_at_ledger" + }, + "val": "void" + }, + { + "key": { + "symbol": "created_at_ledger" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "created_at_time" + }, + "val": { + "u64": "31536000" + } + }, + { + "key": { + "symbol": "creator" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "end_time" + }, + "val": { + "u64": "31622400" + } + }, + { + "key": { + "symbol": "goal_amount" + }, + "val": { + "i128": "3000" + } + }, + { + "key": { + "symbol": "milestone_count" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "min_donation_amount" + }, + "val": { + "i128": "0" + } + }, + { + "key": { + "symbol": "raised_amount" + }, + "val": { + "i128": "3000" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "vec": [ + { + "symbol": "Active" + } + ] + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 1036800 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "MilestoneData" + }, + { + "u32": 0 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "description_hash" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000000" + } + }, + { + "key": { + "symbol": "index" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "release_tx" + }, + "val": "void" + }, + { + "key": { + "symbol": "released_amount" + }, + "val": { + "i128": "3000" + } + }, + { + "key": { + "symbol": "released_at" + }, + "val": "void" + }, + { + "key": { + "symbol": "released_at_ledger" + }, + "val": "void" + }, + { + "key": { + "symbol": "released_to" + }, + "val": "void" + }, + { + "key": { + "symbol": "status" + }, + "val": { + "vec": [ + { + "symbol": "Released" + } + ] + } + }, + { + "key": { + "symbol": "target_amount" + }, + "val": { + "i128": "3000" + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 1036800 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "ReentrancyLock" + } + ] + }, + "durability": "temporary", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 15 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TotalRaised" + } + ] + }, + "durability": "persistent", + "val": { + "i128": "1000" + } + } + }, + "ext": "v0" + }, + "live_until": 1036800 + }, + { + "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_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "10009997000" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "3000" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000004" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 120960 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [ + { + "event": { + "ext": "v0", + "contract_id": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "set_admin" + }, + { + "address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + }, + { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + } + ], + "data": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "mint" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + } + ], + "data": { + "i128": "10000000" + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "mint" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + } + ], + "data": { + "i128": "10000000000" + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "transfer" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + } + ], + "data": { + "i128": "3000" + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "milestone_released" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ], + "data": { + "vec": [ + { + "u32": 0 + }, + { + "i128": "3000" + }, + { + "string": "XLM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "u64": "31536000" + } + ] + } + } + } + }, + "failed_call": false + } + ] +} \ No newline at end of file From c526aac116bcd5cfc04bdd54b126056495a33a1d Mon Sep 17 00:00:00 2001 From: Promise Pius Obi Date: Thu, 25 Jun 2026 09:35:00 +0100 Subject: [PATCH 3/4] Clean up PR #48: remove debug files and use Error::Overflow --- campaign/src/multi_asset_release.rs | 8 ++++---- campaign/src/test/scratch_test.rs | 9 --------- campaign/src/test_token_scratch.rs | 16 ---------------- campaign/src/types.rs | 2 -- 4 files changed, 4 insertions(+), 31 deletions(-) delete mode 100644 campaign/src/test/scratch_test.rs delete mode 100644 campaign/src/test_token_scratch.rs diff --git a/campaign/src/multi_asset_release.rs b/campaign/src/multi_asset_release.rs index c064faa..5bf3601 100644 --- a/campaign/src/multi_asset_release.rs +++ b/campaign/src/multi_asset_release.rs @@ -184,9 +184,9 @@ pub fn release_milestone_multi_asset( // Update per-asset accounting let new_asset_raised = asset_raised .checked_sub(clamped_release) - .unwrap_or_else(|| panic_with_error!(env, Error::LedgerUnderflow)); + .unwrap_or_else(|| panic_with_error!(env, Error::Overflow)); if new_asset_raised < 0 { - panic_with_error!(env, Error::LedgerUnderflow); + panic_with_error!(env, Error::Overflow); } storage_set_asset_raised(env, &token_address, new_asset_raised); @@ -198,9 +198,9 @@ pub fn release_milestone_multi_asset( // ── 8. Update global total-raised bookkeeping ──────────────────────────── let new_total_raised = total_raised .checked_sub(total_released) - .unwrap_or_else(|| panic_with_error!(env, Error::LedgerUnderflow)); + .unwrap_or_else(|| panic_with_error!(env, Error::Overflow)); if new_total_raised < 0 { - panic_with_error!(env, Error::LedgerUnderflow); + panic_with_error!(env, Error::Overflow); } storage_set_total_raised(env, new_total_raised); storage_increment_release_count(env); diff --git a/campaign/src/test/scratch_test.rs b/campaign/src/test/scratch_test.rs deleted file mode 100644 index 576cfec..0000000 --- a/campaign/src/test/scratch_test.rs +++ /dev/null @@ -1,9 +0,0 @@ -use soroban_sdk::{Env, Address}; -use crate::test::release_milestone_tests::*; - -#[test] -fn scratch() { - let env = Env::default(); - env.mock_all_auths(); - // ... -} diff --git a/campaign/src/test_token_scratch.rs b/campaign/src/test_token_scratch.rs deleted file mode 100644 index 4c990f7..0000000 --- a/campaign/src/test_token_scratch.rs +++ /dev/null @@ -1,16 +0,0 @@ -#[cfg(test)] -mod test { - use soroban_sdk::{Env, Address, token::StellarAssetClient, token::Client}; - #[test] - fn test_mint() { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); - let token = env.register_stellar_asset_contract_v2(admin).address(); - let admin_client = StellarAssetClient::new(&env, &token); - let user = Address::generate(&env); - admin_client.mint(&user, &1000); - let client = Client::new(&env, &token); - assert_eq!(client.balance(&user), 1000); - } -} diff --git a/campaign/src/types.rs b/campaign/src/types.rs index c347b58..e3bcfef 100644 --- a/campaign/src/types.rs +++ b/campaign/src/types.rs @@ -108,8 +108,6 @@ pub enum Error { // ── Upgrade / freeze ─────────────────────────────────────────────────── 8x /// Contract is frozen; all mutating operations are blocked. ContractFrozen = 80, - /// A ledger decrement operation underflowed, indicating an invariant violation. - LedgerUnderflow = 81, } From bf0bf78a3bebdba10fe0bc7764910fea3263e6e3 Mon Sep 17 00:00:00 2001 From: Promise Pius Obi Date: Thu, 25 Jun 2026 09:41:35 +0100 Subject: [PATCH 4/4] Format code with cargo fmt --- campaign/src/contract.rs | 20 +- campaign/src/event.rs | 55 +++-- campaign/src/get_all_milestones.rs | 16 +- campaign/src/get_milestone.rs | 2 +- campaign/src/lib.rs | 19 +- campaign/src/multi_asset_release.rs | 53 ++--- campaign/src/release_milestone.rs | 32 +-- campaign/src/storage.rs | 53 ++--- campaign/src/test/claim_refund_tests.rs | 104 ++++++--- .../src/test/get_campaign_status_tests.rs | 6 +- campaign/src/test/integration_tests.rs | 74 +++++-- campaign/src/test/invariant_tests.rs | 51 ++--- campaign/src/test/negative_path_tests.rs | 207 +++++++++++++----- campaign/src/test/refund_eligibility_tests.rs | 52 +++-- campaign/src/test/release_milestone_tests.rs | 16 +- campaign/src/types.rs | 87 ++++---- campaign/src/views.rs | 8 +- common/src/lib.rs | 2 +- crates/contracts/core/src/lib.rs | 96 ++++---- 19 files changed, 577 insertions(+), 376 deletions(-) diff --git a/campaign/src/contract.rs b/campaign/src/contract.rs index 5110717..4e1e3e1 100644 --- a/campaign/src/contract.rs +++ b/campaign/src/contract.rs @@ -3,11 +3,11 @@ //! These are wired into the contract impl in `lib.rs` as methods on //! `CampaignContract`. -use soroban_sdk::{panic_with_error, Address, Env}; use crate::event; use crate::storage::{get_campaign, set_campaign}; use crate::types::{CampaignStatus, Error}; use crate::validate_campaign_transition; +use soroban_sdk::{panic_with_error, Address, Env}; /// Issue #212 – End the campaign early (before deadline). /// @@ -19,8 +19,8 @@ use crate::validate_campaign_transition; /// - `Error::Unauthorized` if caller is not the creator /// - `Error::InvalidCampaignTransition` if campaign is already Ended or Cancelled pub fn end_campaign(env: &Env) { - let mut campaign = get_campaign(env) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + let mut campaign = + get_campaign(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); campaign.creator.require_auth(); @@ -44,8 +44,8 @@ pub fn end_campaign(env: &Env) { /// - `Error::Unauthorized` if caller is not the creator /// - `Error::InvalidCampaignTransition` if campaign is already Cancelled pub fn cancel_campaign(env: &Env) { - let mut campaign = get_campaign(env) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + let mut campaign = + get_campaign(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); campaign.creator.require_auth(); @@ -70,8 +70,8 @@ pub fn cancel_campaign(env: &Env) { /// - `Error::InvalidEndTime` if `new_end_time <= current ledger timestamp` /// - `Error::InvalidCampaignTransition` if campaign is not Active or GoalReached pub fn extend_deadline(env: &Env, new_end_time: u64) { - let mut campaign = get_campaign(env) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + let mut campaign = + get_campaign(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); campaign.creator.require_auth(); @@ -103,9 +103,9 @@ pub fn extend_deadline(env: &Env, new_end_time: u64) { #[must_use] pub fn get_campaign_status(env: &Env) -> crate::types::CampaignStatusResponse { use crate::types::CampaignStatusResponse; - - let campaign = get_campaign(env) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + + let campaign = + get_campaign(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); let now = env.ledger().timestamp(); let days_remaining = if now < campaign.end_time { diff --git a/campaign/src/event.rs b/campaign/src/event.rs index 9d14e68..403c650 100644 --- a/campaign/src/event.rs +++ b/campaign/src/event.rs @@ -9,8 +9,12 @@ pub fn donation_received( raised_total: i128, timestamp: u64, ) { - let topics = (Symbol::new(env, "donation_received"), env.current_contract_address()); - env.events().publish(topics, (donor, amount, asset_code, raised_total, timestamp)); + let topics = ( + Symbol::new(env, "donation_received"), + env.current_contract_address(), + ); + env.events() + .publish(topics, (donor, amount, asset_code, raised_total, timestamp)); } /// Emitted when a milestone transitions from Locked to Unlocked. @@ -20,17 +24,16 @@ pub fn milestone_unlocked( target_amount: i128, raised_total: i128, ) { - let topics = (Symbol::new(env, "milestone_unlocked"), env.current_contract_address()); - env.events().publish(topics, (milestone_index, target_amount, raised_total)); + let topics = ( + Symbol::new(env, "milestone_unlocked"), + env.current_contract_address(), + ); + env.events() + .publish(topics, (milestone_index, target_amount, raised_total)); } /// Emitted when the campaign deadline is extended by the creator. -pub fn deadline_extended( - env: &Env, - creator: &Address, - old_deadline: u64, - new_deadline: u64, -) { +pub fn deadline_extended(env: &Env, creator: &Address, old_deadline: u64, new_deadline: u64) { env.events().publish( ("campaign", "deadline_extended"), (creator, old_deadline, new_deadline), @@ -39,7 +42,8 @@ pub fn deadline_extended( /// Emitted when the campaign is cancelled by the creator. pub fn campaign_cancelled(env: &Env, creator: &Address) { - env.events().publish(("campaign", "campaign_cancelled"), creator); + env.events() + .publish(("campaign", "campaign_cancelled"), creator); } /// Emitted when the campaign ends (deadline passed or ended early). @@ -56,12 +60,23 @@ pub fn milestone_released( recipient: &Address, timestamp: u64, ) { - let topics = (Symbol::new(env, "milestone_released"), env.current_contract_address()); - env.events().publish(topics, (milestone_index, amount, asset_code, recipient, timestamp)); + let topics = ( + Symbol::new(env, "milestone_released"), + env.current_contract_address(), + ); + env.events().publish( + topics, + (milestone_index, amount, asset_code, recipient, timestamp), + ); } /// Issue #246 – Emitted when the contract is upgraded by the admin. -pub fn contract_upgraded(env: &Env, admin: &Address, new_wasm_hash: soroban_sdk::BytesN<32>, timestamp: u64) { +pub fn contract_upgraded( + env: &Env, + admin: &Address, + new_wasm_hash: soroban_sdk::BytesN<32>, + timestamp: u64, +) { env.events().publish( ("campaign", "contract_upgraded"), (admin, new_wasm_hash, timestamp), @@ -70,16 +85,12 @@ pub fn contract_upgraded(env: &Env, admin: &Address, new_wasm_hash: soroban_sdk: /// Issue #246 – Emitted when the contract is frozen by the admin. pub fn contract_frozen(env: &Env, admin: &Address, timestamp: u64) { - env.events().publish( - ("campaign", "contract_frozen"), - (admin, timestamp), - ); + env.events() + .publish(("campaign", "contract_frozen"), (admin, timestamp)); } /// Issue #246 – Emitted when the contract is unfrozen by the admin. pub fn contract_unfrozen(env: &Env, admin: &Address, timestamp: u64) { - env.events().publish( - ("campaign", "contract_unfrozen"), - (admin, timestamp), - ); + env.events() + .publish(("campaign", "contract_unfrozen"), (admin, timestamp)); } diff --git a/campaign/src/get_all_milestones.rs b/campaign/src/get_all_milestones.rs index 729ee49..80d6bef 100644 --- a/campaign/src/get_all_milestones.rs +++ b/campaign/src/get_all_milestones.rs @@ -14,8 +14,8 @@ use crate::views::{self, MilestoneView}; /// - `Error::NotInitialized` — contract not yet initialised. #[must_use] pub fn get_all_milestones_view(env: &Env) -> Vec { - let campaign = get_campaign(env) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + let campaign = + get_campaign(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); let mut result: Vec = Vec::new(env); for i in 0..campaign.milestone_count { @@ -31,8 +31,8 @@ mod tests { use super::*; use soroban_sdk::{testutils::Address as _, Address, Env}; - use crate::types::{CampaignData, CampaignStatus, DataKey, MilestoneStatus}; use crate::test::with_contract; + use crate::types::{CampaignData, CampaignStatus, DataKey, MilestoneStatus}; fn make_env() -> Env { Env::default() @@ -111,8 +111,14 @@ mod tests { seed_milestone(&env, 2, MilestoneStatus::Locked); let result = get_all_milestones_view(&env); assert_eq!(result.len(), 3); - assert_eq!(result.get(0).unwrap().data.status, MilestoneStatus::Released); - assert_eq!(result.get(1).unwrap().data.status, MilestoneStatus::Unlocked); + assert_eq!( + result.get(0).unwrap().data.status, + MilestoneStatus::Released + ); + assert_eq!( + result.get(1).unwrap().data.status, + MilestoneStatus::Unlocked + ); assert_eq!(result.get(2).unwrap().data.status, MilestoneStatus::Locked); }); } diff --git a/campaign/src/get_milestone.rs b/campaign/src/get_milestone.rs index b06e805..5da2960 100644 --- a/campaign/src/get_milestone.rs +++ b/campaign/src/get_milestone.rs @@ -58,8 +58,8 @@ mod tests { use super::*; use soroban_sdk::{testutils::Address as _, Address, Env}; - use crate::types::{CampaignData, CampaignStatus, DataKey, MilestoneStatus}; use crate::test::with_contract; + use crate::types::{CampaignData, CampaignStatus, DataKey, MilestoneStatus}; // ── Helpers ────────────────────────────────────────────────────────────── diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index 0b9c9da..0df9152 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -19,9 +19,20 @@ pub mod storage; pub mod types; pub mod views; -use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec, BytesN}; -use types::{CampaignData, CampaignInitializedEvent, CampaignReport, CampaignStatus, CampaignStatusResponse, DashboardMetrics, DonorRecord, Error, MilestoneData, MilestoneStatus, PlatformSummary, StellarAsset, AssetInfo}; -use storage::{get_campaign, set_campaign, get_milestone, set_milestone, get_donor, set_donor, storage_get_total_raised, storage_set_total_raised, storage_get_donation_count, storage_increment_donation_count, storage_get_unique_donor_count, storage_increment_unique_donor_count, storage_get_release_count, storage_increment_asset_raised, increment_donor_asset_donation, get_donor_asset_donation, is_frozen, set_frozen, acquire_lock, release_lock}; +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec}; +use storage::{ + acquire_lock, get_campaign, get_donor, get_donor_asset_donation, get_milestone, + increment_donor_asset_donation, is_frozen, release_lock, set_campaign, set_donor, set_frozen, + set_milestone, storage_get_donation_count, storage_get_release_count, storage_get_total_raised, + storage_get_unique_donor_count, storage_increment_asset_raised, + storage_increment_donation_count, storage_increment_unique_donor_count, + storage_set_total_raised, +}; +use types::{ + AssetInfo, CampaignData, CampaignInitializedEvent, CampaignReport, CampaignStatus, + CampaignStatusResponse, DashboardMetrics, DonorRecord, Error, MilestoneData, MilestoneStatus, + PlatformSummary, StellarAsset, +}; pub const VERSION: u32 = 1; @@ -784,10 +795,10 @@ mod test { pub mod claim_refund_tests; pub mod get_campaign_status_tests; pub mod integration_tests; + pub mod invariant_tests; pub mod negative_path_tests; pub mod refund_eligibility_tests; pub mod release_milestone_tests; - pub mod invariant_tests; /// Shared helper: register the contract and run the body inside /// `env.as_contract()` so storage, ledger, and auth work correctly. diff --git a/campaign/src/multi_asset_release.rs b/campaign/src/multi_asset_release.rs index 5bf3601..55510ec 100644 --- a/campaign/src/multi_asset_release.rs +++ b/campaign/src/multi_asset_release.rs @@ -1,11 +1,11 @@ -use soroban_sdk::{panic_with_error, symbol_short, token, Address, Env, Vec}; use crate::event; -use crate::types::{Error, MilestoneStatus, StellarAsset}; use crate::storage::{ acquire_lock, get_campaign, get_milestone, release_lock, set_milestone, - storage_get_asset_raised, storage_get_total_raised, - storage_increment_release_count, storage_set_total_raised, storage_set_asset_raised, + storage_get_asset_raised, storage_get_total_raised, storage_increment_release_count, + storage_set_asset_raised, storage_set_total_raised, }; +use crate::types::{Error, MilestoneStatus, StellarAsset}; +use soroban_sdk::{panic_with_error, symbol_short, token, Address, Env, Vec}; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -63,18 +63,13 @@ fn compute_asset_release( /// contract can never release more than it actually holds. /// - Dust amounts below MIN_TRANSFER_AMOUNT are skipped rather than /// causing the whole release to fail. -pub fn release_milestone_multi_asset( - env: &Env, - milestone_index: u32, - recipient: Address, -) { +pub fn release_milestone_multi_asset(env: &Env, milestone_index: u32, recipient: Address) { // Issue #242 – Reentrancy protection: acquire lock acquire_lock(env); // ── 1. Load campaign ──────────────────────────────────────────────────── - let campaign = get_campaign(env).unwrap_or_else(|| { - panic_with_error!(env, Error::NotInitialized) - }); + let campaign = + get_campaign(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); // ── 3. Validate recipient ──────────────────────────────────────────────── if recipient == env.current_contract_address() { @@ -82,9 +77,8 @@ pub fn release_milestone_multi_asset( } // ── 4. Load and validate milestone ────────────────────────────────────── - let mut milestone = get_milestone(env, milestone_index).unwrap_or_else(|| { - panic_with_error!(env, Error::MilestoneNotFound) - }); + let mut milestone = get_milestone(env, milestone_index) + .unwrap_or_else(|| panic_with_error!(env, Error::MilestoneNotFound)); if milestone.status != MilestoneStatus::Unlocked { panic_with_error!(env, Error::InvalidMilestoneTransition); @@ -140,17 +134,14 @@ pub fn release_milestone_multi_asset( // Retrieve the per-asset raised amount from storage for proportional math let asset_raised = storage_get_asset_raised(env, &token_address); - let asset_release = match compute_asset_release( - asset_raised, - milestone_release, - total_raised, - ) { - Some(amount) => amount, - None => { - // Nothing to release for this asset (dust or zero balance) - continue; - } - }; + let asset_release = + match compute_asset_release(asset_raised, milestone_release, total_raised) { + Some(amount) => amount, + None => { + // Nothing to release for this asset (dust or zero balance) + continue; + } + }; // Issue #244 – Verify contract balance is sufficient if contract_balance < asset_release { @@ -266,10 +257,14 @@ mod tests { #[test] #[should_panic(expected = "HostError")] fn test_release_underflow_panics() { - use crate::types::{CampaignData, CampaignStatus, MilestoneData, MilestoneStatus, StellarAsset}; - use crate::storage::{set_campaign, set_milestone, storage_set_asset_raised, storage_set_total_raised}; - use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec}; + use crate::storage::{ + set_campaign, set_milestone, storage_set_asset_raised, storage_set_total_raised, + }; + use crate::types::{ + CampaignData, CampaignStatus, MilestoneData, MilestoneStatus, StellarAsset, + }; use soroban_sdk::token::StellarAssetClient; + use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec}; let env = Env::default(); env.mock_all_auths(); diff --git a/campaign/src/release_milestone.rs b/campaign/src/release_milestone.rs index 871931c..47ca2b9 100644 --- a/campaign/src/release_milestone.rs +++ b/campaign/src/release_milestone.rs @@ -1,10 +1,10 @@ -use soroban_sdk::{Address, Env, token, panic_with_error}; use crate::event; -use crate::types::{Error, MilestoneStatus}; use crate::storage::{ acquire_lock, get_campaign, get_milestone, is_frozen, release_lock, set_milestone, storage_increment_release_count, }; +use crate::types::{Error, MilestoneStatus}; +use soroban_sdk::{panic_with_error, token, Address, Env}; /// Issue #207 – `release_milestone` function /// @@ -41,18 +41,16 @@ pub fn release_milestone(env: &Env, milestone_index: u32, recipient: Address) { // Issue #242 – Reentrancy protection: acquire lock acquire_lock(env); - let campaign = get_campaign(env).unwrap_or_else(|| { - panic_with_error!(env, Error::NotInitialized) - }); + let campaign = + get_campaign(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); // Freeze check — reject all mutating operations while frozen if is_frozen(env) { soroban_sdk::panic_with_error!(env, Error::ContractFrozen); } - let mut milestone = get_milestone(env, milestone_index).unwrap_or_else(|| { - panic_with_error!(env, Error::MilestoneNotFound) - }); + let mut milestone = get_milestone(env, milestone_index) + .unwrap_or_else(|| panic_with_error!(env, Error::MilestoneNotFound)); // Prevent double release: milestone already in Released state if milestone.status == MilestoneStatus::Released { @@ -66,9 +64,8 @@ pub fn release_milestone(env: &Env, milestone_index: u32, recipient: Address) { // Prevent skipping milestones: if not milestone 0, previous must be Released if milestone_index > 0 { - let prev_milestone = get_milestone(env, milestone_index - 1).unwrap_or_else(|| { - soroban_sdk::panic_with_error!(env, Error::MilestoneNotFound) - }); + let prev_milestone = get_milestone(env, milestone_index - 1) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, Error::MilestoneNotFound)); if prev_milestone.status != MilestoneStatus::Released { soroban_sdk::panic_with_error!(env, Error::PreviousMilestoneNotReleased); } @@ -85,9 +82,10 @@ pub fn release_milestone(env: &Env, milestone_index: u32, recipient: Address) { // every accepted asset would multiply the payout by the asset count. // Campaigns with more than one accepted asset must use // `release_milestone_multi_asset`, which distributes proportionally. - let asset = campaign.accepted_assets.first().unwrap_or_else(|| { - panic_with_error!(env, Error::NotInitialized) - }); + let asset = campaign + .accepted_assets + .first() + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); if let Some(issuer) = asset.issuer.clone() { let token_client = token::Client::new(env, &issuer); @@ -104,7 +102,11 @@ pub fn release_milestone(env: &Env, milestone_index: u32, recipient: Address) { // Clamp to available balance (should never be needed due to check above) let transfer_amount = release_amount.min(asset_balance); - token_client.transfer(&env.current_contract_address(), &recipient, &transfer_amount); + token_client.transfer( + &env.current_contract_address(), + &recipient, + &transfer_amount, + ); event::milestone_released( env, diff --git a/campaign/src/storage.rs b/campaign/src/storage.rs index 6a9652f..5553f4f 100644 --- a/campaign/src/storage.rs +++ b/campaign/src/storage.rs @@ -1,7 +1,7 @@ // src/storage.rs -use soroban_sdk::{Address, Env, panic_with_error}; use crate::types::{CampaignData, DataKey, DonorRecord, Error, MilestoneData}; +use soroban_sdk::{panic_with_error, Address, Env}; // ─── TTL Constants ──────────────────────────────────────────────────────────── // @@ -32,9 +32,11 @@ pub const TEMPORARY_BUMP_THRESHOLD: u32 = 17_280; #[inline] fn bump_persistent(env: &Env, key: &DataKey) { if env.storage().persistent().has(key) { - env.storage() - .persistent() - .extend_ttl(key, PERSISTENT_BUMP_THRESHOLD, PERSISTENT_BUMP_AMOUNT); + env.storage().persistent().extend_ttl( + key, + PERSISTENT_BUMP_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); } } @@ -45,9 +47,7 @@ fn bump_persistent(env: &Env, key: &DataKey) { /// (handled automatically by the host — we surface it as `StorageWriteError` /// so callers get a typed error instead of a host trap). pub fn set_campaign(env: &Env, data: &CampaignData) { - env.storage() - .persistent() - .set(&DataKey::CampaignData, data); + env.storage().persistent().set(&DataKey::CampaignData, data); bump_persistent(env, &DataKey::CampaignData); } @@ -55,10 +55,7 @@ pub fn set_campaign(env: &Env, data: &CampaignData) { /// Returns `None` only before the contract is initialised. #[must_use] pub fn get_campaign(env: &Env) -> Option { - let value = env - .storage() - .persistent() - .get(&DataKey::CampaignData)?; + let value = env.storage().persistent().get(&DataKey::CampaignData)?; bump_persistent(env, &DataKey::CampaignData); Some(value) } @@ -93,8 +90,7 @@ pub fn get_milestone(env: &Env, index: u32) -> Option { /// Same as `get_milestone` but panics with `MilestoneNotFound`. #[must_use] pub fn get_milestone_or_panic(env: &Env, index: u32) -> MilestoneData { - get_milestone(env, index) - .unwrap_or_else(|| panic_with_error!(env, Error::MilestoneNotFound)) + get_milestone(env, index).unwrap_or_else(|| panic_with_error!(env, Error::MilestoneNotFound)) } // ─── Donors ─────────────────────────────────────────────────────────────────── @@ -139,11 +135,7 @@ pub fn get_donor_or_default(env: &Env, donor: &Address) -> DonorRecord { /// Returns 0 if no donations in that asset yet. pub fn get_donor_asset_donation(env: &Env, donor: &Address, asset: &Address) -> i128 { let key = DataKey::DonorAssetDonation(donor.clone(), asset.clone()); - let value: i128 = env - .storage() - .persistent() - .get(&key) - .unwrap_or(0); + let value: i128 = env.storage().persistent().get(&key).unwrap_or(0); bump_persistent(env, &key); value } @@ -152,15 +144,12 @@ pub fn get_donor_asset_donation(env: &Env, donor: &Address, asset: &Address) -> /// Panics if the addition would overflow. pub fn increment_donor_asset_donation(env: &Env, donor: &Address, asset: &Address, amount: i128) { let key = DataKey::DonorAssetDonation(donor.clone(), asset.clone()); - let current: i128 = env - .storage() - .persistent() - .get(&key) - .unwrap_or(0); - - let new_amount = current.checked_add(amount) + let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); + + let new_amount = current + .checked_add(amount) .unwrap_or_else(|| panic_with_error!(env, Error::Overflow)); - + env.storage().persistent().set(&key, &new_amount); bump_persistent(env, &key); } @@ -286,11 +275,7 @@ pub fn storage_increment_release_count(env: &Env) -> u64 { /// Load the raised amount for a specific token address. pub fn storage_get_asset_raised(env: &Env, token: &Address) -> i128 { let key = DataKey::AssetRaised(token.clone()); - let value: i128 = env - .storage() - .persistent() - .get(&key) - .unwrap_or(0); + let value: i128 = env.storage().persistent().get(&key).unwrap_or(0); bump_persistent(env, &key); value } @@ -374,11 +359,7 @@ pub fn release_lock(env: &Env) { /// Returns `false` if the flag has never been set. pub fn is_frozen(env: &Env) -> bool { let key = DataKey::Frozen; - let frozen: bool = env - .storage() - .persistent() - .get(&key) - .unwrap_or(false); + let frozen: bool = env.storage().persistent().get(&key).unwrap_or(false); bump_persistent(env, &key); frozen } diff --git a/campaign/src/test/claim_refund_tests.rs b/campaign/src/test/claim_refund_tests.rs index a825ecc..a708583 100644 --- a/campaign/src/test/claim_refund_tests.rs +++ b/campaign/src/test/claim_refund_tests.rs @@ -9,12 +9,15 @@ use core::ops::Add; use soroban_sdk::testutils::{Address as AddressTestUtils, Ledger}; use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{Address, Env, Vec, log, vec}; +use soroban_sdk::{log, vec, Address, Env, Vec}; -use crate::types::{AssetInfo, CampaignData, CampaignStatus, DonorRecord, MilestoneData, MilestoneStatus, StellarAsset}; +use super::with_contract; use crate::storage::{set_campaign, set_donor, set_milestone}; +use crate::types::{ + AssetInfo, CampaignData, CampaignStatus, DonorRecord, MilestoneData, MilestoneStatus, + StellarAsset, +}; use crate::{CampaignContract, CampaignContractClient}; -use super::with_contract; /// Base ledger timestamp (1 year in seconds) used so we can safely subtract /// from it to simulate "past" end_times without underflow. @@ -38,7 +41,11 @@ fn create_test_campaign( let campaign = CampaignData { creator: creator.clone(), goal_amount, - raised_amount: if matches!(status, CampaignStatus::Cancelled | CampaignStatus::Ended) { 1000 } else { 0 }, + raised_amount: if matches!(status, CampaignStatus::Cancelled | CampaignStatus::Ended) { + 1000 + } else { + 0 + }, end_time, status, accepted_assets: { @@ -60,16 +67,15 @@ fn create_test_campaign( } /// Creates a milestone with the given index and status. -fn create_test_milestone( - env: &Env, - index: u32, - target_amount: i128, - status: MilestoneStatus, -) { +fn create_test_milestone(env: &Env, index: u32, target_amount: i128, status: MilestoneStatus) { let milestone = crate::types::MilestoneData { index, target_amount, - released_amount: if status == MilestoneStatus::Released { target_amount } else { 0 }, + released_amount: if status == MilestoneStatus::Released { + target_amount + } else { + 0 + }, description_hash: soroban_sdk::BytesN::from_array(env, &[0u8; 32]), status, released_at: None, @@ -81,12 +87,7 @@ fn create_test_milestone( } /// Creates a donor record for testing. -fn create_test_donor( - env: &Env, - donor: &Address, - total_donated: i128, - refund_claimed: bool, -) { +fn create_test_donor(env: &Env, donor: &Address, total_donated: i128, refund_claimed: bool) { let donor_record = DonorRecord { donor: donor.clone(), total_donated, @@ -220,7 +221,10 @@ fn test_claim_refund_exactly_at_window_boundary() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(eligible, "Should be refund-eligible at exactly 30-day boundary"); + assert!( + eligible, + "Should be refund-eligible at exactly 30-day boundary" + ); }); } @@ -235,7 +239,10 @@ fn test_claim_refund_one_second_past_window() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(!eligible, "Should NOT be refund-eligible past 30-day window"); + assert!( + !eligible, + "Should NOT be refund-eligible past 30-day window" + ); }); } @@ -265,7 +272,10 @@ fn test_claim_refund_ended_no_milestones_eligibility() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(eligible, "Ended campaign with no released milestones should allow refunds"); + assert!( + eligible, + "Ended campaign with no released milestones should allow refunds" + ); }); } @@ -281,11 +291,13 @@ fn test_claim_refund_ended_with_released_milestone_eligibility() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(!eligible, "Ended campaign with released milestones should NOT allow refunds"); + assert!( + !eligible, + "Ended campaign with released milestones should NOT allow refunds" + ); }); } - fn setup<'a>() -> (Env, CampaignContractClient<'a>, Address) { let env = Env::default(); env.mock_all_auths(); @@ -302,11 +314,15 @@ fn create_test_milestone_data( index: u32, target_amount: i128, status: MilestoneStatus, -) -> Vec{ +) -> Vec { let milestone = crate::types::MilestoneData { index, target_amount, - released_amount: if status == MilestoneStatus::Released { target_amount } else { 0 }, + released_amount: if status == MilestoneStatus::Released { + target_amount + } else { + 0 + }, description_hash: soroban_sdk::BytesN::from_array(env, &[0u8; 32]), status, released_at: None, @@ -351,14 +367,24 @@ fn test_claim_refund_ended_donor_100() { let min_donation_amount = 0; let contract_address = &client.address; - client.initialize(&creator, &goal_amount, &end_time, &accepted_assets, &milestones, &min_donation_amount); + client.initialize( + &creator, + &goal_amount, + &end_time, + &accepted_assets, + &milestones, + &min_donation_amount, + ); client.donate(&donor, &100, &AssetInfo::Stellar(token_address.clone())); token.transfer(&donor, contract_address, &100); - client.donate(&donor2, &999_900, &AssetInfo::Stellar(token_address.clone())); + client.donate( + &donor2, + &999_900, + &AssetInfo::Stellar(token_address.clone()), + ); token.transfer(&donor2, contract_address, &999_900); - let recipient = Address::generate(&env); client.release_milestone(&0, &recipient); @@ -375,7 +401,7 @@ fn test_claim_refund_ended_donor_100() { let donor2_balance = token.balance(&donor2); assert_eq!(donor2_balance, 1099); - + let contract_balance = token.balance(&contract_address); assert_eq!(contract_balance, 0); } @@ -404,14 +430,20 @@ fn test_claim_refund_ended_donor_1() { let min_donation_amount = 0; let contract_address = &client.address; - client.initialize(&creator, &goal_amount, &end_time, &accepted_assets, &milestones, &min_donation_amount); + client.initialize( + &creator, + &goal_amount, + &end_time, + &accepted_assets, + &milestones, + &min_donation_amount, + ); client.donate(&donor, &1, &AssetInfo::Stellar(token_address.clone())); token.transfer(&donor, contract_address, &1); client.donate(&donor2, &9999, &AssetInfo::Stellar(token_address.clone())); token.transfer(&donor2, contract_address, &9999); - let recipient = Address::generate(&env); client.release_milestone(&0, &recipient); @@ -455,12 +487,18 @@ fn test_claim_refund_ended_full_refund() { let min_donation_amount = 0; let contract_address = &client.address; - client.initialize(&creator, &goal_amount, &end_time, &accepted_assets, &milestones, &min_donation_amount); + client.initialize( + &creator, + &goal_amount, + &end_time, + &accepted_assets, + &milestones, + &min_donation_amount, + ); client.donate(&donor, &1500, &AssetInfo::Stellar(token_address.clone())); token.transfer(&donor, contract_address, &1500); - let recipient = Address::generate(&env); client.release_milestone(&0, &recipient); @@ -475,4 +513,4 @@ fn test_claim_refund_ended_full_refund() { let contract_balance = token.balance(&contract_address); assert_eq!(contract_balance, 0); -} \ No newline at end of file +} diff --git a/campaign/src/test/get_campaign_status_tests.rs b/campaign/src/test/get_campaign_status_tests.rs index 2ac1096..80dd43f 100644 --- a/campaign/src/test/get_campaign_status_tests.rs +++ b/campaign/src/test/get_campaign_status_tests.rs @@ -5,12 +5,12 @@ #![cfg(test)] use soroban_sdk::testutils::{Address as AddressTestUtils, Ledger}; -use soroban_sdk::{Address, Env, Vec, String, BytesN}; +use soroban_sdk::{Address, BytesN, Env, String, Vec}; -use crate::types::{CampaignStatus, CampaignData, StellarAsset, MilestoneStatus, MilestoneData}; +use super::with_contract; use crate::storage::set_campaign; +use crate::types::{CampaignData, CampaignStatus, MilestoneData, MilestoneStatus, StellarAsset}; use crate::CampaignContract; -use super::with_contract; /// Base ledger timestamp (1 year in seconds) so we can safely subtract /// to simulate "past" end_times without underflow. diff --git a/campaign/src/test/integration_tests.rs b/campaign/src/test/integration_tests.rs index d7824e2..2595ffc 100644 --- a/campaign/src/test/integration_tests.rs +++ b/campaign/src/test/integration_tests.rs @@ -5,19 +5,22 @@ #![cfg(test)] use soroban_sdk::testutils::Address as AddressTestUtils; -use soroban_sdk::{Address, Env, Vec, String, BytesN}; +use soroban_sdk::{Address, BytesN, Env, String, Vec}; -use crate::types::{CampaignStatus, CampaignData, DonorRecord, AssetInfo, StellarAsset, MilestoneStatus, MilestoneData}; +use super::with_contract; use crate::storage::{get_campaign, get_milestone}; +use crate::types::{ + AssetInfo, CampaignData, CampaignStatus, DonorRecord, MilestoneData, MilestoneStatus, + StellarAsset, +}; use crate::CampaignContract; -use super::with_contract; // ─── Helpers ───────────────────────────────────────────────────────────────── /// Builds a minimal valid campaign setup and returns (creator, assets, milestones). fn setup_basic_campaign(env: &Env) -> (Address, Vec, Vec) { let creator = Address::generate(env); - + let mut assets: Vec = Vec::new(env); assets.push_back(StellarAsset { asset_code: String::from_str(env, "XLM"), @@ -101,7 +104,8 @@ fn test_donate_happy_path() { assets.clone(), milestones.clone(), 0, - ).unwrap(); + ) + .unwrap(); // First donation let donor1 = Address::generate(&env); @@ -126,11 +130,19 @@ fn test_donate_happy_path() { let campaign = get_campaign(&env).unwrap(); assert_eq!(campaign.raised_amount, 1000); - assert_eq!(campaign.status, CampaignStatus::GoalReached, "Campaign should transition to GoalReached"); + assert_eq!( + campaign.status, + CampaignStatus::GoalReached, + "Campaign should transition to GoalReached" + ); // Verify milestone was unlocked let milestone = get_milestone(&env, 0).expect("Milestone should exist"); - assert_eq!(milestone.status, MilestoneStatus::Unlocked, "Milestone should be unlocked when goal is reached"); + assert_eq!( + milestone.status, + MilestoneStatus::Unlocked, + "Milestone should be unlocked when goal is reached" + ); // Verify both donor records let donor1_record = CampaignContract::get_donor_record(env.clone(), donor1.clone()) @@ -168,7 +180,8 @@ fn test_lifecycle_end_and_refund_eligibility() { assets.clone(), milestones.clone(), 0, - ).unwrap(); + ) + .unwrap(); // Donate let donor = Address::generate(&env); @@ -238,7 +251,8 @@ fn test_lifecycle_multi_milestone_unlock() { assets.clone(), milestones.clone(), 0, - ).unwrap(); + ) + .unwrap(); // Use different donor addresses to avoid auth-frame conflicts in tests let donor1 = Address::generate(&env); @@ -250,29 +264,53 @@ fn test_lifecycle_multi_milestone_unlock() { CampaignContract::donate(env.clone(), donor1.clone(), 500, AssetInfo::Native); let milestone_0 = get_milestone(&env, 0).unwrap(); - assert_eq!(milestone_0.status, MilestoneStatus::Locked, "Milestone 0 should remain locked at 500 raised"); + assert_eq!( + milestone_0.status, + MilestoneStatus::Locked, + "Milestone 0 should remain locked at 500 raised" + ); // Second donation: 600 — total 1100, milestone 0 should unlock CampaignContract::donate(env.clone(), donor2.clone(), 600, AssetInfo::Native); let milestone_0 = get_milestone(&env, 0).unwrap(); - assert_eq!(milestone_0.status, MilestoneStatus::Unlocked, "Milestone 0 should unlock at 1100 raised"); + assert_eq!( + milestone_0.status, + MilestoneStatus::Unlocked, + "Milestone 0 should unlock at 1100 raised" + ); let milestone_1 = get_milestone(&env, 1).unwrap(); - assert_eq!(milestone_1.status, MilestoneStatus::Locked, "Milestone 1 should remain locked"); + assert_eq!( + milestone_1.status, + MilestoneStatus::Locked, + "Milestone 1 should remain locked" + ); // Third donation: 1000 — total 2100, milestone 1 should unlock CampaignContract::donate(env.clone(), donor3.clone(), 1000, AssetInfo::Native); let milestone_1 = get_milestone(&env, 1).unwrap(); - assert_eq!(milestone_1.status, MilestoneStatus::Unlocked, "Milestone 1 should unlock at 2100 raised"); + assert_eq!( + milestone_1.status, + MilestoneStatus::Unlocked, + "Milestone 1 should unlock at 2100 raised" + ); let milestone_2 = get_milestone(&env, 2).unwrap(); - assert_eq!(milestone_2.status, MilestoneStatus::Locked, "Milestone 2 should remain locked"); + assert_eq!( + milestone_2.status, + MilestoneStatus::Locked, + "Milestone 2 should remain locked" + ); // Fourth donation: 900 — total 3000, milestone 2 should unlock CampaignContract::donate(env.clone(), donor4.clone(), 900, AssetInfo::Native); let milestone_2 = get_milestone(&env, 2).unwrap(); - assert_eq!(milestone_2.status, MilestoneStatus::Unlocked, "Milestone 2 should unlock at 3000 raised"); + assert_eq!( + milestone_2.status, + MilestoneStatus::Unlocked, + "Milestone 2 should unlock at 3000 raised" + ); // Campaign should be GoalReached let campaign = get_campaign(&env).unwrap(); @@ -350,7 +388,8 @@ fn test_campaign_analytics_report_and_summary() { assets.clone(), milestones.clone(), 0, - ).unwrap(); + ) + .unwrap(); let initial = CampaignContract::get_campaign_report(env.clone()).unwrap(); assert_eq!(initial.creator, creator); @@ -428,7 +467,8 @@ fn test_donate_below_minimum_panics_assert() { assets.clone(), milestones.clone(), 100, // min donation is 100 - ).unwrap(); + ) + .unwrap(); let donor = Address::generate(&env); CampaignContract::donate(env.clone(), donor.clone(), 50, AssetInfo::Native); diff --git a/campaign/src/test/invariant_tests.rs b/campaign/src/test/invariant_tests.rs index d3e9a9e..e487367 100644 --- a/campaign/src/test/invariant_tests.rs +++ b/campaign/src/test/invariant_tests.rs @@ -8,15 +8,13 @@ #![cfg(test)] use soroban_sdk::testutils::{Address as AddressTestUtils, Ledger}; -use soroban_sdk::{Address, Env, Vec, String, BytesN}; +use soroban_sdk::{Address, BytesN, Env, String, Vec}; -use crate::types::{ - CampaignData, CampaignStatus, MilestoneData, MilestoneStatus, StellarAsset, -}; +use super::with_contract; use crate::storage::{ get_campaign, get_milestone, set_campaign, set_milestone, storage_get_total_raised, }; -use super::with_contract; +use crate::types::{CampaignData, CampaignStatus, MilestoneData, MilestoneStatus, StellarAsset}; /// Base timestamp: 1 year in seconds, same convention as other test files. const BASE: u64 = 86400 * 365; @@ -100,32 +98,25 @@ fn invariant_last_milestone_target_equals_goal() { let last_milestone = get_milestone(&env, last_index).unwrap(); assert_eq!( - last_milestone.target_amount, - campaign.goal_amount, + last_milestone.target_amount, campaign.goal_amount, "INVARIANT VIOLATED: last milestone target ({}) != goal_amount ({})", - last_milestone.target_amount, - campaign.goal_amount, + last_milestone.target_amount, campaign.goal_amount, ); }); // Case B: three milestones — only the last must equal goal with_contract(&env, || { let goal: i128 = 3000; - setup_campaign_with_milestones( - &env, goal, 0, CampaignStatus::Active, - &[1000, 2000, 3000], - ); + setup_campaign_with_milestones(&env, goal, 0, CampaignStatus::Active, &[1000, 2000, 3000]); let campaign = get_campaign(&env).unwrap(); let last_index = campaign.milestone_count - 1; let last_milestone = get_milestone(&env, last_index).unwrap(); assert_eq!( - last_milestone.target_amount, - campaign.goal_amount, + last_milestone.target_amount, campaign.goal_amount, "INVARIANT VIOLATED: last milestone target ({}) != goal_amount ({})", - last_milestone.target_amount, - campaign.goal_amount, + last_milestone.target_amount, campaign.goal_amount, ); }); } @@ -182,8 +173,8 @@ fn invariant_total_donations_match_raised() { env.ledger().set_timestamp(BASE); with_contract(&env, || { - use crate::types::{AssetInfo, DonorRecord}; use crate::storage::{set_donor, storage_set_total_raised}; + use crate::types::{AssetInfo, DonorRecord}; let goal: i128 = 3000; setup_campaign_with_milestones(&env, goal, 0, CampaignStatus::Active, &[1000, 2000, 3000]); @@ -255,10 +246,7 @@ fn invariant_no_released_milestones_while_active() { with_contract(&env, || { let goal: i128 = 3000; - setup_campaign_with_milestones( - &env, goal, 0, CampaignStatus::Active, - &[1000, 2000, 3000], - ); + setup_campaign_with_milestones(&env, goal, 0, CampaignStatus::Active, &[1000, 2000, 3000]); // Simulate donations that cross each milestone threshold // by updating raised_amount and unlocking milestones as donate() would @@ -295,7 +283,9 @@ fn invariant_no_released_milestones_while_active() { MilestoneStatus::Released, "INVARIANT VIOLATED: milestone {} is Released while campaign is {:?} \ after donations only (raised={})", - i, campaign.status, raised, + i, + campaign.status, + raised, ); } } @@ -314,10 +304,7 @@ fn invariant_milestone_targets_strictly_ascending() { env.ledger().set_timestamp(BASE); with_contract(&env, || { - setup_campaign_with_milestones( - &env, 3000, 0, CampaignStatus::Active, - &[1000, 2000, 3000], - ); + setup_campaign_with_milestones(&env, 3000, 0, CampaignStatus::Active, &[1000, 2000, 3000]); let campaign = get_campaign(&env).unwrap(); let mut prev_target: i128 = 0; @@ -329,7 +316,9 @@ fn invariant_milestone_targets_strictly_ascending() { ms.target_amount > prev_target, "INVARIANT VIOLATED: milestone {} target ({}) is not greater than \ previous target ({})", - i, ms.target_amount, prev_target, + i, + ms.target_amount, + prev_target, ); prev_target = ms.target_amount; @@ -338,11 +327,9 @@ fn invariant_milestone_targets_strictly_ascending() { // Final check: last milestone equals goal let last = get_milestone(&env, campaign.milestone_count - 1).unwrap(); assert_eq!( - last.target_amount, - campaign.goal_amount, + last.target_amount, campaign.goal_amount, "INVARIANT VIOLATED: last milestone target ({}) != goal_amount ({})", - last.target_amount, - campaign.goal_amount, + last.target_amount, campaign.goal_amount, ); }); } diff --git a/campaign/src/test/negative_path_tests.rs b/campaign/src/test/negative_path_tests.rs index ba88fc2..77402c7 100644 --- a/campaign/src/test/negative_path_tests.rs +++ b/campaign/src/test/negative_path_tests.rs @@ -6,16 +6,16 @@ #![cfg(test)] use soroban_sdk::testutils::{Address as AddressTestUtils, Ledger}; -use soroban_sdk::{Address, Env, String, Vec, BytesN}; +use soroban_sdk::{Address, BytesN, Env, String, Vec}; +use super::with_contract; +use crate::storage::{get_campaign, set_campaign, set_donor, set_milestone}; use crate::types::{ - CampaignData, CampaignStatus, DonorRecord, AssetInfo, StellarAsset, MilestoneData, - MilestoneStatus, Error, DataKey, + AssetInfo, CampaignData, CampaignStatus, DataKey, DonorRecord, Error, MilestoneData, + MilestoneStatus, StellarAsset, }; -use crate::storage::{set_campaign, set_donor, set_milestone, get_campaign}; use crate::CampaignContract; use crate::CampaignContractClient; -use super::with_contract; /// Base ledger timestamp (1 year in seconds) so we can safely subtract /// to simulate "past" end_times without underflow. @@ -82,12 +82,7 @@ fn fund_donor(env: &Env, donor: &Address) { set_donor(env, donor, &record); } -fn create_donor_record( - env: &Env, - donor: &Address, - total_donated: i128, - refund_claimed: bool, -) { +fn create_donor_record(env: &Env, donor: &Address, total_donated: i128, refund_claimed: bool) { let record = DonorRecord { donor: donor.clone(), total_donated, @@ -132,8 +127,13 @@ fn test_initialize_fails_zero_goal() { let creator = Address::generate(&env); let end_time = env.ledger().timestamp() + 100_000; let _ = CampaignContract::initialize( - env.clone(), creator, 0, end_time, - default_accepted_assets(&env), default_milestones(&env), 0, + env.clone(), + creator, + 0, + end_time, + default_accepted_assets(&env), + default_milestones(&env), + 0, ); }); } @@ -147,8 +147,13 @@ fn test_initialize_fails_negative_goal() { let creator = Address::generate(&env); let end_time = env.ledger().timestamp() + 100_000; let _ = CampaignContract::initialize( - env.clone(), creator, -100, end_time, - default_accepted_assets(&env), default_milestones(&env), 0, + env.clone(), + creator, + -100, + end_time, + default_accepted_assets(&env), + default_milestones(&env), + 0, ); }); } @@ -163,8 +168,13 @@ fn test_initialize_fails_past_end_time() { let creator = Address::generate(&env); let end_time = env.ledger().timestamp() - 1; let _ = CampaignContract::initialize( - env.clone(), creator, 1000, end_time, - default_accepted_assets(&env), default_milestones(&env), 0, + env.clone(), + creator, + 1000, + end_time, + default_accepted_assets(&env), + default_milestones(&env), + 0, ); }); } @@ -179,8 +189,13 @@ fn test_initialize_fails_empty_assets() { let end_time = env.ledger().timestamp() + 100_000; let empty_assets: Vec = Vec::new(&env); let _ = CampaignContract::initialize( - env.clone(), creator, 1000, end_time, - empty_assets, default_milestones(&env), 0, + env.clone(), + creator, + 1000, + end_time, + empty_assets, + default_milestones(&env), + 0, ); }); } @@ -199,8 +214,13 @@ fn test_initialize_fails_empty_asset_code() { issuer: Some(Address::generate(&env)), }); let _ = CampaignContract::initialize( - env.clone(), creator, 1000, end_time, - assets, default_milestones(&env), 0, + env.clone(), + creator, + 1000, + end_time, + assets, + default_milestones(&env), + 0, ); }); } @@ -215,8 +235,13 @@ fn test_initialize_fails_zero_milestones() { let end_time = env.ledger().timestamp() + 100_000; let empty_milestones: Vec = Vec::new(&env); let _ = CampaignContract::initialize( - env.clone(), creator, 1000, end_time, - default_accepted_assets(&env), empty_milestones, 0, + env.clone(), + creator, + 1000, + end_time, + default_accepted_assets(&env), + empty_milestones, + 0, ); }); } @@ -244,8 +269,13 @@ fn test_initialize_fails_too_many_milestones() { }); } let _ = CampaignContract::initialize( - env.clone(), creator, 6000, end_time, - default_accepted_assets(&env), milestones, 0, + env.clone(), + creator, + 6000, + end_time, + default_accepted_assets(&env), + milestones, + 0, ); }); } @@ -260,22 +290,35 @@ fn test_initialize_fails_milestone_targets_not_ascending() { let end_time = env.ledger().timestamp() + 100_000; let mut milestones: Vec = Vec::new(&env); milestones.push_back(MilestoneData { - index: 0, target_amount: 500, released_amount: 0, + index: 0, + target_amount: 500, + released_amount: 0, description_hash: BytesN::from_array(&env, &[0u8; 32]), status: MilestoneStatus::Locked, - released_at: None, released_at_ledger: None, - release_tx: None, released_to: None, + released_at: None, + released_at_ledger: None, + release_tx: None, + released_to: None, }); milestones.push_back(MilestoneData { - index: 1, target_amount: 300, released_amount: 0, + index: 1, + target_amount: 300, + released_amount: 0, description_hash: BytesN::from_array(&env, &[0u8; 32]), status: MilestoneStatus::Locked, - released_at: None, released_at_ledger: None, - release_tx: None, released_to: None, + released_at: None, + released_at_ledger: None, + release_tx: None, + released_to: None, }); let _ = CampaignContract::initialize( - env.clone(), creator, 500, end_time, - default_accepted_assets(&env), milestones, 0, + env.clone(), + creator, + 500, + end_time, + default_accepted_assets(&env), + milestones, + 0, ); }); } @@ -290,15 +333,24 @@ fn test_initialize_fails_milestone_last_target_not_equal_goal() { let end_time = env.ledger().timestamp() + 100_000; let mut milestones: Vec = Vec::new(&env); milestones.push_back(MilestoneData { - index: 0, target_amount: 500, released_amount: 0, + index: 0, + target_amount: 500, + released_amount: 0, description_hash: BytesN::from_array(&env, &[0u8; 32]), status: MilestoneStatus::Locked, - released_at: None, released_at_ledger: None, - release_tx: None, released_to: None, + released_at: None, + released_at_ledger: None, + release_tx: None, + released_to: None, }); let _ = CampaignContract::initialize( - env.clone(), creator, 1000, end_time, - default_accepted_assets(&env), milestones, 0, + env.clone(), + creator, + 1000, + end_time, + default_accepted_assets(&env), + milestones, + 0, ); }); } @@ -375,8 +427,13 @@ fn test_donate_fails_below_minimum() { let creator = Address::generate(&env); let end_time = env.ledger().timestamp() + 100_000; let _ = CampaignContract::initialize( - env.clone(), creator, 1000, end_time, - default_accepted_assets(&env), default_milestones(&env), 100, + env.clone(), + creator, + 1000, + end_time, + default_accepted_assets(&env), + default_milestones(&env), + 100, ); let donor = Address::generate(&env); CampaignContract::donate(env.clone(), donor, 50, AssetInfo::Native); @@ -481,8 +538,13 @@ fn test_is_refund_eligible_fails_goal_reached() { let creator = Address::generate(&env); let end_time = env.ledger().timestamp() + 100_000; let _ = CampaignContract::initialize( - env.clone(), creator, 1000, end_time, - default_accepted_assets(&env), default_milestones(&env), 0, + env.clone(), + creator, + 1000, + end_time, + default_accepted_assets(&env), + default_milestones(&env), + 0, ); let mut campaign = get_campaign(&env).unwrap(); campaign.status = CampaignStatus::GoalReached; @@ -505,8 +567,13 @@ fn test_is_refund_eligible_fails_window_closed() { // Initialize with future end_time, then manually set to past + Ended let future_end = env.ledger().timestamp() + 100_000; let _ = CampaignContract::initialize( - env.clone(), creator.clone(), 1000, future_end, - default_accepted_assets(&env), default_milestones(&env), 0, + env.clone(), + creator.clone(), + 1000, + future_end, + default_accepted_assets(&env), + default_milestones(&env), + 0, ); let mut campaign = get_campaign(&env).unwrap(); campaign.end_time = env.ledger().timestamp() - (31 * 24 * 60 * 60); @@ -547,7 +614,10 @@ fn test_is_refund_eligible_fails_ended_with_released_milestones() { let donor = Address::generate(&env); create_donor_record(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor); - assert!(!eligible, "Ended campaign with released milestone should not allow refunds"); + assert!( + !eligible, + "Ended campaign with released milestone should not allow refunds" + ); }); } @@ -693,7 +763,10 @@ fn test_claim_refund_eligible_cancelled() { let donor = Address::generate(&env); create_donor_record(&env, &donor, 500, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor); - assert!(eligible, "Donor should be eligible for refund on cancelled campaign"); + assert!( + eligible, + "Donor should be eligible for refund on cancelled campaign" + ); }); } @@ -764,8 +837,13 @@ fn test_refund_window_edge_boundary() { // Initialize with future end_time, then manually set to exact boundary let future_end = env.ledger().timestamp() + 100_000; let _ = CampaignContract::initialize( - env.clone(), creator.clone(), 1000, future_end, - default_accepted_assets(&env), default_milestones(&env), 0, + env.clone(), + creator.clone(), + 1000, + future_end, + default_accepted_assets(&env), + default_milestones(&env), + 0, ); let mut campaign = get_campaign(&env).unwrap(); campaign.end_time = env.ledger().timestamp() - (30 * 24 * 60 * 60); @@ -788,8 +866,13 @@ fn test_refund_window_just_after_boundary() { // Initialize with future end_time, then manually set to just past boundary let future_end = env.ledger().timestamp() + 100_000; let _ = CampaignContract::initialize( - env.clone(), creator.clone(), 1000, future_end, - default_accepted_assets(&env), default_milestones(&env), 0, + env.clone(), + creator.clone(), + 1000, + future_end, + default_accepted_assets(&env), + default_milestones(&env), + 0, ); let mut campaign = get_campaign(&env).unwrap(); campaign.end_time = env.ledger().timestamp() - (30 * 24 * 60 * 60 + 1); @@ -798,7 +881,10 @@ fn test_refund_window_just_after_boundary() { let donor = Address::generate(&env); create_donor_record(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor); - assert!(!eligible, "Should NOT be eligible just past 30-day boundary"); + assert!( + !eligible, + "Should NOT be eligible just past 30-day boundary" + ); }); } @@ -826,7 +912,10 @@ fn test_upgrade_succeeds_when_not_frozen() { // Verify the contract is not frozen by default; upgrade should not panic on the // freeze check (it will panic later when the deployer rejects the dummy hash, // so we only assert that is_frozen returns false before the call). - assert!(!crate::storage::is_frozen(&env), "Contract should not be frozen initially"); + assert!( + !crate::storage::is_frozen(&env), + "Contract should not be frozen initially" + ); }); } @@ -839,7 +928,10 @@ fn test_upgrade_succeeds_after_unfreeze() { CampaignContract::freeze(env.clone()); assert!(crate::storage::is_frozen(&env), "Contract should be frozen"); CampaignContract::unfreeze(env.clone()); - assert!(!crate::storage::is_frozen(&env), "Contract should be unfrozen after unfreeze"); + assert!( + !crate::storage::is_frozen(&env), + "Contract should be unfrozen after unfreeze" + ); }); } @@ -868,8 +960,13 @@ fn test_initialize_requires_auth() { let creator = Address::generate(&env); let end_time = env.ledger().timestamp() + 100_000; let _ = CampaignContract::initialize( - env.clone(), creator, 1000, end_time, - default_accepted_assets(&env), default_milestones(&env), 0, + env.clone(), + creator, + 1000, + end_time, + default_accepted_assets(&env), + default_milestones(&env), + 0, ); }); } diff --git a/campaign/src/test/refund_eligibility_tests.rs b/campaign/src/test/refund_eligibility_tests.rs index 96ee378..f28b633 100644 --- a/campaign/src/test/refund_eligibility_tests.rs +++ b/campaign/src/test/refund_eligibility_tests.rs @@ -8,21 +8,19 @@ use soroban_sdk::testutils::{Address as AddressTestUtils, Ledger}; use soroban_sdk::{Address, Env}; -use crate::types::{CampaignStatus, CampaignData, DonorRecord, AssetInfo, StellarAsset, MilestoneStatus}; +use super::with_contract; use crate::storage::{set_campaign, set_donor, set_milestone}; +use crate::types::{ + AssetInfo, CampaignData, CampaignStatus, DonorRecord, MilestoneStatus, StellarAsset, +}; use crate::CampaignContract; -use super::with_contract; /// Base ledger timestamp (1 year in seconds) so we can safely subtract /// to simulate "past" end_times without underflow. const BASE: u64 = 86400 * 365; /// Helper to create a test milestone -fn create_test_milestone( - env: &Env, - campaign_index: u32, - status: MilestoneStatus, -) { +fn create_test_milestone(env: &Env, campaign_index: u32, status: MilestoneStatus) { let milestone = crate::types::MilestoneData { index: campaign_index, target_amount: 1000, @@ -114,7 +112,10 @@ fn test_refund_not_eligible_campaign_goal_reached() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(!eligible, "GoalReached campaign should not be refund-eligible"); + assert!( + !eligible, + "GoalReached campaign should not be refund-eligible" + ); }); } @@ -142,7 +143,10 @@ fn test_refund_eligible_campaign_ended_no_milestone_released() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(eligible, "Ended campaign with no milestone released should be refund-eligible"); + assert!( + eligible, + "Ended campaign with no milestone released should be refund-eligible" + ); }); } @@ -167,7 +171,10 @@ fn test_refund_not_eligible_no_campaign() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(!eligible, "Should not be refund-eligible if campaign not initialized"); + assert!( + !eligible, + "Should not be refund-eligible if campaign not initialized" + ); }); } @@ -181,7 +188,10 @@ fn test_refund_not_eligible_window_closed() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(!eligible, "Refund should not be eligible after 30-day window closes"); + assert!( + !eligible, + "Refund should not be eligible after 30-day window closes" + ); }); } @@ -195,7 +205,10 @@ fn test_refund_not_eligible_already_claimed() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, true); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(!eligible, "Donor should not be refund-eligible if already claimed"); + assert!( + !eligible, + "Donor should not be refund-eligible if already claimed" + ); }); } @@ -209,7 +222,10 @@ fn test_refund_window_edge_case_exactly_30_days() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(eligible, "Should be refund-eligible at exactly 30-day boundary"); + assert!( + eligible, + "Should be refund-eligible at exactly 30-day boundary" + ); }); } @@ -223,7 +239,10 @@ fn test_refund_window_edge_case_one_second_after_30_days() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(!eligible, "Should not be refund-eligible after 30-day window closes"); + assert!( + !eligible, + "Should not be refund-eligible after 30-day window closes" + ); }); } @@ -237,6 +256,9 @@ fn test_refund_eligibility_all_conditions() { let donor = Address::generate(&env); create_test_donor(&env, &donor, 100, false); let eligible = CampaignContract::is_refund_eligible(env.clone(), donor.clone()); - assert!(eligible, "Should be eligible with cancelled campaign, no claim, within window"); + assert!( + eligible, + "Should be eligible with cancelled campaign, no claim, within window" + ); }); } diff --git a/campaign/src/test/release_milestone_tests.rs b/campaign/src/test/release_milestone_tests.rs index 24468c6..fb689b9 100644 --- a/campaign/src/test/release_milestone_tests.rs +++ b/campaign/src/test/release_milestone_tests.rs @@ -20,13 +20,13 @@ #![cfg(test)] use soroban_sdk::testutils::{Address as AddressTestUtils, Ledger}; -use soroban_sdk::{Address, Env, Vec, String, BytesN}; use soroban_sdk::token::StellarAssetClient; +use soroban_sdk::{Address, BytesN, Env, String, Vec}; -use crate::types::{CampaignStatus, StellarAsset, MilestoneData, MilestoneStatus, CampaignData}; +use super::with_contract; use crate::storage::{get_milestone, set_campaign, set_milestone}; +use crate::types::{CampaignData, CampaignStatus, MilestoneData, MilestoneStatus, StellarAsset}; use crate::CampaignContractClient; -use super::with_contract; /// Base ledger timestamp (1 year in seconds). const BASE: u64 = 86400 * 365; @@ -83,12 +83,7 @@ fn mint_tokens_to_contract(env: &Env) { } /// Creates a milestone with the given index, target, and status. -fn create_test_milestone( - env: &Env, - index: u32, - target_amount: i128, - status: MilestoneStatus, -) { +fn create_test_milestone(env: &Env, index: u32, target_amount: i128, status: MilestoneStatus) { let milestone = MilestoneData { index, target_amount, @@ -203,8 +198,7 @@ fn test_valid_release_sets_released_amount() { crate::release_milestone::release_milestone(&env, 0, recipient); let milestone = get_milestone(&env, 0).expect("Milestone should exist"); assert_eq!( - milestone.released_amount, - milestone.target_amount, + milestone.released_amount, milestone.target_amount, "Released amount should equal target amount after release" ); }); diff --git a/campaign/src/types.rs b/campaign/src/types.rs index e3bcfef..49cc064 100644 --- a/campaign/src/types.rs +++ b/campaign/src/types.rs @@ -1,6 +1,6 @@ // src/types.rs -use soroban_sdk::{contracttype, contracterror, Address, BytesN, String, Vec}; +use soroban_sdk::{contracterror, contracttype, Address, BytesN, String, Vec}; // ─── Error enum ─────────────────────────────────────────────────────────────── @@ -14,103 +14,102 @@ use soroban_sdk::{contracttype, contracterror, Address, BytesN, String, Vec}; pub enum Error { // ── Requested contract error codes ──────────────────────────────────── /// `initialize` called on an already-initialised contract. - AlreadyInitialized = 1, + AlreadyInitialized = 1, /// Contract has not been initialised yet. - NotInitialized = 2, + NotInitialized = 2, /// Caller is not authorised to perform the operation. - Unauthorized = 3, + Unauthorized = 3, /// The campaign deadline has already passed. - CampaignEnded = 4, + CampaignEnded = 4, /// Operation requires the campaign to be `Active` or `GoalReached`. - CampaignNotActive = 5, + CampaignNotActive = 5, /// Donated asset is not in the campaign's accepted assets list. - AssetNotAccepted = 6, + AssetNotAccepted = 6, /// Donation amount is below the campaign's minimum threshold. - DonationTooSmall = 7, + DonationTooSmall = 7, /// Milestone index is out of range for this campaign. - MilestoneNotFound = 8, + MilestoneNotFound = 8, /// Milestone has not been unlocked yet and cannot be released. - MilestoneNotUnlocked = 9, + MilestoneNotUnlocked = 9, /// A previous milestone must be released before this one can be released. PreviousMilestoneNotReleased = 10, /// Cannot cancel the campaign while it still holds funds. - CannotCancelWithFunds = 11, + CannotCancelWithFunds = 11, /// Refunds are no longer permitted for this campaign. - RefundWindowClosed = 12, + RefundWindowClosed = 12, /// `goal_amount` must be strictly positive. - InvalidGoalAmount = 13, + InvalidGoalAmount = 13, /// `end_time` must be strictly greater than the current ledger timestamp. - InvalidEndTime = 14, + InvalidEndTime = 14, /// Milestones must be strictly ascending and the last must equal `goal_amount`. - InvalidMilestones = 15, + InvalidMilestones = 15, /// Contract does not hold enough funds to fulfil the requested transfer. InsufficientContractBalance = 16, /// A checked arithmetic operation overflowed. - Overflow = 17, + Overflow = 17, // ── Additional contract errors ───────────────────────────────────────── /// `accepted_assets` must be non-empty. - InvalidAssets = 18, + InvalidAssets = 18, /// `asset_code` must be non-empty and ≤ 12 characters (Stellar limit). - InvalidAssetCode = 19, + InvalidAssetCode = 19, /// Last milestone `target_amount` does not equal `goal_amount`. - MilestoneMismatch = 20, + MilestoneMismatch = 20, /// Milestone count must be in the range [1, MAX_MILESTONES]. - InvalidMilestoneCount = 21, + InvalidMilestoneCount = 21, /// The requested campaign status transition is not permitted. - InvalidCampaignTransition = 22, + InvalidCampaignTransition = 22, /// The requested milestone status transition is not permitted. - InvalidMilestoneTransition = 23, + InvalidMilestoneTransition = 23, /// Cannot transition to `GoalReached` — raised amount < goal. - GoalNotReached = 24, + GoalNotReached = 24, /// A storage read returned an unexpectedly invalid value. - InvalidStorageValue = 25, + InvalidStorageValue = 25, /// A storage write failed (entry too large, quota exceeded, etc.). - StorageWriteError = 26, + StorageWriteError = 26, // ── Asset / transfer ───────────────────────────────────────────────── 3x /// Recipient address is the contract itself — would lock funds permanently. - InvalidRecipient = 30, + InvalidRecipient = 30, /// The asset has no issuer address; transfers require a token contract address. - MissingIssuerAddress = 31, + MissingIssuerAddress = 31, /// Computed release amount is zero after proportional rounding. - ZeroReleaseAmount = 32, + ZeroReleaseAmount = 32, /// `released_amount` already equals `target_amount`; nothing left to release. - NothingToRelease = 33, + NothingToRelease = 33, /// `released_amount` would exceed `target_amount` after this operation. MilestoneReleasedExceedsTarget = 34, // ── Milestone ──────────────────────────────────────────────────────── 4x /// Milestone is already in the `Released` state. - MilestoneAlreadyReleased = 40, + MilestoneAlreadyReleased = 40, /// All milestones must be Released before the campaign can be concluded. - UnreleasedMilestonesExist = 41, + UnreleasedMilestonesExist = 41, // ── Refunds ────────────────────────────────────────────────────────── 5x /// Refunds are only permitted when the campaign is `Cancelled` or /// `Ended` without reaching the goal. - RefundNotPermitted = 50, + RefundNotPermitted = 50, /// No donor record found for the requesting address. - NoDonorRecord = 51, + NoDonorRecord = 51, /// Donor has already claimed a refund for this campaign. - RefundAlreadyClaimed = 52, + RefundAlreadyClaimed = 52, // RefundWindowClosed is defined above as RefundWindowClosed = 12 // ── Re-entrancy / concurrency ──────────────────────────────────────── 6x /// A re-entrant call was detected; operation aborted. - ReentrantCall = 60, + ReentrantCall = 60, // ── Amount validation ───────────────────────────────────────────────────────── 7x /// A generic negative or otherwise invalid amount was supplied. - InvalidAmount = 70, + InvalidAmount = 70, // ── Upgrade / freeze ─────────────────────────────────────────────────── 8x /// Contract is frozen; all mutating operations are blocked. - ContractFrozen = 80, + ContractFrozen = 80, } - // ─── Campaign lifecycle ─────────────────────────────────────────────────────── /// Campaign status with documented transition rules. @@ -163,11 +162,11 @@ impl CampaignStatus { pub fn can_transition_to(self, next: Self) -> bool { matches!( (self, next), - (Self::Active, Self::GoalReached) - | (Self::Active, Self::Ended) - | (Self::Active, Self::Cancelled) - | (Self::GoalReached, Self::Ended) - | (Self::GoalReached, Self::Cancelled) + (Self::Active, Self::GoalReached) + | (Self::Active, Self::Ended) + | (Self::Active, Self::Cancelled) + | (Self::GoalReached, Self::Ended) + | (Self::GoalReached, Self::Cancelled) ) } } @@ -566,5 +565,3 @@ pub struct RefundProcessedEvent { pub asset: AssetInfo, pub ledger: u32, } - - diff --git a/campaign/src/views.rs b/campaign/src/views.rs index 2457fcf..2b984e7 100644 --- a/campaign/src/views.rs +++ b/campaign/src/views.rs @@ -33,8 +33,8 @@ pub struct MilestoneView { /// Returns `milestone_count` if all milestones are released. #[must_use] pub fn find_next_pending_index(env: &Env) -> u32 { - let campaign = get_campaign(env) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + let campaign = + get_campaign(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); for i in 0..campaign.milestone_count { if let Some(milestone) = get_milestone(env, i) { @@ -53,8 +53,8 @@ pub fn find_next_pending_index(env: &Env) -> u32 { /// - `Error::MilestoneNotFound` — `index` ≥ `milestone_count` or missing storage. #[must_use] pub fn get_milestone_by_index(env: &Env, index: u32) -> MilestoneView { - let campaign = get_campaign(env) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + let campaign = + get_campaign(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); if index >= campaign.milestone_count { panic_with_error!(env, Error::MilestoneNotFound); diff --git a/common/src/lib.rs b/common/src/lib.rs index 1bbd9bd..a41e8bf 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -7,7 +7,7 @@ //! All discriminants are stable — never renumber existing variants. #![no_std] -use soroban_sdk::{contracttype, contracterror}; +use soroban_sdk::{contracterror, contracttype}; #[contracttype] #[derive(Copy, Clone, Debug, Eq, PartialEq)] diff --git a/crates/contracts/core/src/lib.rs b/crates/contracts/core/src/lib.rs index 4fbc21e..152b89c 100644 --- a/crates/contracts/core/src/lib.rs +++ b/crates/contracts/core/src/lib.rs @@ -8,7 +8,7 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracterror, contracttype, symbol_short, vec, Address, Env, String, + contract, contracterror, contractimpl, contracttype, symbol_short, vec, Address, Env, String, Symbol, Vec, }; @@ -148,7 +148,7 @@ pub struct Campaign { #[derive(Clone, Debug, Eq, PartialEq)] pub struct DonationRecord { pub donor: Address, - pub amount: i128, // net amount after fee + pub amount: i128, // net amount after fee pub fee: i128, pub asset: Symbol, pub timestamp: u64, @@ -229,7 +229,9 @@ impl OrbitChainContract { /// Initialize the contract with admin address pub fn initialize(env: Env, admin: Address) { admin.require_auth(); - env.storage().instance().set(&symbol_short!("admin"), &admin); + env.storage() + .instance() + .set(&symbol_short!("admin"), &admin); env.storage().instance().set(&symbol_short!("count"), &0u64); } @@ -267,14 +269,16 @@ impl OrbitChainContract { }; // Issue #99 – store each campaign keyed by its ID - env.storage().persistent().set(&campaign_key(count), &campaign); - env.storage().instance().set(&symbol_short!("count"), &count); + env.storage() + .persistent() + .set(&campaign_key(count), &campaign); + env.storage() + .instance() + .set(&symbol_short!("count"), &count); // Emit CampaignCreated event - env.events().publish( - (Symbol::new(&env, "CampaignCreated"), creator), - count, - ); + env.events() + .publish((Symbol::new(&env, "CampaignCreated"), creator), count); count } @@ -315,7 +319,9 @@ impl OrbitChainContract { // Update overall raised total campaign.raised += net; - env.storage().persistent().set(&campaign_key(campaign_id), &campaign); + env.storage() + .persistent() + .set(&campaign_key(campaign_id), &campaign); // Issue #102 – update per-asset raised total let prev_asset_raised: i128 = env @@ -323,9 +329,10 @@ impl OrbitChainContract { .persistent() .get(&asset_raised_key(campaign_id, &asset)) .unwrap_or(0); - env.storage() - .persistent() - .set(&asset_raised_key(campaign_id, &asset), &(prev_asset_raised + net)); + env.storage().persistent().set( + &asset_raised_key(campaign_id, &asset), + &(prev_asset_raised + net), + ); // Issue #104 – append to donation history let record = DonationRecord { @@ -341,7 +348,9 @@ impl OrbitChainContract { .get(&history_key(campaign_id)) .unwrap_or_else(|| vec![&env]); history.push_back(record); - env.storage().persistent().set(&history_key(campaign_id), &history); + env.storage() + .persistent() + .set(&history_key(campaign_id), &history); // Issue #100 – store donation metadata let metadata = DonationMetadata { @@ -364,7 +373,9 @@ impl OrbitChainContract { if !donors.contains(&donor) { donors.push_back(donor.clone()); - env.storage().persistent().set(&donors_key(campaign_id), &donors); + env.storage() + .persistent() + .set(&donors_key(campaign_id), &donors); } // Emit DonationReceived event @@ -374,12 +385,10 @@ impl OrbitChainContract { ); // Issue #142 – increment global transaction counter - let tx_count: u64 = env - .storage() + let tx_count: u64 = env.storage().instance().get(&total_tx_key()).unwrap_or(0); + env.storage() .instance() - .get(&total_tx_key()) - .unwrap_or(0); - env.storage().instance().set(&total_tx_key(), &(tx_count + 1)); + .set(&total_tx_key(), &(tx_count + 1)); // Issue #145 – increment dedicated donation counter so it can be queried // independently of withdrawals. @@ -485,7 +494,13 @@ impl OrbitChainContract { /// Issue #129 – request a withdrawal from a campaign. /// Creates a pending WithdrawalRequest that must be approved by admin (issue #131). - pub fn withdraw(env: Env, creator: Address, campaign_id: u64, recipient: Address, amount: i128) { + pub fn withdraw( + env: Env, + creator: Address, + campaign_id: u64, + recipient: Address, + amount: i128, + ) { creator.require_auth(); // Issue #130 – validate recipient (non-zero amount, valid address type enforced by SDK) @@ -529,12 +544,10 @@ impl OrbitChainContract { .set(&pending_withdrawal_key(campaign_id), &request); // Issue #142 – increment global transaction counter - let tx_count: u64 = env - .storage() + let tx_count: u64 = env.storage().instance().get(&total_tx_key()).unwrap_or(0); + env.storage() .instance() - .get(&total_tx_key()) - .unwrap_or(0); - env.storage().instance().set(&total_tx_key(), &(tx_count + 1)); + .set(&total_tx_key(), &(tx_count + 1)); // Issue #145 – increment dedicated withdrawal counter so it can be queried // independently of donations. @@ -548,7 +561,11 @@ impl OrbitChainContract { .set(&total_withdrawals_key(), &(withdrawal_count + 1)); env.events().publish( - (Symbol::new(&env, "WithdrawalRequested"), creator, campaign_id), + ( + Symbol::new(&env, "WithdrawalRequested"), + creator, + campaign_id, + ), (recipient, amount), ); } @@ -586,7 +603,9 @@ impl OrbitChainContract { panic_with_error(&env, CoreError::InsufficientFunds); } campaign.raised -= request.amount; - env.storage().persistent().set(&campaign_key(campaign_id), &campaign); + env.storage() + .persistent() + .set(&campaign_key(campaign_id), &campaign); request.status = WithdrawalStatus::Approved; env.storage() @@ -633,7 +652,11 @@ impl OrbitChainContract { .set(&pending_withdrawal_key(campaign_id), &request); env.events().publish( - (Symbol::new(&env, "TransactionSubmitted"), admin, campaign_id), + ( + Symbol::new(&env, "TransactionSubmitted"), + admin, + campaign_id, + ), request.amount, ); @@ -649,10 +672,7 @@ impl OrbitChainContract { /// Issue #142 – expose total transaction count (donations + withdrawal requests) pub fn get_total_tx_count(env: Env) -> u64 { - env.storage() - .instance() - .get(&total_tx_key()) - .unwrap_or(0) + env.storage().instance().get(&total_tx_key()).unwrap_or(0) } // ── Analytics & reporting (issues #145, #146, #147, #148) ──────────────────────────── @@ -684,10 +704,7 @@ impl OrbitChainContract { /// Issue #147 – build a per-campaign report including funding progress, donor /// count and donation count. Returns `None` if the campaign does not exist. pub fn get_campaign_report(env: Env, campaign_id: u64) -> Option { - let campaign: Campaign = env - .storage() - .persistent() - .get(&campaign_key(campaign_id))?; + let campaign: Campaign = env.storage().persistent().get(&campaign_key(campaign_id))?; // Donor count (issue #101 storage) let donors: Vec
= env @@ -967,7 +984,10 @@ mod tests { // ── Analytics & reporting tests (issues #145, #146, #147, #148) ──────────────────── /// Helper: bootstrap a contract with admin + N campaigns and return the IDs. - fn setup_with_campaigns(env: &Env, n: u32) -> (OrbitChainContractClient<'_>, Address, Address, Vec) { + fn setup_with_campaigns( + env: &Env, + n: u32, + ) -> (OrbitChainContractClient<'_>, Address, Address, Vec) { env.mock_all_auths(); let contract_id = env.register_contract(None, OrbitChainContract); let client = OrbitChainContractClient::new(env, &contract_id);