Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ debug = 0
[profile.test]
debug = 0

# CI trigger
49 changes: 49 additions & 0 deletions contracts/credence_bond/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,55 @@ pub fn emit_bond_drift_detected(e: &Env, details: &crate::invariants::BondDriftD
e.events().publish(topics, data);
}

/// Emitted when the bond-creation fee config (treasury or fee_bps) changes
/// (issue #1027 — fee config safety rails).
///
/// The event carries every relevant governance field before and after the
/// update so auditors and indexers can reconstruct the diff without
/// re-reading storage. `old_treasury = None` signals the config was
/// previously unset (contract fresh or fee config never configured).
///
/// # Topics (Indexed)
/// * `Symbol` - `"fee_config_updated"`
/// * `Address` - The admin that authorised the change (indexed per-admin)
///
/// # Data
/// * `Option<Address>` - Treasury address *before* the update (`None` if
/// not previously set)
/// * `Address` - Treasury address *after* the update
/// * `u32` - `fee_bps` *before* the update (0 if not previously set)
/// * `u32` - `fee_bps` *after* the update (already bounds-checked to
/// `[MIN_FEE_BPS, MAX_FEE_BPS]`)
///
/// # Replay semantics
/// A replayer that has tracked fee config from `fee_config_updated` MUST set
/// `(treasury, fee_bps) = (topics[1].new, data[1])`, regardless of whether
/// either field actually changed (callers may re-issue the same config to
/// force a re-emission of the audit trail). Failed setter calls (rejected
/// for out-of-range values) do **not** emit this event.
///
/// # Range invariants
/// `new_fee_bps` is guaranteed to lie in `[MIN_FEE_BPS, MAX_FEE_BPS]` =
/// `[0, 1 000]` (0%..10%) — see [`crate::fees`] for the governance bounds.
#[allow(dead_code)]
pub fn emit_fee_config_updated(
e: &Env,
admin: &Address,
old_treasury: Option<Address>,
new_treasury: &Address,
old_fee_bps: u32,
new_fee_bps: u32,
) {
let topics = (Symbol::new(e, "fee_config_updated"), admin.clone());
let data = (
old_treasury,
new_treasury.clone(),
old_fee_bps,
new_fee_bps,
);
e.events().publish(topics, data);
}

/// Emitted when a bond is finalized through `liquidate` (issue #366).
///
/// # Topics (Indexed)
Expand Down
68 changes: 64 additions & 4 deletions contracts/credence_bond/src/fees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,46 @@
//! Charges a configurable percentage of the bonded amount on creation, transfers
//! the fee to the protocol treasury, and supports fee waiver for certain conditions.
//! Emits fee collection events.
//!
//! # Governance safety rails (issue #1027)
//!
//! `set_config` requires the caller (`admin`) to be the contract's stored
//! admin — **enforced by the entrypoint** (`lib.rs::set_fee_config`). The
//! helper additionally enforces:
//!
//! - **Range check**: `fee_bps` MUST lie within
//! [`MIN_FEE_BPS`, `MAX_FEE_BPS`] = `[0, 1_000]` (0%..10%). Bounds mirror
//! the other fee rails in [`crate::parameters`] (`MAX_PROTOCOL_FEE_BPS`)
//! and [`crate::fee`] (`MAX_FEE_BPS`).
//! - **Event transparency**: every successful update emits
//! `fee_config_updated` with `(admin, old_treasury, new_treasury,
//! old_fee_bps, new_fee_bps)` so off-chain indexers can audit fee-config
//! governance without re-reading storage. Range-check rejections emit no
//! event (the state is unchanged).
//!
//! Panics with `"fee_bps out of bounds"` if the proposed `fee_bps` is outside
//! the inclusive range; this matches the convention used by
//! [`crate::parameters`] for `protocol_fee_bps` / `attestation_fee_bps`.

use soroban_sdk::{Address, Env, Symbol};

use crate::events;
use crate::math;

// ============================================================================
// Governance bounds (issue #1027)
// ============================================================================

/// Minimum bond-creation fee in basis points (0 bps = 0%, fee disabled).
pub const MIN_FEE_BPS: u32 = 0;

/// Maximum bond-creation fee in basis points (1 000 bps = 10%).
///
/// No admin call can ramp the bond-creation fee above this value. Picked to
/// match `crate::parameters::MAX_PROTOCOL_FEE_BPS` and `crate::fee::MAX_FEE_BPS`
/// so all fee rails across the contract have one consistent ceiling.
pub const MAX_FEE_BPS: u32 = 1_000;

