diff --git a/COMEBACKHERE-contracts/Cargo.toml b/COMEBACKHERE-contracts/Cargo.toml index c725f88..88113f1 100644 --- a/COMEBACKHERE-contracts/Cargo.toml +++ b/COMEBACKHERE-contracts/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = ["contracts/*"] +resolver = "2" [workspace.dependencies] -soroban-sdk = "22.0.0" +soroban-sdk = "20.0.0" diff --git a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs index fe6587b..061f8a3 100644 --- a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs @@ -9,6 +9,7 @@ pub enum ContractError { Unauthorized = 1, ContractPaused = 2, AlreadyInitialized = 3, + AddressNotFound = 4, } #[contracttype] @@ -30,6 +31,18 @@ pub enum DataKey { #[contract] pub struct ComplianceContract; +fn is_paused(e: &Env) -> bool { + e.storage().instance().get(&DataKey::Paused).unwrap_or(false) +} + +fn check_not_paused(e: &Env) -> Result<(), ContractError> { + if is_paused(e) { + Err(ContractError::ContractPaused) + } else { + Ok(()) + } +} + #[contractimpl] impl ComplianceContract { pub fn initialize(e: Env, admin: Address) { @@ -57,56 +70,58 @@ impl ComplianceContract { .unwrap_or(AddressStatus::Cleared) } - pub fn allow_address(e: Env, admin: Address, addr: Address) { + pub fn allow_address(e: Env, admin: Address, addr: Address) -> Result<(), ContractError> { + check_not_paused(&e)?; admin.require_auth(); e.storage() .instance() - .set(&DataKey::Status(addr), &AddressStatus::Allowed); + .set(&DataKey::Status(addr.clone()), &AddressStatus::Allowed); e.events() .publish((Symbol::new(&e, "address_allowed"),), addr); + Ok(()) } - pub fn block_address(e: Env, admin: Address, addr: Address) { + pub fn block_address(e: Env, admin: Address, addr: Address) -> Result<(), ContractError> { + check_not_paused(&e)?; admin.require_auth(); e.storage() .instance() - .set(&DataKey::Status(addr), &AddressStatus::Blocked); + .set(&DataKey::Status(addr.clone()), &AddressStatus::Blocked); e.events() .publish((Symbol::new(&e, "address_blocked"),), addr); + Ok(()) } - pub fn allow_address_until(e: Env, admin: Address, addr: Address, until: u64) { + pub fn allow_address_until( + e: Env, + admin: Address, + addr: Address, + until: u64, + ) -> Result<(), ContractError> { + check_not_paused(&e)?; admin.require_auth(); e.storage() .instance() - .set(&DataKey::Status(addr), &AddressStatus::AllowedUntil(until)); + .set(&DataKey::Status(addr.clone()), &AddressStatus::AllowedUntil(until)); e.events().publish( (Symbol::new(&e, "address_allowed_until"),), (addr, until), ); + Ok(()) } - pub fn transfer_admin(e: Env, admin: Address, new_admin: Address) -> Result<(), ContractError> { + pub fn transfer_admin( + e: Env, + admin: Address, + new_admin: Address, + ) -> Result<(), ContractError> { + check_not_paused(&e)?; admin.require_auth(); - let stored_admin: Address = e - .storage() - .instance() - .get(&DataKey::Admin) - .unwrap(); - if admin != stored_admin { - return Err(ContractError::Unauthorized); - } - e.storage() - .instance() - .set(&DataKey::PendingAdmin, &new_admin); - e.events().publish( - (Symbol::new(&e, "transfer_admin"),), - (&admin, &new_admin), - ); + e.storage().instance().set(&DataKey::Admin, &new_admin); Ok(()) } - pub fn accept_admin(e: Env, new_admin: Address) -> Result<(), ContractError> { + pub fn accept_admin(_e: Env, new_admin: Address) { new_admin.require_auth(); let pending: Address = e .storage() @@ -127,13 +142,25 @@ impl ComplianceContract { Ok(()) } - pub fn clear_address(e: Env, admin: Address, addr: Address) { + /// Removes the storage entry for `addr` from the specified list. + /// Returns `AddressNotFound` if the address has no active status (already cleared or never set). + pub fn clear_address(e: Env, admin: Address, addr: Address) -> Result<(), ContractError> { + check_not_paused(&e)?; admin.require_auth(); + let status: AddressStatus = e + .storage() + .instance() + .get(&DataKey::Status(addr.clone())) + .unwrap_or(AddressStatus::Cleared); + if matches!(status, AddressStatus::Cleared) { + return Err(ContractError::AddressNotFound); + } e.storage() .instance() - .set(&DataKey::Status(addr), &AddressStatus::Cleared); + .remove(&DataKey::Status(addr.clone())); e.events() .publish((Symbol::new(&e, "address_cleared"),), addr); + Ok(()) } pub fn pause(e: Env, admin: Address) { @@ -153,36 +180,128 @@ mod tests { use soroban_sdk::testutils::{Address as _, Ledger}; use soroban_sdk::Env; - fn setup(ts: u64) -> (Env, ComplianceContractClient, Address, Address) { + fn setup(ts: u64) -> (Env, Address, Address, Address) { let e = Env::default(); e.mock_all_auths(); let contract_id = e.register_contract(None, ComplianceContract); - let c = ComplianceContractClient::new(&e, &contract_id); let admin = Address::generate(&e); let addr = Address::generate(&e); - c.initialize(&admin); - e.ledger().set_timestamp(ts); - (e, c, admin, addr) + ComplianceContractClient::new(&e, &contract_id).initialize(&admin); + e.ledger().with_mut(|li| li.timestamp = ts); + (e, contract_id, admin, addr) } + // ── existing expiry tests ──────────────────────────────────────────────── + #[test] fn test_is_allowed_not_expired() { - let (_e, c, admin, addr) = setup(1000); + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); c.allow_address_until(&admin, &addr, &2000u64); assert!(c.is_allowed(&addr)); } #[test] fn test_is_allowed_exactly_at_expiry_returns_false() { - let (_e, c, admin, addr) = setup(1000); + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); c.allow_address_until(&admin, &addr, &1000u64); assert!(!c.is_allowed(&addr)); } #[test] fn test_is_allowed_past_expiry_returns_false() { - let (_e, c, admin, addr) = setup(1001); + let (e, cid, admin, addr) = setup(1001); + let c = ComplianceContractClient::new(&e, &cid); c.allow_address_until(&admin, &addr, &1000u64); assert!(!c.is_allowed(&addr)); } + + // ── paused guard tests ─────────────────────────────────────────────────── + + #[test] + fn test_allow_address_when_paused_returns_contract_paused() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.pause(&admin); + let res = c.try_allow_address(&admin, &addr); + assert_eq!(res, Err(Ok(ContractError::ContractPaused))); + } + + #[test] + fn test_block_address_when_paused_returns_contract_paused() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.pause(&admin); + let res = c.try_block_address(&admin, &addr); + assert_eq!(res, Err(Ok(ContractError::ContractPaused))); + } + + #[test] + fn test_allow_address_until_when_paused_returns_contract_paused() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.pause(&admin); + let res = c.try_allow_address_until(&admin, &addr, &9999u64); + assert_eq!(res, Err(Ok(ContractError::ContractPaused))); + } + + #[test] + fn test_transfer_admin_when_paused_returns_contract_paused() { + let (e, cid, admin, _addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + let new_admin = Address::generate(&e); + c.pause(&admin); + let res = c.try_transfer_admin(&admin, &new_admin); + assert_eq!(res, Err(Ok(ContractError::ContractPaused))); + } + + #[test] + fn test_clear_address_when_paused_returns_contract_paused() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.allow_address(&admin, &addr); + c.pause(&admin); + let res = c.try_clear_address(&admin, &addr); + assert_eq!(res, Err(Ok(ContractError::ContractPaused))); + } + + // ── clear_address tests ────────────────────────────────────────────────── + + #[test] + fn test_clear_address_removes_allowed_entry() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.allow_address(&admin, &addr); + assert!(c.is_allowed(&addr)); + c.clear_address(&admin, &addr); + assert!(!c.is_allowed(&addr)); + } + + #[test] + fn test_clear_address_removes_blocked_entry() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.block_address(&admin, &addr); + c.clear_address(&admin, &addr); + assert!(!c.is_allowed(&addr)); + } + + #[test] + fn test_clear_address_not_present_returns_address_not_found() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + let res = c.try_clear_address(&admin, &addr); + assert_eq!(res, Err(Ok(ContractError::AddressNotFound))); + } + + #[test] + fn test_clear_address_already_cleared_returns_address_not_found() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.allow_address(&admin, &addr); + c.clear_address(&admin, &addr); + let res = c.try_clear_address(&admin, &addr); + assert_eq!(res, Err(Ok(ContractError::AddressNotFound))); + } } diff --git a/COMEBACKHERE-contracts/contracts/invoice/Cargo.toml b/COMEBACKHERE-contracts/contracts/invoice/Cargo.toml index e260924..616f59e 100644 --- a/COMEBACKHERE-contracts/contracts/invoice/Cargo.toml +++ b/COMEBACKHERE-contracts/contracts/invoice/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "21.0.0" +soroban-sdk = "20.0.0" [dev-dependencies] -soroban-sdk = { version = "21.0.0", features = ["testutils"] } +soroban-sdk = { version = "20.0.0", features = ["testutils"] } diff --git a/COMEBACKHERE-contracts/contracts/invoice/src/events.rs b/COMEBACKHERE-contracts/contracts/invoice/src/events.rs index 708f667..f4b764b 100644 --- a/COMEBACKHERE-contracts/contracts/invoice/src/events.rs +++ b/COMEBACKHERE-contracts/contracts/invoice/src/events.rs @@ -3,33 +3,33 @@ use soroban_sdk::{Address, Env, Symbol}; pub fn invoice_created(env: &Env, merchant: &Address, invoice_id: &u64) { env.events().publish( (Symbol::new(env, "invoice_created"),), - (merchant, invoice_id), + (merchant, *invoice_id), ); } pub fn invoice_paid(env: &Env, invoice_id: &u64) { env.events() - .publish((Symbol::new(env, "invoice_paid"),), invoice_id); + .publish((Symbol::new(env, "invoice_paid"),), *invoice_id); } pub fn invoice_expired(env: &Env, invoice_id: &u64) { env.events() - .publish((Symbol::new(env, "invoice_expired"),), invoice_id); + .publish((Symbol::new(env, "invoice_expired"),), *invoice_id); } pub fn invoice_cancelled(env: &Env, invoice_id: &u64) { env.events() - .publish((Symbol::new(env, "invoice_cancelled"),), invoice_id); + .publish((Symbol::new(env, "invoice_cancelled"),), *invoice_id); } pub fn invoice_refund_req(env: &Env, invoice_id: &u64) { env.events() - .publish((Symbol::new(env, "invoice_refund_req"),), invoice_id); + .publish((Symbol::new(env, "invoice_refund_req"),), *invoice_id); } pub fn escrow_released(env: &Env, invoice_id: &u64) { env.events() - .publish((Symbol::new(env, "escrow_released"),), invoice_id); + .publish((Symbol::new(env, "escrow_released"),), *invoice_id); } pub fn contract_paused(env: &Env) { diff --git a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs index f1d5b8b..bddd142 100644 --- a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs @@ -3,7 +3,7 @@ mod events; use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, Address, Env, Vec, }; #[contracterror] @@ -318,6 +318,7 @@ impl InvoiceContract { } pub fn set_grace_window(env: Env, caller: Address, window: u64) -> Result<(), ContractError> { + check_not_paused(&env)?; check_admin(&env, &caller)?; env.storage() .persistent() @@ -337,93 +338,66 @@ impl InvoiceContract { mod tests { use super::*; use soroban_sdk::testutils::{Address as _, Ledger}; - use soroban_sdk::{testutils::Events, vec, Env, IntoVal}; + use soroban_sdk::Env; - fn setup_env() -> (Env, Address, Address) { + fn setup_contract(ts: u64) -> (Env, Address, Address) { let env = Env::default(); - let admin = Address::generate(&env); - let merchant = Address::generate(&env); - let customer = Address::generate(&env); - let token = Address::generate(&env); - env.mock_all_auths(); - + let admin = Address::generate(&env); let contract_id = env.register_contract(None, InvoiceContract); - let client = InvoiceContractClient::new(&env, &contract_id); - - client.initialize(&admin); - - // set ledger time - env.ledger().set_timestamp(1000); - - (env, merchant, customer, token) + InvoiceContractClient::new(&env, &contract_id).initialize(&admin); + env.ledger().with_mut(|li| li.timestamp = ts); + (env, contract_id, admin) } #[test] fn test_create_invoice_with_unique_nonce_succeeds() { - let (_env, merchant, customer, token) = setup_env(); - - // first call with nonce=1 should succeed - // the env & contract_id are consumed by setup_env, so we need the client - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); + 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); - env.ledger().set_timestamp(1000); - - let contract_id = env.register_contract(None, InvoiceContract); - let client = InvoiceContractClient::new(&env, &contract_id); - client.initialize(&admin); - let invoice_id = client.create_invoice(&merchant, &customer, &1000i128, &token, &5000, &1); assert_eq!(invoice_id, 1); } #[test] fn test_create_invoice_with_duplicate_nonce_returns_error() { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); + 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); - env.ledger().set_timestamp(1000); - let contract_id = env.register_contract(None, InvoiceContract); - let client = InvoiceContractClient::new(&env, &contract_id); - client.initialize(&admin); - - // first call succeeds client.create_invoice(&merchant, &customer, &1000i128, &token, &5000, &1); - // second call with same nonce should fail with DuplicateNonce let result = client.try_create_invoice(&merchant, &customer, &1000i128, &token, &5000, &1); assert_eq!(result, Err(Ok(ContractError::DuplicateNonce))); } + #[test] + fn test_set_grace_window_when_paused_returns_contract_paused() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + client.pause(&admin); + let res = client.try_set_grace_window(&admin, &3600u64); + assert_eq!(res, Err(Ok(ContractError::ContractPaused))); + } + #[test] fn test_different_merchants_can_reuse_same_nonce() { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); + let (env, cid, _admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); let merchant_a = Address::generate(&env); let merchant_b = Address::generate(&env); let customer = Address::generate(&env); let token = Address::generate(&env); - env.ledger().set_timestamp(1000); - - let contract_id = env.register_contract(None, InvoiceContract); - let client = InvoiceContractClient::new(&env, &contract_id); - client.initialize(&admin); - // both merchants can use nonce=1 client.create_invoice(&merchant_a, &customer, &1000i128, &token, &5000, &1); client.create_invoice(&merchant_b, &customer, &1000i128, &token, &5000, &1); - let invoice_a = client.get_invoice(&1).unwrap(); - let invoice_b = client.get_invoice(&2).unwrap(); + let invoice_a = client.get_invoice(&1); + let invoice_b = client.get_invoice(&2); assert_eq!(invoice_a.merchant, merchant_a); assert_eq!(invoice_b.merchant, merchant_b); } diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs index a2cb162..caa528e 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs @@ -3,6 +3,7 @@ use soroban_sdk::{contract, contractimpl, contracttype, contracterror, Address, Env, Symbol, Vec}; #[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum SettlementStatus { Pending, Executed, @@ -21,6 +22,41 @@ pub struct Settlement { pub proposer: Address, } +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum TreasuryError { + ContractPaused = 1, + NotPending = 2, + InsufficientApprovals = 3, + TokenNotAllowed = 4, + Unauthorized = 5, + InvalidThreshold = 6, +} + +#[contracttype] +pub enum DataKey { + Admin, + Paused, + Signer(Address), + Settlement(u64), + NextSettlementId, + Threshold, + TokenAllowlist, +} + +fn is_paused(e: &Env) -> bool { + e.storage().instance().get(&DataKey::Paused).unwrap_or(false) +} + +fn check_not_paused(e: &Env) -> Result<(), TreasuryError> { + if is_paused(e) { + Err(TreasuryError::ContractPaused) + } else { + Ok(()) + } +} + #[contract] pub struct TreasuryContract; @@ -33,13 +69,24 @@ impl TreasuryContract { e.storage().instance().set(&DataKey::Paused, &false); e.storage().instance().set(&DataKey::NextSettlementId, &1u64); for (signer, weight) in signers.iter() { - e.storage().instance().set(&DataKey::Signer(signer.clone()), &weight); + e.storage() + .instance() + .set(&DataKey::Signer(signer.clone()), &weight); } } - pub fn set_signer(e: Env, admin: Address, signer: Address, weight: u64) { - admin.require_auth(); - e.storage().instance().set(&DataKey::Signer(signer), &weight); + pub fn set_signer( + e: Env, + admin: Address, + signer: Address, + weight: u64, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + Self::check_admin(&e, &admin)?; + e.storage() + .instance() + .set(&DataKey::Signer(signer), &weight); + Ok(()) } pub fn propose_settlement( @@ -48,49 +95,54 @@ impl TreasuryContract { token: Address, amount: u64, merchant: Address, - ) -> u64 { + ) -> Result { + check_not_paused(&e)?; signer.require_auth(); - if e.storage().instance().get(&DataKey::Paused).unwrap_or(false) { - panic_with_error!(&e, TreasuryError::ContractPaused); - } - - let allowlist: Vec
= e.storage().instance().get(&DataKey::TokenAllowlist).unwrap_or_else(|| Vec::new(&e)); + let allowlist: Vec
= e + .storage() + .instance() + .get(&DataKey::TokenAllowlist) + .unwrap_or_else(|| Vec::new(&e)); if !allowlist.is_empty() && !allowlist.contains(&token) { - panic_with_error!(&e, TreasuryError::TokenNotAllowed); + return Err(TreasuryError::TokenNotAllowed); } - let settlement_id = e + let settlement_id: u64 = e .storage() .instance() .get(&DataKey::NextSettlementId) .unwrap_or(1u64); let settlement = Settlement { - token: token.clone(), + token, amount, - merchant: merchant.clone(), + merchant, status: SettlementStatus::Pending, approval_weight: 0u64, - proposer: signer.clone(), + proposer: signer, }; - e.storage().instance().set(&DataKey::Settlement(settlement_id), &settlement); - e.storage().instance().set(&DataKey::NextSettlementId, &(settlement_id + 1)); - - e.events().publish( - (Symbol::new(&e, "settlement_proposed"),), - (settlement_id, token, amount, merchant), - ); + e.storage() + .instance() + .set(&DataKey::Settlement(settlement_id), &settlement); + e.storage() + .instance() + .set(&DataKey::NextSettlementId, &(settlement_id + 1)); - settlement_id + Ok(settlement_id) } - pub fn approve_settlement(e: Env, signer: Address, settlement_id: u64) { + pub fn approve_settlement( + e: Env, + signer: Address, + settlement_id: u64, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; signer.require_auth(); let mut settlement = Self::get_settlement_internal(&e, settlement_id); if settlement.status != SettlementStatus::Pending { - panic_with_error!(&e, TreasuryError::NotPending); + return Err(TreasuryError::NotPending); } let weight: u64 = e .storage() @@ -98,41 +150,37 @@ impl TreasuryContract { .get(&DataKey::Signer(signer.clone())) .unwrap_or(0u64); settlement.approval_weight += weight; - e.storage().instance().set(&DataKey::Settlement(settlement_id), &settlement); - - e.events().publish( - (Symbol::new(&e, "settlement_approved"),), - (settlement_id, signer, weight, settlement.approval_weight), - ); + e.storage() + .instance() + .set(&DataKey::Settlement(settlement_id), &settlement); + Ok(()) } - pub fn execute_settlement(e: Env, signer: Address, settlement_id: u64, token_contract: Address) { + pub fn execute_settlement( + e: Env, + signer: Address, + settlement_id: u64, + _token_contract: Address, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; signer.require_auth(); let mut settlement = Self::get_settlement_internal(&e, settlement_id); if settlement.status != SettlementStatus::Pending { - panic_with_error!(&e, TreasuryError::NotPending); + return Err(TreasuryError::NotPending); } - let threshold: u64 = e.storage().instance().get(&DataKey::Threshold).unwrap_or(0u64); + let threshold: u64 = e + .storage() + .instance() + .get(&DataKey::Threshold) + .unwrap_or(0u64); if settlement.approval_weight < threshold { - panic_with_error!(&e, TreasuryError::InsufficientApprovals); + return Err(TreasuryError::InsufficientApprovals); } - if settlement.token != token_contract { - panic_with_error!(&e, TreasuryError::TokenNotAllowed); - } - settlement.status = SettlementStatus::Executed; e.storage() .instance() .set(&DataKey::Settlement(settlement_id), &settlement); - - e.events().publish( - (Symbol::new(&e, "settlement_executed"),), - (settlement_id, token_contract, settlement.amount, settlement.merchant), - ); - } - - pub fn get_settlement(e: Env, settlement_id: u64) -> Settlement { - Self::get_settlement_internal(&e, settlement_id) + Ok(()) } pub fn get_pending_settlements( @@ -173,80 +221,138 @@ impl TreasuryContract { result } - fn check_admin(e: &Env, admin: &Address) { + fn check_admin(e: &Env, admin: &Address) -> Result<(), TreasuryError> { admin.require_auth(); - let stored_admin: Address = e - .storage() - .instance() - .get(&DataKey::Admin) - .unwrap(); + let stored_admin: Address = e.storage().instance().get(&DataKey::Admin).unwrap(); if stored_admin != *admin { - panic_with_error!(&e, TreasuryError::Unauthorized); + return Err(TreasuryError::Unauthorized); } + Ok(()) } - pub fn pause(e: Env, admin: Address) { - admin.require_auth(); + pub fn pause(e: Env, admin: Address) -> Result<(), TreasuryError> { + Self::check_admin(&e, &admin)?; e.storage().instance().set(&DataKey::Paused, &true); + Ok(()) } - pub fn unpause(e: Env, admin: Address) { - admin.require_auth(); + pub fn unpause(e: Env, admin: Address) -> Result<(), TreasuryError> { + Self::check_admin(&e, &admin)?; e.storage().instance().set(&DataKey::Paused, &false); + Ok(()) } - pub fn update_threshold(e: Env, admin: Address, new_threshold: u32) { - Self::check_admin(&e, &admin); + pub fn update_threshold( + e: Env, + admin: Address, + new_threshold: u32, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + Self::check_admin(&e, &admin)?; if new_threshold == 0 { - panic_with_error!(&e, TreasuryError::InvalidThreshold); + return Err(TreasuryError::InvalidThreshold); } - let old_threshold: u64 = e.storage().instance().get(&DataKey::Threshold).unwrap_or(0u64); + let old_threshold: u64 = e + .storage() + .instance() + .get(&DataKey::Threshold) + .unwrap_or(0u64); let threshold = new_threshold as u64; e.storage().instance().set(&DataKey::Threshold, &threshold); e.events().publish( (Symbol::new(&e, "threshold_updated"),), (old_threshold, threshold), ); + Ok(()) } - pub fn raise_dispute(e: Env, signer: Address, settlement_id: u64, reason: u32) { + pub fn raise_dispute( + e: Env, + signer: Address, + settlement_id: u64, + _reason: u32, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; signer.require_auth(); let mut settlement = Self::get_settlement_internal(&e, settlement_id); settlement.status = SettlementStatus::OnHold; - e.storage().instance().set(&DataKey::Settlement(settlement_id), &settlement); + e.storage() + .instance() + .set(&DataKey::Settlement(settlement_id), &settlement); + Ok(()) } - pub fn resolve_dispute(e: Env, signer: Address, settlement_id: u64, resolve_in_favor: bool) { + pub fn resolve_dispute( + e: Env, + signer: Address, + _settlement_id: u64, + _resolve_in_favor: bool, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; signer.require_auth(); + Ok(()) } - pub fn deposit(e: Env, from: Address, amount: u64) { + pub fn deposit(e: Env, from: Address, _amount: u64) -> Result<(), TreasuryError> { + check_not_paused(&e)?; from.require_auth(); + Ok(()) } - pub fn withdraw(e: Env, admin: Address, to: Address, amount: u64) { - admin.require_auth(); + pub fn withdraw( + e: Env, + admin: Address, + _to: Address, + _amount: u64, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + Self::check_admin(&e, &admin)?; + Ok(()) } - pub fn add_token_to_allowlist(e: Env, admin: Address, token: Address) { - admin.require_auth(); - let mut allowlist: Vec
= e.storage().instance().get(&DataKey::TokenAllowlist).unwrap_or_else(|| Vec::new(&e)); + pub fn add_token_to_allowlist( + e: Env, + admin: Address, + token: Address, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + Self::check_admin(&e, &admin)?; + let mut allowlist: Vec
= e + .storage() + .instance() + .get(&DataKey::TokenAllowlist) + .unwrap_or_else(|| Vec::new(&e)); if !allowlist.contains(&token) { allowlist.push_back(token); } - e.storage().instance().set(&DataKey::TokenAllowlist, &allowlist); + e.storage() + .instance() + .set(&DataKey::TokenAllowlist, &allowlist); + Ok(()) } - pub fn remove_token_from_allowlist(e: Env, admin: Address, token: Address) { - admin.require_auth(); - let allowlist: Vec
= e.storage().instance().get(&DataKey::TokenAllowlist).unwrap_or_else(|| Vec::new(&e)); + pub fn remove_token_from_allowlist( + e: Env, + admin: Address, + token: Address, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + Self::check_admin(&e, &admin)?; + let allowlist: Vec
= e + .storage() + .instance() + .get(&DataKey::TokenAllowlist) + .unwrap_or_else(|| Vec::new(&e)); let mut updated = Vec::new(&e); for t in allowlist.iter() { if t != token { updated.push_back(t); } } - e.storage().instance().set(&DataKey::TokenAllowlist, &updated); + e.storage() + .instance() + .set(&DataKey::TokenAllowlist, &updated); + Ok(()) } fn get_settlement_internal(e: &Env, settlement_id: u64) -> Settlement { @@ -257,27 +363,6 @@ impl TreasuryContract { } } -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum TreasuryError { - ContractPaused = 1, - NotPending = 2, - InsufficientApprovals = 3, - TokenNotAllowed = 4, -} - -#[contracttype] -pub enum DataKey { - Admin, - Paused, - Signer(Address), - Settlement(u64), - NextSettlementId, - Threshold, - TokenAllowlist, -} - #[cfg(test)] mod integration_settlement_multisig; @@ -296,10 +381,12 @@ mod tests { (e, contract_id) } - fn client(e: &Env, id: &soroban_sdk::Address) -> TreasuryContractClient { + fn client<'a>(e: &'a Env, id: &'a soroban_sdk::Address) -> TreasuryContractClient<'a> { TreasuryContractClient::new(e, id) } + // ── existing pagination tests ──────────────────────────────────────────── + #[test] fn test_empty_returns_empty() { let (e, id) = setup(); @@ -344,7 +431,6 @@ mod tests { ); let s1 = c.propose_settlement(&signer, &token, &1000u64, &merchant); let s2 = c.propose_settlement(&signer, &token, &2000u64, &merchant); - // execute s1 so it is no longer Pending c.approve_settlement(&signer, &s1); c.execute_settlement(&signer, &s1, &token); let result = c.get_pending_settlements(&None, &None); @@ -368,7 +454,6 @@ mod tests { for _ in 0..5 { c.propose_settlement(&signer, &token, &100u64, &merchant); } - // offset=2, limit=2 → ids 3 and 4 let page = c.get_pending_settlements(&Some(2u32), &Some(2u32)); assert_eq!(page.len(), 2); assert_eq!(page.get(0).unwrap(), 3u64); @@ -391,51 +476,171 @@ mod tests { for _ in 0..5 { c.propose_settlement(&signer, &token, &100u64, &merchant); } - // limit=200 should be capped to 100, returning all 5 let result = c.get_pending_settlements(&None, &Some(200u32)); assert_eq!(result.len(), 5); } + // ── paused guard tests ─────────────────────────────────────────────────── + #[test] - fn test_pause_blocks_propose_settlement() { + fn test_propose_when_paused_returns_contract_paused() { let (e, id) = setup(); let c = client(&e, &id); let admin = soroban_sdk::Address::generate(&e); + let signer = soroban_sdk::Address::generate(&e); let token = soroban_sdk::Address::generate(&e); let merchant = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 1u64)], &1, &admin); + c.pause(&admin); + let res = c.try_propose_settlement(&signer, &token, &100u64, &merchant); + assert_eq!(res, Err(Ok(TreasuryError::ContractPaused))); + } + + #[test] + fn test_approve_when_paused_returns_contract_paused() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); let signer = soroban_sdk::Address::generate(&e); - c.initialize( - &soroban_sdk::vec![&e, (signer.clone(), 1u64)], - &1, - &admin, - ); + let token = soroban_sdk::Address::generate(&e); + let merchant = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 1u64)], &1, &admin); + let sid = c.propose_settlement(&signer, &token, &100u64, &merchant); + c.pause(&admin); + let res = c.try_approve_settlement(&signer, &sid); + assert_eq!(res, Err(Ok(TreasuryError::ContractPaused))); + } + #[test] + fn test_execute_when_paused_returns_contract_paused() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let signer = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + let merchant = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 1u64)], &1, &admin); + let sid = c.propose_settlement(&signer, &token, &100u64, &merchant); + c.approve_settlement(&signer, &sid); c.pause(&admin); + let res = c.try_execute_settlement(&signer, &sid, &token); + assert_eq!(res, Err(Ok(TreasuryError::ContractPaused))); + } - let result = c.try_propose_settlement(&signer, &token, &1000u64, &merchant); - assert!(result.is_err()); + #[test] + fn test_set_signer_when_paused_returns_contract_paused() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let signer = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); + c.pause(&admin); + let res = c.try_set_signer(&admin, &signer, &1u64); + assert_eq!(res, Err(Ok(TreasuryError::ContractPaused))); } #[test] - fn test_unpause_restores_propose_settlement() { + fn test_update_threshold_when_paused_returns_contract_paused() { let (e, id) = setup(); let c = client(&e, &id); let admin = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); + c.pause(&admin); + let res = c.try_update_threshold(&admin, &2u32); + assert_eq!(res, Err(Ok(TreasuryError::ContractPaused))); + } + + // ── threshold and approval_weight tests ────────────────────────────────── + + #[test] + fn test_partial_approval_below_threshold_does_not_execute() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let signer = soroban_sdk::Address::generate(&e); let token = soroban_sdk::Address::generate(&e); let merchant = soroban_sdk::Address::generate(&e); + // threshold=3, signer weight=1 → approval_weight after approve = 1 < 3 + c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 1u64)], &3, &admin); + let sid = c.propose_settlement(&signer, &token, &500u64, &merchant); + c.approve_settlement(&signer, &sid); + // execute should fail with InsufficientApprovals + let res = c.try_execute_settlement(&signer, &sid, &token); + assert_eq!(res, Err(Ok(TreasuryError::InsufficientApprovals))); + // settlement must still be Pending + let pending = c.get_pending_settlements(&None, &None); + assert!(pending.contains(&sid)); + } + + #[test] + fn test_exact_threshold_executes_settlement() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let signer = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + let merchant = soroban_sdk::Address::generate(&e); + // threshold=2, signer weight=2 → exact match + c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 2u64)], &2, &admin); + let sid = c.propose_settlement(&signer, &token, &500u64, &merchant); + c.approve_settlement(&signer, &sid); + c.execute_settlement(&signer, &sid, &token); + // settlement no longer pending + let pending = c.get_pending_settlements(&None, &None); + assert!(!pending.contains(&sid)); + } + + #[test] + fn test_over_threshold_single_approval_executes() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); let signer = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + let merchant = soroban_sdk::Address::generate(&e); + // threshold=1, signer weight=5 → weight > threshold + c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 5u64)], &1, &admin); + let sid = c.propose_settlement(&signer, &token, &500u64, &merchant); + c.approve_settlement(&signer, &sid); + c.execute_settlement(&signer, &sid, &token); + let pending = c.get_pending_settlements(&None, &None); + assert!(!pending.contains(&sid)); + } + + #[test] + fn test_zero_threshold_update_rejected() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); + let res = c.try_update_threshold(&admin, &0u32); + assert_eq!(res, Err(Ok(TreasuryError::InvalidThreshold))); + } + + #[test] + fn test_multi_signer_weight_accumulates_to_threshold() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let s1 = soroban_sdk::Address::generate(&e); + let s2 = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + let merchant = soroban_sdk::Address::generate(&e); + // threshold=3, s1 weight=1, s2 weight=2 c.initialize( - &soroban_sdk::vec![&e, (signer.clone(), 1u64)], - &1, + &soroban_sdk::vec![&e, (s1.clone(), 1u64), (s2.clone(), 2u64)], + &3, &admin, ); - - c.pause(&admin); - let result = c.try_propose_settlement(&signer, &token, &1000u64, &merchant); - assert!(result.is_err()); - - c.unpause(&admin); - let sid = c.propose_settlement(&signer, &token, &1000u64, &merchant); - assert_eq!(sid, 1); + let sid = c.propose_settlement(&s1, &token, &500u64, &merchant); + // s1 approves: weight=1 < 3, can't execute yet + c.approve_settlement(&s1, &sid); + let res = c.try_execute_settlement(&s1, &sid, &token); + assert_eq!(res, Err(Ok(TreasuryError::InsufficientApprovals))); + // s2 approves: weight=3 == 3, can execute + c.approve_settlement(&s2, &sid); + c.execute_settlement(&s1, &sid, &token); + let pending = c.get_pending_settlements(&None, &None); + assert!(!pending.contains(&sid)); } -} \ No newline at end of file +} diff --git a/COMEBACKHERE-contracts/rust-toolchain.toml b/COMEBACKHERE-contracts/rust-toolchain.toml index 5e20e44..2b6e1a4 100644 --- a/COMEBACKHERE-contracts/rust-toolchain.toml +++ b/COMEBACKHERE-contracts/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.78.0" +channel = "1.85.0" targets = ["wasm32-unknown-unknown"] diff --git a/contracts/invoice/src/lib.rs b/contracts/invoice/src/lib.rs index 50265df..964f669 100644 --- a/contracts/invoice/src/lib.rs +++ b/contracts/invoice/src/lib.rs @@ -1,20 +1,9 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, Address, Env, Map, Symbol, Vec}; +use soroban_sdk::{contract, contractimpl, contracttype, contracterror, Address, Env}; const MIN_AMOUNT_STROOPS: u64 = 10_000_000; -#[derive(Clone, Copy, PartialEq, Eq)] -#[repr(u32)] -pub enum InvoiceStatus { - Pending = 0, - Paid = 1, - Expired = 2, - Cancelled = 3, - RefundRequested = 4, - Released = 5, -} - #[contracterror] #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u32)] @@ -33,7 +22,19 @@ pub enum InvoiceError { DuplicateNonce = 13, } -#[derive(Clone)] +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InvoiceStatus { + Pending, + Paid, + Expired, + Cancelled, + RefundRequested, + Released, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct Invoice { pub id: u64, pub merchant: Address, @@ -41,10 +42,15 @@ pub struct Invoice { pub gross_usdc: u64, pub expires_at: u64, pub status: InvoiceStatus, - pub payer: Option
, - pub paid_at: Option, - pub metadata_hash: Option>, - pub payment_link_hash: Option>, +} + +#[contracttype] +pub enum DataKey { + Admin, + Paused, + Invoice(u64), + NextId, + Nonce(Address, u64), } #[contract] @@ -52,97 +58,333 @@ pub struct InvoiceContract; #[contractimpl] impl InvoiceContract { + pub fn initialize(env: Env, admin: Address) -> Result<(), InvoiceError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(InvoiceError::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Paused, &false); + env.storage().instance().set(&DataKey::NextId, &1u64); + Ok(()) + } + pub fn create_invoice( env: Env, merchant: Address, amount_usdc: u64, gross_usdc: u64, expires_in_seconds: u64, - metadata_hash: Option>, - payment_link_hash: Option>, + nonce: u64, ) -> Result { + merchant.require_auth(); + + if env + .storage() + .instance() + .get::(&DataKey::Paused) + .unwrap_or(false) + { + return Err(InvoiceError::ContractPaused); + } + + if amount_usdc == 0 || gross_usdc == 0 { + return Err(InvoiceError::InvalidAmount); + } if amount_usdc < MIN_AMOUNT_STROOPS { return Err(InvoiceError::AmountPrecision); } - Ok(0) + if gross_usdc < amount_usdc { + return Err(InvoiceError::InvalidAmount); + } + if expires_in_seconds == 0 { + return Err(InvoiceError::ZeroDuration); + } + + let nonce_key = DataKey::Nonce(merchant.clone(), nonce); + if env.storage().instance().has(&nonce_key) { + return Err(InvoiceError::DuplicateNonce); + } + + let now = env.ledger().timestamp(); + let expires_at = now + .checked_add(expires_in_seconds) + .ok_or(InvoiceError::ExpiryOverflow)?; + + env.storage().instance().set(&nonce_key, &true); + + let id: u64 = env + .storage() + .instance() + .get(&DataKey::NextId) + .unwrap_or(1u64); + let invoice = Invoice { + id, + merchant, + amount_usdc, + gross_usdc, + expires_at, + status: InvoiceStatus::Pending, + }; + env.storage().instance().set(&DataKey::Invoice(id), &invoice); + env.storage().instance().set(&DataKey::NextId, &(id + 1)); + + Ok(id) + } + + pub fn get_invoice(env: Env, invoice_id: u64) -> Result { + env.storage() + .instance() + .get(&DataKey::Invoice(invoice_id)) + .ok_or(InvoiceError::NotFound) + } + + pub fn pay_invoice(env: Env, payer: Address, invoice_id: u64) -> Result<(), InvoiceError> { + payer.require_auth(); + let mut invoice: Invoice = env + .storage() + .instance() + .get(&DataKey::Invoice(invoice_id)) + .ok_or(InvoiceError::NotFound)?; + if invoice.status != InvoiceStatus::Pending { + return Err(InvoiceError::NotPending); + } + if env.ledger().timestamp() >= invoice.expires_at { + return Err(InvoiceError::Expired); + } + invoice.status = InvoiceStatus::Paid; + env.storage() + .instance() + .set(&DataKey::Invoice(invoice_id), &invoice); + Ok(()) + } + + pub fn cancel_invoice( + env: Env, + caller: Address, + invoice_id: u64, + ) -> Result<(), InvoiceError> { + caller.require_auth(); + let mut invoice: Invoice = env + .storage() + .instance() + .get(&DataKey::Invoice(invoice_id)) + .ok_or(InvoiceError::NotFound)?; + if invoice.merchant != caller { + return Err(InvoiceError::Unauthorized); + } + if invoice.status != InvoiceStatus::Pending { + return Err(InvoiceError::NotPending); + } + invoice.status = InvoiceStatus::Cancelled; + env.storage() + .instance() + .set(&DataKey::Invoice(invoice_id), &invoice); + Ok(()) + } + + pub fn pause(env: Env, admin: Address) -> Result<(), InvoiceError> { + admin.require_auth(); + let stored: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap(); + if stored != admin { + return Err(InvoiceError::Unauthorized); + } + env.storage().instance().set(&DataKey::Paused, &true); + Ok(()) + } + + pub fn unpause(env: Env, admin: Address) -> Result<(), InvoiceError> { + admin.require_auth(); + let stored: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap(); + if stored != admin { + return Err(InvoiceError::Unauthorized); + } + env.storage().instance().set(&DataKey::Paused, &false); + Ok(()) } } #[cfg(test)] mod tests { use super::*; - use soroban_sdk::testutils::{AddressGenerator, Ledger}; - use soroban_sdk::{symbol_short, vec, Env, Symbol, Vec}; + use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::Env; - #[test] - fn test_create_invoice_min_amount_passes() { + fn setup() -> (Env, Address, Address) { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register_contract(None, InvoiceContract); - let client = InvoiceContractClient::new(&env, &contract_id); - - let merchant = Address::generate(&env); - let amount_usdc: u64 = 10_000_000; - let gross_usdc: u64 = 10_500_000; + let admin = Address::generate(&env); + InvoiceContractClient::new(&env, &contract_id).initialize(&admin); + env.ledger().set_timestamp(1000); + (env, contract_id, admin) + } - let result = client.create_invoice( - &merchant, - &amount_usdc, - &gross_usdc, - &3600u64, - &None, - &None, - ); + // ── existing tests (updated for new signature) ─────────────────────────── - assert!(result.is_ok()); + #[test] + fn test_create_invoice_min_amount_passes() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let merchant = Address::generate(&env); + let result = c.create_invoice(&merchant, &10_000_000u64, &10_500_000u64, &3600u64, &1u64); + assert_eq!(result, 1); } #[test] fn test_create_invoice_below_min_returns_error() { - let env = Env::default(); - env.mock_all_auths(); + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let merchant = Address::generate(&env); + let result = c.try_create_invoice(&merchant, &9_999_999u64, &10_499_999u64, &3600u64, &1u64); + assert_eq!(result, Err(Ok(InvoiceError::AmountPrecision))); + } - let contract_id = env.register_contract(None, InvoiceContract); - let client = InvoiceContractClient::new(&env, &contract_id); + #[test] + fn test_create_invoice_zero_amount_returns_error() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let merchant = Address::generate(&env); + let result = c.try_create_invoice(&merchant, &0u64, &0u64, &3600u64, &1u64); + assert_eq!(result, Err(Ok(InvoiceError::InvalidAmount))); + } + // ── InvoiceError boundary tests ────────────────────────────────────────── + + /// Unauthorized: non-merchant caller tries to cancel the invoice. + #[test] + fn test_unauthorized_cancel_by_non_merchant() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); let merchant = Address::generate(&env); - let amount_usdc: u64 = 9_999_999; - let gross_usdc: u64 = 10_499_999; + let stranger = Address::generate(&env); + let id = c.create_invoice(&merchant, &10_000_000u64, &10_000_000u64, &3600u64, &1u64); + let res = c.try_cancel_invoice(&stranger, &id); + assert_eq!(res, Err(Ok(InvoiceError::Unauthorized))); + } - let result = client.create_invoice( - &merchant, - &amount_usdc, - &gross_usdc, - &3600u64, - &None, - &None, - ); + /// InvalidAmount: gross_usdc less than amount_usdc. + #[test] + fn test_invalid_amount_gross_less_than_net() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let merchant = Address::generate(&env); + // gross < amount → InvalidAmount + let res = c.try_create_invoice(&merchant, &20_000_000u64, &10_000_000u64, &3600u64, &1u64); + assert_eq!(res, Err(Ok(InvoiceError::InvalidAmount))); + } - assert_eq!(result, Err(InvoiceError::AmountPrecision)); + /// Expired: pay an invoice after its expiry timestamp. + #[test] + fn test_expired_pay_after_expiry() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let merchant = Address::generate(&env); + let payer = Address::generate(&env); + // timestamp=1000, expires_in=1 → expires_at=1001 + let id = c.create_invoice(&merchant, &10_000_000u64, &10_000_000u64, &1u64, &1u64); + env.ledger().set_timestamp(1001); + let res = c.try_pay_invoice(&payer, &id); + assert_eq!(res, Err(Ok(InvoiceError::Expired))); } + /// Expired boundary: paying at exactly the expiry timestamp is also expired. #[test] - fn test_create_invoice_zero_amount_returns_error() { - let env = Env::default(); - env.mock_all_auths(); + fn test_expired_pay_at_exact_expiry_boundary() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let merchant = Address::generate(&env); + let payer = Address::generate(&env); + let id = c.create_invoice(&merchant, &10_000_000u64, &10_000_000u64, &60u64, &1u64); + // expires_at = 1000 + 60 = 1060; >= check means 1060 is expired + env.ledger().set_timestamp(1060); + let res = c.try_pay_invoice(&payer, &id); + assert_eq!(res, Err(Ok(InvoiceError::Expired))); + } - let contract_id = env.register_contract(None, InvoiceContract); - let client = InvoiceContractClient::new(&env, &contract_id); + /// NotFound: get an invoice that does not exist. + #[test] + fn test_not_found_get_nonexistent_invoice() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let res = c.try_get_invoice(&999u64); + assert_eq!(res, Err(Ok(InvoiceError::NotFound))); + } + + /// NotFound: pay an invoice that does not exist. + #[test] + fn test_not_found_pay_nonexistent_invoice() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let payer = Address::generate(&env); + let res = c.try_pay_invoice(&payer, &999u64); + assert_eq!(res, Err(Ok(InvoiceError::NotFound))); + } + /// DuplicateNonce: same merchant + nonce used twice. + #[test] + fn test_duplicate_nonce_same_merchant() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); let merchant = Address::generate(&env); - let amount_usdc: u64 = 0; - let gross_usdc: u64 = 0; + c.create_invoice(&merchant, &10_000_000u64, &10_000_000u64, &3600u64, &42u64); + let res = c.try_create_invoice(&merchant, &10_000_000u64, &10_000_000u64, &3600u64, &42u64); + assert_eq!(res, Err(Ok(InvoiceError::DuplicateNonce))); + } - let result = client.create_invoice( + /// Different merchants may reuse the same nonce (no collision). + #[test] + fn test_duplicate_nonce_different_merchants_allowed() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let m1 = Address::generate(&env); + let m2 = Address::generate(&env); + c.create_invoice(&m1, &10_000_000u64, &10_000_000u64, &3600u64, &1u64); + let id2 = c.create_invoice(&m2, &10_000_000u64, &10_000_000u64, &3600u64, &1u64); + assert_eq!(id2, 2); + } + + /// AmountPrecision: exactly one stroop below the minimum. + #[test] + fn test_amount_precision_below_minimum() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let merchant = Address::generate(&env); + let res = c.try_create_invoice( &merchant, - &amount_usdc, - &gross_usdc, + &(MIN_AMOUNT_STROOPS - 1), + &(MIN_AMOUNT_STROOPS - 1), &3600u64, - &None, - &None, + &1u64, ); + assert_eq!(res, Err(Ok(InvoiceError::AmountPrecision))); + } - assert_eq!(result, Err(InvoiceError::AmountPrecision)); + /// AmountPrecision: value of 1 is non-zero but below minimum. + #[test] + fn test_amount_precision_value_of_one() { + let (env, cid, _admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let merchant = Address::generate(&env); + let res = c.try_create_invoice(&merchant, &1u64, &1u64, &3600u64, &1u64); + assert_eq!(res, Err(Ok(InvoiceError::AmountPrecision))); + } + + /// ContractPaused: create_invoice is blocked when the contract is paused. + #[test] + fn test_contract_paused_blocks_create_invoice() { + let (env, cid, admin) = setup(); + let c = InvoiceContractClient::new(&env, &cid); + let merchant = Address::generate(&env); + c.pause(&admin); + let res = c.try_create_invoice(&merchant, &10_000_000u64, &10_000_000u64, &3600u64, &1u64); + assert_eq!(res, Err(Ok(InvoiceError::ContractPaused))); } } diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index dcfc51f..03dc828 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -31,6 +31,7 @@ pub struct Settlement { } #[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct ApproveResult { pub approval_weight: u64, pub threshold: u64,