diff --git a/COMEBACKHERE-contracts/Cargo.lock b/COMEBACKHERE-contracts/Cargo.lock index e982e7c..8bb0281 100644 --- a/COMEBACKHERE-contracts/Cargo.lock +++ b/COMEBACKHERE-contracts/Cargo.lock @@ -325,6 +325,7 @@ name = "comebackhere-treasury" version = "1.1.0" dependencies = [ "proptest", + "serde_json", "soroban-sdk", ] diff --git a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs index 614d0c3..0ea5d07 100644 --- a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs @@ -13,6 +13,13 @@ const MAX_REFERENCE_LEN: u32 = 64; /// Minimum invoice amount, in stroops (10,000,000 stroops == 1 USDC given 7 decimals). const MIN_AMOUNT_USDC: i128 = 10_000_000; +// At five seconds per ledger, these keep state alive for roughly 335 days before +// renewal and extend it to roughly 359 days after an access or mutation. +const INSTANCE_TTL_THRESHOLD: u32 = 5_800_000; +const INSTANCE_TTL_EXTEND_TO: u32 = 6_200_000; +const INVOICE_TTL_THRESHOLD: u32 = 5_800_000; +const INVOICE_TTL_EXTEND_TO: u32 = 6_200_000; + #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum ContractError { @@ -94,10 +101,32 @@ fn check_not_paused(env: &Env) -> Result<(), ContractError> { if is_paused(env) { Err(ContractError::ContractPaused) } else { + extend_instance_ttl(env); Ok(()) } } +fn extend_instance_ttl(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); +} + +fn extend_invoice_ttl(env: &Env, invoice_id: u64) { + env.storage().persistent().extend_ttl( + &DataKey::Invoice(invoice_id), + INVOICE_TTL_THRESHOLD, + INVOICE_TTL_EXTEND_TO, + ); +} + +fn store_invoice(env: &Env, invoice_id: u64, invoice: &Invoice) { + env.storage() + .persistent() + .set(&DataKey::Invoice(invoice_id), invoice); + extend_invoice_ttl(env, invoice_id); +} + fn check_admin(env: &Env, addr: &Address) -> Result<(), ContractError> { if addr != &admin(env) { Err(ContractError::Unauthorized) @@ -139,6 +168,7 @@ impl InvoiceContract { .persistent() .set(&DataKey::InvoiceCount, &0u64); env.storage().persistent().set(&DataKey::Paused, &false); + extend_instance_ttl(&env); Ok(()) } @@ -222,9 +252,7 @@ impl InvoiceContract { expires_at, reference, }; - env.storage() - .persistent() - .set(&DataKey::Invoice(count), &invoice); + store_invoice(&env, count, &invoice); events::invoice_created(&env, &merchant, &count); Ok(count) @@ -238,10 +266,13 @@ impl InvoiceContract { /// # Errors /// - [`ContractError::InvoiceNotFound`] if no invoice with that ID exists. pub fn get_invoice(env: Env, invoice_id: u64) -> Result { - env.storage() + let invoice = env + .storage() .persistent() .get(&DataKey::Invoice(invoice_id)) - .ok_or(ContractError::InvoiceNotFound) + .ok_or(ContractError::InvoiceNotFound)?; + extend_invoice_ttl(&env, invoice_id); + Ok(invoice) } /// Returns only the [`InvoiceStatus`] for a given invoice ID, without fetching @@ -258,6 +289,7 @@ impl InvoiceContract { .persistent() .get::(&DataKey::Invoice(invoice_id)) .ok_or(ContractError::InvoiceNotFound)?; + extend_invoice_ttl(&env, invoice_id); Ok(invoice.status) } @@ -393,9 +425,7 @@ impl InvoiceContract { } invoice.status = InvoiceStatus::Paid; - env.storage() - .persistent() - .set(&DataKey::Invoice(id), &invoice); + store_invoice(&env, id, &invoice); events::invoice_paid(&env, &id); } Ok(()) @@ -431,9 +461,7 @@ impl InvoiceContract { // No funds have moved yet — simple cancellation. InvoiceStatus::Pending => { invoice.status = InvoiceStatus::Cancelled; - env.storage() - .persistent() - .set(&DataKey::Invoice(invoice_id), &invoice); + store_invoice(&env, invoice_id, &invoice); events::invoice_cancelled(&env, &invoice_id); Ok(()) } @@ -442,9 +470,7 @@ impl InvoiceContract { // complete the refund without leaving funds stuck. InvoiceStatus::Paid => { invoice.status = InvoiceStatus::RefundRequested; - env.storage() - .persistent() - .set(&DataKey::Invoice(invoice_id), &invoice); + store_invoice(&env, invoice_id, &invoice); events::invoice_refund_req(&env, &invoice_id); Ok(()) } @@ -497,9 +523,7 @@ impl InvoiceContract { return Err(ContractError::AlreadyRefundRequested); } invoice.status = InvoiceStatus::RefundRequested; - env.storage() - .persistent() - .set(&DataKey::Invoice(invoice_id), &invoice); + store_invoice(&env, invoice_id, &invoice); events::invoice_refund_req(&env, &invoice_id); Ok(()) } @@ -555,9 +579,7 @@ impl InvoiceContract { return Err(ContractError::GraceWindowNotExpired); } invoice.status = InvoiceStatus::Released; - env.storage() - .persistent() - .set(&DataKey::Invoice(invoice_id), &invoice); + store_invoice(&env, invoice_id, &invoice); events::escrow_released(&env, &invoice_id); Ok(()) } @@ -587,9 +609,7 @@ impl InvoiceContract { .ok_or(ContractError::InvoiceNotFound)?; if invoice.status == InvoiceStatus::Pending && now >= invoice.expires_at { invoice.status = InvoiceStatus::Expired; - env.storage() - .persistent() - .set(&DataKey::Invoice(id), &invoice); + store_invoice(&env, id, &invoice); events::invoice_expired(&env, &id); } } @@ -698,6 +718,7 @@ impl InvoiceContract { pub fn pause(env: Env, caller: Address) -> Result<(), ContractError> { check_admin(&env, &caller)?; env.storage().persistent().set(&DataKey::Paused, &true); + extend_instance_ttl(&env); events::contract_paused(&env); Ok(()) } @@ -715,6 +736,7 @@ impl InvoiceContract { pub fn unpause(env: Env, caller: Address) -> Result<(), ContractError> { check_admin(&env, &caller)?; env.storage().persistent().set(&DataKey::Paused, &false); + extend_instance_ttl(&env); events::contract_unpaused(&env); Ok(()) } @@ -778,6 +800,30 @@ mod tests { assert_eq!(invoice_id, 1); } + #[test] + fn test_invoice_storage_remains_readable_after_many_ledgers() { + let (env, cid, _admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let merchant = Address::generate(&env); + let customer = Address::generate(&env); + let token = Address::generate(&env); + let invoice_id = client.create_invoice( + &merchant, + &customer, + &10_000_000i128, + &token, + &5000, + &1, + &None, + ); + + env.ledger().with_mut(|li| { + li.sequence_number = INVOICE_TTL_EXTEND_TO - INVOICE_TTL_THRESHOLD + 1; + }); + assert_eq!(client.get_invoice(&invoice_id).id, invoice_id); + assert_eq!(client.get_invoice_status(&invoice_id), InvoiceStatus::Pending); + } + #[test] fn test_create_invoice_with_duplicate_nonce_returns_error() { let (env, cid, _admin) = setup_contract(1000); diff --git a/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml b/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml index 30cf5dc..7d1c093 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml +++ b/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml @@ -12,6 +12,7 @@ soroban-sdk = "28.0.0" [dev-dependencies] soroban-sdk = { version = "28.0.0", features = ["testutils"] } proptest = "1.4" +serde_json = "1" [profile.release] opt-level = "z" diff --git a/COMEBACKHERE-contracts/contracts/treasury/resources/approve_settlement_baseline.json b/COMEBACKHERE-contracts/contracts/treasury/resources/approve_settlement_baseline.json new file mode 100644 index 0000000..5fa718b --- /dev/null +++ b/COMEBACKHERE-contracts/contracts/treasury/resources/approve_settlement_baseline.json @@ -0,0 +1,9 @@ +{ + "test": "bench_approve_settlement", + "baseline_status": "provisional_budget_not_measured", + "cpu_instructions": 100000, + "memory_bytes": 50000, + "tolerance_percent": 20, + "timestamp": "2026-09-26", + "soroban_sdk": "28.0.0" +} \ No newline at end of file diff --git a/COMEBACKHERE-contracts/contracts/treasury/resources/execute_settlement_baseline.json b/COMEBACKHERE-contracts/contracts/treasury/resources/execute_settlement_baseline.json new file mode 100644 index 0000000..d847ff5 --- /dev/null +++ b/COMEBACKHERE-contracts/contracts/treasury/resources/execute_settlement_baseline.json @@ -0,0 +1,9 @@ +{ + "test": "bench_execute_settlement", + "baseline_status": "provisional_budget_not_measured", + "cpu_instructions": 100000, + "memory_bytes": 50000, + "tolerance_percent": 20, + "timestamp": "2026-09-26", + "soroban_sdk": "28.0.0" +} \ No newline at end of file diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/benchmark.rs b/COMEBACKHERE-contracts/contracts/treasury/src/benchmark.rs index 2b38c8d..b5608f7 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/benchmark.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/benchmark.rs @@ -3,6 +3,33 @@ use super::*; use soroban_sdk::{testutils::Address as _, vec, Address, Env}; +const APPROVE_BASELINE: &str = include_str!("../resources/approve_settlement_baseline.json"); +const EXECUTE_BASELINE: &str = include_str!("../resources/execute_settlement_baseline.json"); + +fn assert_cost_within_baseline(baseline_json: &str, cpu: u64, memory: u64) { + let baseline: serde_json::Value = + serde_json::from_str(baseline_json).expect("benchmark baseline must be valid JSON"); + let tolerance_percent = baseline["tolerance_percent"] + .as_u64() + .expect("benchmark baseline must define tolerance_percent"); + let cpu_limit = baseline["cpu_instructions"] + .as_u64() + .expect("benchmark baseline must define cpu_instructions") + .saturating_mul(100 + tolerance_percent) + / 100; + let memory_limit = baseline["memory_bytes"] + .as_u64() + .expect("benchmark baseline must define memory_bytes") + .saturating_mul(100 + tolerance_percent) + / 100; + + assert!(cpu <= cpu_limit, "CPU instructions ({cpu}) exceeded baseline tolerance ({cpu_limit})"); + assert!( + memory <= memory_limit, + "Memory bytes ({memory}) exceeded baseline tolerance ({memory_limit})" + ); +} + fn setup_bench_env() -> (Env, Address) { let e = Env::default(); e.mock_all_auths(); @@ -117,3 +144,50 @@ fn bench_propose_settlement_rejected_token_cheaper_than_accepted() { accepted proposal ({cpu_accept} cpu) now that the allowlist check runs first" ); } + +#[test] +fn bench_approve_settlement() { + let e = Env::default(); + e.mock_all_auths(); + let contract_id = e.register(TreasuryContract, ()); + let client = TreasuryContractClient::new(&e, &contract_id); + let admin = Address::generate(&e); + let signer = Address::generate(&e); + let token = Address::generate(&e); + let merchant = Address::generate(&e); + client.initialize(&vec![&e, (signer.clone(), 1u64)], &1, &admin); + let settlement_id = client.propose_settlement(&signer, &token, &5_000_000u64, &merchant); + + e.budget().reset_unlimited(); + let cpu_before = e.budget().cpu_instruction_cost(); + let memory_before = e.budget().memory_bytes_cost(); + client.approve_settlement(&signer, &settlement_id); + let cpu = e.budget().cpu_instruction_cost() - cpu_before; + let memory = e.budget().memory_bytes_cost() - memory_before; + + assert_cost_within_baseline(APPROVE_BASELINE, cpu, memory); +} + +#[test] +fn bench_execute_settlement() { + let e = Env::default(); + e.mock_all_auths(); + let contract_id = e.register(TreasuryContract, ()); + let client = TreasuryContractClient::new(&e, &contract_id); + let admin = Address::generate(&e); + let signer = Address::generate(&e); + let token = Address::generate(&e); + let merchant = Address::generate(&e); + client.initialize(&vec![&e, (signer.clone(), 1u64)], &1, &admin); + let settlement_id = client.propose_settlement(&signer, &token, &5_000_000u64, &merchant); + client.approve_settlement(&signer, &settlement_id); + + e.budget().reset_unlimited(); + let cpu_before = e.budget().cpu_instruction_cost(); + let memory_before = e.budget().memory_bytes_cost(); + client.execute_settlement(&signer, &settlement_id, &token); + let cpu = e.budget().cpu_instruction_cost() - cpu_before; + let memory = e.budget().memory_bytes_cost() - memory_before; + + assert_cost_within_baseline(EXECUTE_BASELINE, cpu, memory); +} diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/integration_dispute_lifecycle.rs b/COMEBACKHERE-contracts/contracts/treasury/src/integration_dispute_lifecycle.rs index 9284ab9..7b39614 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/integration_dispute_lifecycle.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/integration_dispute_lifecycle.rs @@ -1,7 +1,7 @@ #![cfg(test)] use super::*; -use soroban_sdk::{testutils::Address as _, vec, Address, Env}; +use soroban_sdk::{testutils::{Address as _, Ledger}, vec, Address, Env}; fn setup_env() -> (Env, Address) { let env = Env::default(); @@ -161,6 +161,34 @@ fn test_resolve_dispute_callable_by_signer() { client.resolve_dispute(&signer_b, &settlement_id, &true); } +#[test] +fn test_dispute_remains_resolvable_after_many_ledgers() { + let (env, contract_id) = setup_env(); + let client = make_client(&env, &contract_id); + + let admin = Address::generate(&env); + let signer_a = Address::generate(&env); + let signer_b = Address::generate(&env); + let token = Address::generate(&env); + let merchant = Address::generate(&env); + let signers = vec![&env, (signer_a.clone(), 1u64), (signer_b.clone(), 1u64)]; + client.initialize(&signers, &2u64, &admin); + + let settlement_id = client.propose_settlement(&signer_a, &token, &5_000_000u64, &merchant); + client.raise_dispute(&merchant, &settlement_id, &1u32); + + env.ledger().with_mut(|li| { + li.sequence_number = INSTANCE_TTL_EXTEND_TO - INSTANCE_TTL_THRESHOLD + 1; + }); + assert_eq!( + client.get_settlement(&settlement_id).unwrap().status, + SettlementStatus::OnHold + ); + + client.resolve_dispute(&signer_a, &settlement_id, &true); + assert!(client.get_settlement(&settlement_id).is_some()); +} + #[test] fn test_multiple_disputes_independent() { let (env, contract_id) = setup_env(); diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs index 7d276e1..57eb9ff 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs @@ -85,6 +85,11 @@ pub enum TreasuryError { DailyLimitExceeded = 13, } +// At five seconds per ledger, renew instance state from roughly 335 days +// remaining back to roughly 359 days on each successful mutation. +const INSTANCE_TTL_THRESHOLD: u32 = 5_800_000; +const INSTANCE_TTL_EXTEND_TO: u32 = 6_200_000; + /// Storage keys for Treasury contract instance state. #[contracttype] pub enum DataKey { @@ -123,10 +128,17 @@ fn check_not_paused(e: &Env) -> Result<(), TreasuryError> { if is_paused(e) { Err(TreasuryError::ContractPaused) } else { + extend_instance_ttl(e); Ok(()) } } +fn extend_instance_ttl(e: &Env) { + e.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); +} + /// Main Treasury contract managing multi-sig settlement approvals, token allowlists, and contract pauses. #[contract] pub struct TreasuryContract; @@ -164,6 +176,7 @@ impl TreasuryContract { signer_list.push_back(signer.clone()); } e.storage().instance().set(&DataKey::SignerList, &signer_list); + extend_instance_ttl(&e); Ok(()) } @@ -572,6 +585,7 @@ impl TreasuryContract { pub fn pause(e: Env, admin: Address) -> Result<(), TreasuryError> { Self::check_admin(&e, &admin)?; e.storage().instance().set(&DataKey::Paused, &true); + extend_instance_ttl(&e); e.events().publish( (Symbol::new(&e, "contract_paused"),), (), @@ -590,6 +604,7 @@ impl TreasuryContract { pub fn unpause(e: Env, admin: Address) -> Result<(), TreasuryError> { Self::check_admin(&e, &admin)?; e.storage().instance().set(&DataKey::Paused, &false); + extend_instance_ttl(&e); e.events().publish( (Symbol::new(&e, "contract_unpaused"),), (), @@ -809,9 +824,18 @@ impl TreasuryContract { ) -> Result<(), TreasuryError> { check_not_paused(&e)?; Self::check_admin(&e, &admin)?; - e.storage() - .instance() - .set(&DataKey::DailyWithdrawLimit(token.clone()), &limit); + if limit == 0 { + e.storage() + .instance() + .remove(&DataKey::DailyWithdrawLimit(token.clone())); + e.storage() + .instance() + .remove(&DataKey::WithdrawWindow(token.clone())); + } else { + e.storage() + .instance() + .set(&DataKey::DailyWithdrawLimit(token.clone()), &limit); + } e.events().publish( (Symbol::new(&e, "daily_withdraw_limit_set"),), (token, limit), @@ -1612,9 +1636,31 @@ mod tests { Err(Ok(TreasuryError::DailyLimitExceeded)) ); - e.ledger().with_mut(|li| li.timestamp += 86_400); - // A full window has elapsed, so the cap applies fresh. + e.ledger().with_mut(|li| li.timestamp += 86_399); + assert_eq!( + c.try_withdraw(&admin, &token, &user, &1_000u64), + Err(Ok(TreasuryError::DailyLimitExceeded)) + ); + + e.ledger().with_mut(|li| li.timestamp += 1); + c.withdraw(&admin, &token, &user, &1_000u64); + } + + #[test] + fn test_zero_daily_withdraw_limit_clears_cap_and_window() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let user = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); + + c.set_daily_withdraw_limit(&admin, &token, &1_000u64); c.withdraw(&admin, &token, &user, &1_000u64); + c.set_daily_withdraw_limit(&admin, &token, &0u64); + + assert_eq!(c.get_daily_withdraw_limit(&token), None); + c.withdraw(&admin, &token, &user, &1_000_000u64); } #[test]