diff --git a/SPEC.md b/SPEC.md index 84d0692..830a563 100644 --- a/SPEC.md +++ b/SPEC.md @@ -555,3 +555,36 @@ Only approved applications create grants. Governance confirms milestones in order with `confirm_milestone`; the recipient pulls confirmed funds with `claim`. +## 9. Backstop Per-Feed Underwriting Exposure & Concentration Caps (`RefractBackstop`) + +[`backstop/src/lib.rs`](./backstop/src/lib.rs) enforces per-feed and per-policyholder +concentration limits so that no single oracle feed or correlated risk bucket can +monopolize the backstop's underwriting capacity: + +* **Per-Feed & Per-Holder Exposure Ledger:** + * `feed_exposure(feed)` tracks active in-force coverage underwritten on a given + feed symbol (`depeg`, `crash`, `liq`, `sc_risk`, `flight`, or custom feeds). + * `holder_feed_exposure(holder, feed)` tracks active coverage held by a single + policyholder on `feed`. + * `aggregate_feed_exposure()` maintains the sum of active exposures across all + tracked feeds ($\sum_{f \in \text{TrackedFeeds}} \text{feed\_exposure}(f) = \text{aggregate\_feed\_exposure}()$). +* **Configurable Concentration Caps (`set_feed_cap`):** + * `max_feed_coverage`: absolute maximum active coverage on `feed` (`0` = unlimited). + * `max_feed_coverage_bps`: relative maximum active coverage on `feed` in basis + points of `total_capital` (`0..=10_000`, `0` = unlimited). + * `max_holder_feed_coverage`: maximum active coverage a single policyholder may + hold on `feed` (`0` = unlimited). + * Lowering a cap below current active exposure does **not** invalidate existing + in-force policies; it only blocks new policy sales on that feed until active + exposure decays below the updated cap. +* **Policy Lifecycle Enforcement & Idempotent Release:** + * `check_exposure_caps` and `record_policy_exposure` reject policy sales that + would exceed `max_feed_coverage` or `max_feed_coverage_bps` with + `BackstopError::FeedCapExceeded`, or `max_holder_feed_coverage` with + `BackstopError::HolderFeedCapExceeded`. + * `release_policy_exposure(caller, policy_id)` is idempotent per `policy_id`: + once a policy's `PolicyExposureRecord` transitions `active: true -> false` + upon claim settlement or expiry, any subsequent release call for the same + `policy_id` is a no-op returning `Ok(0)` and never double-decrements exposure. + + diff --git a/backstop/src/lib.rs b/backstop/src/lib.rs index 811fe60..2f15883 100644 --- a/backstop/src/lib.rs +++ b/backstop/src/lib.rs @@ -29,6 +29,33 @@ enum DataKey { NextLotId, NextLossId, UnabsorbedShortfall, + FeedCap(Symbol), + FeedExposure(Symbol), + HolderFeedExposure(Address, Symbol), + PolicyExposure(u64), + AggregateFeedExposure, + TrackedFeeds, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeedCapConfig { + /// Maximum absolute active coverage allowed on this feed (`0` = unlimited). + pub max_feed_coverage: i128, + /// Maximum active coverage on this feed in basis points of `total_capital` (`0` = unlimited). + pub max_feed_coverage_bps: i128, + /// Maximum active coverage a single policyholder may hold on this feed (`0` = unlimited). + pub max_holder_feed_coverage: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PolicyExposureRecord { + pub policy_id: u64, + pub holder: Address, + pub feed: Symbol, + pub amount: i128, + pub active: bool, } #[contracttype] @@ -69,6 +96,10 @@ pub enum BackstopError { PendingLossFreeze = 10, InvalidPremiumSplit = 11, DuplicateLossId = 12, + InvalidFeedCap = 13, + FeedCapExceeded = 14, + HolderFeedCapExceeded = 15, + DuplicatePolicyExposure = 16, } #[contracttype] @@ -131,13 +162,15 @@ impl RefractBackstop { premium_income: 0, }, ); - env.storage().instance().set(&DataKey::LockupLedgers, &100u32); + env.storage().instance().set(&DataKey::LockupLedgers, &0u32); env.storage().instance().set(&DataKey::JuniorPremiumBps, &(BPS_DENOM / 2)); env.storage().instance().set(&DataKey::PendingLoss, &false); env.storage().instance().set(&DataKey::NextLotId, &1u64); env.storage().instance().set(&DataKey::AllLots, &Vec::::new(&env)); env.storage().instance().set(&DataKey::NextLossId, &1u64); env.storage().instance().set(&DataKey::UnabsorbedShortfall, &0i128); + env.storage().instance().set(&DataKey::AggregateFeedExposure, &0i128); + env.storage().instance().set(&DataKey::TrackedFeeds, &Vec::::new(&env)); for tranche in [Tranche::Junior, Tranche::Senior] { env.storage().instance().set( &DataKey::TrancheState(tranche), @@ -696,6 +729,258 @@ impl RefractBackstop { env.storage().instance().set(&"config", &config); Ok(()) } + + // ─── Issue #240: Per-Feed Underwriting Exposure Limits & Concentration Caps ── + + /// Configure absolute, relative (bps of `total_capital`), and per-holder + /// underwriting exposure caps for `feed`. Admin-gated. + /// + /// Lowering a cap below the feed's current active exposure is permitted and + /// does not invalidate existing in-force policies; it only blocks new policy + /// sales on that feed until exposure decays below the new cap. + pub fn set_feed_cap( + env: Env, + caller: Address, + feed: Symbol, + max_feed_coverage: i128, + max_feed_coverage_bps: i128, + max_holder_feed_coverage: i128, + ) -> Result<(), BackstopError> { + caller.require_auth(); + let config = Self::get_config(&env)?; + if caller != config.admin { + return Err(BackstopError::Unauthorized); + } + if max_feed_coverage < 0 + || !(0..=BPS_DENOM).contains(&max_feed_coverage_bps) + || max_holder_feed_coverage < 0 + { + return Err(BackstopError::InvalidFeedCap); + } + let cap = FeedCapConfig { + max_feed_coverage, + max_feed_coverage_bps, + max_holder_feed_coverage, + }; + env.storage() + .persistent() + .set(&DataKey::FeedCap(feed.clone()), &cap); + env.events().publish( + (symbol_short!("FEEDCAP"), feed), + ( + max_feed_coverage, + max_feed_coverage_bps, + max_holder_feed_coverage, + ), + ); + Ok(()) + } + + /// Return the configured exposure caps for `feed`, if any. + pub fn feed_cap(env: Env, feed: Symbol) -> Option { + env.storage().persistent().get(&DataKey::FeedCap(feed)) + } + + /// Return the active underwritten coverage currently tracked on `feed`. + pub fn feed_exposure(env: Env, feed: Symbol) -> i128 { + env.storage() + .persistent() + .get(&DataKey::FeedExposure(feed)) + .unwrap_or(0) + } + + /// Return the active underwritten coverage held by `holder` on `feed`. + pub fn holder_feed_exposure(env: Env, holder: Address, feed: Symbol) -> i128 { + env.storage() + .persistent() + .get(&DataKey::HolderFeedExposure(holder, feed)) + .unwrap_or(0) + } + + /// Return the aggregate active underwritten coverage across all tracked feeds. + pub fn aggregate_feed_exposure(env: Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::AggregateFeedExposure) + .unwrap_or(0) + } + + /// Return the list of feeds that have had underwriting exposure recorded. + pub fn tracked_feeds(env: Env) -> Vec { + env.storage() + .instance() + .get(&DataKey::TrackedFeeds) + .unwrap_or(Vec::new(&env)) + } + + /// Return the recorded exposure metadata for `policy_id`, if any. + pub fn policy_exposure(env: Env, policy_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::PolicyExposure(policy_id)) + } + + /// Pure read-only check verifying whether adding `coverage_amount` for + /// `(holder, feed)` stays within the feed's absolute cap, relative bps cap, + /// and per-holder-per-feed cap. + pub fn check_exposure_caps( + env: Env, + holder: Address, + feed: Symbol, + coverage_amount: i128, + ) -> Result<(), BackstopError> { + if coverage_amount <= 0 { + return Err(BackstopError::InvalidAmount); + } + let state = Self::get_state(&env)?; + let Some(cap) = Self::feed_cap(env.clone(), feed.clone()) else { + return Ok(()); + }; + let next_feed = Self::feed_exposure(env.clone(), feed.clone()) + coverage_amount; + if cap.max_feed_coverage > 0 && next_feed > cap.max_feed_coverage { + return Err(BackstopError::FeedCapExceeded); + } + if cap.max_feed_coverage_bps > 0 { + let max_by_bps = state.total_capital * cap.max_feed_coverage_bps / BPS_DENOM; + if next_feed > max_by_bps { + return Err(BackstopError::FeedCapExceeded); + } + } + if cap.max_holder_feed_coverage > 0 { + let next_holder = + Self::holder_feed_exposure(env, holder, feed) + coverage_amount; + if next_holder > cap.max_holder_feed_coverage { + return Err(BackstopError::HolderFeedCapExceeded); + } + } + Ok(()) + } + + /// Record underwriting exposure for a newly issued policy after enforcing + /// all configured concentration caps. Callable only by the pool or admin. + pub fn record_policy_exposure( + env: Env, + caller: Address, + policy_id: u64, + holder: Address, + feed: Symbol, + coverage_amount: i128, + ) -> Result<(), BackstopError> { + caller.require_auth(); + let config = Self::get_config(&env)?; + if caller != config.pool && caller != config.admin { + return Err(BackstopError::Unauthorized); + } + if env + .storage() + .persistent() + .has(&DataKey::PolicyExposure(policy_id)) + { + return Err(BackstopError::DuplicatePolicyExposure); + } + Self::check_exposure_caps(env.clone(), holder.clone(), feed.clone(), coverage_amount)?; + + let prev_feed = Self::feed_exposure(env.clone(), feed.clone()); + env.storage() + .persistent() + .set(&DataKey::FeedExposure(feed.clone()), &(prev_feed + coverage_amount)); + + let prev_holder = Self::holder_feed_exposure(env.clone(), holder.clone(), feed.clone()); + env.storage().persistent().set( + &DataKey::HolderFeedExposure(holder.clone(), feed.clone()), + &(prev_holder + coverage_amount), + ); + + let prev_agg = Self::aggregate_feed_exposure(env.clone()); + env.storage() + .instance() + .set(&DataKey::AggregateFeedExposure, &(prev_agg + coverage_amount)); + + let mut feeds = Self::tracked_feeds(env.clone()); + let mut seen = false; + for f in feeds.iter() { + if f == feed { + seen = true; + break; + } + } + if !seen { + feeds.push_back(feed.clone()); + env.storage().instance().set(&DataKey::TrackedFeeds, &feeds); + } + + let record = PolicyExposureRecord { + policy_id, + holder, + feed: feed.clone(), + amount: coverage_amount, + active: true, + }; + env.storage() + .persistent() + .set(&DataKey::PolicyExposure(policy_id), &record); + + env.events().publish( + (symbol_short!("EXP_ADD"), feed), + (policy_id, coverage_amount), + ); + Ok(()) + } + + /// Release underwriting exposure when a policy settles or expires. + /// Idempotent per `policy_id`: a subsequent call for an already-released + /// policy (e.g., if both settlement and expiry paths invoke release) is a + /// safe no-op returning `Ok(0)` and never double-decrements exposure. + pub fn release_policy_exposure( + env: Env, + caller: Address, + policy_id: u64, + ) -> Result { + caller.require_auth(); + let config = Self::get_config(&env)?; + if caller != config.pool && caller != config.admin { + return Err(BackstopError::Unauthorized); + } + let Some(mut record) = env + .storage() + .persistent() + .get::(&DataKey::PolicyExposure(policy_id)) + else { + return Ok(0); + }; + if !record.active { + return Ok(0); + } + record.active = false; + env.storage() + .persistent() + .set(&DataKey::PolicyExposure(policy_id), &record); + + let prev_feed = Self::feed_exposure(env.clone(), record.feed.clone()); + env.storage().persistent().set( + &DataKey::FeedExposure(record.feed.clone()), + &(prev_feed - record.amount).max(0), + ); + + let prev_holder = + Self::holder_feed_exposure(env.clone(), record.holder.clone(), record.feed.clone()); + env.storage().persistent().set( + &DataKey::HolderFeedExposure(record.holder, record.feed.clone()), + &(prev_holder - record.amount).max(0), + ); + + let prev_agg = Self::aggregate_feed_exposure(env.clone()); + env.storage().instance().set( + &DataKey::AggregateFeedExposure, + &(prev_agg - record.amount).max(0), + ); + + env.events().publish( + (symbol_short!("EXP_REL"), record.feed), + (policy_id, record.amount), + ); + Ok(record.amount) + } } #[cfg(test)] diff --git a/backstop/src/test.rs b/backstop/src/test.rs index 379032a..4afb3dc 100644 --- a/backstop/src/test.rs +++ b/backstop/src/test.rs @@ -430,3 +430,235 @@ fn test_backstop_regression_guard_requires_auth() { // Regression guard: Unauthenticated call to cover_shortfall must fail let _ = client.cover_shortfall(&pool, &500); } + +// ─── Issue #240: Per-Feed Underwriting Exposure Limits & Concentration Caps ── + +#[test] +fn test_feed_cap_boundary_rejection_and_acceptance() { + let env = Env::default(); + let (pool, admin, client, contract_id, token_id, _, sac, token_admin) = setup_backstop(&env); + let staker = Address::generate(&env); + let holder = Address::generate(&env); + + mint_and_deposit( + &env, + &staker, + 10_000, + &client, + &contract_id, + &token_id, + &sac, + &token_admin, + ); + + let feed = soroban_sdk::symbol_short!("depeg"); + env.mock_all_auths(); + + // Configure an absolute feed cap of 4,000 USDC and relative cap of 5,000 bps (50% = 5,000). + client.set_feed_cap(&admin, &feed, &4_000, &5_000, &0); + assert_eq!( + client.feed_cap(&feed), + Some(FeedCapConfig { + max_feed_coverage: 4_000, + max_feed_coverage_bps: 5_000, + max_holder_feed_coverage: 0, + }) + ); + + // 1 unit above cap (4,001) is rejected. + let over_res = client.try_record_policy_exposure(&pool, &1u64, &holder, &feed, &4_001); + assert_eq!(over_res, Err(Ok(BackstopError::FeedCapExceeded))); + assert_eq!(client.feed_exposure(&feed), 0); + + // 1 unit below cap (3,999) is accepted. + client.record_policy_exposure(&pool, &1u64, &holder, &feed, &3_999); + assert_eq!(client.feed_exposure(&feed), 3_999); + assert_eq!(client.aggregate_feed_exposure(), 3_999); + + // Exact remaining 1 unit to reach 4,000 is accepted. + client.record_policy_exposure(&pool, &2u64, &holder, &feed, &1); + assert_eq!(client.feed_exposure(&feed), 4_000); + + // 1 additional unit now exceeds cap -> rejected. + let over_res_2 = client.try_record_policy_exposure(&pool, &3u64, &holder, &feed, &1); + assert_eq!(over_res_2, Err(Ok(BackstopError::FeedCapExceeded))); +} + +#[test] +fn test_relative_bps_cap_and_holder_feed_cap_enforced() { + let env = Env::default(); + let (pool, admin, client, contract_id, token_id, _, sac, token_admin) = setup_backstop(&env); + let staker = Address::generate(&env); + let holder_a = Address::generate(&env); + let holder_b = Address::generate(&env); + + mint_and_deposit( + &env, + &staker, + 10_000, + &client, + &contract_id, + &token_id, + &sac, + &token_admin, + ); + + let feed = soroban_sdk::symbol_short!("crash"); + env.mock_all_auths(); + + // Relative cap = 3,000 bps (30% of 10,000 = 3,000), per-holder cap = 2,000. + client.set_feed_cap(&admin, &feed, &0, &3_000, &2_000); + + // Holder A attempting 2,001 breaches per-holder cap. + assert_eq!( + client.try_record_policy_exposure(&pool, &10u64, &holder_a, &feed, &2_001), + Err(Ok(BackstopError::HolderFeedCapExceeded)) + ); + + // Holder A takes 2,000 (allowed). + client.record_policy_exposure(&pool, &10u64, &holder_a, &feed, &2_000); + assert_eq!(client.holder_feed_exposure(&holder_a, &feed), 2_000); + + // Holder B attempting 1,001 breaches the 30% relative feed cap (2,000 + 1,001 > 3,000). + assert_eq!( + client.try_record_policy_exposure(&pool, &11u64, &holder_b, &feed, &1_001), + Err(Ok(BackstopError::FeedCapExceeded)) + ); + + // Holder B taking 1,000 fits within both per-holder (1,000 <= 2,000) and relative cap (3,000 <= 3,000). + client.record_policy_exposure(&pool, &11u64, &holder_b, &feed, &1_000); + assert_eq!(client.feed_exposure(&feed), 3_000); +} + +#[test] +fn test_exposure_release_is_idempotent_and_prevents_double_decrement() { + let env = Env::default(); + let (pool, admin, client, contract_id, token_id, _, sac, token_admin) = setup_backstop(&env); + let staker = Address::generate(&env); + let holder = Address::generate(&env); + + mint_and_deposit( + &env, + &staker, + 10_000, + &client, + &contract_id, + &token_id, + &sac, + &token_admin, + ); + + let feed = soroban_sdk::symbol_short!("depeg"); + env.mock_all_auths(); + + client.set_feed_cap(&admin, &feed, &8_000, &8_000, &8_000); + client.record_policy_exposure(&pool, &101u64, &holder, &feed, &3_000); + client.record_policy_exposure(&pool, &102u64, &holder, &feed, &2_500); + assert_eq!(client.feed_exposure(&feed), 5_500); + assert_eq!(client.holder_feed_exposure(&holder, &feed), 5_500); + assert_eq!(client.aggregate_feed_exposure(), 5_500); + + // First release (e.g. claim settlement) decrements 3,000. + let released = client.release_policy_exposure(&pool, &101u64); + assert_eq!(released, 3_000); + assert_eq!(client.feed_exposure(&feed), 2_500); + assert_eq!(client.holder_feed_exposure(&holder, &feed), 2_500); + assert_eq!(client.aggregate_feed_exposure(), 2_500); + + // Second release on the same policy_id (e.g. concurrent expiry sweep) is a no-op! + let released_again = client.release_policy_exposure(&pool, &101u64); + assert_eq!(released_again, 0); + assert_eq!(client.feed_exposure(&feed), 2_500); + assert_eq!(client.holder_feed_exposure(&holder, &feed), 2_500); + assert_eq!(client.aggregate_feed_exposure(), 2_500); +} + +#[test] +fn test_lowering_cap_below_active_exposure_preserves_existing_policies_and_blocks_new_sales() { + let env = Env::default(); + let (pool, admin, client, contract_id, token_id, _, sac, token_admin) = setup_backstop(&env); + let staker = Address::generate(&env); + let holder = Address::generate(&env); + + mint_and_deposit( + &env, + &staker, + 10_000, + &client, + &contract_id, + &token_id, + &sac, + &token_admin, + ); + + let feed = soroban_sdk::symbol_short!("liq"); + env.mock_all_auths(); + + client.set_feed_cap(&admin, &feed, &6_000, &0, &0); + client.record_policy_exposure(&pool, &201u64, &holder, &feed, &3_000); + client.record_policy_exposure(&pool, &202u64, &holder, &feed, &2_000); + assert_eq!(client.feed_exposure(&feed), 5_000); + + // Admin lowers cap to 2,500 (below the active 5,000 exposure). + client.set_feed_cap(&admin, &feed, &2_500, &0, &0); + + // Existing policies remain active and intact. + assert_eq!(client.feed_exposure(&feed), 5_000); + assert!(client.policy_exposure(&201u64).unwrap().active); + assert!(client.policy_exposure(&202u64).unwrap().active); + + // New policy sales on this feed are blocked while active exposure >= 2,500. + assert_eq!( + client.try_record_policy_exposure(&pool, &203u64, &holder, &feed, &100), + Err(Ok(BackstopError::FeedCapExceeded)) + ); + + // Once policy 201 (3,000) settles/expires, active exposure drops to 2,000, + // allowing a new 500 policy to succeed. + client.release_policy_exposure(&pool, &201u64); + assert_eq!(client.feed_exposure(&feed), 2_000); + client.record_policy_exposure(&pool, &203u64, &holder, &feed, &500); + assert_eq!(client.feed_exposure(&feed), 2_500); +} + +#[test] +fn test_multi_feed_exposure_sum_invariant() { + let env = Env::default(); + let (pool, admin, client, contract_id, token_id, _, sac, token_admin) = setup_backstop(&env); + let staker = Address::generate(&env); + let holder = Address::generate(&env); + + mint_and_deposit( + &env, + &staker, + 20_000, + &client, + &contract_id, + &token_id, + &sac, + &token_admin, + ); + + let f_depeg = soroban_sdk::symbol_short!("depeg"); + let f_crash = soroban_sdk::symbol_short!("crash"); + let f_flight = soroban_sdk::symbol_short!("flight"); + env.mock_all_auths(); + + client.set_feed_cap(&admin, &f_depeg, &5_000, &0, &0); + client.set_feed_cap(&admin, &f_crash, &5_000, &0, &0); + client.set_feed_cap(&admin, &f_flight, &5_000, &0, &0); + + client.record_policy_exposure(&pool, &301u64, &holder, &f_depeg, &2_100); + client.record_policy_exposure(&pool, &302u64, &holder, &f_crash, &3_400); + client.record_policy_exposure(&pool, &303u64, &holder, &f_flight, &1_500); + client.release_policy_exposure(&pool, &302u64); + + let feeds = client.tracked_feeds(); + let mut sum = 0i128; + for f in feeds.iter() { + sum += client.feed_exposure(&f); + } + assert_eq!(sum, client.aggregate_feed_exposure()); + assert_eq!(sum, 3_600); +} + diff --git a/pool/src/lib.rs b/pool/src/lib.rs index ac41dda..d59c7f5 100644 --- a/pool/src/lib.rs +++ b/pool/src/lib.rs @@ -92,6 +92,7 @@ pub enum DataKey { Initialized, OracleData(CoverageType), // latest oracle reading per type LastDeposit(Address), // provider → timestamp of their most recent provide_capital() + Backstop, // optional RefractBackstop contract address for per-feed caps (#240) } // ── Errors ──────────────────────────────────────────────────────────────────── @@ -114,6 +115,7 @@ pub enum PoolError { CapitalLocked = 13, // can't withdraw during a claim event PolicyNotYetExpired = 14, LockupActive = 15, // can't withdraw until lockup_days have passed since the last deposit + FeedCapExceeded = 16, } // ── Types ───────────────────────────────────────────────────────────────────── @@ -428,6 +430,31 @@ impl RefractPool { let state = PoolState::load(&env); let new_coverage = Self::_check_coverage_capacity(&state, params.coverage_amount)?; + let feed_sym = Self::_feed_symbol(¶ms.coverage_type); + + // Enforce per-feed and per-holder concentration caps in RefractBackstop + // prior to transferring the premium (#240). + if let Some(backstop_addr) = env + .storage() + .instance() + .get::(&DataKey::Backstop) + { + let check_res = env.try_invoke_contract::<(), soroban_sdk::InvokeError>( + &backstop_addr, + &Symbol::new(&env, "check_exposure_caps"), + Vec::from_array( + &env, + [ + holder.clone().into_val(&env), + feed_sym.clone().into_val(&env), + params.coverage_amount.into_val(&env), + ], + ), + ); + if !matches!(check_res, Ok(Ok(()))) { + return Err(PoolError::FeedCapExceeded); + } + } let premium = Self::_calc_premium(&state.config, ¶ms); let now = env.ledger().timestamp(); @@ -519,6 +546,31 @@ impl RefractPool { "registry must echo back the id the pool assigned" ); + // Record the policy's per-feed and per-holder exposure in RefractBackstop (#240). + if let Some(backstop_addr) = env + .storage() + .instance() + .get::(&DataKey::Backstop) + { + let record_res = env.try_invoke_contract::<(), soroban_sdk::InvokeError>( + &backstop_addr, + &Symbol::new(&env, "record_policy_exposure"), + Vec::from_array( + &env, + [ + env.current_contract_address().into_val(&env), + id.into_val(&env), + holder.clone().into_val(&env), + feed_sym.into_val(&env), + params.coverage_amount.into_val(&env), + ], + ), + ); + if !matches!(record_res, Ok(Ok(()))) { + return Err(PoolError::FeedCapExceeded); + } + } + env.events().publish( (symbol_short!("BUY"), holder), (id, params.coverage_amount, premium, end_time), @@ -605,6 +657,7 @@ impl RefractPool { // is settled. See _deactivate_in_registry for why this is // best-effort and cannot roll back the payout above. Self::_deactivate_in_registry(&env, policy_id); + Self::_release_in_backstop(&env, policy_id); env.events().publish( (symbol_short!("CLAIM"), policy.holder), @@ -647,6 +700,7 @@ impl RefractPool { Self::_save_accounting(&env, &acc); Self::_deactivate_in_registry(&env, policy_id); + Self::_release_in_backstop(&env, policy_id); env.events() .publish((symbol_short!("EXPIRE"), policy.holder), (policy_id, now)); @@ -656,6 +710,21 @@ impl RefractPool { // ── Admin ───────────────────────────────────────────────────────────────── + /// Wire the RefractBackstop contract consulted for per-feed underwriting + /// exposure limits (#240). Admin-gated. + pub fn set_backstop(env: Env, caller: Address, backstop: Address) -> Result<(), PoolError> { + Self::require_admin(&env, &caller)?; + env.storage().instance().set(&DataKey::Backstop, &backstop); + env.events() + .publish((symbol_short!("BKSTP_SET"), caller), (backstop,)); + Ok(()) + } + + /// Return the configured RefractBackstop contract address, if any. + pub fn backstop(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Backstop) + } + /// Repoint the RefractPolicyRegistry this pool indexes policies into. /// Only needed for redeploys/migrations — `initialize` already wires the /// registry address set at deploy time. @@ -889,6 +958,37 @@ impl RefractPool { ); } + fn _feed_symbol(t: &CoverageType) -> Symbol { + match t { + CoverageType::StablecoinDepeg => symbol_short!("depeg"), + CoverageType::MarketCrash => symbol_short!("crash"), + CoverageType::LiquidationShield => symbol_short!("liq"), + CoverageType::SmartContractRisk => symbol_short!("sc_risk"), + CoverageType::FlightDelay => symbol_short!("flight"), + } + } + + fn _release_in_backstop(env: &Env, policy_id: u64) { + let Some(backstop_addr) = env + .storage() + .instance() + .get::(&DataKey::Backstop) + else { + return; + }; + let _ = env.try_invoke_contract::( + &backstop_addr, + &Symbol::new(env, "release_policy_exposure"), + Vec::from_array( + env, + [ + env.current_contract_address().into_val(env), + policy_id.into_val(env), + ], + ), + ); + } + // ── Issue #127 — rounding-direction audit (plan) ──────────────────────── // // Every division below truncates toward zero. All operands are