/// Get treasury and fee rate (basis points). Returns (treasury, fee_bps).
/// If not set, fee is zero (no treasury = no fee).
pub fn get_config(e: &Env) -> (Option<Address>, u32) {
Expand All @@ -20,17 +55,42 @@ pub fn get_config(e: &Env) -> (Option<Address>, u32) {
(treasury, fee_bps)
}

/// Set fee config. Admin only (enforced by caller). fee_bps in basis points (e.g. 100 = 1%).
pub fn set_config(e: &Env, treasury: Address, fee_bps: u32) {
if fee_bps > math::BPS_DENOMINATOR as u32 {
panic!("fee_bps must be <= {}", math::BPS_DENOMINATOR);
/// Set fee config. Caller must be the contract admin (enforced by the
/// `set_fee_config` entrypoint in `lib.rs`, which also makes the call
/// reentrancy-checked and paused-gated).
///
/// `fee_bps` is in basis points (e.g. `100` = 1%). It MUST lie within
/// [`MIN_FEE_BPS`, `MAX_FEE_BPS`] = `[0, 1_000]`; out-of-range values are
/// rejected with `panic!("fee_bps out of bounds")` and the call leaves
/// storage unchanged.
///
/// On success the helper emits `events::emit_fee_config_updated` carrying
/// `(admin, old_treasury, new_treasury, old_fee_bps, new_fee_bps)` so
/// governance transparency is preserved even when both fields are updated
/// in a single call.
///
/// # Panics
/// * `"fee_bps out of bounds"` if `fee_bps` is outside
/// `[MIN_FEE_BPS, MAX_FEE_BPS]`.
pub fn set_config(e: &Env, admin: &Address, treasury: Address, fee_bps: u32) {
// ── Range check (issue #1027 governance safety rail) ─────────────────
if !(MIN_FEE_BPS..=MAX_FEE_BPS).contains(&fee_bps) {
panic!("fee_bps out of bounds");
}

// ── CEI: read previous values before overwriting ────────────────────
let (old_treasury, old_fee_bps) = get_config(e);

// ── Effects: persist the new config ─────────────────────────────────
e.storage()
.instance()
.set(&crate::DataKey::FeeTreasury, &treasury);
e.storage()
.instance()
.set(&crate::DataKey::FeeBps, &fee_bps);

// ── Interaction: emit governance event (old/new values) ────────────
events::emit_fee_config_updated(e, admin, old_treasury, &treasury, old_fee_bps, fee_bps);
}

/// Calculate fee for a bond amount. Returns (fee_amount, net_amount).
Expand Down
45 changes: 45 additions & 0 deletions contracts/credence_bond/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod early_exit_penalty;
pub mod emergency;
mod emergency_drain;
mod events;
mod fees;
mod guards;
mod idempotency;
mod invariants;
Expand Down Expand Up @@ -318,6 +319,15 @@ pub enum DataKey {
/// Used by the same-ledger sequencing guard to prevent cooldown
/// withdrawal execution in the same ledger as a collateral increase.
CooldownRequestLedger,
/// Treasury address that receives bond-creation fees. Set by
/// [`CredenceBond::set_fee_config`]; absent ⇒ no fee collected.
/// Value: `Address`.
FeeTreasury,
/// Bond-creation fee rate in basis points. Set by
/// [`CredenceBond::set_fee_config`] and bounded to
/// `[crate::fees::MIN_FEE_BPS, crate::fees::MAX_FEE_BPS]` per
/// issue #1027. Value: `u32`.
FeeBps,
}

/// Sub-key namespace for upgrade-authorization storage entries.
Expand Down Expand Up @@ -1957,6 +1967,41 @@ impl CredenceBond {
e.storage().instance().set(&key, &(current + amount));
}

/// Configure the bond-creation fee (treasury recipient + basis-points rate).
/// Admin-only.
///
/// # Safety rails (issue #1027)
/// * Caller must be the stored admin (`guards::require_admin`).
/// * `fee_bps` MUST lie in `[crate::fees::MIN_FEE_BPS,
/// crate::fees::MAX_FEE_BPS]` = `[0, 1_000]` (0%..10%); out-of-range
/// values are rejected and storage is left untouched.
/// * Every successful update emits a `fee_config_updated` event
/// carrying the old/new treasury and old/new `fee_bps`, so
/// off-chain indexers can audit the governance timeline.
///
/// # Errors
/// * `ContractError::ContractPaused` when the contract is paused.
/// * `ContractError::NotAdmin` when `admin` is not the stored admin.
/// * Panics with `"fee_bps out of bounds"` if `fee_bps` is out of range.
///
/// See also: [`docs/fees.md`](../../../docs/fees.md),
/// [`crate::fees::set_config`](../../src/fees.rs).
pub fn set_fee_config(e: Env, admin: Address, treasury: Address, fee_bps: u32) {
Self::require_not_paused(&e);
admin.require_auth();
guards::require_admin(&e, &admin);
fees::set_config(&e, &admin, treasury, fee_bps);
}

/// Read the current bond-creation fee configuration.
///
/// Returns `(Option<treasury>, fee_bps)`. `treasury = None` means the
/// fee config was never set — in which case `fee_bps` is also 0 and no
/// fee is ever charged at bond creation.
pub fn get_fee_config(e: Env) -> (Option<Address>, u32) {
fees::get_config(&e)
}

/// Withdraw the full bonded amount with a reentrancy guard.
///
/// Errors:
Expand Down
Loading
Loading