From b1f3600e09ee67a330212745a4883180e52403ef Mon Sep 17 00:00:00 2001 From: morelucks Date: Thu, 25 Jun 2026 15:27:55 +0100 Subject: [PATCH] fix: sub-day deadline precision via get_campaign_hours_remaining view (closes #45) --- campaign/src/contract.rs | 22 ++ campaign/src/lib.rs | 6 + .../src/test/get_campaign_status_tests.rs | 76 ++++++- .../calculates_past_subday_remaining.1.json | 197 ++++++++++++++++++ .../calculates_subday_remaining.1.json | 197 ++++++++++++++++++ 5 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 campaign/test_snapshots/test/get_campaign_status_tests/calculates_past_subday_remaining.1.json create mode 100644 campaign/test_snapshots/test/get_campaign_status_tests/calculates_subday_remaining.1.json diff --git a/campaign/src/contract.rs b/campaign/src/contract.rs index 189fbf7..8d75866 100644 --- a/campaign/src/contract.rs +++ b/campaign/src/contract.rs @@ -142,3 +142,25 @@ pub fn get_campaign_status(env: &Env) -> crate::types::CampaignStatusResponse { days_remaining, } } + +/// Issue #45 — Get campaign hours remaining until deadline. +/// +/// Returns the hours portion of the remaining time (0 to 23) in the current day before the deadline. +/// If the deadline has passed, returns 0. +/// No auth required (read-only view). +/// +/// # Panics +/// - `Error::NotInitialized` if campaign not initialized +#[must_use] +pub fn get_campaign_hours_remaining(env: &Env) -> u32 { + let campaign = + get_campaign(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + + let now = env.ledger().timestamp(); + if now < campaign.end_time { + let delta = campaign.end_time - now; + ((delta % 86_400) / 3_600) as u32 + } else { + 0 + } +} diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index 7a911b9..750cf87 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -514,6 +514,12 @@ impl CampaignContract { contract::get_campaign_status(&env) } + /// Issue #45 – Get campaign hours remaining until deadline. + /// No auth required (read-only view). + pub fn get_campaign_hours_remaining(env: Env) -> u32 { + contract::get_campaign_hours_remaining(&env) + } + /// Issue #207 – Release a single milestone (all assets proportionally). /// /// Issue #242 – Reentrancy protection: acquires lock at entry, releases at exit. diff --git a/campaign/src/test/get_campaign_status_tests.rs b/campaign/src/test/get_campaign_status_tests.rs index 80dd43f..c9bd449 100644 --- a/campaign/src/test/get_campaign_status_tests.rs +++ b/campaign/src/test/get_campaign_status_tests.rs @@ -128,6 +128,80 @@ fn calculates_days_remaining() { with_contract(&env, || { setup_active_campaign(&env); let result = CampaignContract::get_campaign_status(env.clone()); - assert!(result.days_remaining > 0); + assert_eq!(result.days_remaining, 1); // 100_000 seconds / 86_400 seconds = 1 day + }); +} + +#[test] +fn calculates_subday_remaining() { + let env = make_env(); + env.ledger().set_timestamp(BASE); + with_contract(&env, || { + let creator = Address::generate(&env); + let campaign = CampaignData { + creator: creator.clone(), + goal_amount: 1000, + raised_amount: 0, + end_time: env.ledger().timestamp() + 36_000, // 10 hours from now + status: CampaignStatus::Active, + accepted_assets: { + let mut assets: Vec = Vec::new(&env); + assets.push_back(StellarAsset { + asset_code: String::from_str(&env, "XLM"), + issuer: Some(Address::generate(&env)), + }); + assets + }, + milestone_count: 1, + min_donation_amount: 0, + created_at_ledger: env.ledger().sequence(), + created_at_time: env.ledger().timestamp(), + concluded_at_ledger: None, + }; + set_campaign(&env, &campaign); + + let status = CampaignContract::get_campaign_status(env.clone()); + assert_eq!(status.status, CampaignStatus::Active); + assert_eq!(status.days_remaining, 0); + + let hours = CampaignContract::get_campaign_hours_remaining(env.clone()); + assert_eq!(hours, 10); + }); +} + +#[test] +fn calculates_past_subday_remaining() { + let env = make_env(); + env.ledger().set_timestamp(BASE); + with_contract(&env, || { + let creator = Address::generate(&env); + let campaign = CampaignData { + creator: creator.clone(), + goal_amount: 1000, + raised_amount: 0, + end_time: env.ledger().timestamp() - 36_000, // 10 hours ago + status: CampaignStatus::Ended, + accepted_assets: { + let mut assets: Vec = Vec::new(&env); + assets.push_back(StellarAsset { + asset_code: String::from_str(&env, "XLM"), + issuer: Some(Address::generate(&env)), + }); + assets + }, + milestone_count: 1, + min_donation_amount: 0, + created_at_ledger: env.ledger().sequence(), + created_at_time: env.ledger().timestamp(), + concluded_at_ledger: None, + }; + set_campaign(&env, &campaign); + + let status = CampaignContract::get_campaign_status(env.clone()); + assert_eq!(status.status, CampaignStatus::Ended); + assert_eq!(status.days_remaining, 0); + + let hours = CampaignContract::get_campaign_hours_remaining(env.clone()); + assert_eq!(hours, 0); }); } diff --git a/campaign/test_snapshots/test/get_campaign_status_tests/calculates_past_subday_remaining.1.json b/campaign/test_snapshots/test/get_campaign_status_tests/calculates_past_subday_remaining.1.json new file mode 100644 index 0000000..50c8e61 --- /dev/null +++ b/campaign/test_snapshots/test/get_campaign_status_tests/calculates_past_subday_remaining.1.json @@ -0,0 +1,197 @@ +{ + "generators": { + "address": 3, + "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": { + "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": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + } + ] + } + ] + } + }, + { + "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": "31500000" + } + }, + { + "key": { + "symbol": "goal_amount" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "milestone_count" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "min_donation_amount" + }, + "val": { + "i128": "0" + } + }, + { + "key": { + "symbol": "raised_amount" + }, + "val": { + "i128": "0" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "vec": [ + { + "symbol": "Ended" + } + ] + } + } + ] + } + } + }, + "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_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/campaign/test_snapshots/test/get_campaign_status_tests/calculates_subday_remaining.1.json b/campaign/test_snapshots/test/get_campaign_status_tests/calculates_subday_remaining.1.json new file mode 100644 index 0000000..db5424c --- /dev/null +++ b/campaign/test_snapshots/test/get_campaign_status_tests/calculates_subday_remaining.1.json @@ -0,0 +1,197 @@ +{ + "generators": { + "address": 3, + "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": { + "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": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + } + ] + } + ] + } + }, + { + "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": "31572000" + } + }, + { + "key": { + "symbol": "goal_amount" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "milestone_count" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "min_donation_amount" + }, + "val": { + "i128": "0" + } + }, + { + "key": { + "symbol": "raised_amount" + }, + "val": { + "i128": "0" + } + }, + { + "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": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file