// SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.24; import {ISolvencyGuard, ISettlementEscrow} from "../interfaces/IUSDR.sol"; /// @dev Read interface of a live Rain market pool (from rain-sdk-v2 MarketsAbi, /// production factory 0x38B3Ba1ee001E6785224E31b3031ae96CA06C677 on Arbitrum One). /// Each pool supports AMM and order-book trading over N mutually exclusive options. interface IRainMarket { /// @dev number of sub-pools; options are 1-INDEXED (verified on-chain vs pool /// 0xd190f9a7490d7feb44473211f654d7f9cdaef363, Arbitrum One) function numberOfOptions() external view returns (uint256); function totalMarketFunds() external view returns (uint256); // base-token units function totalMarketShares() external view returns (uint256); function baseToken() external view returns (address); /// @dev returns the SCALE FACTOR (e.g. 1e6 for USDT), not the digit count — verified on-chain function baseTokenDecimals() external view returns (uint256); function tradingModel() external view returns (uint8); function optionWinningPoolShare(uint256 option) external view returns (uint256); function totalOptionLPShares(uint256 option) external view returns (uint256); function optionWinner(uint256 option) external view returns (uint8); } /// @title RainMarketAdapter — bridges live Rain markets to the USDR solvency layer /// @notice Implements White Paper v3.2 §4.1 for real Rain pools: /// /// MaxLoss per market = worst-case NET delta across all mutually exclusive options: /// for each option o, if o resolves true the protocol (as market maker / liquidity /// underwriter) pays the winning pool share for o and keeps every other option's /// funds. Net cost(o) = winningPayout(o) − totalMarketFunds. MaxLoss = max over o /// of max(0, cost(o)). Balanced books net to ~0 (WP S2); one-sided books grow /// toward (1−p)/p × premium (WP S9) — computed, never assumed small. /// /// Stress-MaxLoss applies the configured stress multiplier (WP §4.3) and a /// worst-admissible-resolution floor: void/tie/dispute outcomes are covered by /// also considering the refund-everyone case (WP §4.1 "resolution risk"). /// /// @dev One adapter instance serves many markets. `syncMarket` is permissionless — /// anyone (keepers, the protocol frontend, LPs) can refresh a market's reported /// exposure. Registering markets is timelock-gated so only genuine Rain pools /// (deployed by the canonical factory) enter the exposure set. contract RainMarketAdapter { error NotTimelock(); error NotRegistered(); error AlreadyRegistered(); struct MarketInfo { IRainMarket market; bytes32 groupId; // correlated group (WP §4.1); e.g. keccak256("CRYPTO_PRICE") bool registered; uint256 protocolShareBps; // share of the book the protocol underwrites (10000 = all) } uint256 private constant BPS = 10_000; ISolvencyGuard public immutable guard; address public immutable timelock; /// @notice stress multiplier in BPS applied on top of normal MaxLoss (WP §4.3 /// fixed conservative stress: correlated crash, breaker active, thin depth). uint256 public stressMultiplierBps = 15_000; // 1.5x default, timelock-tunable mapping(bytes32 => MarketInfo) public markets; // marketId => info event MarketRegistered(bytes32 indexed marketId, address market, bytes32 groupId, uint256 shareBps); event MarketSynced(bytes32 indexed marketId, uint256 normalMaxLoss, uint256 stressMaxLoss); event StressMultiplierSet(uint256 bps); constructor(address _guard, address _timelock) { guard = ISolvencyGuard(_guard); timelock = _timelock; } function setStressMultiplier(uint256 bps) external { if (msg.sender != timelock) revert NotTimelock(); require(bps >= BPS, "stress < 1x"); // stress may never be below normal stressMultiplierBps = bps; emit StressMultiplierSet(bps); } /// @notice Register a live Rain pool. Timelock-gated (only canonical factory pools). function registerMarket(address market, bytes32 groupId, uint256 protocolShareBps) external { if (msg.sender != timelock) revert NotTimelock(); bytes32 marketId = keccak256(abi.encodePacked(market)); if (markets[marketId].registered) revert AlreadyRegistered(); markets[marketId] = MarketInfo({market: IRainMarket(market), groupId: groupId, registered: true, protocolShareBps: protocolShareBps}); emit MarketRegistered(marketId, market, groupId, protocolShareBps); _sync(marketId); } /// @notice Permissionless refresh of a market's exposure report. /// Called after trades/liquidity changes; keepers keep it current. function syncMarket(address market) external { bytes32 marketId = keccak256(abi.encodePacked(market)); if (!markets[marketId].registered) revert NotRegistered(); _sync(marketId); } /// @notice Deregister a resolved market (exposure → 0). Timelock-gated. function retireMarket(address market) external { if (msg.sender != timelock) revert NotTimelock(); bytes32 marketId = keccak256(abi.encodePacked(market)); if (!markets[marketId].registered) revert NotRegistered(); markets[marketId].registered = false; guard.reportExposure(marketId, markets[marketId].groupId, 0, 0); emit MarketSynced(marketId, 0, 0); } // -- exposure math (WP §4.1) ------------------------------------------------ function _sync(bytes32 marketId) internal { MarketInfo memory info = markets[marketId]; IRainMarket m = info.market; uint256 n = m.numberOfOptions(); uint256 funds = m.totalMarketFunds(); // base-token units uint256 scale = m.baseTokenDecimals(); // scale factor, e.g. 1e6 // worst-case net delta across mutually exclusive outcomes (1-indexed options) uint256 worst = 0; for (uint256 o = 1; o <= n; o++) { uint256 payout = m.optionWinningPoolShare(o); if (payout > funds) { uint256 cost = payout - funds; if (cost > worst) worst = cost; } } // resolution risk (WP §4.1): worst admissible resolution incl. void/refund-all. // A refund returns collected funds, netting to <= 0 for the protocol, so the // clean-winner worst case dominates; kept explicit for auditability. // scale to the protocol's underwritten share and normalize to WAD uint256 normalWad = worst * info.protocolShareBps / BPS * 1e18 / scale; uint256 stressWad = normalWad * stressMultiplierBps / BPS; guard.reportExposure(marketId, info.groupId, normalWad, stressWad); emit MarketSynced(marketId, normalWad, stressWad); } /// @notice Dashboard helper: current computed exposure for a market (view). function previewExposure(address market) external view returns (uint256 normalWad, uint256 stressWad) { bytes32 marketId = keccak256(abi.encodePacked(market)); MarketInfo memory info = markets[marketId]; IRainMarket m = info.market; uint256 n = m.numberOfOptions(); uint256 funds = m.totalMarketFunds(); uint256 scale = m.baseTokenDecimals(); uint256 worst = 0; for (uint256 o = 1; o <= n; o++) { uint256 payout = m.optionWinningPoolShare(o); if (payout > funds && payout - funds > worst) worst = payout - funds; } normalWad = worst * info.protocolShareBps / BPS * 1e18 / scale; stressWad = normalWad * stressMultiplierBps / BPS; } }