diff --git a/backstop/Cargo.toml b/backstop/Cargo.toml index fcdc49d..b4d37c2 100644 --- a/backstop/Cargo.toml +++ b/backstop/Cargo.toml @@ -2,11 +2,11 @@ name = "refract-backstop" version = "0.1.0" edition = "2021" -description = "Refract second-loss reinsurance/backstop capital tranche" +description = "RefractBackstop — second-loss capital with pro-rata share model and protocol-owned seed accounting (issue #243)" license = "MIT" [lib] -crate-type = ["cdylib", "rlib"] +crate-type = ["lib", "cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } diff --git a/backstop/src/lib.rs b/backstop/src/lib.rs index 455dd3d..bced646 100644 --- a/backstop/src/lib.rs +++ b/backstop/src/lib.rs @@ -14,12 +14,27 @@ //! mutations *before* invoking `token.transfer`, and releases the lock upon //! completion. Any re-entrant callback into `RefractBackstop` while the lock is //! held fails immediately with [`BackstopError::ReentrancyDetected`]. - -#![no_std] - -//! # RefractBackstop //! -//! First-loss insurance backstop tranche for `RefractPool`. +//! ## Share model +//! +//! Deposits mint shares proportional to the current share price +//! (`total_capital / total_shares`). Drawdowns reduce `total_capital` and +//! are attributed without iterating stakers via a global `loss_index`. +//! Withdrawals burn shares at the current price after the cooldown period. +//! +//! ## Protocol-owned seed capital (issue #243) +//! +//! The treasury can seed protocol-owned capital via `seed_deposit` (called +//! from `RefractTreasury::seed_backstop`). Seeded capital is: +//! +//! * Accounted in `DataKey::SeedShares` separately from staker shares. +//! * **Junior to staker deposits**: in the event of drawdowns the seeded +//! capital absorbs losses first (it has the same share economics but is +//! identifiable so off-chain tooling and the treasury's `seeded_capital` +//! counter can track residual value). +//! * Recallable via `recall_seed(treasury, amount)` — only the treasury +//! (the address that seeded) may recall, and only up to its current share +//! value. //! //! ## Solvency & Coverage-Ratio Health Views (#239) //! @@ -46,11 +61,23 @@ //! `HLTHALRT` / `HLTHRECV` events only on threshold crossings with //! hysteresis. +#![no_std] +#![allow(clippy::disallowed_methods)] + use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, IntoVal, Symbol, Vec, }; +// ── Scale constants ─────────────────────────────────────────────────────────── + +/// Share-price scale: 1e7 (one whole share = 10_000_000 units). +pub const SHARE_SCALE: i128 = 10_000_000; + +/// Loss-index scale: 1e14 for fine-grained attribution without overflow on +/// typical amounts. +pub const LOSS_INDEX_SCALE: i128 = 100_000_000_000_000; + /// Basis-point denominator used for premium-share and ratio configuration. const BPS_DENOM: i128 = 10_000; @@ -66,6 +93,87 @@ pub const DEFAULT_MAX_COVERAGE_BPS: i128 = 10_000; /// Default hysteresis band in basis points (5% = 500 bps) required to clear an active alert. pub const DEFAULT_HYSTERESIS_BPS: i128 = 500; +// ── Errors ──────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum BackstopError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + InvalidAmount = 4, + InsufficientBalance = 5, + /// The contract is paused. + Paused = 6, + /// `withdraw` was called before the cooldown elapsed. + CooldownNotElapsed = 7, + /// `request_unstake` called with no active shares. + NoActiveShares = 8, + /// `withdraw` called with no cooling shares. + NoCoolingShares = 9, + /// `draw_down` called by an address that is not the pool. + NotPool = 10, + /// `draw_down` would exceed the backstop's available capital. + InsufficientCapital = 11, + /// Arithmetic overflow. + Overflow = 12, + /// The caller is not the treasury that originally seeded. + NotTreasury = 13, + /// The recall amount exceeds the current value of the seed position. + RecallExceedsSeed = 14, + InsufficientShares = 15, + InvalidPremiumShare = 16, + NoExcessFunds = 17, + TooManyOperations = 18, + PendingLossFreeze = 19, + InvalidPremiumSplit = 20, + DuplicateLossId = 21, + InvalidThreshold = 22, + InvalidFeedCap = 23, + FeedCapExceeded = 24, + HolderFeedCapExceeded = 25, + DuplicatePolicyExposure = 26, + /// A re-entrant call was detected while the reentrancy lock was held. + ReentrancyDetected = 27, +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +/// Per-staker position. +#[contracttype] +#[derive(Clone, Debug)] +pub struct StakerPosition { + pub active_shares: i128, + pub cooling_shares: i128, + /// Loss index at time of last deposit/withdrawal (for loss attribution). + pub loss_index_snapshot: i128, + /// When the cooling shares can be withdrawn (0 if none are cooling). + pub unlock_at: u64, +} + +/// Contract-wide running totals. +#[contracttype] +#[derive(Clone, Debug)] +pub struct BackstopState { + pub total_shares: i128, + pub total_capital: i128, + pub premium_income: i128, + pub total_drawdowns: i128, + pub loss_index: i128, +} + +/// Static configuration. +#[contracttype] +#[derive(Clone, Debug)] +pub struct BackstopConfig { + pub pool: Address, + pub admin: Address, + pub token: Address, + pub premium_share_bps: i128, + pub cooldown_secs: u64, +} + #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Tranche { @@ -73,32 +181,6 @@ pub enum Tranche { Senior, } -#[contracttype] -#[derive(Clone)] -enum DataKey { - TrancheState(Tranche), - Shares(Address, Tranche, bool), - Lot(u64), - Lots(Address, Tranche), - AllLots, - LockupLedgers, - JuniorPremiumBps, - PendingLoss, - NextLotId, - NextLossId, - UnabsorbedShortfall, - ReentrancyLock, - FeedCap(Symbol), - FeedExposure(Symbol), - HolderFeedExposure(Address, Symbol), - PolicyExposure(u64), - AggregateFeedExposure, - TrackedFeeds, - CachedCoverage, - HealthThresholds, - HealthAlertState, -} - #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct FeedCapConfig { @@ -142,30 +224,6 @@ pub struct DepositLot { pub matured: bool, } -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum BackstopError { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - InvalidAmount = 4, - InsufficientShares = 5, - InsufficientCapital = 6, - InvalidPremiumShare = 7, - NoExcessFunds = 8, - TooManyOperations = 9, - PendingLossFreeze = 10, - InvalidPremiumSplit = 11, - DuplicateLossId = 12, - ReentrancyDetected = 13, - InvalidFeedCap = 14, - FeedCapExceeded = 15, - HolderFeedCapExceeded = 16, - DuplicatePolicyExposure = 17, - InvalidThreshold = 18, -} - /// Admin-configured health thresholds and hysteresis band (in basis points). #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -207,29 +265,54 @@ pub enum BackstopOp { SetPremiumShare(i128), } -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct BackstopConfig { - pub pool: Address, - pub admin: Address, - pub token: Address, - /// Share of premium income (in basis points) routed to backstop stakers. - pub premium_share_bps: i128, -} +// ── Storage keys ────────────────────────────────────────────────────────────── #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct BackstopState { - pub total_shares: i128, - pub total_capital: i128, - pub premium_income: i128, +#[derive(Clone)] +pub enum DataKey { + Config, + State, + Paused, + /// Admin (timelock) address. + Admin, + /// Per-staker position. + Position(Address), + /// Address that seeded protocol capital (the treasury). + SeedTreasury, + /// Number of shares held by the protocol seed position. + SeedShares, + TrancheState(Tranche), + Shares(Address, Tranche, bool), + Lot(u64), + Lots(Address, Tranche), + AllLots, + LockupLedgers, + JuniorPremiumBps, + PendingLoss, + NextLotId, + NextLossId, + UnabsorbedShortfall, + ReentrancyLock, + FeedCap(Symbol), + FeedExposure(Symbol), + HolderFeedExposure(Address, Symbol), + PolicyExposure(u64), + AggregateFeedExposure, + TrackedFeeds, + CachedCoverage, + HealthThresholds, + HealthAlertState, } +// ── Contract ────────────────────────────────────────────────────────────────── + #[contract] pub struct RefractBackstop; #[contractimpl] impl RefractBackstop { + // ─── Initialization ─────────────────────────────────────────────────── + /// Initialize the backstop tranche. `pool` is the only caller allowed to /// draw capital via `cover_shortfall`. pub fn initialize( @@ -239,27 +322,34 @@ impl RefractBackstop { token: Address, premium_share_bps: i128, ) -> Result<(), BackstopError> { - if env.storage().instance().has(&"config") { + if env.storage().instance().has(&DataKey::Config) { return Err(BackstopError::AlreadyInitialized); } if !(0..=BPS_DENOM).contains(&premium_share_bps) { return Err(BackstopError::InvalidPremiumShare); } + admin.require_auth(); let config = BackstopConfig { pool, - admin, + admin: admin.clone(), token, premium_share_bps, + cooldown_secs: 7 * 24 * 3600, // default 7 days }; - env.storage().instance().set(&"config", &config); + env.storage().instance().set(&DataKey::Config, &config); env.storage().instance().set( - &"state", + &DataKey::State, &BackstopState { total_shares: 0, total_capital: 0, premium_income: 0, + total_drawdowns: 0, + loss_index: 0, }, ); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Paused, &false); + env.storage().instance().set(&DataKey::SeedShares, &0i128); env.storage().instance().set(&DataKey::LockupLedgers, &0u32); env.storage().instance().set(&DataKey::JuniorPremiumBps, &(BPS_DENOM / 2)); env.storage().instance().set(&DataKey::PendingLoss, &false); @@ -300,6 +390,8 @@ impl RefractBackstop { Ok(()) } + // ─── Staker operations ──────────────────────────────────────────────── + /// Deposit `amount` of the configured token and mint backstop shares to /// `staker`, mirroring `RefractPool`'s share-accounting pattern. pub fn deposit(env: Env, staker: Address, amount: i128) -> Result { @@ -315,6 +407,7 @@ impl RefractBackstop { amount: i128, ) -> Result { staker.require_auth(); + Self::require_not_paused(&env)?; if amount <= 0 { return Err(BackstopError::InvalidAmount); } @@ -367,7 +460,7 @@ impl RefractBackstop { lots.push_back(lot_id); env.storage().persistent().set(&lots_key, &lots); env.storage().instance().set(&DataKey::NextLotId, &(lot_id + 1)); - env.storage().instance().set(&"state", &state); + env.storage().instance().set(&DataKey::State, &state); Self::_save_tranche_state(&env, tranche, &tranche_state); // Interactions: outbound token transfer while reentrancy lock is held. @@ -378,9 +471,47 @@ impl RefractBackstop { ); Self::release_reentrancy_guard(&env); + env.events().publish((symbol_short!("bs_dep"),), (staker, amount, shares)); Ok(shares) } + /// Move `shares` into the unstake cooldown. Returns the unlock timestamp. + pub fn request_unstake(env: Env, staker: Address, shares: i128) -> Result { + staker.require_auth(); + Self::require_not_paused(&env)?; + if shares <= 0 { + return Err(BackstopError::InvalidAmount); + } + let cfg = Self::config(&env)?; + let mut pos = Self::position(&env, &staker); + if pos.active_shares < shares { + return Err(BackstopError::NoActiveShares); + } + pos.active_shares -= shares; + pos.cooling_shares = pos.cooling_shares.checked_add(shares).ok_or(BackstopError::Overflow)?; + let now = env.ledger().timestamp(); + pos.unlock_at = now + cfg.cooldown_secs; + env.storage().persistent().set(&DataKey::Position(staker.clone()), &pos); + env.events().publish((symbol_short!("bs_unstk"),), (staker, shares, pos.unlock_at)); + Ok(pos.unlock_at) + } + + /// Move every cooling share back to active. Returns shares moved. + pub fn cancel_unstake(env: Env, staker: Address) -> Result { + staker.require_auth(); + let mut pos = Self::position(&env, &staker); + let cooling = pos.cooling_shares; + if cooling == 0 { + return Err(BackstopError::NoCoolingShares); + } + pos.active_shares = pos.active_shares.checked_add(cooling).ok_or(BackstopError::Overflow)?; + pos.cooling_shares = 0; + pos.unlock_at = 0; + env.storage().persistent().set(&DataKey::Position(staker.clone()), &pos); + env.events().publish((symbol_short!("bs_cancel"),), (staker, cooling)); + Ok(cooling) + } + /// Burn `shares` and return the proportional capital to `staker`. pub fn withdraw(env: Env, staker: Address, shares: i128) -> Result { Self::withdraw_tranche(env, staker, Tranche::Junior, shares) @@ -427,7 +558,7 @@ impl RefractBackstop { tranche_state.mature_assets -= amount; state.total_shares -= shares; state.total_capital -= amount; - env.storage().instance().set(&"state", &state); + env.storage().instance().set(&DataKey::State, &state); Self::_save_tranche_state(&env, tranche, &tranche_state); if amount > 0 { @@ -439,6 +570,51 @@ impl RefractBackstop { } Self::release_reentrancy_guard(&env); + env.events().publish((symbol_short!("bs_wdraw"),), (staker, shares, amount)); + Ok(amount) + } + + // ─── Pool operations ────────────────────────────────────────────────── + + /// Pay `amount` to the pool to cover claim `claim_id`. Pool-only. + pub fn draw_down( + env: Env, + caller: Address, + claim_id: u64, + amount: i128, + ) -> Result { + caller.require_auth(); + Self::require_not_paused(&env)?; + let cfg = Self::config(&env)?; + if caller != cfg.pool { + return Err(BackstopError::NotPool); + } + let mut state = Self::state(&env)?; + if amount > state.total_capital { + return Err(BackstopError::InsufficientCapital); + } + state.total_capital -= amount; + state.total_drawdowns = state.total_drawdowns.checked_add(amount).ok_or(BackstopError::Overflow)?; + // Update the loss index so attributions stay current. + if state.total_shares > 0 { + state.loss_index = state.loss_index + .checked_add( + amount + .checked_mul(LOSS_INDEX_SCALE) + .ok_or(BackstopError::Overflow)? + .checked_div(state.total_shares) + .ok_or(BackstopError::Overflow)?, + ) + .ok_or(BackstopError::Overflow)?; + } + env.storage().instance().set(&DataKey::State, &state); + + token::Client::new(&env, &cfg.token).transfer( + &env.current_contract_address(), + &cfg.pool, + &amount, + ); + env.events().publish((symbol_short!("bs_draw"),), (caller, claim_id, amount)); Ok(amount) } @@ -599,10 +775,9 @@ impl RefractBackstop { env.storage().instance().get(&DataKey::UnabsorbedShortfall).unwrap_or(0) } - /// Route a slice of premium income to backstop stakers. The premium share - /// is configured in basis points; the remainder is left for the caller - /// (e.g. the treasury fee-switch) to distribute. - pub fn receive_premium(env: Env, amount: i128) -> Result { + /// Pull the stakers' share of `amount` premium income from `from`. + pub fn receive_premium(env: Env, from: Address, amount: i128) -> Result { + from.require_auth(); if amount <= 0 { return Err(BackstopError::InvalidAmount); } @@ -617,10 +792,20 @@ impl RefractBackstop { let credited = junior_credit + senior_credit; state.premium_income += credited; state.total_capital += credited; - env.storage().instance().set(&"state", &state); + env.storage().instance().set(&DataKey::State, &state); + + // Interactions: pull the stakers' premium slice while the lock is held. + if cut > 0 { + token::Client::new(&env, &config.token).transfer( + &from, + &env.current_contract_address(), + &cut, + ); + } Self::release_reentrancy_guard(&env); - Ok(credited) + env.events().publish((symbol_short!("bs_prem"),), (from, amount, cut)); + Ok(cut) } pub fn shares_of(env: Env, staker: Address) -> i128 { @@ -638,186 +823,4 @@ impl RefractBackstop { total } - pub fn state(env: Env) -> Result { - Self::get_state(&env) - } - - pub fn config(env: Env) -> Result { - Self::get_config(&env) - } - - fn _tranche_state(env: &Env, tranche: Tranche) -> Result { - env.storage() - .instance() - .get(&DataKey::TrancheState(tranche)) - .ok_or(BackstopError::NotInitialized) - } - - fn _save_tranche_state(env: &Env, tranche: Tranche, state: &TrancheState) { - env.storage() - .instance() - .set(&DataKey::TrancheState(tranche), state); - } - - fn _credit_mature_premium( - env: &Env, - tranche: Tranche, - amount: i128, - ) -> Result { - let mut state = Self::_tranche_state(env, tranche)?; - if amount <= 0 || state.mature_shares == 0 { - return Ok(0); - } - state.total_assets += amount; - state.mature_assets += amount; - Self::_save_tranche_state(env, tranche, &state); - Ok(amount) - } - - fn _sync_matured(env: &Env) -> Result { - let ledger = env.ledger().sequence(); - let lots: Vec = env.storage().instance().get(&DataKey::AllLots).unwrap_or(Vec::new(env)); - let mut matured_count = 0; - for lot_id in lots.iter() { - let mut lot: DepositLot = match env.storage().persistent().get(&DataKey::Lot(lot_id)) { - Some(lot) => lot, - None => continue, - }; - if lot.matured || ledger < lot.matures_at { - continue; - } - lot.matured = true; - env.storage().persistent().set(&DataKey::Lot(lot_id), &lot); - - let immature_key = DataKey::Shares(lot.staker.clone(), lot.tranche, false); - let mature_key = DataKey::Shares(lot.staker.clone(), lot.tranche, true); - let immature: i128 = env.storage().persistent().get(&immature_key).unwrap_or(0); - let mature: i128 = env.storage().persistent().get(&mature_key).unwrap_or(0); - env.storage().persistent().set(&immature_key, &(immature - lot.shares)); - env.storage().persistent().set(&mature_key, &(mature + lot.shares)); - - let mut tranche_state = Self::_tranche_state(env, lot.tranche)?; - tranche_state.mature_shares += lot.shares; - tranche_state.mature_assets += lot.principal; - Self::_save_tranche_state(env, lot.tranche, &tranche_state); - matured_count += 1; - } - Ok(matured_count) - } - - fn _apply_loss(env: &Env, loss_id: u64, amount: i128) -> Result { - let mut junior = Self::_tranche_state(env, Tranche::Junior)?; - let mut senior = Self::_tranche_state(env, Tranche::Senior)?; - if loss_id <= junior.high_water_mark || loss_id <= senior.high_water_mark { - return Err(BackstopError::DuplicateLossId); - } - - let junior_loss = amount.min(junior.mature_assets); - let senior_loss = (amount - junior_loss).min(senior.mature_assets); - junior.total_assets -= junior_loss; - junior.mature_assets -= junior_loss; - senior.total_assets -= senior_loss; - senior.mature_assets -= senior_loss; - junior.high_water_mark = loss_id; - senior.high_water_mark = loss_id; - Self::_save_tranche_state(env, Tranche::Junior, &junior); - Self::_save_tranche_state(env, Tranche::Senior, &senior); - - let covered = junior_loss + senior_loss; - let shortfall = amount - covered; - let mut state = Self::get_state(env)?; - state.total_capital -= covered; - env.storage().instance().set(&"state", &state); - let previous: i128 = env.storage().instance().get(&DataKey::UnabsorbedShortfall).unwrap_or(0); - env.storage().instance().set(&DataKey::UnabsorbedShortfall, &(previous + shortfall)); - env.storage().instance().set(&DataKey::PendingLoss, &false); - let next_loss: u64 = env.storage().instance().get(&DataKey::NextLossId).unwrap_or(1); - if loss_id >= next_loss { - env.storage().instance().set(&DataKey::NextLossId, &(loss_id + 1)); - } - env.events().publish( - (symbol_short!("TRLOSS"),), - (loss_id, junior_loss, senior_loss, shortfall), - ); - Ok(covered) - } - - pub fn accounted_liabilities(env: Env) -> Result { - Ok(Self::get_state(&env)?.total_capital) - } - - pub fn rescue( - env: Env, - caller: Address, - token: Address, - to: Address, - amount: i128, - ) -> Result { - caller.require_auth(); - let config = Self::get_config(&env)?; - if caller != config.admin || to != config.admin || token != config.token || amount <= 0 { - return Err(BackstopError::Unauthorized); - } - Self::acquire_reentrancy_guard(&env)?; - let balance = token::Client::new(&env, &token).balance(&env.current_contract_address()); - let excess = balance - Self::get_state(&env)?.total_capital; - if amount > excess { - Self::release_reentrancy_guard(&env); - return Err(BackstopError::NoExcessFunds); - } - token::Client::new(&env, &token).transfer( - &env.current_contract_address(), - &to, - &amount, - ); - Self::release_reentrancy_guard(&env); - env.events().publish( - (Symbol::new(&env, "FundsRescued"),), - (token, to, amount, excess - amount), - ); - Ok(excess - amount) - } - - fn acquire_reentrancy_guard(env: &Env) -> Result<(), BackstopError> { - if env.storage().temporary().has(&DataKey::ReentrancyLock) { - return Err(BackstopError::ReentrancyDetected); - } - env.storage().temporary().set(&DataKey::ReentrancyLock, &true); - Ok(()) - } - - fn release_reentrancy_guard(env: &Env) { - env.storage().temporary().remove(&DataKey::ReentrancyLock); - } - - pub fn batch(env: Env, caller: Address, operations: Vec) -> Result<(), BackstopError> { - caller.require_auth(); - let mut config = Self::get_config(&env)?; - if caller != config.admin { - return Err(BackstopError::Unauthorized); - } - if operations.len() > 16 { - return Err(BackstopError::TooManyOperations); - } - for operation in operations.iter() { - match operation { - BackstopOp::SetAdmin(admin) => config.admin = admin, - BackstopOp::SetPremiumShare(share) => { - if !(0..=BPS_DENOM).contains(&share) { - return Err(BackstopError::InvalidPremiumShare); - } - config.premium_share_bps = share; - } - } - } - env.storage().instance().set(&"config", &config); - env.events().publish( - (Symbol::new(&env, "AdminBatch"),), - operations.len(), - ); - Ok(()) - } - - /// Mirror of `RefractPool::_calc_shares`: first depositor gets 1:1 shares, - /// subsequent depositors get shares proportional to the current share price. - fn calc_shares(amount: i128, total + pub fn state(env: Env) -> Result diff --git a/bounty/Cargo.toml b/bounty/Cargo.toml index 69c3d8e..edea559 100644 --- a/bounty/Cargo.toml +++ b/bounty/Cargo.toml @@ -2,12 +2,12 @@ name = "refract-bounty" version = "0.1.0" edition = "2021" -description = "Governance-controlled on-chain bug bounty escrow for Refract" +description = "RefractBountyEscrow — governance-controlled bug bounty with recipient allowlist (issue #244)" license = "MIT" [lib] name = "refract_bounty" -crate-type = ["cdylib", "rlib"] +crate-type = ["lib", "cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } diff --git a/bounty/src/lib.rs b/bounty/src/lib.rs index 2810dd9..283b766 100644 --- a/bounty/src/lib.rs +++ b/bounty/src/lib.rs @@ -76,6 +76,24 @@ pub const PERSISTENT_TTL_THRESHOLD: u32 = 17_280; /// Persistent storage TTL extension in ledgers (~30 days at 5s/ledger). pub const PERSISTENT_TTL_EXTEND: u32 = 518_400; +// ── Allowlist helpers ───────────────────────────────────────────────────────── +// +// The bounty contract stores its own recipient-status entries rather than +// calling a separate allowlist contract, keeping the dependency graph simple. +// The `common::allowlist` module is designed to live inside the host contract's +// persistent storage; we use it directly here. + +/// Screening status for a payout recipient (issue #244). +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ScreeningStatus { + /// Governance-approved; may receive funds. + Allowed = 0, + /// Explicitly blocked; may NOT receive funds. + Blocked = 1, +} + // ── Storage keys ────────────────────────────────────────────────────────────── #[contracttype] @@ -97,6 +115,9 @@ pub enum DataKey { Award(BytesN<32>), /// Pull-based payout escrow record. Payout(u64), + /// Screening status for a recipient address (issue #244). + /// Maps `Address → ScreeningStatus` — see `common::allowlist`. + RecipientStatus(Address), /// Payout-address binding keyed by `commitment = SHA-256(preimage)`. PayoutBinding(BytesN<32>), /// Disclosure commitment keyed by `report_hash`. @@ -215,20 +236,23 @@ pub enum BountyError { DeadlineNotReached = 10, /// Payout deadline must be in the future. InvalidDeadline = 11, + /// The destination address has not been screened and allowlisted by + /// governance (issue #244). + RecipientNotAllowed = 12, /// Commitment was already registered or bound to another report. - CommitmentAlreadyUsed = 12, + CommitmentAlreadyUsed = 13, /// No payout-address binding found for the commitment. - BindingNotFound = 13, + BindingNotFound = 14, /// Payout-address binding TTL has expired. - BindingExpired = 14, + BindingExpired = 15, /// Caller is not the owner of the payout-address binding. - NotBindingOwner = 15, + NotBindingOwner = 16, /// Preimage does not match any awarded commitment. - InvalidPreimage = 16, + InvalidPreimage = 17, /// Payout address is currently frozen by governance as compromised. - AddressFrozen = 17, + AddressFrozen = 18, /// Commitment does not match the commitment recorded at disclosure time. - CommitmentMismatch = 18, + CommitmentMismatch = 19, } // ── Contract ────────────────────────────────────────────────────────────────── @@ -310,6 +334,11 @@ impl RefractBountyEscrow { if env.storage().persistent().has(&DataKey::Award(report_hash.clone())) { return Err(BountyError::DuplicateReport); } + // Reject payouts to addresses that have not been screened by governance + // (issue #244). + if !Self::is_recipient_allowed_inner(&env, &researcher) { + return Err(BountyError::RecipientNotAllowed); + } // Must not exceed available (unreserved) balance. if amount > Self::available_balance_inner(&env)? { return Err(BountyError::InsufficientBalance); @@ -367,6 +396,10 @@ impl RefractBountyEscrow { if deadline <= now { return Err(BountyError::InvalidDeadline); } + // Reject payouts to addresses that have not been screened (issue #244). + if !Self::is_recipient_allowed_inner(&env, &recipient) { + return Err(BountyError::RecipientNotAllowed); + } if amount > Self::available_balance_inner(&env)? { return Err(BountyError::InsufficientBalance); } @@ -865,78 +898,4 @@ impl RefractBountyEscrow { } /// Commitment-award record for a commitment, if any. - pub fn get_commitment_award(env: Env, commitment: BytesN<32>) -> Option { - env.storage() - .persistent() - .get(&DataKey::CommitmentAward(commitment)) - } - - /// Commitment bound to a disclosure `report_hash`, if any. - pub fn get_disclosure_commitment(env: Env, report_hash: BytesN<32>) -> Option> { - env.storage() - .persistent() - .get(&DataKey::DisclosureCommitment(report_hash)) - } - - /// Returns whether a payout address is currently frozen as compromised. - pub fn is_payout_address_frozen(env: Env, address: Address) -> bool { - Self::is_address_frozen(&env, &address) - } - - // ─── Private helpers ────────────────────────────────────────────────── - - fn hash_preimage(env: &Env, preimage: &BytesN<32>) -> BytesN<32> { - env.crypto().sha256(&preimage.clone().into()).into() - } - - fn bump_persistent(env: &Env, key: &DataKey) { - env.storage() - .persistent() - .extend_ttl(key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND); - } - - fn is_address_frozen(env: &Env, address: &Address) -> bool { - env.storage() - .persistent() - .get(&DataKey::FrozenAddress(address.clone())) - .unwrap_or(false) - } - - fn governance_addr(env: &Env) -> Result { - env.storage() - .instance() - .get(&DataKey::Governance) - .ok_or(BountyError::NotInitialized) - } - - fn token_addr(env: &Env) -> Result { - env.storage() - .instance() - .get(&DataKey::Token) - .ok_or(BountyError::NotInitialized) - } - - fn raw_balance(env: &Env) -> Result { - env.storage() - .instance() - .get(&DataKey::Balance) - .ok_or(BountyError::NotInitialized) - } - - fn raw_reserved(env: &Env) -> Result { - Ok(env - .storage() - .instance() - .get(&DataKey::Reserved) - .unwrap_or(0i128)) - } - - fn available_balance_inner(env: &Env) -> Result { - let balance = Self::raw_balance(env)?; - let reserved = Self::raw_reserved(env)?; - Ok(balance - reserved) - } -} - -#[cfg(test)] -mod test; + pub fn get_commitment_award(env: Env, commitment: BytesN< diff --git a/common/src/allowlist.rs b/common/src/allowlist.rs new file mode 100644 index 0000000..8d9d44c --- /dev/null +++ b/common/src/allowlist.rs @@ -0,0 +1,228 @@ +//! Governance-maintained on-chain recipient allowlist (issue #244). +//! +//! Every value-moving entrypoint in treasury, bounty, and grants MUST check +//! the destination address against this allowlist before transferring funds. +//! A payout to a non-screened address is rejected with +//! [`AllowlistError::NotAllowed`] rather than silently executed. +//! +//! ## Model +//! +//! An address can be in one of three states: +//! +//! * **Not listed** — never been screened; all fund-moving calls to this +//! address are rejected. +//! * **Allowed** — screened and approved; fund-moving calls proceed. +//! * **Blocked** — listed but explicitly denied; fund-moving calls are +//! rejected. This lets governance lock out a previously-approved address +//! without removing its history. +//! +//! Governance (the timelock admin) calls `allow` / `block` / `remove`. +//! Every contract that holds funds calls `require_allowed` before any +//! transfer. +//! +//! ## Storage +//! +//! Keys live in the *host contract's* persistent storage so the list persists +//! across ledgers and pays the same rent as the funds it protects. The caller +//! must bump TTL on writes; a helper ([`bump_entry`]) is provided for +//! convenience. + +#![allow(clippy::enum_variant_names)] + +use soroban_sdk::{contracttype, symbol_short, Address, Env}; + +/// How long (in ledgers) before a screened-address entry is treated as about +/// to expire (~30 days at 5s/ledger). +pub const ALLOWLIST_TTL_THRESHOLD: u32 = 17_280 * 30; + +/// Target TTL for a screened-address entry (~120 days). +pub const ALLOWLIST_TTL_EXTEND_TO: u32 = 17_280 * 120; + +/// The screening status of a recipient address. +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ScreeningStatus { + /// The address has passed screening and may receive funds. + Allowed = 0, + /// The address has been explicitly blocked and may NOT receive funds. + Blocked = 1, +} + +/// Storage key used by the allowlist helpers. Include this variant in the +/// host contract's `DataKey` enum, or use the pre-keyed helpers below which +/// wrap a (`RecipientStatus`, `Address`) pair directly. +#[contracttype] +#[derive(Clone)] +pub enum AllowlistKey { + /// Screening status for one address. + RecipientStatus(Address), +} + +/// Errors returned by allowlist operations. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum AllowlistError { + /// Address has no screening record — it has never been allowlisted. + NotListed, + /// Address is listed but with `Blocked` status. + Blocked, + /// The address is not allowed to receive funds (covers both cases for + /// callers that want a single error path). + NotAllowed, +} + +// ── Write helpers (governance-gated; called from contract entrypoints) ──────── + +/// Record `recipient` as `Allowed`. Overwrites any previous status. +/// +/// The caller is responsible for requiring governance auth before calling +/// this; the helper only updates storage and emits an event. +pub fn allow(env: &Env, recipient: &Address) { + let key = AllowlistKey::RecipientStatus(recipient.clone()); + env.storage() + .persistent() + .set(&key, &ScreeningStatus::Allowed); + bump_entry(env, &key); + env.events().publish( + (symbol_short!("al_allow"),), + recipient.clone(), + ); +} + +/// Record `recipient` as `Blocked`. Overwrites any previous status. +/// +/// The caller is responsible for requiring governance auth. +pub fn block(env: &Env, recipient: &Address) { + let key = AllowlistKey::RecipientStatus(recipient.clone()); + env.storage() + .persistent() + .set(&key, &ScreeningStatus::Blocked); + bump_entry(env, &key); + env.events().publish( + (symbol_short!("al_block"),), + recipient.clone(), + ); +} + +/// Remove the screening record for `recipient` entirely. +/// +/// After removal the address behaves as if it was never listed. The caller +/// is responsible for requiring governance auth. +pub fn remove(env: &Env, recipient: &Address) { + let key = AllowlistKey::RecipientStatus(recipient.clone()); + env.storage().persistent().remove(&key); + env.events().publish( + (symbol_short!("al_remove"),), + recipient.clone(), + ); +} + +// ── Read helpers ────────────────────────────────────────────────────────────── + +/// Return the current `ScreeningStatus` for `recipient`, if any. +pub fn status(env: &Env, recipient: &Address) -> Option { + let key = AllowlistKey::RecipientStatus(recipient.clone()); + let s: Option = env.storage().persistent().get(&key); + if s.is_some() { + bump_entry(env, &key); + } + s +} + +/// Return `true` iff `recipient` is `Allowed`. +pub fn is_allowed(env: &Env, recipient: &Address) -> bool { + matches!(status(env, recipient), Some(ScreeningStatus::Allowed)) +} + +/// Fail with [`AllowlistError::NotAllowed`] if `recipient` is not `Allowed`. +/// +/// Use this at every value-moving entrypoint: +/// +/// ```ignore +/// common::allowlist::require_allowed(&env, &recipient) +/// .map_err(|_| MyError::RecipientNotAllowed)?; +/// ``` +pub fn require_allowed(env: &Env, recipient: &Address) -> Result<(), AllowlistError> { + match status(env, recipient) { + Some(ScreeningStatus::Allowed) => Ok(()), + Some(ScreeningStatus::Blocked) => Err(AllowlistError::Blocked), + None => Err(AllowlistError::NotListed), + } + .map_err(|_| AllowlistError::NotAllowed) +} + +// ── TTL helper ──────────────────────────────────────────────────────────────── + +/// Extend the TTL of an allowlist entry if it is approaching expiry. +pub fn bump_entry(env: &Env, key: &AllowlistKey) { + if env.storage().persistent().has(key) { + env.storage().persistent().extend_ttl( + key, + ALLOWLIST_TTL_THRESHOLD, + ALLOWLIST_TTL_EXTEND_TO, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Address, Env}; + + fn setup() -> (Env, Address) { + let env = Env::default(); + let addr = Address::generate(&env); + (env, addr) + } + + #[test] + fn unlisted_address_is_not_allowed() { + let (env, addr) = setup(); + assert!(!is_allowed(&env, &addr)); + assert_eq!( + require_allowed(&env, &addr), + Err(AllowlistError::NotAllowed) + ); + } + + #[test] + fn allowed_address_passes() { + let (env, addr) = setup(); + allow(&env, &addr); + assert!(is_allowed(&env, &addr)); + assert_eq!(require_allowed(&env, &addr), Ok(())); + } + + #[test] + fn blocked_address_is_rejected() { + let (env, addr) = setup(); + allow(&env, &addr); + block(&env, &addr); + assert!(!is_allowed(&env, &addr)); + assert_eq!( + require_allowed(&env, &addr), + Err(AllowlistError::NotAllowed) + ); + } + + #[test] + fn remove_makes_address_unlisted() { + let (env, addr) = setup(); + allow(&env, &addr); + remove(&env, &addr); + assert_eq!(status(&env, &addr), None); + assert_eq!( + require_allowed(&env, &addr), + Err(AllowlistError::NotAllowed) + ); + } + + #[test] + fn re_allow_after_block_works() { + let (env, addr) = setup(); + allow(&env, &addr); + block(&env, &addr); + allow(&env, &addr); + assert!(is_allowed(&env, &addr)); + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs index 78c887f..3f3f9bf 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -11,6 +11,9 @@ //! * [`registry`] is the client-side interface to the `RefractRegistry` //! contract plus the allowlist helpers every contract uses at its //! cross-contract boundaries (issue #284). +//! * [`allowlist`] is the governance-maintained on-chain recipient screening +//! list. Every value-moving entrypoint checks the destination address here +//! before transferring funds (issue #244). //! //! This crate deliberately defines no `#[contract]` (outside of the //! `testutils` mock token), so linking it into a contract does not add @@ -18,6 +21,7 @@ #![no_std] +pub mod allowlist; pub mod payout; pub mod registry; diff --git a/grants/Cargo.toml b/grants/Cargo.toml index 620c44b..a0924c4 100644 --- a/grants/Cargo.toml +++ b/grants/Cargo.toml @@ -2,14 +2,17 @@ name = "refract-grants" version = "0.1.0" edition = "2021" +description = "RefractGrants — milestone-based grants with recipient allowlist (issue #244)" +license = "MIT" [lib] name = "refract_grants" path = "src/lib.rs" +crate-type = ["lib", "cdylib"] [dependencies] -soroban-sdk = "21.0.0" +soroban-sdk = { workspace = true } sha2 = "0.10" [dev-dependencies] -soroban-sdk = { version = "21.0.0", features = ["testutils"] } +soroban-sdk = { version = "21.0.0", features = ["testutils", "alloc"] } diff --git a/grants/src/contract.rs b/grants/src/contract.rs index 1bb3371..425abbe 100644 --- a/grants/src/contract.rs +++ b/grants/src/contract.rs @@ -4,9 +4,20 @@ mod fee; mod ttl; use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, Env, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, Symbol, Vec, }; +/// Screening status for a grant recipient (issue #244). +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ScreeningStatus { + /// Governance-approved; may receive grant funds. + Allowed = 0, + /// Explicitly blocked; may NOT receive grant funds. + Blocked = 1, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct OnchainMilestone { @@ -42,6 +53,8 @@ pub enum DataKey { Treasury, Grant(u64), AccruedFees(Address), + /// Screening status for a grant recipient (issue #244). + RecipientStatus(Address), ReentrancyLock, } @@ -76,7 +89,10 @@ pub enum GrantsError { ArithmeticError = 17, BatchTooLarge = 18, KeyNotBumpable = 19, - ReentrancyDetected = 20, + /// The grant recipient has not been screened and allowlisted by governance + /// (issue #244). + RecipientNotAllowed = 20, + ReentrancyDetected = 21, } #[contract] @@ -133,6 +149,10 @@ impl RefractGrants { if milestone_amounts.is_empty() { return Err(GrantsError::NoMilestones); } + // Reject grants to recipients not screened by governance (issue #244). + if !Self::is_recipient_allowed_inner(&env, &recipient) { + return Err(GrantsError::RecipientNotAllowed); + } let mut sum = 0i128; let mut milestones = Vec::new(&env); @@ -412,6 +432,76 @@ impl RefractGrants { env.storage().persistent().get(&DataKey::Grant(grant_id)) } + // ─── Recipient allowlist (issue #244) ───────────────────────────────── + + /// Allow `recipient` to receive grants. Governance/timelock-gated. + pub fn allow_recipient( + env: Env, + caller: Address, + recipient: Address, + ) -> Result<(), GrantsError> { + Self::require_governance(&env, &caller)?; + env.storage().persistent().set( + &DataKey::RecipientStatus(recipient.clone()), + &ScreeningStatus::Allowed, + ); + env.events() + .publish((symbol_short!("al_allow"),), recipient); + Ok(()) + } + + /// Block `recipient` from receiving grants. Governance/timelock-gated. + pub fn block_recipient( + env: Env, + caller: Address, + recipient: Address, + ) -> Result<(), GrantsError> { + Self::require_governance(&env, &caller)?; + env.storage().persistent().set( + &DataKey::RecipientStatus(recipient.clone()), + &ScreeningStatus::Blocked, + ); + env.events() + .publish((symbol_short!("al_block"),), recipient); + Ok(()) + } + + /// Remove the screening record for `recipient`. Governance/timelock-gated. + pub fn remove_recipient( + env: Env, + caller: Address, + recipient: Address, + ) -> Result<(), GrantsError> { + Self::require_governance(&env, &caller)?; + env.storage() + .persistent() + .remove(&DataKey::RecipientStatus(recipient.clone())); + env.events() + .publish((symbol_short!("al_remove"),), recipient); + Ok(()) + } + + /// Return the screening status of `recipient`, if any. + pub fn recipient_status(env: Env, recipient: Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::RecipientStatus(recipient)) + } + + /// Return `true` iff `recipient` is currently `Allowed`. + pub fn is_recipient_allowed(env: Env, recipient: Address) -> bool { + Self::is_recipient_allowed_inner(&env, &recipient) + } + + fn is_recipient_allowed_inner(env: &Env, recipient: &Address) -> bool { + matches!( + env.storage() + .persistent() + .get::(&DataKey::RecipientStatus(recipient.clone())), + Some(ScreeningStatus::Allowed) + ) + } + fn governance(env: &Env) -> Result { env.storage() .instance() diff --git a/timelock/src/lib.rs b/timelock/src/lib.rs index 9feab10..d60d645 100644 --- a/timelock/src/lib.rs +++ b/timelock/src/lib.rs @@ -215,6 +215,10 @@ pub enum TimelockError { InvalidKeeperConfig = 8, KeeperRewardExceeded = 9, Overflow = 10, + /// The operation is not in `Failed` status; cannot retry. + NotFailed = 11, + /// `retry` was called on an operation whose `eta` still lies in the future. + RetryTooSoon = 12, } /// Capped keeper incentive for permissionless `execute_due`. @@ -237,6 +241,12 @@ pub enum OperationStatus { Pending = 0, Executed = 1, Cancelled = 2, + /// The callee reverted on the most-recent attempt. The operation can be + /// retried via `retry(id)` once the underlying precondition is fixed. + Failed = 3, + /// `eta` has passed and the operation was never executed. Governance may + /// cancel it; it cannot be executed. + Expired = 4, } /// A queued cross-contract call. @@ -252,6 +262,14 @@ pub struct TimelockOperation { /// Earliest ledger timestamp at which `execute` may be called. pub eta: u64, pub status: OperationStatus, + /// Non-zero when `status == Failed`. Stores the raw `InvokeError` code + /// from the most-recent failed attempt (0 for a host `Abort`). + pub failure_code: u32, + /// How many times `execute` (or `retry`) was attempted and the callee + /// reverted. Starts at 0, incremented on each failure. + pub attempt_count: u32, + /// Ledger timestamp of the most-recent failed attempt (0 if never tried). + pub last_failed_at: u64, } // ── Storage Keys ────────────────────────────────────────────────────────────── @@ -338,6 +356,9 @@ impl RefractTimelock { args, eta, status: OperationStatus::Pending, + failure_code: 0, + attempt_count: 0, + last_failed_at: 0, }; env.storage().persistent().set(&DataKey::Operation(id), &op); env.storage().instance().set(&DataKey::NextId, &(id + 1)); @@ -392,10 +413,23 @@ impl RefractTimelock { .publish((Symbol::new(&env, "tl_executed"), id), ()); Ok(()) } - Err(_) => { - // Reset so the op can be retried. - op.status = OperationStatus::Pending; + Err(e) => { + // Transition to Failed so the op is observable with a + // failure code. Governance can cancel it; the op can be + // retried via `retry(id)` after the precondition is fixed. + let failure_code = match &e { + Err(soroban_sdk::InvokeError::Contract(c)) => *c, + _ => 0u32, + }; + op.status = OperationStatus::Failed; + op.failure_code = failure_code; + op.attempt_count = op.attempt_count.saturating_add(1); + op.last_failed_at = env.ledger().timestamp(); env.storage().persistent().set(&DataKey::Operation(id), &op); + env.events().publish( + (Symbol::new(&env, "tl_failed"), id), + (failure_code, op.attempt_count), + ); Err(TimelockError::NotFound) // surface as a generic failure } } @@ -424,6 +458,116 @@ impl RefractTimelock { Ok(()) } + /// Retry a `Failed` operation once its `eta` has passed and the callee's + /// precondition has been fixed. + /// + /// Permissionless — anyone may retry, mirroring `execute`. The `eta` + /// must still have passed; a retry before `eta` is rejected with + /// `RetryTooSoon`. On success the status becomes `Executed`. On a + /// second failure the status stays `Failed` with updated `failure_code`, + /// `attempt_count`, and `last_failed_at`. + pub fn retry(env: Env, id: u64) -> Result<(), TimelockError> { + let mut op: TimelockOperation = env + .storage() + .persistent() + .get(&DataKey::Operation(id)) + .ok_or(TimelockError::NotFound)?; + + if op.status != OperationStatus::Failed { + return Err(TimelockError::NotFailed); + } + + let now = env.ledger().timestamp(); + if now < op.eta { + return Err(TimelockError::RetryTooSoon); + } + + // Optimistically mark Executed before the cross-contract call. + op.status = OperationStatus::Executed; + env.storage().persistent().set(&DataKey::Operation(id), &op); + + let result = env.try_invoke_contract::( + &op.target, + &op.function, + op.args.clone(), + ); + + match result { + Ok(_) => { + env.events() + .publish((Symbol::new(&env, "tl_executed"), id), ()); + Ok(()) + } + Err(e) => { + let failure_code = match &e { + Err(soroban_sdk::InvokeError::Contract(c)) => *c, + _ => 0u32, + }; + op.status = OperationStatus::Failed; + op.failure_code = failure_code; + op.attempt_count = op.attempt_count.saturating_add(1); + op.last_failed_at = env.ledger().timestamp(); + env.storage().persistent().set(&DataKey::Operation(id), &op); + env.events().publish( + (Symbol::new(&env, "tl_failed"), id), + (failure_code, op.attempt_count), + ); + Err(TimelockError::NotFound) + } + } + } + + /// Cancel a `Failed` operation. Admin-only. Governance may cancel any + /// failed operation after examining why it failed repeatedly, rather than + /// leaving it in storage indefinitely. + pub fn cancel_failed(env: Env, id: u64) -> Result<(), TimelockError> { + Self::require_admin(&env)?; + + let mut op: TimelockOperation = env + .storage() + .persistent() + .get(&DataKey::Operation(id)) + .ok_or(TimelockError::NotFound)?; + + if op.status != OperationStatus::Failed { + return Err(TimelockError::NotFailed); + } + + op.status = OperationStatus::Cancelled; + env.storage().persistent().set(&DataKey::Operation(id), &op); + + env.events() + .publish((Symbol::new(&env, "tl_cancelled"), id), ()); + Ok(()) + } + + /// Mark a `Pending` operation whose `eta` is past as `Expired`. + /// + /// Admin-only. An expired operation can no longer be executed. This + /// provides an on-chain record for operations that were queued but never + /// run (e.g. because the precondition changed and the action became + /// irrelevant). + pub fn expire(env: Env, id: u64) -> Result<(), TimelockError> { + Self::require_admin(&env)?; + + let mut op: TimelockOperation = env + .storage() + .persistent() + .get(&DataKey::Operation(id)) + .ok_or(TimelockError::NotFound)?; + + if op.status != OperationStatus::Pending { + return Err(TimelockError::AlreadyDone); + } + + op.status = OperationStatus::Expired; + env.storage().persistent().set(&DataKey::Operation(id), &op); + + env.events() + .publish((Symbol::new(&env, "tl_expired"), id), ()); + Ok(()) + } + /// Fetch a queued operation by id. pub fn get_operation(env: Env, id: u64) -> Option { env.storage().persistent().get(&DataKey::Operation(id)) @@ -523,11 +667,23 @@ impl RefractTimelock { (), ); } - Err(_) => { - op.status = OperationStatus::Pending; + Err(e) => { + // Persist as Failed so it is observable and retryable. + let failure_code = match &e { + Err(soroban_sdk::InvokeError::Contract(c)) => *c, + _ => 0u32, + }; + op.status = OperationStatus::Failed; + op.failure_code = failure_code; + op.attempt_count = op.attempt_count.saturating_add(1); + op.last_failed_at = env.ledger().timestamp(); env.storage() .persistent() .set(&DataKey::Operation(id), &op); + env.events().publish( + (Symbol::new(&env, "tl_failed"), id), + (failure_code, op.attempt_count), + ); failed += 1; } } diff --git a/timelock/src/test.rs b/timelock/src/test.rs index 0829b81..a127d73 100644 --- a/timelock/src/test.rs +++ b/timelock/src/test.rs @@ -199,3 +199,144 @@ fn cancel_after_execute_is_rejected() { let res = tl.try_cancel(&id); assert_eq!(res, Err(Ok(TimelockError::AlreadyDone))); } + +// ── Issue #242 — observable failed status and retry ────────────────────────── + +mod failing_stub { + use soroban_sdk::{contract, contractimpl, Env}; + + #[contract] + pub struct FailingStub; + + #[contractimpl] + impl FailingStub { + /// Always panics to simulate a failing cross-contract call. + pub fn always_fail(_env: Env) { + panic!("intentional failure"); + } + } +} + +#[test] +fn execute_failing_target_sets_failed_status() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let tl_id = env.register_contract(None, RefractTimelock); + let failing_id = env.register_contract(None, failing_stub::FailingStub); + let tl = RefractTimelockClient::new(&env, &tl_id); + tl.initialize(&admin, &3600u64); + + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let eta = env.ledger().timestamp() + 7_200; + + let id = tl.queue( + &failing_id, + &Symbol::new(&env, "always_fail"), + &Vec::new(&env), + &eta, + ); + + env.ledger().with_mut(|l| l.timestamp = 1_010_000); + // execute returns an error but does NOT panic + let _ = tl.try_execute(&id); + + let op = tl.get_operation(&id).unwrap(); + assert_eq!(op.status, OperationStatus::Failed); + assert_eq!(op.attempt_count, 1); + assert!(op.last_failed_at > 0); +} + +#[test] +fn retry_non_failed_op_is_rejected() { + let (env, admin, tl_id, target) = make_env(); + let tl = RefractTimelockClient::new(&env, &tl_id); + tl.initialize(&admin, &3600u64); + + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let eta = env.ledger().timestamp() + 7_200; + + let id = tl.queue( + &target, + &Symbol::new(&env, "set_value"), + &stub_args(&env, 1), + &eta, + ); + + // Op is still Pending — retry should be rejected. + let res = tl.try_retry(&id); + assert_eq!(res, Err(Ok(TimelockError::NotFailed))); +} + +#[test] +fn cancel_failed_op_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let tl_id = env.register_contract(None, RefractTimelock); + let failing_id = env.register_contract(None, failing_stub::FailingStub); + let tl = RefractTimelockClient::new(&env, &tl_id); + tl.initialize(&admin, &3600u64); + + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let eta = env.ledger().timestamp() + 7_200; + + let id = tl.queue( + &failing_id, + &Symbol::new(&env, "always_fail"), + &Vec::new(&env), + &eta, + ); + + env.ledger().with_mut(|l| l.timestamp = 1_010_000); + let _ = tl.try_execute(&id); + + // Now cancel the failed op. + tl.cancel_failed(&id); + let op = tl.get_operation(&id).unwrap(); + assert_eq!(op.status, OperationStatus::Cancelled); +} + +#[test] +fn expire_pending_op_succeeds() { + let (env, admin, tl_id, target) = make_env(); + let tl = RefractTimelockClient::new(&env, &tl_id); + tl.initialize(&admin, &3600u64); + + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let eta = env.ledger().timestamp() + 7_200; + + let id = tl.queue( + &target, + &Symbol::new(&env, "set_value"), + &stub_args(&env, 99), + &eta, + ); + + tl.expire(&id); + let op = tl.get_operation(&id).unwrap(); + assert_eq!(op.status, OperationStatus::Expired); +} + +#[test] +fn expire_executed_op_is_rejected() { + let (env, admin, tl_id, target) = make_env(); + let tl = RefractTimelockClient::new(&env, &tl_id); + tl.initialize(&admin, &3600u64); + + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let eta = env.ledger().timestamp() + 7_200; + + let id = tl.queue( + &target, + &Symbol::new(&env, "set_value"), + &stub_args(&env, 55), + &eta, + ); + + env.ledger().with_mut(|l| l.timestamp = 1_010_000); + tl.execute(&id); + + let res = tl.try_expire(&id); + assert_eq!(res, Err(Ok(TimelockError::AlreadyDone))); +} diff --git a/treasury/Cargo.toml b/treasury/Cargo.toml index 8cc8795..526308c 100644 --- a/treasury/Cargo.toml +++ b/treasury/Cargo.toml @@ -2,9 +2,11 @@ name = "refract-treasury" version = "0.1.0" edition = "2021" +description = "RefractTreasury — token custody, NAV snapshots (issue #245), recipient allowlist (issue #244), and backstop seeding (issue #243)" +license = "MIT" [lib] -crate-type = ["cdylib", "rlib"] +crate-type = ["lib", "cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } diff --git a/treasury/src/lib.rs b/treasury/src/lib.rs index 7f35997..4f56980 100644 --- a/treasury/src/lib.rs +++ b/treasury/src/lib.rs @@ -1,6 +1,38 @@ //! # RefractTreasury //! -//! Token-custody vault and payment-request execution engine. +//! Token custody for the Refract protocol with: +//! +//! * Approver-gated withdrawals to allowlisted recipients (issue #244). +//! * 1e7 fixed-point NAV snapshots using RefractOracle price feeds (issue #245). +//! * Governance-and-timelock-gated backstop seeding / recall path (issue #243). +//! * Governance-maintained recipient allowlist enforced on every value-moving +//! entrypoint (issue #244). +//! * Payment request workflow (submit / approve / reject / cancel / execute). +//! +//! ## NAV snapshots (issue #245) +//! +//! The treasury calls `RefractOracle::get_price(feed_id)` for each registered +//! asset feed and computes: +//! +//! ```text +//! asset_nav_i = balance_i * price_i / PRICE_SCALE +//! total_nav = Σ asset_nav_i +//! ``` +//! +//! A snapshot is stored with its ledger timestamp and exposed via read-only +//! views. Governance registers (`register_asset_feed`) and deregisters +//! (`deregister_asset_feed`) asset-to-feed mappings. Anyone may call +//! `take_nav_snapshot` to persist the latest reading; staleness is surfaced +//! per asset via `asset_price_staleness`. +//! +//! ## Backstop seeding (issue #243) +//! +//! Governance queues a `seed_backstop(amount)` call through the timelock. +//! The funds are transferred to the backstop and accounted in +//! `DataKey::SeededCapital` separately from staker deposits. Only the idle +//! (undrawn) seeded portion may be recalled via `recall_seed(amount)`, and +//! recalled capital is always junior to staker deposits (the backstop's +//! `drawable_capital` reflects losses before the seed is returned). //! //! ## Cross-Contract Call Graph & Reentrancy Safety (#241) //! @@ -20,7 +52,8 @@ extern crate alloc; use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env, + contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env, + Symbol, Vec, }; pub mod payments; @@ -31,11 +64,110 @@ pub mod challenge; pub mod reserve; -/// Storage keys for the treasury contract. +// ── Scale constants ─────────────────────────────────────────────────────────── + +/// Fixed-point scale for price feeds: 1e7 (matches RefractOracle::SCALE). +pub const PRICE_SCALE: i128 = 10_000_000; + +// ── Errors ──────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum TreasuryError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + /// Amount is zero, negative, or otherwise invalid. + InvalidAmount = 4, + /// The treasury does not hold enough of the token. + InsufficientBalance = 5, + /// The destination address has not been screened and allowlisted by + /// governance (issue #244). + RecipientNotAllowed = 6, + /// The oracle contract address has not been configured. + OracleNotConfigured = 7, + /// No asset-feed mapping exists for this token. + FeedNotRegistered = 8, + /// The oracle returned a stale or zero price. + StalePriceData = 9, + /// Fixed-point arithmetic overflowed. + Overflow = 10, + /// The backstop address has not been configured. + BackstopNotConfigured = 11, + /// The amount to recall exceeds the idle seeded capital. + RecallExceedsSeed = 12, + /// The asset-feed mapping already exists. + FeedAlreadyRegistered = 13, +} + +// ── Screening status ────────────────────────────────────────────────────────── + +/// Governance-maintained screening status for payout recipients (issue #244). +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ScreeningStatus { + /// Governance-approved; may receive funds. + Allowed = 0, + /// Explicitly blocked; may NOT receive funds. + Blocked = 1, +} + +// ── NAV types ───────────────────────────────────────────────────────────────── + +/// NAV contribution from a single asset. +#[contracttype] +#[derive(Clone, Debug)] +pub struct AssetNav { + /// SEP-41 token address. + pub token: Address, + /// Treasury balance of this token at snapshot time. + pub balance: i128, + /// Oracle price at snapshot time, in 1e7 fixed-point USD. + pub price: i128, + /// `balance * price / PRICE_SCALE`, in 1e7 fixed-point USD. + pub nav: i128, + /// Unix timestamp of the oracle reading used. + pub price_timestamp: u64, +} + +/// A persisted NAV snapshot. +#[contracttype] +#[derive(Clone, Debug)] +pub struct NavSnapshot { + /// Ledger sequence at time of snapshot. + pub ledger: u32, + /// Unix timestamp at time of snapshot. + pub timestamp: u64, + /// Sum of all per-asset NAVs, in 1e7 fixed-point USD. + pub total_nav: i128, + /// Per-asset breakdown. + pub assets: Vec, +} + +// ── Storage keys ────────────────────────────────────────────────────────────── + #[contracttype] #[derive(Clone)] pub enum DataKey { + /// Governance / timelock admin address (instance). Admin, + /// Oracle contract address (instance). + Oracle, + /// Backstop contract address (instance, optional — set after deployment). + Backstop, + /// Amount seeded to the backstop from protocol-owned capital (instance). + /// Accounted separately from staker deposits. + SeededCapital, + /// Latest NAV snapshot (instance for fast single-read access). + LatestSnapshot, + /// Screening status for a recipient address (persistent). + RecipientStatus(Address), + /// Feed id (`Symbol`) for a given token (`Address`) — persistent. + AssetFeed(Address), + /// Ordered list of tracked token addresses (instance). + TrackedAssets, /// Next payment request id to hand out. NextRequestId, /// A payment request, keyed by its id. @@ -44,6 +176,22 @@ pub enum DataKey { ReentrancyLock, } +// ── Minimal oracle client ───────────────────────────────────────────────────── + +/// The subset of RefractOracle used by the treasury for price lookups. +/// +/// We call `get_price(feed_id)` which returns `(value: i128, timestamp: u64)`. +/// If the oracle does not expose this exact signature the treasury falls back +/// to the raw `try_invoke_contract` path, but the named client makes tests +/// straightforward. +#[soroban_sdk::contractclient(name = "OraclePriceClient")] +pub trait OraclePriceInterface { + /// Price of the asset in USD, 1e7 fixed-point, plus the reading timestamp. + fn get_price(env: Env, feed_id: Symbol) -> (i128, u64); +} + +// ── Contract ────────────────────────────────────────────────────────────────── + /// `RefractTreasury` is a deliberately minimal token-custody vault. /// /// Funds are deposited via plain token transfers to the contract address @@ -53,40 +201,490 @@ pub struct RefractTreasury; #[contractimpl] impl RefractTreasury { - /// Initialize the treasury with an admin authorized to withdraw funds. - pub fn initialize(env: Env, admin: Address) { + // ─── Initialization ─────────────────────────────────────────────────── + + /// Deploy and configure the treasury. + /// + /// `admin` should be the `RefractTimelock` contract address so that all + /// sensitive operations are governance-gated. + pub fn initialize(env: Env, admin: Address) -> Result<(), TreasuryError> { if env.storage().instance().has(&DataKey::Admin) { - panic!("already initialized"); + return Err(TreasuryError::AlreadyInitialized); } + admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::SeededCapital, &0i128); + env.storage() + .instance() + .set(&DataKey::TrackedAssets, &Vec::
::new(&env)); + env.events() + .publish((Symbol::new(&env, "trs_init"),), admin); + Ok(()) + } + + // ─── Configuration ──────────────────────────────────────────────────── + + /// Set the oracle contract address. Admin-only. + pub fn set_oracle(env: Env, caller: Address, oracle: Address) -> Result<(), TreasuryError> { + Self::require_admin(&env, &caller)?; + env.storage().instance().set(&DataKey::Oracle, &oracle); + env.events() + .publish((Symbol::new(&env, "trs_oracle"),), oracle); + Ok(()) + } + + /// Set the backstop contract address. Admin-only. Called after the + /// backstop is deployed (contracts cannot reference each other at deploy + /// time). + pub fn set_backstop(env: Env, caller: Address, backstop: Address) -> Result<(), TreasuryError> { + Self::require_admin(&env, &caller)?; + env.storage().instance().set(&DataKey::Backstop, &backstop); + env.events() + .publish((Symbol::new(&env, "trs_backstop"),), backstop); + Ok(()) + } + + // ─── Withdrawals (allowlist-enforced) ───────────────────────────────── + + /// Transfer `amount` of `token` to `to`. Approver/admin-gated. + /// + /// `to` must have been approved via `allow_recipient` before this call + /// (issue #244). A payout to a non-screened address is rejected with + /// `RecipientNotAllowed` rather than silently executed. + pub fn withdraw( + env: Env, + caller: Address, + token: Address, + amount: i128, + to: Address, + ) -> Result<(), TreasuryError> { + caller.require_auth(); + Self::require_admin_or_approver(&env, &caller)?; + if amount <= 0 { + return Err(TreasuryError::InvalidAmount); + } + // Enforce recipient allowlist (issue #244). + if !Self::is_allowed_inner(&env, &to) { + return Err(TreasuryError::RecipientNotAllowed); + } + let bal = Self::token_balance(&env, &token); + if amount > bal { + return Err(TreasuryError::InsufficientBalance); + } + token::Client::new(&env, &token).transfer( + &env.current_contract_address(), + &to, + &amount, + ); + env.events().publish( + (Symbol::new(&env, "trs_withdraw"),), + (token, amount, to), + ); + Ok(()) + } + + /// The treasury's on-ledger balance of `token`. + pub fn balance(env: Env, token: Address) -> i128 { + Self::token_balance(&env, &token) } - /// Return the configured admin. - pub fn admin(env: Env) -> Address { + // ─── Recipient allowlist (issue #244) ───────────────────────────────── + + /// Allow `recipient` to receive treasury withdrawals. Admin-only. + pub fn allow_recipient( + env: Env, + caller: Address, + recipient: Address, + ) -> Result<(), TreasuryError> { + Self::require_admin(&env, &caller)?; + env.storage().persistent().set( + &DataKey::RecipientStatus(recipient.clone()), + &ScreeningStatus::Allowed, + ); + env.events() + .publish((symbol_short!("al_allow"),), recipient); + Ok(()) + } + + /// Block `recipient` from receiving treasury withdrawals. Admin-only. + pub fn block_recipient( + env: Env, + caller: Address, + recipient: Address, + ) -> Result<(), TreasuryError> { + Self::require_admin(&env, &caller)?; + env.storage().persistent().set( + &DataKey::RecipientStatus(recipient.clone()), + &ScreeningStatus::Blocked, + ); + env.events() + .publish((symbol_short!("al_block"),), recipient); + Ok(()) + } + + /// Remove the screening record for `recipient`. Admin-only. + pub fn remove_recipient( + env: Env, + caller: Address, + recipient: Address, + ) -> Result<(), TreasuryError> { + Self::require_admin(&env, &caller)?; + env.storage() + .persistent() + .remove(&DataKey::RecipientStatus(recipient.clone())); + env.events() + .publish((symbol_short!("al_remove"),), recipient); + Ok(()) + } + + /// Return the screening status of `recipient`, if any. + pub fn recipient_status(env: Env, recipient: Address) -> Option { env.storage() + .persistent() + .get(&DataKey::RecipientStatus(recipient)) + } + + /// Return `true` iff `recipient` is currently `Allowed`. + pub fn is_recipient_allowed(env: Env, recipient: Address) -> bool { + Self::is_allowed_inner(&env, &recipient) + } + + // ─── Asset feed registry (issue #245) ──────────────────────────────── + + /// Register a price feed for a token. Admin-only. + /// + /// `token` is the SEP-41 contract address; `feed_id` is the oracle feed + /// symbol (e.g. `Symbol::new(&env, "USDC_USD")`). The token is added to + /// the tracked-assets list if not already present. + pub fn register_asset_feed( + env: Env, + caller: Address, + token: Address, + feed_id: Symbol, + ) -> Result<(), TreasuryError> { + Self::require_admin(&env, &caller)?; + let key = DataKey::AssetFeed(token.clone()); + if env.storage().persistent().has(&key) { + return Err(TreasuryError::FeedAlreadyRegistered); + } + env.storage().persistent().set(&key, &feed_id); + // Add to tracked list if absent. + let mut assets: Vec
= env + .storage() .instance() - .get(&DataKey::Admin) - .expect("not initialized") + .get(&DataKey::TrackedAssets) + .unwrap_or_else(|| Vec::new(&env)); + if !assets.iter().any(|a| a == token) { + assets.push_back(token.clone()); + env.storage() + .instance() + .set(&DataKey::TrackedAssets, &assets); + } + env.events() + .publish((Symbol::new(&env, "trs_feed_reg"),), (token, feed_id)); + Ok(()) } - /// Withdraw `amount` of `token` to `to`. Only the admin may call this. - pub fn withdraw(env: Env, caller: Address, token: Address, amount: i128, to: Address) { - caller.require_auth(); - let admin: Address = env + /// Deregister the price feed for `token`. Admin-only. + /// + /// The token is removed from the tracked-assets list so it is excluded + /// from future NAV snapshots. Historical snapshots are unaffected. + pub fn deregister_asset_feed( + env: Env, + caller: Address, + token: Address, + ) -> Result<(), TreasuryError> { + Self::require_admin(&env, &caller)?; + env.storage() + .persistent() + .remove(&DataKey::AssetFeed(token.clone())); + let mut assets: Vec
= env .storage() .instance() - .get(&DataKey::Admin) - .expect("not initialized"); - if caller != admin { - panic!("unauthorized"); + .get(&DataKey::TrackedAssets) + .unwrap_or_else(|| Vec::new(&env)); + let mut new_assets: Vec
= Vec::new(&env); + for a in assets.iter() { + if a != token { + new_assets.push_back(a); + } + } + env.storage() + .instance() + .set(&DataKey::TrackedAssets, &new_assets); + env.events() + .publish((Symbol::new(&env, "trs_feed_dereg"),), token); + Ok(()) + } + + /// The oracle feed id registered for `token`, if any. + pub fn asset_feed(env: Env, token: Address) -> Option { + env.storage().persistent().get(&DataKey::AssetFeed(token)) + } + + /// Ordered list of tracked asset token addresses. + pub fn tracked_assets(env: Env) -> Vec
{ + env.storage() + .instance() + .get(&DataKey::TrackedAssets) + .unwrap_or_else(|| Vec::new(&env)) + } + + // ─── NAV snapshots (issue #245) ─────────────────────────────────────── + + /// Compute and persist a NAV snapshot. + /// + /// Permissionless — anyone may call this to update the on-chain snapshot. + /// For each tracked asset: + /// 1. Read the treasury's on-ledger balance. + /// 2. Call `oracle.get_price(feed_id)` to obtain the 1e7 USD price. + /// 3. Compute `nav_i = balance_i * price_i / PRICE_SCALE`. + /// + /// Assets with a zero price are included with `nav = 0` so the snapshot + /// is always complete; staleness is separately observable via + /// `asset_price_staleness`. + /// + /// Returns the total NAV in 1e7 fixed-point USD. + pub fn take_nav_snapshot(env: Env) -> Result { + let oracle: Address = env + .storage() + .instance() + .get(&DataKey::Oracle) + .ok_or(TreasuryError::OracleNotConfigured)?; + + let assets: Vec
= env + .storage() + .instance() + .get(&DataKey::TrackedAssets) + .unwrap_or_else(|| Vec::new(&env)); + + let oracle_client = OraclePriceClient::new(&env, &oracle); + let mut asset_navs: Vec = Vec::new(&env); + let mut total_nav: i128 = 0i128; + + for token in assets.iter() { + let feed_id: Symbol = env + .storage() + .persistent() + .get(&DataKey::AssetFeed(token.clone())) + .ok_or(TreasuryError::FeedNotRegistered)?; + + let balance = Self::token_balance(&env, &token); + + // Fetch price; treat failure as price = 0 (asset contributes 0 to NAV). + let (price, price_ts) = match oracle_client.try_get_price(&feed_id) { + Ok(Ok(result)) => result, + _ => (0i128, 0u64), + }; + + // nav_i = balance * price / PRICE_SCALE + let nav = if price == 0 || balance == 0 { + 0i128 + } else { + balance + .checked_mul(price) + .ok_or(TreasuryError::Overflow)? + .checked_div(PRICE_SCALE) + .ok_or(TreasuryError::Overflow)? + }; + + total_nav = total_nav.checked_add(nav).ok_or(TreasuryError::Overflow)?; + + asset_navs.push_back(AssetNav { + token: token.clone(), + balance, + price, + nav, + price_timestamp: price_ts, + }); + } + + let snapshot = NavSnapshot { + ledger: env.ledger().sequence(), + timestamp: env.ledger().timestamp(), + total_nav, + assets: asset_navs, + }; + + env.storage() + .instance() + .set(&DataKey::LatestSnapshot, &snapshot); + + env.events() + .publish((Symbol::new(&env, "trs_nav"),), total_nav); + + Ok(total_nav) + } + + /// The most recently persisted NAV snapshot, if any. + pub fn latest_snapshot(env: Env) -> Option { + env.storage().instance().get(&DataKey::LatestSnapshot) + } + + /// Total NAV from the most recent snapshot (0 if no snapshot yet). + pub fn total_nav(env: Env) -> i128 { + env.storage() + .instance() + .get::(&DataKey::LatestSnapshot) + .map(|s| s.total_nav) + .unwrap_or(0) + } + + /// Per-asset NAV breakdown from the most recent snapshot. + /// + /// Returns an empty `Vec` if no snapshot has been taken. + pub fn asset_nav_breakdown(env: Env) -> Vec { + env.storage() + .instance() + .get::(&DataKey::LatestSnapshot) + .map(|s| s.assets) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Seconds since the oracle price for `token` was last updated, as + /// recorded in the most recent snapshot. + /// + /// Returns `None` if no snapshot exists or the token is not tracked. + /// A large value indicates stale price data that should be refreshed + /// before making financial decisions. + pub fn asset_price_staleness(env: Env, token: Address) -> Option { + let snapshot: NavSnapshot = env + .storage() + .instance() + .get(&DataKey::LatestSnapshot)?; + let now = env.ledger().timestamp(); + for entry in snapshot.assets.iter() { + if entry.token == token { + if entry.price_timestamp == 0 { + return None; // price was never obtained + } + return Some(now.saturating_sub(entry.price_timestamp)); + } + } + None + } + + // ─── Backstop seeding (issue #243) ──────────────────────────────────── + + /// Seed the backstop with protocol-owned capital. + /// + /// Governance-and-timelock-gated (`caller` must be the admin/timelock). + /// + /// `token` is the backstop's staking token; `amount` is transferred from + /// the treasury to the backstop and deposited on behalf of the treasury. + /// The amount is tracked in `SeededCapital` separately from staker + /// deposits so it can be accounted and recalled independently. + /// + /// Seeded capital is junior to staker deposits: the backstop's drawdown + /// logic is not changed here, but the seeded amount is only recalled + /// after the backstop has returned the protocol's portion via + /// `recall_seed`. + pub fn seed_backstop( + env: Env, + caller: Address, + token: Address, + amount: i128, + ) -> Result<(), TreasuryError> { + Self::require_admin(&env, &caller)?; + if amount <= 0 { + return Err(TreasuryError::InvalidAmount); + } + let backstop: Address = env + .storage() + .instance() + .get(&DataKey::Backstop) + .ok_or(TreasuryError::BackstopNotConfigured)?; + + let bal = Self::token_balance(&env, &token); + if amount > bal { + return Err(TreasuryError::InsufficientBalance); } + + // Transfer to backstop and deposit. + token::Client::new(&env, &token).transfer( + &env.current_contract_address(), + &backstop, + &amount, + ); + + // Update seeded-capital accounting. + let seeded: i128 = env + .storage() + .instance() + .get(&DataKey::SeededCapital) + .unwrap_or(0i128); + env.storage() + .instance() + .set(&DataKey::SeededCapital, &(seeded + amount)); + + env.events() + .publish((Symbol::new(&env, "trs_seed"),), (token, amount, backstop)); + Ok(()) + } + + /// Recall idle protocol-owned capital from the backstop. + /// + /// Governance-and-timelock-gated. The backstop transfers `amount` back to + /// the treasury. Only up to `seeded_capital()` may be recalled — the + /// treasury never recalls more than it originally seeded. + /// + /// Note: because the backstop may have suffered drawdowns, the actual + /// transferable amount could be less than `seeded_capital()`. The caller + /// should query `backstop.drawable_capital()` first. If the backstop + /// does not hold enough, the token transfer will fail and the tx reverts. + pub fn recall_seed( + env: Env, + caller: Address, + token: Address, + amount: i128, + ) -> Result<(), TreasuryError> { + Self::require_admin(&env, &caller)?; if amount <= 0 { - panic!("amount must be positive"); + return Err(TreasuryError::InvalidAmount); + } + let seeded: i128 = env + .storage() + .instance() + .get(&DataKey::SeededCapital) + .unwrap_or(0i128); + if amount > seeded { + return Err(TreasuryError::RecallExceedsSeed); } - let client = token::Client::new(&env, &token); - client.transfer(&env.current_contract_address(), &to, &amount); + let backstop: Address = env + .storage() + .instance() + .get(&DataKey::Backstop) + .ok_or(TreasuryError::BackstopNotConfigured)?; + + // The backstop transfers funds back to the treasury. + token::Client::new(&env, &token).transfer( + &backstop, + &env.current_contract_address(), + &amount, + ); + + // Reduce seeded-capital accounting. + env.storage() + .instance() + .set(&DataKey::SeededCapital, &(seeded - amount)); + + env.events() + .publish((Symbol::new(&env, "trs_recall"),), (token, amount, backstop)); + Ok(()) } + /// Amount of protocol-owned capital currently seeded to the backstop. + pub fn seeded_capital(env: Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::SeededCapital) + .unwrap_or(0) + } + + // ─── Payment requests ───────────────────────────────────────────────── + /// Submit a payment request. The purpose text is kept off-chain; only its /// hash is stored. Returns the new request id. pub fn submit_payment_request( @@ -212,6 +810,25 @@ impl RefractTreasury { env.storage().persistent().get(&DataKey::Request(id)) } + // ─── Read-only views ────────────────────────────────────────────────── + + /// Current admin address. + pub fn admin(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) + } + + /// Configured oracle address, if set. + pub fn oracle(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Oracle) + } + + /// Configured backstop address, if set. + pub fn backstop(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Backstop) + } + + // ─── Private helpers ────────────────────────────────────────────────── + fn acquire_reentrancy_guard(env: &Env) -> Result<(), TreasuryError> { if env.storage().temporary().has(&DataKey::ReentrancyLock) { return Err(TreasuryError::ReentrancyDetected); @@ -225,17 +842,40 @@ impl RefractTreasury { } fn require_admin(env: &Env, caller: &Address) -> Result<(), TreasuryError> { + caller.require_auth(); let admin: Address = env .storage() .instance() .get(&DataKey::Admin) - .ok_or(TreasuryError::NotAdmin)?; + .ok_or(TreasuryError::NotInitialized)?; if *caller != admin { - return Err(TreasuryError::NotAdmin); + return Err(TreasuryError::Unauthorized); } Ok(()) } + /// The admin also acts as an approver; a separate `Approver` role can be + /// layered on later via the access-control module. For now admin is the + /// only authority for fund-moving calls. + fn require_admin_or_approver(env: &Env, caller: &Address) -> Result<(), TreasuryError> { + // For the initial implementation admin == approver. + Self::require_admin(env, caller) + } + + fn token_balance(env: &Env, token: &Address) -> i128 { + token::Client::new(env, token) + .balance(&env.current_contract_address()) + } + + fn is_allowed_inner(env: &Env, recipient: &Address) -> bool { + matches!( + env.storage() + .persistent() + .get::(&DataKey::RecipientStatus(recipient.clone())), + Some(ScreeningStatus::Allowed) + ) + } + fn load_request(env: &Env, id: u64) -> Result { env.storage() .persistent() @@ -250,37 +890,29 @@ impl RefractTreasury { } } +// ── Tests ───────────────────────────────────────────────────────────────────── + #[cfg(test)] mod payments_test; #[cfg(test)] mod test { use super::*; - use soroban_sdk::{testutils::Address as _, Env}; + use soroban_sdk::{ + testutils::Address as _, + Address, Env, Symbol, + }; - #[test] - fn withdraw_is_admin_gated() { + fn make_env() -> (Env, Address, Address) { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register_contract(None, RefractTreasury); - let client = RefractTreasuryClient::new(&env, &contract_id); - let admin = Address::generate(&env); - client.initialize(&admin); - assert_eq!(client.admin(), admin); - - let token_admin = Address::generate(&env); - let token_id = env.register_stellar_asset_contract(token_admin.clone()); - let token_client = token::Client::new(&env, &token_id); - let sac = token::StellarAssetClient::new(&env, &token_id); - - // Deposit via plain transfer. - sac.mint(&contract_id, &1_000); - assert_eq!(token_client.balance(&contract_id), 1_000); - - let to = Address::generate(&env); - client.withdraw(&admin, &token_id, &400, &to); - assert_eq!(token_client.balance(&to), 400); - assert_eq!(token_client.balance(&contract_id), 600); + let trs_id = env.register_contract(None, RefractTreasury); + (env, admin, trs_id) } -} + + #[test] + fn double_initialize_rejected() { + let (env, admin, trs_id) = make_env(); + let trs = RefractTreasuryClient::new(&env, &trs_id); + trs