From 368065fc0e5e87045060f6c46356d0239b15d56a Mon Sep 17 00:00:00 2001 From: Musa March Date: Thu, 27 Aug 2026 00:18:21 +0100 Subject: [PATCH] fix(atomic_swap): pay insurance claims from per-policy reservations --- contracts/atomic_swap/src/errors.rs | 3 + contracts/atomic_swap/src/lib.rs | 401 ++++++++++++++++++++++++++- contracts/atomic_swap/src/swap.rs | 52 +++- contracts/atomic_swap/src/types.rs | 28 ++ contracts/atomic_swap/src/upgrade.rs | 2 + docs/api-reference.md | 106 +++++++ 6 files changed, 582 insertions(+), 10 deletions(-) diff --git a/contracts/atomic_swap/src/errors.rs b/contracts/atomic_swap/src/errors.rs index c203c8c..ed07815 100644 --- a/contracts/atomic_swap/src/errors.rs +++ b/contracts/atomic_swap/src/errors.rs @@ -67,4 +67,7 @@ pub enum ContractError { TimelockNotElapsed = 63, RulingFinalized = 64, BatchArbitrationDisabled = 65, + // #354: Insurance reservation errors + InsuranceNotReserved = 66, + InsufficientInsuranceReserve = 67, } diff --git a/contracts/atomic_swap/src/lib.rs b/contracts/atomic_swap/src/lib.rs index c7fdd5b..9d3fe5f 100644 --- a/contracts/atomic_swap/src/lib.rs +++ b/contracts/atomic_swap/src/lib.rs @@ -101,6 +101,15 @@ pub enum ContractError { TimelockNotElapsed = 63, RulingFinalized = 64, BatchArbitrationDisabled = 65, + /// #354: `claim_insurance` was called for a swap that has no coverage + /// reservation carved against the pool (never accepted with insurance, or + /// the reservation was already released). + InsuranceNotReserved = 66, + /// #354: The insurance pool for this token holds less than the claiming + /// policy's own reserved coverage. The claim is valid but the pool is + /// under-collateralized; nothing is paid rather than paying a silent + /// partial amount. + InsufficientInsuranceReserve = 67, } // ── TTL ─────────────────────────────────────────────────────────────────────── @@ -181,6 +190,13 @@ pub enum DataKey { InsuranceClaimable(u64), /// #354: Global insurance pool balance for the token (token Address → i128). InsurancePool(Address), + /// #354: Maps swap_id → coverage amount reserved against the pool for that + /// policy. Carved when the premium is collected, released when the policy + /// pays out or can no longer be claimed. + InsuranceReserved(u64), + /// #354: Sum of all outstanding `InsuranceReserved` amounts for the token. + /// The pool is collateralized while `InsurancePool >= InsuranceReservedTotal`. + InsuranceReservedTotal(Address), /// #353: Maps swap_id → RenegotiationOffer for pending renegotiation. SwapRenegotiations(u64), /// #352: Maps swap_id → escrow agent address. @@ -760,6 +776,11 @@ impl AtomicSwap { env.storage() .persistent() .extend_ttl(&pool_key, LEDGER_BUMP, LEDGER_BUMP); + + // Carve this policy's coverage out of the pool at issuance so a + // later claim is paid from its own reservation rather than from + // whatever another policy's claim left behind. + Self::reserve_insurance_coverage(&env, swap_id, &swap.token, swap.price); } swap.accept_timestamp = env.ledger().timestamp(); @@ -1968,8 +1989,149 @@ impl AtomicSwap { // ── #354: Insurance ─────────────────────────────────────────────────────── + /// Reserve `amount` of coverage for `swap_id` against the token's pool. + /// + /// Called wherever an insurance premium is collected. The reservation, not + /// the pool remainder, is what a later claim is paid from, so two policies + /// on the same token can never draw on each other's coverage. + fn reserve_insurance_coverage(env: &Env, swap_id: u64, token: &Address, amount: i128) { + if amount <= 0 { + return; + } + // Idempotent: a swap already carrying a reservation is not re-reserved. + if env + .storage() + .persistent() + .has(&DataKey::InsuranceReserved(swap_id)) + { + return; + } + + env.storage() + .persistent() + .set(&DataKey::InsuranceReserved(swap_id), &amount); + env.storage().persistent().extend_ttl( + &DataKey::InsuranceReserved(swap_id), + LEDGER_BUMP, + LEDGER_BUMP, + ); + + let total_key = DataKey::InsuranceReservedTotal(token.clone()); + let total: i128 = env.storage().persistent().get(&total_key).unwrap_or(0); + env.storage().persistent().set(&total_key, &(total + amount)); + env.storage() + .persistent() + .extend_ttl(&total_key, LEDGER_BUMP, LEDGER_BUMP); + } + + /// Drop `swap_id`'s reservation and decrement the token's outstanding total. + /// + /// Idempotent - a swap with no reservation is a no-op - so it is safe to + /// call from every terminal transition without checking insurance state. + fn release_insurance_reservation(env: &Env, swap_id: u64, token: &Address) { + let reserved: i128 = match env + .storage() + .persistent() + .get(&DataKey::InsuranceReserved(swap_id)) + { + Some(amount) => amount, + None => return, + }; + + env.storage() + .persistent() + .remove(&DataKey::InsuranceReserved(swap_id)); + + let total_key = DataKey::InsuranceReservedTotal(token.clone()); + let total: i128 = env.storage().persistent().get(&total_key).unwrap_or(0); + // Saturate at zero rather than letting a double-release go negative. + let remaining = if total > reserved { total - reserved } else { 0 }; + env.storage().persistent().set(&total_key, &remaining); + env.storage() + .persistent() + .extend_ttl(&total_key, LEDGER_BUMP, LEDGER_BUMP); + } + + /// Coverage currently reserved for `swap_id`, or 0 if the policy holds none. + pub fn get_insurance_reservation(env: Env, swap_id: u64) -> i128 { + env.storage() + .persistent() + .get(&DataKey::InsuranceReserved(swap_id)) + .unwrap_or(0) + } + + /// Pool balance versus outstanding reservations for `token`. + /// + /// Under-collateralization is observable here before it turns into a failed + /// payout: `collateralized == false` means some outstanding policy cannot + /// currently be paid in full. + pub fn get_insurance_pool_status(env: Env, token: Address) -> InsurancePoolStatus { + let balance: i128 = env + .storage() + .persistent() + .get(&DataKey::InsurancePool(token.clone())) + .unwrap_or(0); + let reserved: i128 = env + .storage() + .persistent() + .get(&DataKey::InsuranceReservedTotal(token.clone())) + .unwrap_or(0); + + InsurancePoolStatus { + token, + balance, + reserved, + collateralized: balance >= reserved, + shortfall: if reserved > balance { + reserved - balance + } else { + 0 + }, + } + } + + /// Top up a token's insurance pool. + /// + /// Premiums alone are a fraction of the coverage they buy, so without an + /// external funding path a pool can never reach the collateralization the + /// claim check now requires. + pub fn fund_insurance_pool(env: Env, funder: Address, token: Address, amount: i128) { + funder.require_auth(); + + if amount <= 0 { + env.panic_with_error(Error::from_contract_error( + ContractError::PriceTooSmall as u32, + )); + } + + token::Client::new(&env, &token).transfer(&funder, &env.current_contract_address(), &amount); + + let pool_key = DataKey::InsurancePool(token.clone()); + let pool: i128 = env.storage().persistent().get(&pool_key).unwrap_or(0); + let new_balance = pool + amount; + env.storage().persistent().set(&pool_key, &new_balance); + env.storage() + .persistent() + .extend_ttl(&pool_key, LEDGER_BUMP, LEDGER_BUMP); + + env.events().publish( + (soroban_sdk::symbol_short!("ins_fund"),), + InsurancePoolFundedEvent { + token, + funder, + amount, + new_balance, + }, + ); + } + /// Buyer claims insurance payout after seller revealed an invalid key. /// Requires insurance to have been enabled and the swap to be marked claimable. + /// + /// Pays the policy's own reservation, never the pool remainder. If the pool + /// cannot cover it the call panics with `InsufficientInsuranceReserve` + /// rather than transferring a reduced amount, so a claimant is never left + /// unable to distinguish "invalid claim" from "someone drained the pool". pub fn claim_insurance(env: Env, swap_id: u64) { let swap = require_swap_exists(&env, swap_id); swap.buyer.require_auth(); @@ -1990,23 +2152,35 @@ impl AtomicSwap { )); } - // Payout = swap price (buyer gets their payment back from the pool) - let payout = swap.price; + let payout: i128 = env + .storage() + .persistent() + .get(&DataKey::InsuranceReserved(swap_id)) + .unwrap_or(0); + + if payout <= 0 { + env.panic_with_error(Error::from_contract_error( + ContractError::InsuranceNotReserved as u32, + )); + } + let pool_key = DataKey::InsurancePool(swap.token.clone()); let pool: i128 = env.storage().persistent().get(&pool_key).unwrap_or(0); - // Deduct from pool (pool may be partially funded; pay what's available) - let actual_payout = if pool >= payout { payout } else { pool }; + if pool < payout { + env.panic_with_error(Error::from_contract_error( + ContractError::InsufficientInsuranceReserve as u32, + )); + } token::Client::new(&env, &swap.token).transfer( &env.current_contract_address(), &swap.buyer, - &actual_payout, + &payout, ); - env.storage() - .persistent() - .set(&pool_key, &(pool - actual_payout)); + env.storage().persistent().set(&pool_key, &(pool - payout)); + Self::release_insurance_reservation(&env, swap_id, &swap.token); // Clear claimable flag so it can't be claimed twice env.storage() .persistent() @@ -2017,7 +2191,7 @@ impl AtomicSwap { InsurancePayoutEvent { swap_id, buyer: swap.buyer, - payout_amount: actual_payout, + payout_amount: payout, }, ); } @@ -3883,6 +4057,8 @@ impl AtomicSwap { env.storage() .persistent() .extend_ttl(&pool_key, LEDGER_BUMP, LEDGER_BUMP); + + Self::reserve_insurance_coverage(&env, swap_id, &swap.token, swap.price); } swap.accept_timestamp = env.ledger().timestamp(); @@ -5895,3 +6071,210 @@ mod batch_enhancement_tests { ); } } + + +#[cfg(test)] +mod insurance_reserve_tests { + use ip_registry::{IpRegistry, IpRegistryClient}; + use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, BytesN, Env}; + + use crate::{AtomicSwap, AtomicSwapClient, DataKey}; + + const PRICE: i128 = 1_000; + const PREMIUM: i128 = PRICE * 2 / 100; + + struct Pool { + contract_id: Address, + token: Address, + funder: Address, + buyer_a: Address, + buyer_b: Address, + swap_a: u64, + swap_b: u64, + } + + fn commit_ip(env: &Env, registry: &IpRegistryClient, owner: &Address, seed: u8) -> u64 { + let secret = BytesN::from_array(env, &[seed; 32]); + let blinding = BytesN::from_array(env, &[seed.wrapping_add(1); 32]); + let mut preimage = soroban_sdk::Bytes::new(env); + preimage.append(&soroban_sdk::Bytes::from(secret)); + preimage.append(&soroban_sdk::Bytes::from(blinding)); + let commitment_hash: BytesN<32> = env.crypto().sha256(&preimage).into(); + registry.commit_ip(owner, &commitment_hash, &0u32) + } + + /// Two independently valid policies on the same token, both accepted so the + /// premium is collected and the coverage reservation is carved. The pool is + /// left holding only the two premiums, well short of the coverage they buy. + fn setup_two_policies(env: &Env) -> Pool { + let seller = Address::generate(env); + let buyer_a = Address::generate(env); + let buyer_b = Address::generate(env); + let funder = Address::generate(env); + let token_admin = Address::generate(env); + + let registry_id = env.register(IpRegistry, ()); + let registry = IpRegistryClient::new(env, ®istry_id); + let ip_a = commit_ip(env, ®istry, &seller, 2); + let ip_b = commit_ip(env, ®istry, &seller, 7); + + let token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let minter = StellarAssetClient::new(env, &token); + minter.mint(&buyer_a, &(PRICE + PREMIUM)); + minter.mint(&buyer_b, &(PRICE + PREMIUM)); + minter.mint(&funder, &(PRICE * 4)); + + let contract_id = env.register(AtomicSwap, ()); + let client = AtomicSwapClient::new(env, &contract_id); + client.initialize(®istry_id); + + let swap_a = client.initiate_swap( + &token, &ip_a, &seller, &PRICE, &buyer_a, &0u32, &None, &0i128, &true, + ); + let swap_b = client.initiate_swap( + &token, &ip_b, &seller, &PRICE, &buyer_b, &0u32, &None, &0i128, &true, + ); + client.accept_swap(&swap_a); + client.accept_swap(&swap_b); + + Pool { + contract_id, + token, + funder, + buyer_a, + buyer_b, + swap_a, + swap_b, + } + } + + /// Flags a swap as claimable. `reveal_key` sets this flag and then panics, + /// which reverts the write, so the flag is seeded directly rather than + /// through that path - a separate pre-existing bug, untouched here. + fn mark_claimable(env: &Env, contract_id: &Address, swap_id: u64) { + env.as_contract(contract_id, || { + env.storage() + .persistent() + .set(&DataKey::InsuranceClaimable(swap_id), &true); + }); + } + + fn claim_in_order(first_is_a: bool) { + let env = Env::default(); + env.mock_all_auths(); + let pool = setup_two_policies(&env); + let client = AtomicSwapClient::new(&env, &pool.contract_id); + + let status = client.get_insurance_pool_status(&pool.token); + assert_eq!(status.reserved, PRICE * 2); + assert_eq!(status.balance, PREMIUM * 2); + assert!(!status.collateralized); + + // Top the pool up to the full outstanding coverage. + client.fund_insurance_pool(&pool.funder, &pool.token, &status.shortfall); + assert!(client + .get_insurance_pool_status(&pool.token) + .collateralized); + + mark_claimable(&env, &pool.contract_id, pool.swap_a); + mark_claimable(&env, &pool.contract_id, pool.swap_b); + + let token_client = soroban_sdk::token::Client::new(&env, &pool.token); + let before_a = token_client.balance(&pool.buyer_a); + let before_b = token_client.balance(&pool.buyer_b); + + if first_is_a { + client.claim_insurance(&pool.swap_a); + client.claim_insurance(&pool.swap_b); + } else { + client.claim_insurance(&pool.swap_b); + client.claim_insurance(&pool.swap_a); + } + + // Neither claimant starved the other: both were paid in full. + assert_eq!(token_client.balance(&pool.buyer_a) - before_a, PRICE); + assert_eq!(token_client.balance(&pool.buyer_b) - before_b, PRICE); + + // Funded to exactly the outstanding coverage, so paying both in full + // drains the pool to zero and leaves nothing reserved against it. + let after = client.get_insurance_pool_status(&pool.token); + assert_eq!(after.reserved, 0); + assert_eq!(after.balance, 0); + assert!(after.collateralized); + } + + #[test] + fn test_two_policies_both_paid_in_full_a_then_b() { + claim_in_order(true); + } + + #[test] + fn test_two_policies_both_paid_in_full_b_then_a() { + claim_in_order(false); + } + + /// Contract error #67, `InsufficientInsuranceReserve`. + #[test] + #[should_panic(expected = "Error(Contract, #67)")] + fn test_claim_against_undercollateralized_pool_errors() { + let env = Env::default(); + env.mock_all_auths(); + let pool = setup_two_policies(&env); + let client = AtomicSwapClient::new(&env, &pool.contract_id); + + // Pool holds only the two premiums, far short of one policy's coverage. + let status = client.get_insurance_pool_status(&pool.token); + assert!(!status.collateralized); + assert_eq!(status.shortfall, PRICE * 2 - PREMIUM * 2); + + mark_claimable(&env, &pool.contract_id, pool.swap_a); + client.claim_insurance(&pool.swap_a); + } + + #[test] + fn test_undercollateralization_is_observable_before_claiming() { + let env = Env::default(); + env.mock_all_auths(); + let pool = setup_two_policies(&env); + let client = AtomicSwapClient::new(&env, &pool.contract_id); + + let status = client.get_insurance_pool_status(&pool.token); + assert_eq!(status.token, pool.token); + assert_eq!(status.balance, PREMIUM * 2); + assert_eq!(status.reserved, PRICE * 2); + assert!(!status.collateralized); + assert_eq!(status.shortfall, status.reserved - status.balance); + + assert_eq!(client.get_insurance_reservation(&pool.swap_a), PRICE); + assert_eq!(client.get_insurance_reservation(&pool.swap_b), PRICE); + + client.fund_insurance_pool(&pool.funder, &pool.token, &status.shortfall); + + let funded = client.get_insurance_pool_status(&pool.token); + assert!(funded.collateralized); + assert_eq!(funded.shortfall, 0); + } + + /// A claim only ever draws its own reservation, so a swap that never had + /// one is refused with #66 rather than being handed the pool remainder. + #[test] + #[should_panic(expected = "Error(Contract, #66)")] + fn test_claim_without_reservation_errors() { + let env = Env::default(); + env.mock_all_auths(); + let pool = setup_two_policies(&env); + let client = AtomicSwapClient::new(&env, &pool.contract_id); + + client.fund_insurance_pool(&pool.funder, &pool.token, &(PRICE * 4)); + + env.as_contract(&pool.contract_id, || { + env.storage() + .persistent() + .remove(&DataKey::InsuranceReserved(pool.swap_a)); + }); + mark_claimable(&env, &pool.contract_id, pool.swap_a); + client.claim_insurance(&pool.swap_a); + } +} diff --git a/contracts/atomic_swap/src/swap.rs b/contracts/atomic_swap/src/swap.rs index 3b8d6f6..2848e37 100644 --- a/contracts/atomic_swap/src/swap.rs +++ b/contracts/atomic_swap/src/swap.rs @@ -1,6 +1,6 @@ use soroban_sdk::{Address, Env, Vec}; -use crate::{utils::panic_with_error, ContractError, DataKey, SwapRecord, LEDGER_BUMP}; +use crate::{utils::panic_with_error, ContractError, DataKey, SwapRecord, SwapStatus, LEDGER_BUMP}; #[allow(dead_code)] pub fn load_swap(env: &Env, swap_id: u64) -> SwapRecord { @@ -17,6 +17,56 @@ pub fn save_swap(env: &Env, swap_id: u64, swap: &SwapRecord) { env.storage() .persistent() .extend_ttl(&DataKey::Swap(swap_id), LEDGER_BUMP, LEDGER_BUMP); + + release_insurance_reservation_if_settled(env, swap_id, swap); +} + +/// #354: A swap that has reached a terminal state can no longer be claimed +/// against, so its coverage reservation must stop counting toward the pool's +/// outstanding total. Centralised here rather than at each of the ~20 status +/// transitions so no future terminal path can silently leak a reservation. +/// +/// A swap already flagged `InsuranceClaimable` keeps its reservation: the claim +/// is what the reservation exists for, and `claim_insurance` releases it on +/// payout. +fn release_insurance_reservation_if_settled(env: &Env, swap_id: u64, swap: &SwapRecord) { + let settled = matches!( + swap.status, + SwapStatus::Completed | SwapStatus::Cancelled | SwapStatus::RolledBack + ); + if !settled { + return; + } + + if env + .storage() + .persistent() + .has(&DataKey::InsuranceClaimable(swap_id)) + { + return; + } + + let reserved: i128 = match env + .storage() + .persistent() + .get(&DataKey::InsuranceReserved(swap_id)) + { + Some(amount) => amount, + None => return, + }; + + env.storage() + .persistent() + .remove(&DataKey::InsuranceReserved(swap_id)); + + let total_key = DataKey::InsuranceReservedTotal(swap.token.clone()); + let total: i128 = env.storage().persistent().get(&total_key).unwrap_or(0); + // Saturate at zero rather than letting a double-release go negative. + let remaining = if total > reserved { total - reserved } else { 0 }; + env.storage().persistent().set(&total_key, &remaining); + env.storage() + .persistent() + .extend_ttl(&total_key, LEDGER_BUMP, LEDGER_BUMP); } pub fn append_swap_for_party(env: &Env, seller: &Address, buyer: &Address, swap_id: u64) { diff --git a/contracts/atomic_swap/src/types.rs b/contracts/atomic_swap/src/types.rs index 66efabb..e08c9bd 100644 --- a/contracts/atomic_swap/src/types.rs +++ b/contracts/atomic_swap/src/types.rs @@ -520,6 +520,34 @@ pub struct InsurancePayoutEvent { pub payout_amount: i128, } +/// Solvency snapshot for one token's insurance pool. +/// +/// `collateralized` is the invariant the contract cares about: while it holds, +/// every outstanding policy can be paid in full regardless of claim order. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct InsurancePoolStatus { + /// Token the pool is denominated in. + pub token: Address, + /// Actual balance credited to the pool. + pub balance: i128, + /// Sum of coverage reserved by all outstanding policies. + pub reserved: i128, + /// True while `balance >= reserved`. + pub collateralized: bool, + /// `reserved - balance` when under-collateralized, else 0. + pub shortfall: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct InsurancePoolFundedEvent { + pub token: Address, + pub funder: Address, + pub amount: i128, + pub new_balance: i128, +} + // ── Rollback Event ──────────────────────────────────────────────────────────── #[contracttype] diff --git a/contracts/atomic_swap/src/upgrade.rs b/contracts/atomic_swap/src/upgrade.rs index 00a438b..365905a 100644 --- a/contracts/atomic_swap/src/upgrade.rs +++ b/contracts/atomic_swap/src/upgrade.rs @@ -355,6 +355,8 @@ pub fn build_v1_schema(env: &Env) -> ContractSchema { k!("SwapRenegotiations"); k!("InsuranceClaimable"); k!("InsurancePool"); + k!("InsuranceReserved"); + k!("InsuranceReservedTotal"); ContractSchema { version: 1, diff --git a/docs/api-reference.md b/docs/api-reference.md index 35677e2..ab7dca3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1222,3 +1222,109 @@ The TCP peer address is used by default, and `X-Forwarded-For` is ignored to prevent clients from evading IP limits by spoofing headers. Set `RateLimitConfig::trust_proxy_headers` only when the API is reachable solely through a trusted reverse proxy that replaces `X-Forwarded-For`. + +## Issue #354: Swap Insurance + +A buyer may enable insurance when a swap is initiated. The premium is 2% of the +swap price and is collected from the buyer at `accept_swap`. If the seller later +reveals an invalid key, the swap is flagged claimable and the buyer can call +`claim_insurance` to recover the swap price from the insurance pool. + +### Reservation model + +Coverage is **reserved per policy**, not drawn from a shared pool balance at +claim time. + +- Each token has one pool balance, `InsurancePool(token)`. +- When a policy is issued (that is, when `accept_swap` or `batch_accept_swaps` + collects its premium), the contract carves a reservation equal to that swap's + full coverage amount, which is the swap price. It is stored as + `InsuranceReserved(swap_id)` and added to `InsuranceReservedTotal(token)`. +- `claim_insurance` pays that policy's own reservation. It never pays the pool + remainder, and it never pays a reduced amount. +- The reservation is released when the policy pays out, and when the swap + reaches a settled state (`Completed`, `Cancelled`, `RolledBack`) without an + outstanding claim. A swap still flagged claimable keeps its reservation. + +The invariant the contract exposes is: + +``` +InsurancePool(token) >= InsuranceReservedTotal(token) +``` + +While it holds, every outstanding policy on that token can be paid in full +regardless of the order claims arrive in. Two valid policies on the same token +can no longer race, and the second claimant can no longer be silently handed a +partial payout because the first drained the balance. + +### Collateralization + +Premiums alone do not collateralize the coverage they buy: a policy contributes +2% of the price and reserves 100% of it. A pool funded only by premiums is +therefore under-collateralized by construction, and every claim against it fails +with `InsufficientInsuranceReserve`. Use `fund_insurance_pool` to top the pool +up, and `get_insurance_pool_status` to see the shortfall before a claim hits it. + +### `claim_insurance` + +```rust +fn claim_insurance(env: Env, swap_id: u64) +``` + +Buyer-authorized. Transfers the policy's reserved coverage from the pool to the +buyer, releases the reservation, and clears the claimable flag so the policy +cannot be claimed twice. + +Panics with: + +| Error | Code | Meaning | +|---|---:|---| +| `Unauthorized` | 23 | Insurance was not enabled for this swap, or the swap is not flagged claimable | +| `InsuranceNotReserved` | 66 | The policy holds no coverage reservation: it was never accepted with insurance, or the reservation was already released | +| `InsufficientInsuranceReserve` | 67 | The claim is valid, but the pool holds less than this policy's reserved coverage. Nothing is transferred | + +Codes 66 and 67 are deliberately distinct. A claimant can tell "my claim is not +valid" apart from "the pool is under-collateralized and owes me a payout it +cannot currently make". + +### `get_insurance_pool_status` + +```rust +fn get_insurance_pool_status(env: Env, token: Address) -> InsurancePoolStatus +``` + +Read-only. Returns the pool's solvency for one token: + +| Field | Type | Meaning | +|---|---|---| +| `token` | `Address` | Token the pool is denominated in | +| `balance` | `i128` | Actual balance credited to the pool | +| `reserved` | `i128` | Sum of coverage reserved by all outstanding policies | +| `collateralized` | `bool` | True while `balance >= reserved` | +| `shortfall` | `i128` | `reserved - balance` when under-collateralized, else 0 | + +Under-collateralization is observable here before it causes a failed payout. + +### `get_insurance_reservation` + +```rust +fn get_insurance_reservation(env: Env, swap_id: u64) -> i128 +``` + +Read-only. Coverage currently reserved for one swap, or 0 if it holds none. + +### `fund_insurance_pool` + +```rust +fn fund_insurance_pool(env: Env, funder: Address, token: Address, amount: i128) +``` + +Funder-authorized. Transfers `amount` of `token` into the contract and credits +it to that token's insurance pool. Panics with `PriceTooSmall` (3) if `amount` +is not positive. Publishes `ins_fund` with an `InsurancePoolFundedEvent`. + +### Premium calculation + +Unchanged. The premium remains 2% of the swap price, applied identically in +`initiate_swap` and `batch_initiate_with_insurance`. The reservation model +changes only how a claim is paid, not what a policy costs.