diff --git a/contracts/parametric_insurance/src/lib.rs b/contracts/parametric_insurance/src/lib.rs index ff7b1c74..049ed16f 100644 --- a/contracts/parametric_insurance/src/lib.rs +++ b/contracts/parametric_insurance/src/lib.rs @@ -1,20 +1,22 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, Symbol, + contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env, + Symbol, }; #[contracttype] #[derive(Clone)] pub enum DataKey { Admin, + Token, Oracle, TotalCapital, LockedLiability, UnderwriterBalance(Address), Policy(u64), NextPolicyId, - OracleSignal(Symbol), + OracleValue(Symbol), Claimable(Address), } @@ -24,6 +26,8 @@ pub struct Policy { pub id: u64, pub buyer: Address, pub trigger_key: Symbol, + pub trigger_value: i128, + pub trigger_above: bool, pub premium: i128, pub payout: i128, pub expires_at: u64, @@ -49,13 +53,14 @@ pub struct ParametricInsuranceContract; #[contractimpl] impl ParametricInsuranceContract { - pub fn initialize(env: Env, admin: Address, oracle: Address) { + pub fn initialize(env: Env, admin: Address, token: Address, oracle: Address) { if env.storage().instance().has(&DataKey::Admin) { panic_with_error!(&env, InsuranceError::AlreadyInitialized); } admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Token, &token); env.storage().instance().set(&DataKey::Oracle, &oracle); env.storage().instance().set(&DataKey::TotalCapital, &0i128); env.storage().instance().set(&DataKey::LockedLiability, &0i128); @@ -70,6 +75,15 @@ impl ParametricInsuranceContract { panic_with_error!(&env, InsuranceError::InvalidAmount); } + let token: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .unwrap_or_else(|| panic_with_error!(&env, InsuranceError::NotInitialized)); + + let token_client = token::Client::new(&env, &token); + token_client.transfer(&underwriter, &env.current_contract_address(), &amount); + let mut total = total_capital(&env); total += amount; env.storage().instance().set(&DataKey::TotalCapital, &total); @@ -91,6 +105,8 @@ impl ParametricInsuranceContract { payout: i128, expires_at: u64, trigger_key: Symbol, + trigger_value: i128, + trigger_above: bool, ) -> u64 { ensure_initialized(&env); buyer.require_auth(); @@ -102,34 +118,53 @@ impl ParametricInsuranceContract { panic_with_error!(&env, InsuranceError::InvalidAmount); } - let mut total = total_capital(&env); - total += premium; - let mut locked = locked_liability(&env); locked += payout; - if total < locked { + let mut total = total_capital(&env); + if total + premium < locked { panic_with_error!(&env, InsuranceError::Insolvent); } - let id: u64 = env.storage().instance().get(&DataKey::NextPolicyId).unwrap_or(1); + let token: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .unwrap_or_else(|| panic_with_error!(&env, InsuranceError::NotInitialized)); + + let token_client = token::Client::new(&env, &token); + token_client.transfer(&buyer, &env.current_contract_address(), &premium); + + total += premium; + env.storage().instance().set(&DataKey::TotalCapital, &total); + env.storage().instance().set(&DataKey::LockedLiability, &locked); + + let id: u64 = env + .storage() + .instance() + .get(&DataKey::NextPolicyId) + .unwrap_or(1); let policy = Policy { id, buyer, trigger_key, + trigger_value, + trigger_above, premium, payout, expires_at, claimed: false, }; - env.storage().instance().set(&DataKey::Policy(id), &policy); - env.storage().instance().set(&DataKey::TotalCapital, &total); - env.storage().instance().set(&DataKey::LockedLiability, &locked); - env.storage().instance().set(&DataKey::NextPolicyId, &(id + 1)); + env.storage() + .instance() + .set(&DataKey::Policy(id), &policy); + env.storage() + .instance() + .set(&DataKey::NextPolicyId, &(id + 1)); id } - pub fn post_oracle_signal(env: Env, oracle: Address, trigger_key: Symbol, fired: bool) { + pub fn post_oracle_value(env: Env, oracle: Address, trigger_key: Symbol, value: i128) { ensure_initialized(&env); oracle.require_auth(); @@ -144,7 +179,7 @@ impl ParametricInsuranceContract { env.storage() .instance() - .set(&DataKey::OracleSignal(trigger_key), &fired); + .set(&DataKey::OracleValue(trigger_key), &value); } pub fn claim(env: Env, buyer: Address, policy_id: u64) -> i128 { @@ -167,12 +202,18 @@ impl ParametricInsuranceContract { panic_with_error!(&env, InsuranceError::Expired); } - let fired: bool = env + let oracle_value: i128 = env .storage() .instance() - .get(&DataKey::OracleSignal(policy.trigger_key.clone())) - .unwrap_or(false); - if !fired { + .get(&DataKey::OracleValue(policy.trigger_key.clone())) + .unwrap_or_else(|| panic_with_error!(&env, InsuranceError::TriggerNotMet)); + + let trigger_met = if policy.trigger_above { + oracle_value >= policy.trigger_value + } else { + oracle_value <= policy.trigger_value + }; + if !trigger_met { panic_with_error!(&env, InsuranceError::TriggerNotMet); } @@ -187,7 +228,9 @@ impl ParametricInsuranceContract { let mut locked = locked_liability(&env); locked -= policy.payout; - env.storage().instance().set(&DataKey::LockedLiability, &locked); + env.storage() + .instance() + .set(&DataKey::LockedLiability, &locked); let claimable: i128 = env .storage() @@ -226,8 +269,22 @@ impl ParametricInsuranceContract { env.storage() .instance() - .set(&DataKey::UnderwriterBalance(underwriter), &(current - amount)); - env.storage().instance().set(&DataKey::TotalCapital, &(total - amount)); + .set( + &DataKey::UnderwriterBalance(underwriter.clone()), + &(current - amount), + ); + env.storage() + .instance() + .set(&DataKey::TotalCapital, &(total - amount)); + + let token: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .unwrap_or_else(|| panic_with_error!(&env, InsuranceError::NotInitialized)); + + let token_client = token::Client::new(&env, &token); + token_client.transfer(&env.current_contract_address(), &underwriter, &amount); } pub fn get_policy(env: Env, policy_id: u64) -> Option { @@ -242,6 +299,37 @@ impl ParametricInsuranceContract { } (total * 10_000) / locked } + + pub fn withdraw_claim(env: Env, buyer: Address, amount: i128) { + ensure_initialized(&env); + buyer.require_auth(); + + if amount <= 0 { + panic_with_error!(&env, InsuranceError::InvalidAmount); + } + + let claimable: i128 = env + .storage() + .instance() + .get(&DataKey::Claimable(buyer.clone())) + .unwrap_or(0); + if claimable < amount { + panic_with_error!(&env, InsuranceError::InvalidAmount); + } + + env.storage() + .instance() + .set(&DataKey::Claimable(buyer.clone()), &(claimable - amount)); + + let token: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .unwrap_or_else(|| panic_with_error!(&env, InsuranceError::NotInitialized)); + + let token_client = token::Client::new(&env, &token); + token_client.transfer(&env.current_contract_address(), &buyer, &amount); + } } fn ensure_initialized(env: &Env) { @@ -251,74 +339,272 @@ fn ensure_initialized(env: &Env) { } fn total_capital(env: &Env) -> i128 { - env.storage().instance().get(&DataKey::TotalCapital).unwrap_or(0) + env.storage() + .instance() + .get(&DataKey::TotalCapital) + .unwrap_or(0) } fn locked_liability(env: &Env) -> i128 { - env.storage().instance().get(&DataKey::LockedLiability).unwrap_or(0) + env.storage() + .instance() + .get(&DataKey::LockedLiability) + .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; - use soroban_sdk::{testutils::Address as _, Address, Env, Symbol}; - - fn client(env: &Env) -> ParametricInsuranceContractClient<'_> { - let id = env.register(ParametricInsuranceContract, ()); - ParametricInsuranceContractClient::new(env, &id) + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, Symbol, + }; + + fn create_token(env: &Env, admin: &Address) -> (Address, token::StellarAssetClient<'_>) { + let token_id = env.register_stellar_asset_contract_v2(admin.clone()); + let sac = token::StellarAssetClient::new(env, &token_id); + (token_id, sac) } - #[test] - fn pays_policy_when_oracle_trigger_fires() { + fn setup() -> (Env, Address, Address, Address, Address, Address, Address) { let env = Env::default(); env.mock_all_auths(); - let client = client(&env); + env.ledger().set(Ledger { timestamp: 1_000_000, ..Default::default() }); let admin = Address::generate(&env); let oracle = Address::generate(&env); let buyer = Address::generate(&env); let underwriter = Address::generate(&env); - client.initialize(&admin, &oracle); - client.underwrite(&underwriter, &10_000); + let (token, sac) = create_token(&env, &admin); + sac.mint(&underwriter, &50_000); + sac.mint(&buyer, &10_000); + + let contract_id = env.register(ParametricInsuranceContract, ()); + let client = ParametricInsuranceContractClient::new(&env, &contract_id); + client.initialize(&admin, &token, &oracle); + + (env, contract_id, token, admin, oracle, buyer, underwriter) + } + + #[test] + fn buys_policy_and_claims_on_trigger() { + let (env, _contract_id, _token, _admin, oracle, buyer, underwriter) = setup(); + let client = ParametricInsuranceContractClient::new(&env, &_contract_id); + + client.underwrite(&underwriter, &30_000); + + let trigger = Symbol::new(&env, "temp_celsius"); + let policy_id = client.buy_policy( + &buyer, &500, &10_000, &(env.ledger().timestamp() + 100), &trigger, &35i128, &true, + ); + + let policy = client.get_policy(&policy_id).unwrap(); + assert_eq!(policy.premium, 500); + assert_eq!(policy.payout, 10_000); + assert!(!policy.claimed); + + client.post_oracle_value(&oracle, &trigger, &42i128); + + let payout = client.claim(&buyer, &policy_id); + assert_eq!(payout, 10_000); + } + + #[test] + fn rejects_claim_when_oracle_not_set() { + let (env, _contract_id, _token, _admin, _oracle, buyer, underwriter) = setup(); + let client = ParametricInsuranceContractClient::new(&env, &_contract_id); + + client.underwrite(&underwriter, &30_000); - let trigger = Symbol::new(&env, "flight_delayed"); + let trigger = Symbol::new(&env, "temp_celsius"); + let policy_id = client.buy_policy( + &buyer, &500, &10_000, &(env.ledger().timestamp() + 100), &trigger, &35i128, &true, + ); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.claim(&buyer, &policy_id); + })); + assert!(result.is_err()); + } + + #[test] + fn rejects_claim_when_trigger_not_met_below() { + let (env, _contract_id, _token, _admin, oracle, buyer, underwriter) = setup(); + let client = ParametricInsuranceContractClient::new(&env, &_contract_id); + + client.underwrite(&underwriter, &30_000); + + // Policy triggers when temp is BELOW 10 + let trigger = Symbol::new(&env, "temp_celsius"); + let policy_id = client.buy_policy( + &buyer, &500, &5_000, &(env.ledger().timestamp() + 100), &trigger, &10i128, &false, + ); + + // Oracle posts 25 - trigger NOT met (25 > 10, but we need <= 10) + client.post_oracle_value(&oracle, &trigger, &25i128); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.claim(&buyer, &policy_id); + })); + assert!(result.is_err()); + } + + #[test] + fn claims_when_trigger_below() { + let (env, _contract_id, _token, _admin, oracle, buyer, underwriter) = setup(); + let client = ParametricInsuranceContractClient::new(&env, &_contract_id); + + client.underwrite(&underwriter, &30_000); + + // Policy triggers when oracle value is BELOW 10 + let trigger = Symbol::new(&env, "temp_celsius"); + let policy_id = client.buy_policy( + &buyer, &500, &5_000, &(env.ledger().timestamp() + 100), &trigger, &10i128, &false, + ); + + client.post_oracle_value(&oracle, &trigger, &5i128); + + let payout = client.claim(&buyer, &policy_id); + assert_eq!(payout, 5_000); + } + + #[test] + fn rejects_expired_policy() { + let (env, _contract_id, _token, _admin, oracle, buyer, underwriter) = setup(); + let client = ParametricInsuranceContractClient::new(&env, &_contract_id); + + client.underwrite(&underwriter, &30_000); + + let trigger = Symbol::new(&env, "rainfall_mm"); let policy_id = client.buy_policy( &buyer, - &500, - &4_000, - &(env.ledger().timestamp() + 50), + &200, + &3_000, + &(env.ledger().timestamp() + 10), &trigger, + &100i128, + &true, ); - client.post_oracle_signal(&oracle, &trigger, &true); + // Advance time past expiry + env.ledger().set(Ledger { + timestamp: 1_000_020, + ..Default::default() + }); - let payout = client.claim(&buyer, &policy_id); - assert_eq!(payout, 4_000); - assert!(client.solvency_ratio_bps() > 0); + client.post_oracle_value(&oracle, &trigger, &150i128); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.claim(&buyer, &policy_id); + })); + assert!(result.is_err()); + } + + #[test] + fn prevents_double_claim() { + let (env, _contract_id, _token, _admin, oracle, buyer, underwriter) = setup(); + let client = ParametricInsuranceContractClient::new(&env, &_contract_id); + + client.underwrite(&underwriter, &30_000); + + let trigger = Symbol::new(&env, "wind_speed"); + let policy_id = client.buy_policy( + &buyer, &300, &5_000, &(env.ledger().timestamp() + 100), &trigger, &80i128, &true, + ); + + client.post_oracle_value(&oracle, &trigger, &120i128); + + client.claim(&buyer, &policy_id); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.claim(&buyer, &policy_id); + })); + assert!(result.is_err()); } #[test] - #[should_panic(expected = "Error(Contract, #5)")] fn rejects_policy_that_would_break_solvency() { let env = Env::default(); env.mock_all_auths(); - let client = client(&env); + env.ledger().set(Ledger { + timestamp: 1_000_000, + ..Default::default() + }); let admin = Address::generate(&env); let oracle = Address::generate(&env); let buyer = Address::generate(&env); let underwriter = Address::generate(&env); - client.initialize(&admin, &oracle); + let (token, sac) = create_token(&env, &admin); + sac.mint(&underwriter, &1_000); + + let contract_id = env.register(ParametricInsuranceContract, ()); + let client = ParametricInsuranceContractClient::new(&env, &contract_id); + client.initialize(&admin, &token, &oracle); client.underwrite(&underwriter, &1_000); - let _ = client.buy_policy( - &buyer, - &10, - &5_000, - &(env.ledger().timestamp() + 100), - &Symbol::new(&env, "price_crash"), + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = client.buy_policy( + &buyer, + &10, + &5_000, + &(env.ledger().timestamp() + 100), + &Symbol::new(&env, "price_crash"), + &0i128, + &false, + ); + })); + assert!(result.is_err()); + } + + #[test] + fn underwriter_can_withdraw() { + let (env, _contract_id, _token, _admin, _oracle, _buyer, underwriter) = setup(); + let client = ParametricInsuranceContractClient::new(&env, &_contract_id); + + client.underwrite(&underwriter, &20_000); + client.withdraw_underwriting(&underwriter, &5_000); + + let token_client = token::Client::new(&env, &_token); + // Underwriter deposited 20k, withdrew 5k => balance unchanged (just moved back) + let bal = token_client.balance(&underwriter); + // Started with 50k, deposited 20k (30k remaining), withdrew 5k (35k) + assert_eq!(bal, 35_000); + } + + #[test] + fn buyer_can_withdraw_claim() { + let (env, _contract_id, _token, _admin, oracle, buyer, underwriter) = setup(); + let client = ParametricInsuranceContractClient::new(&env, &_contract_id); + + client.underwrite(&underwriter, &30_000); + + let trigger = Symbol::new(&env, "temp"); + let policy_id = client.buy_policy( + &buyer, &500, &10_000, &(env.ledger().timestamp() + 100), &trigger, &35i128, &true, ); + + client.post_oracle_value(&oracle, &trigger, &42i128); + client.claim(&buyer, &policy_id); + client.withdraw_claim(&buyer, &10_000); + + let token_client = token::Client::new(&env, &_token); + let buyer_bal = token_client.balance(&buyer); + // Started with 10_000, paid 500 premium, withdrew 10_000 claim => 19_500 + assert_eq!(buyer_bal, 19_500); + } + + #[test] + fn solvency_ratio_works() { + let (env, _contract_id, _token, _admin, _oracle, _buyer, underwriter) = setup(); + let client = ParametricInsuranceContractClient::new(&env, &_contract_id); + + // No liabilities yet + assert_eq!(client.solvency_ratio_bps(), 100_000); + + client.underwrite(&underwriter, &20_000); + assert_eq!(client.solvency_ratio_bps(), 100_000); } }