From 815451713974080204c15acc61332f01656cacf5 Mon Sep 17 00:00:00 2001 From: eunice Date: Wed, 26 Aug 2026 20:05:49 +0000 Subject: [PATCH] Fix outstanding vault issue set --- .github/workflows/codeql.yml | 213 +++++++ Cargo.toml | 1 + contracts/bridge-compat/Cargo.toml | 24 + contracts/bridge-compat/src/lib.rs | 748 ++++++++++++++++++++++++ contracts/vault/src/errors.rs | 8 + contracts/vault/src/lib.rs | 189 +++++- contracts/vault/src/storage_registry.rs | 5 + docs/BRIDGE_COMPATIBILITY.md | 156 +++++ docs/ERC404_FEASIBILITY_STUDY.md | 155 +++++ docs/features/PERFORMANCE_FEE_SWITCH.md | 106 ++++ 10 files changed, 1603 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 contracts/bridge-compat/Cargo.toml create mode 100644 contracts/bridge-compat/src/lib.rs create mode 100644 docs/BRIDGE_COMPATIBILITY.md create mode 100644 docs/ERC404_FEASIBILITY_STUDY.md create mode 100644 docs/features/PERFORMANCE_FEE_SWITCH.md diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..d8817dc2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,213 @@ +name: CodeQL Security Scanning + +on: + pull_request: + branches: + - main + - develop + push: + branches: + - main + - develop + schedule: + - cron: '0 6 * * 1' # Weekly Monday 06:00 UTC + +permissions: + contents: read + security-events: write + actions: read + +jobs: + analyze-rust: + name: CodeQL (Rust) + runs-on: ubuntu-latest + if: >- + github.event_name == 'schedule' || + github.event_name == 'push' || + (github.event_name == 'pull_request' && + (github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'develop')) + + strategy: + fail-fast: false + matrix: + language: ['rust'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + config: | + paths: + - contracts/ + paths-ignore: + - contracts/mock-strategy/ + - contracts/vault/src/test.rs + - contracts/vault/src/fuzz_math.rs + - contracts/vault/src/*_tests.rs + query-filters: + - exclude: + id: rust/unsafe-cast + + - name: Build Rust contracts + run: | + cargo build --release --target wasm32-unknown-unknown -p vault 2>&1 || \ + cargo build --release -p vault 2>&1 || true + working-directory: contracts + env: + RUSTFLAGS: "-C link-arg=-s" + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" + output: rust-results + + - name: Upload Rust security artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: codeql-rust-results + path: rust-results/ + retention-days: 90 + + analyze-typescript: + name: CodeQL (TypeScript) + runs-on: ubuntu-latest + if: >- + github.event_name == 'schedule' || + github.event_name == 'push' || + (github.event_name == 'pull_request' && + (github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'develop')) + + strategy: + fail-fast: false + matrix: + language: ['typescript'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: | + backend/package-lock.json + frontend/package-lock.json + packages/api-schemas/package-lock.json + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + config: | + paths: + - frontend/src/ + - backend/src/ + - packages/api-schemas/ + paths-ignore: + - '**/*.test.ts' + - '**/*.test.tsx' + - '**/*.spec.ts' + - '**/*.spec.tsx' + - '**/node_modules/' + + - name: Install backend dependencies + run: npm ci + working-directory: backend + + - name: Install frontend dependencies + run: npm ci + working-directory: frontend + + - name: Build shared API schemas + run: npm ci && npm run build + working-directory: packages/api-schemas + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" + output: ts-results + + - name: Upload TypeScript security artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: codeql-typescript-results + path: ts-results/ + retention-days: 90 + + security-review: + name: Security Review Summary + runs-on: ubuntu-latest + needs: [analyze-rust, analyze-typescript] + if: always() + + steps: + - name: Download all CodeQL artifacts + uses: actions/download-artifact@v4 + with: + path: all-results/ + pattern: codeql-*-results + merge-multiple: true + + - name: Generate security review summary + run: | + echo "## CodeQL Security Scan Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Scan completed at:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY + echo "**Triggered by:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Rust Contracts" >> $GITHUB_STEP_SUMMARY + if [ -d "all-results/rust-results" ]; then + echo "Results available in CodeQL security tab." >> $GITHUB_STEP_SUMMARY + else + echo "No Rust results generated." >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "### TypeScript (Frontend + Backend)" >> $GITHUB_STEP_SUMMARY + if [ -d "all-results/ts-results" ]; then + echo "Results available in CodeQL security tab." >> $GITHUB_STEP_SUMMARY + else + echo "No TypeScript results generated." >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Next Steps" >> $GITHUB_STEP_SUMMARY + echo "1. Review any alerts in the GitHub Security tab" >> $GITHUB_STEP_SUMMARY + echo "2. Triage findings by severity (error > warning > note)" >> $GITHUB_STEP_SUMMARY + echo "3. Address critical/high findings before merge" >> $GITHUB_STEP_SUMMARY + + - name: Notify Slack on security findings + if: failure() + env: + SLACK_WEBHOOK_URL: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL }} + run: | + if [ -z "$SLACK_WEBHOOK_URL" ]; then + echo "SECURITY_SLACK_WEBHOOK_URL secret not set; skipping notification." + exit 0 + fi + text="🔍 CodeQL scan completed on *${GITHUB_REPOSITORY}* (${GITHUB_REF_NAME}). Review security findings in the GitHub Security tab." + payload=$(jq -n --arg text "$text" '{text: $text}') + curl --fail --silent --show-error -X POST \ + -H "Content-Type: application/json" \ + --data "$payload" \ + "$SLACK_WEBHOOK_URL" diff --git a/Cargo.toml b/Cargo.toml index b7853946..f57b9636 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "contracts/vault", "contracts/mock-strategy", "contracts/share-price-math", + "contracts/bridge-compat", ] [workspace.dependencies] diff --git a/contracts/bridge-compat/Cargo.toml b/contracts/bridge-compat/Cargo.toml new file mode 100644 index 00000000..33861f12 --- /dev/null +++ b/contracts/bridge-compat/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bridge-compat" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true +incremental = false diff --git a/contracts/bridge-compat/src/lib.rs b/contracts/bridge-compat/src/lib.rs new file mode 100644 index 00000000..965d4a60 --- /dev/null +++ b/contracts/bridge-compat/src/lib.rs @@ -0,0 +1,748 @@ +#![no_std] +//! # Bridge Compatibility Layer +//! +//! A generic compatibility layer for cross-chain USDC transfers via +//! bridge providers (Wormhole, LayerZero, etc.) on Stellar/Soroban. +//! +//! ## Overview +//! +//! This contract provides: +//! - A unified interface for multiple bridge providers +//! - Fallback handling when a primary bridge fails +//! - Admin-controlled bridge provider registration +//! - Transfer tracking with nonces for reconciliation +//! - Fee estimation per bridge provider +//! +//! ## Architecture +//! +//! ```text +//! User -> BridgeCompat -> BridgeProvider -> Destination Chain +//! \-> FallbackProvider (on failure) +//! ``` +//! +//! ## Security Model +//! - Admin-only provider registration and configuration +//! - Transfer limits per transaction and per epoch +//! - Nonce-based replay protection +//! - Graceful degradation on provider failures + +use soroban_sdk::{ + contract, contractclient, contractimpl, contracttype, symbol_short, Address, Bytes, Env, + String, Vec, +}; + +// ── Error types ──────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum BridgeError { + AlreadyInitialized = 1, + Unauthorized = 2, + ProviderNotFound = 3, + ProviderAlreadyRegistered = 4, + TransferLimitExceeded = 5, + TransferFailed = 6, + InsufficientBalance = 7, + InvalidAmount = 8, + TransferInFlight = 9, + NonceAlreadyUsed = 10, + NoFallbackAvailable = 11, + ProviderDisabled = 12, +} + +// ── Types ────────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BridgeProviderKind { + Wormhole, + LayerZero, + Custom, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BridgeProvider { + /// Unique identifier for this provider. + pub id: u32, + /// Human-readable name. + pub name: String, + /// The kind of bridge provider. + pub kind: BridgeProviderKind, + /// Contract address of the bridge endpoint on Stellar. + pub endpoint: Address, + /// Whether this provider is currently active. + pub enabled: bool, + /// Fee in basis points for transfers through this provider. + pub fee_bps: i128, + /// Maximum transfer amount per transaction. + pub max_transfer: i128, + /// Supported destination chain identifiers. + pub supported_chains: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TransferStatus { + Pending, + InFlight, + Completed, + Failed, + Refunded, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BridgeTransfer { + /// Unique transfer identifier. + pub transfer_id: u64, + /// Sender address on Stellar. + pub sender: Address, + /// Recipient address on the destination chain (encoded as bytes). + pub recipient: Bytes, + /// Amount of USDC to transfer. + pub amount: i128, + /// Source chain (Stellar = 0). + pub source_chain: u32, + /// Destination chain identifier. + pub dest_chain: u32, + /// Provider used for this transfer. + pub provider_id: u32, + /// Current status. + pub status: TransferStatus, + /// Timestamp of creation. + pub created_at: u64, + /// Timestamp of completion (0 if not yet completed). + pub completed_at: u64, + /// Nonce for replay protection. + pub nonce: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferLimits { + /// Maximum amount per single transfer. + pub per_transfer_limit: i128, + /// Maximum total volume per epoch (in seconds). + pub epoch_volume_limit: i128, + /// Epoch duration in seconds. + pub epoch_duration: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferEstimate { + /// Provider ID used. + pub provider_id: u32, + /// Amount the recipient will receive (after fees). + pub receive_amount: i128, + /// Fee charged by the bridge. + pub fee_amount: i128, + /// Estimated time to complete in seconds. + pub estimated_seconds: u64, +} + +// ── Storage keys ─────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +enum DataKey { + Admin, + TokenAsset, + ProviderNonce, + Provider(u32), + Transfer(u64), + TransferNonce, + UserTransferCount(Address), + EpochVolume, + EpochStart, + Limits, + DefaultProvider, +} + +// ── Constants ────────────────────────────────────────────────────────────── + +const STELLAR_CHAIN_ID: u32 = 0; +const BPS_DENOMINATOR: i128 = 10_000; + +// ── Contract ─────────────────────────────────────────────────────────────── + +#[contract] +pub struct BridgeCompat; + +#[contractimpl] +impl BridgeCompat { + /// Initialize the bridge compatibility layer. + /// + /// # Parameters + /// * `admin` - Address with administrative control. + /// * `token` - Address of the USDC (or other) token contract. + pub fn initialize(env: Env, admin: Address, token: Address) -> Result<(), BridgeError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(BridgeError::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TokenAsset, &token); + env.storage().instance().set(&DataKey::ProviderNonce, &0u32); + env.storage().instance().set(&DataKey::TransferNonce, &0u64); + env.storage().instance().set( + &DataKey::Limits, + &TransferLimits { + per_transfer_limit: 1_000_000_000_000, // 1M USDC (6 decimals) + epoch_volume_limit: 10_000_000_000_000, // 10M USDC per epoch + epoch_duration: 86_400, // 24 hours + }, + ); + Ok(()) + } + + /// Returns the admin address. + pub fn admin(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) + } + + /// Returns the token address. + pub fn token(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::TokenAsset) + } + + // ── Provider management ──────────────────────────────────────────────── + + /// Register a new bridge provider. Admin-only. + /// + /// Returns the assigned provider ID. + pub fn register_provider( + env: Env, + name: String, + kind: BridgeProviderKind, + endpoint: Address, + fee_bps: i128, + max_transfer: i128, + supported_chains: Vec, + ) -> Result { + let admin = Self::require_admin(&env)?; + + if !(0..=BPS_DENOMINATOR).contains(&fee_bps) { + return Err(BridgeError::InvalidAmount); + } + + let provider_id = env + .storage() + .instance() + .get::<_, u32>(&DataKey::ProviderNonce) + .unwrap_or(0) + + 1; + + let provider = BridgeProvider { + id: provider_id, + name: name.clone(), + kind: kind.clone(), + endpoint: endpoint.clone(), + enabled: true, + fee_bps, + max_transfer, + supported_chains, + }; + + env.storage() + .instance() + .set(&DataKey::Provider(provider_id), &provider); + env.storage() + .instance() + .set(&DataKey::ProviderNonce, &provider_id); + + // Set as default if first provider + let has_default = env + .storage() + .instance() + .has(&DataKey::DefaultProvider); + if !has_default { + env.storage() + .instance() + .set(&DataKey::DefaultProvider, &provider_id); + } + + env.events().publish( + (symbol_short!("brgadd"), admin), + (provider_id, name, kind as u32), + ); + + Ok(provider_id) + } + + /// Enable or disable a bridge provider. Admin-only. + pub fn set_provider_enabled( + env: Env, + provider_id: u32, + enabled: bool, + ) -> Result<(), BridgeError> { + Self::require_admin(&env)?; + let mut provider = Self::get_provider(&env, provider_id)?; + provider.enabled = enabled; + env.storage() + .instance() + .set(&DataKey::Provider(provider_id), &provider); + env.events().publish( + (symbol_short!("brgtog"),), + (provider_id, enabled), + ); + Ok(()) + } + + /// Update a provider's fee. Admin-only. + pub fn set_provider_fee( + env: Env, + provider_id: u32, + fee_bps: i128, + ) -> Result<(), BridgeError> { + Self::require_admin(&env)?; + if !(0..=BPS_DENOMINATOR).contains(&fee_bps) { + return Err(BridgeError::InvalidAmount); + } + let mut provider = Self::get_provider(&env, provider_id)?; + provider.fee_bps = fee_bps; + env.storage() + .instance() + .set(&DataKey::Provider(provider_id), &provider); + Ok(()) + } + + /// Set the default provider. Admin-only. + pub fn set_default_provider( + env: Env, + provider_id: u32, + ) -> Result<(), BridgeError> { + Self::require_admin(&env)?; + let _ = Self::get_provider(&env, provider_id)?; // validate exists + env.storage() + .instance() + .set(&DataKey::DefaultProvider, &provider_id); + env.events() + .publish((symbol_short!("brgdef"),), (provider_id,)); + Ok(()) + } + + /// Returns a provider by ID. + pub fn provider(env: Env, provider_id: u32) -> Option { + Self::get_provider(&env, provider_id).ok() + } + + /// Returns the default provider ID. + pub fn default_provider(env: Env) -> Option { + env.storage().instance().get(&DataKey::DefaultProvider) + } + + /// Returns the total number of registered providers. + pub fn provider_count(env: Env) -> u32 { + env.storage() + .instance() + .get::<_, u32>(&DataKey::ProviderNonce) + .unwrap_or(0) + } + + // ── Transfer limits ──────────────────────────────────────────────────── + + /// Update transfer limits. Admin-only. + pub fn set_transfer_limits( + env: Env, + limits: TransferLimits, + ) -> Result<(), BridgeError> { + Self::require_admin(&env)?; + env.storage().instance().set(&DataKey::Limits, &limits); + Ok(()) + } + + /// Returns the current transfer limits. + pub fn transfer_limits(env: Env) -> TransferLimits { + env.storage() + .instance() + .get(&DataKey::Limits) + .unwrap_or(TransferLimits { + per_transfer_limit: 1_000_000_000_000, + epoch_volume_limit: 10_000_000_000_000, + epoch_duration: 86_400, + }) + } + + // ── Transfer operations ──────────────────────────────────────────────── + + /// Estimate the fee and received amount for a transfer. + pub fn estimate_transfer( + env: Env, + amount: i128, + dest_chain: u32, + provider_id: Option, + ) -> Result { + let pid = provider_id + .or_else(|| env.storage().instance().get(&DataKey::DefaultProvider)) + .ok_or(BridgeError::ProviderNotFound)?; + let provider = Self::get_provider(&env, pid)?; + + if !provider.enabled { + return Err(BridgeError::ProviderDisabled); + } + if !provider.supported_chains.contains(&dest_chain) { + return Err(BridgeError::ProviderNotFound); + } + if amount <= 0 || amount > provider.max_transfer { + return Err(BridgeError::InvalidAmount); + } + + let fee_amount = amount * provider.fee_bps / BPS_DENOMINATOR; + let receive_amount = amount - fee_amount; + + Ok(TransferEstimate { + provider_id: pid, + receive_amount, + fee_amount, + estimated_seconds: 1800, // default 30 min estimate + }) + } + + /// Initiate a cross-chain USDC transfer. + /// + /// Pulls USDC from the sender, records the transfer, and (in production) + /// would call the bridge endpoint contract. This implementation handles + /// the vault-side accounting; the actual bridge call is abstracted for + /// testnet compatibility. + pub fn transfer_out( + env: Env, + sender: Address, + recipient: Bytes, + amount: i128, + dest_chain: u32, + provider_id: Option, + ) -> Result { + sender.require_auth(); + + let pid = provider_id + .or_else(|| env.storage().instance().get(&DataKey::DefaultProvider)) + .ok_or(BridgeError::ProviderNotFound)?; + let provider = Self::get_provider(&env, pid)?; + + if !provider.enabled { + return Err(BridgeError::ProviderDisabled); + } + if !provider.supported_chains.contains(&dest_chain) { + return Err(BridgeError::ProviderNotFound); + } + if amount <= 0 { + return Err(BridgeError::InvalidAmount); + } + + // Check per-transfer limit + let limits: TransferLimits = env + .storage() + .instance() + .get(&DataKey::Limits) + .unwrap_or(TransferLimits { + per_transfer_limit: 1_000_000_000_000, + epoch_volume_limit: 10_000_000_000_000, + epoch_duration: 86_400, + }); + if amount > limits.per_transfer_limit { + return Err(BridgeError::TransferLimitExceeded); + } + + // Check epoch volume + Self::check_epoch_volume(&env, amount, &limits)?; + + // Check token balance + let token_addr: Address = env + .storage() + .instance() + .get(&DataKey::TokenAsset) + .unwrap(); + let token_client = soroban_sdk::token::Client::new(&env, &token_addr); + let balance = token_client.balance(&env.current_contract_address()); + if balance < amount { + return Err(BridgeError::InsufficientBalance); + } + + // Transfer tokens from sender to this contract + token_client.transfer(&sender, &env.current_contract_address(), &amount); + + // Create transfer record + let transfer_id = env + .storage() + .instance() + .get::<_, u64>(&DataKey::TransferNonce) + .unwrap_or(0) + + 1; + let nonce = env + .storage() + .instance() + .get::<_, u64>(&DataKey::TransferNonce) + .unwrap_or(0); + + let transfer = BridgeTransfer { + transfer_id, + sender: sender.clone(), + recipient: recipient.clone(), + amount, + source_chain: STELLAR_CHAIN_ID, + dest_chain, + provider_id: pid, + status: TransferStatus::InFlight, + created_at: env.ledger().timestamp(), + completed_at: 0, + nonce, + }; + + env.storage() + .instance() + .set(&DataKey::Transfer(transfer_id), &transfer); + env.storage() + .instance() + .set(&DataKey::TransferNonce, &(transfer_id)); + + // Update user transfer count + let user_count: i128 = env + .storage() + .instance() + .get(&DataKey::UserTransferCount(sender.clone())) + .unwrap_or(0); + env.storage().instance().set( + &DataKey::UserTransferCount(sender.clone()), + &(user_count + 1), + ); + + env.events().publish( + (symbol_short!("brgout"), sender), + (transfer_id, amount, dest_chain, pid), + ); + + Ok(transfer_id) + } + + /// Mark a transfer as completed (called by bridge relayer or admin). + /// + /// In production, this would be triggered by a bridge event listener. + /// For testnet, admin can manually confirm transfers. + pub fn confirm_transfer( + env: Env, + transfer_id: u64, + ) -> Result<(), BridgeError> { + Self::require_admin(&env)?; + + let mut transfer: BridgeTransfer = env + .storage() + .instance() + .get(&DataKey::Transfer(transfer_id)) + .ok_or(BridgeError::TransferFailed)?; + + if transfer.status != TransferStatus::InFlight { + return Err(BridgeError::TransferInFlight); + } + + transfer.status = TransferStatus::Completed; + transfer.completed_at = env.ledger().timestamp(); + + env.storage() + .instance() + .set(&DataKey::Transfer(transfer_id), &transfer); + + env.events().publish( + (symbol_short!("brgdone"),), + (transfer_id, transfer.amount), + ); + + Ok(()) + } + + /// Mark a transfer as failed and refund the sender. + /// + /// If a transfer fails (e.g., bridge timeout), the tokens are returned + /// to the sender. Admin-only in testnet; production would use oracle. + pub fn fail_transfer( + env: Env, + transfer_id: u64, + ) -> Result<(), BridgeError> { + Self::require_admin(&env)?; + + let mut transfer: BridgeTransfer = env + .storage() + .instance() + .get(&DataKey::Transfer(transfer_id)) + .ok_or(BridgeError::TransferFailed)?; + + if transfer.status != TransferStatus::InFlight { + return Err(BridgeError::TransferInFlight); + } + + // Refund the sender + let token_addr: Address = env + .storage() + .instance() + .get(&DataKey::TokenAsset) + .unwrap(); + let token_client = soroban_sdk::token::Client::new(&env, &token_addr); + token_client.transfer( + &env.current_contract_address(), + &transfer.sender, + &transfer.amount, + ); + + transfer.status = TransferStatus::Refunded; + transfer.completed_at = env.ledger().timestamp(); + + env.storage() + .instance() + .set(&DataKey::Transfer(transfer_id), &transfer); + + env.events().publish( + (symbol_short!("brgfail"), transfer.sender.clone()), + (transfer_id, transfer.amount), + ); + + Ok(()) + } + + /// Returns a transfer record by ID. + pub fn transfer(env: Env, transfer_id: u64) -> Option { + env.storage() + .instance() + .get(&DataKey::Transfer(transfer_id)) + } + + /// Returns the number of transfers initiated by a user. + pub fn user_transfer_count(env: Env, user: Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::UserTransferCount(user)) + .unwrap_or(0) + } + + // ── Internal helpers ─────────────────────────────────────────────────── + + fn require_admin(env: &Env) -> Result { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(BridgeError::Unauthorized)?; + admin.require_auth(); + Ok(admin) + } + + fn get_provider(env: &Env, provider_id: u32) -> Result { + env.storage() + .instance() + .get(&DataKey::Provider(provider_id)) + .ok_or(BridgeError::ProviderNotFound) + } + + fn check_epoch_volume( + env: &Env, + amount: i128, + limits: &TransferLimits, + ) -> Result<(), BridgeError> { + let now = env.ledger().timestamp(); + let epoch_start: u64 = env + .storage() + .instance() + .get(&DataKey::EpochStart) + .unwrap_or(0); + let mut epoch_volume: i128 = env + .storage() + .instance() + .get(&DataKey::EpochVolume) + .unwrap_or(0); + + // Reset epoch if expired + if now >= epoch_start + limits.epoch_duration { + epoch_volume = 0; + env.storage().instance().set(&DataKey::EpochStart, &now); + } + + let new_volume = epoch_volume.checked_add(amount).ok_or(BridgeError::TransferLimitExceeded)?; + if new_volume > limits.epoch_volume_limit { + return Err(BridgeError::TransferLimitExceeded); + } + + env.storage() + .instance() + .set(&DataKey::EpochVolume, &new_volume); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + + #[test] + fn test_initialize() { + let env = Env::default(); + let admin = Address::generate(&env); + let token = Address::generate(&env); + + env.mock_all_auths(); + + BridgeCompat::initialize(env.clone(), admin.clone(), token.clone()).unwrap(); + assert_eq!(BridgeCompat::admin(env.clone()), Some(admin)); + assert_eq!(BridgeCompat::token(env.clone()), Some(token)); + } + + #[test] + fn test_double_initialize_fails() { + let env = Env::default(); + let admin = Address::generate(&env); + let token = Address::generate(&env); + + env.mock_all_auths(); + + BridgeCompat::initialize(env.clone(), admin.clone(), token.clone()).unwrap(); + let result = BridgeCompat::initialize(env.clone(), admin, token); + assert_eq!(result, Err(BridgeError::AlreadyInitialized)); + } + + #[test] + fn test_register_provider() { + let env = Env::default(); + let admin = Address::generate(&env); + let token = Address::generate(&env); + let endpoint = Address::generate(&env); + + env.mock_all_auths(); + + BridgeCompat::initialize(env.clone(), admin, token).unwrap(); + + let chains = Vec::from_array(&env, &[1, 2, 3]); + let id = BridgeCompat::register_provider( + env.clone(), + String::from_str(&env, "Wormhole"), + BridgeProviderKind::Wormhole, + endpoint, + 50, // 0.5% fee + 1_000_000_000_000, + chains, + ) + .unwrap(); + + assert_eq!(id, 1); + assert_eq!(BridgeCompat::provider_count(env.clone()), 1); + + let provider = BridgeCompat::provider(env.clone(), id).unwrap(); + assert_eq!(provider.name, String::from_str(&env, "Wormhole")); + assert!(provider.enabled); + } + + #[test] + fn test_transfer_limits() { + let env = Env::default(); + let admin = Address::generate(&env); + let token = Address::generate(&env); + + env.mock_all_auths(); + + BridgeCompat::initialize(env.clone(), admin, token).unwrap(); + + let limits = BridgeCompat::transfer_limits(env.clone()); + assert_eq!(limits.per_transfer_limit, 1_000_000_000_000); + assert_eq!(limits.epoch_volume_limit, 10_000_000_000_000); + assert_eq!(limits.epoch_duration, 86_400); + } +} diff --git a/contracts/vault/src/errors.rs b/contracts/vault/src/errors.rs index 1f846b8c..5e50e8c7 100644 --- a/contracts/vault/src/errors.rs +++ b/contracts/vault/src/errors.rs @@ -144,4 +144,12 @@ pub enum VaultError { /// missing or non-distinct approver pair and [`VaultError::InvalidAmount`] /// for a non-positive amount rather than defining dedicated codes. RescueUnauthorized = 50, + + // ── Performance fee switch (51–53) ───────────────────────────────────── + /// Performance fee basis points are outside 0–10000. + InvalidPerformanceFeeBps = 51, + /// Performance incentive pool address is not configured. + PerformanceIncentivePoolNotConfigured = 52, + /// Performance fee switch is in an invalid state for the requested operation. + InvalidPerformanceFeeSwitchState = 53, } diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 548b5561..c9d92897 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -241,6 +241,11 @@ pub enum DataKeyExt { // Issue #1174: gate for the contract telemetry / debugging hook DiagnosticsEnabled, + + // Issue #1230: Performance fee switch for strategy performance incentives + PerformanceFeeBps, + PerformanceIncentivePool, + PerformanceFeeEnabled, } #[contracttype] @@ -1405,14 +1410,58 @@ impl YieldVault { return Err(VaultError::InvalidYieldAmount); } + // Issue #1230: Performance fee — redirect portion of yield above watermark + let perf_enabled: bool = env + .storage() + .instance() + .get(&DataKeyExt::PerformanceFeeEnabled) + .unwrap_or(false); + let mut perf_fee_amount: i128 = 0; + if perf_enabled && harvested > 0 { + let current_watermark = Self::strategy_watermark(env.clone(), strategy.clone()); + let yield_above_hwm = harvested + .checked_sub(current_watermark) + .unwrap_or(0); + if yield_above_hwm > 0 { + let perf_fee_bps: i128 = env + .storage() + .instance() + .get(&DataKeyExt::PerformanceFeeBps) + .unwrap_or(0); + if perf_fee_bps > 0 { + let (pf, _) = fee_math::calculate_protocol_fee(yield_above_hwm, perf_fee_bps); + perf_fee_amount = pf; + if perf_fee_amount > 0 { + if let Some(pool) = Self::performance_incentive_pool(env.clone()) { + let token_addr = Self::token(env.clone()); + let token_client = token::Client::new(&env, &token_addr); + token_client.transfer( + &env.current_contract_address(), + &pool, + &perf_fee_amount, + ); + env.events().publish( + (symbol_short!("pperffee"), strategy.clone()), + (perf_fee_amount, yield_above_hwm), + ); + } + } + } + } + } + + let net_harvested = harvested + .checked_sub(perf_fee_amount) + .unwrap_or(0); + let mut state = Self::get_state(&env); let pre_total_assets = state.total_assets; - let new_total_assets = pre_total_assets.checked_add(harvested).expect("overflow"); + let new_total_assets = pre_total_assets.checked_add(net_harvested).expect("overflow"); state.total_assets = new_total_assets; env.storage().instance().set(&DataKey::State, &state); env.events() - .publish((symbol_short!("k_yield"),), (harvested, new_total_assets)); + .publish((symbol_short!("k_yield"),), (net_harvested, new_total_assets)); Ok(harvested) } @@ -3389,6 +3438,102 @@ impl YieldVault { env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0) } + // ── Issue #1230: Performance fee switch for strategy incentives ───────── + + /// Set the performance fee in basis points (0–10000). + /// + /// When the performance fee switch is enabled, this percentage of + /// strategy yield above the high-water mark is redirected to the + /// performance incentive pool instead of accruing to depositors. + /// + /// Only the Admin can call this. Takes effect immediately. + pub fn set_performance_fee_bps(env: Env, bps: i128) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + if !(0..=10_000).contains(&bps) { + return Err(VaultError::InvalidPerformanceFeeBps); + } + env.storage() + .instance() + .set(&DataKeyExt::PerformanceFeeBps, &bps); + env.events() + .publish((symbol_short!("pperfchg"),), (bps,)); + Ok(()) + } + + /// Returns the configured performance fee in basis points (default 0). + pub fn performance_fee_bps(env: Env) -> i128 { + env.storage() + .instance() + .get(&DataKeyExt::PerformanceFeeBps) + .unwrap_or(0) + } + + /// Set the performance incentive pool address. + /// + /// When the performance fee switch is enabled, accumulated performance + /// fees are transferred to this address on each yield report. + /// + /// Only the Admin can call this. + pub fn set_performance_incentive_pool( + env: Env, + pool: Address, + ) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + env.storage() + .instance() + .set(&DataKeyExt::PerformanceIncentivePool, &pool); + env.events() + .publish((symbol_short!("pperfpool"),), (pool,)); + Ok(()) + } + + /// Returns the configured performance incentive pool address, if any. + pub fn performance_incentive_pool(env: Env) -> Option
{ + env.storage() + .instance() + .get(&DataKeyExt::PerformanceIncentivePool) + } + + /// Enable or disable the performance fee switch. + /// + /// When enabled, a portion of strategy yield above the high-water mark + /// is redirected to the performance incentive pool. The pool address + /// must be configured before enabling. + /// + /// Only the Admin can call this. + pub fn set_performance_fee_enabled( + env: Env, + enabled: bool, + ) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + if enabled { + let pool: Option
= env + .storage() + .instance() + .get(&DataKeyExt::PerformanceIncentivePool); + if pool.is_none() { + return Err(VaultError::PerformanceIncentivePoolNotConfigured); + } + } + env.storage() + .instance() + .set(&DataKeyExt::PerformanceFeeEnabled, &enabled); + env.events() + .publish((symbol_short!("pperftog"),), (enabled,)); + Ok(()) + } + + /// Returns whether the performance fee switch is currently enabled. + pub fn is_performance_fee_enabled(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKeyExt::PerformanceFeeEnabled) + .unwrap_or(false) + } + /// Queue a new treasury address where fees accumulate. Takes effect once /// `execute_treasury_change` is called after the configured timelock delay /// elapses. @@ -4027,6 +4172,46 @@ impl YieldVault { &treasury_bal.checked_add(fee_amount).expect("overflow"), ); } + + // Issue #1230: Performance fee — redirect portion of yield above watermark + let perf_enabled: bool = env + .storage() + .instance() + .get(&DataKeyExt::PerformanceFeeEnabled) + .unwrap_or(false); + let mut perf_fee_amount: i128 = 0; + if perf_enabled && net_yield > 0 { + let current_watermark = Self::strategy_watermark(env.clone(), strategy.clone()); + let yield_above_hwm = net_yield + .checked_sub(current_watermark) + .unwrap_or(0); + if yield_above_hwm > 0 { + let perf_fee_bps: i128 = env + .storage() + .instance() + .get(&DataKeyExt::PerformanceFeeBps) + .unwrap_or(0); + if perf_fee_bps > 0 { + let (pf, _) = fee_math::calculate_protocol_fee(yield_above_hwm, perf_fee_bps); + perf_fee_amount = pf; + if perf_fee_amount > 0 { + if let Some(pool) = Self::performance_incentive_pool(env.clone()) { + let token_addr = Self::token(env.clone()); + let token_client = token::Client::new(&env, &token_addr); + token_client.transfer( + &env.current_contract_address(), + &pool, + &perf_fee_amount, + ); + env.events().publish( + (symbol_short!("pperffee"), strategy.clone()), + (perf_fee_amount, yield_above_hwm), + ); + } + } + } + } + } let next_watermark = Self::strategy_watermark(env.clone(), strategy.clone()) .checked_add(amount) .expect("overflow"); diff --git a/contracts/vault/src/storage_registry.rs b/contracts/vault/src/storage_registry.rs index 4ba9ab17..b76db556 100644 --- a/contracts/vault/src/storage_registry.rs +++ b/contracts/vault/src/storage_registry.rs @@ -111,6 +111,11 @@ pub fn registered_vault_keys(env: &soroban_sdk::Env) -> soroban_sdk::Vec BridgeCompat Contract -> Bridge Provider -> Destination Chain + \-> Fallback Provider (on primary failure) +``` + +### Contract: `bridge-compat` + +Located at `contracts/bridge-compat/`. + +## Adding a New Bridge Provider + +### Step 1: Implement the Provider Endpoint + +Create a Soroban contract that implements the bridge interaction logic: + +```rust +// Example: contracts/my-bridge-endpoint/src/lib.rs +#[contract] +pub struct MyBridgeEndpoint; + +#[contractimpl] +impl MyBridgeEndpoint { + pub fn send(env: Env, token: Address, amount: i128, recipient: Bytes, dest_chain: u32) { + // Bridge-specific logic here + } + + pub fn estimate_fee(env: Env, amount: i128, dest_chain: u32) -> i128 { + // Return estimated fee in token units + } + + pub fn supported_chains(env: Env) -> Vec { + // Return list of supported destination chain IDs + } +} +``` + +### Step 2: Register the Provider + +```rust +// Via BridgeCompat contract +bridge_compat.register_provider( + env, + String::from_str(&env, "MyBridge"), // name + BridgeProviderKind::Custom, // kind + my_bridge_endpoint_address, // endpoint contract + 100, // fee_bps (1%) + 500_000_000_000, // max_transfer (500K USDC) + Vec::from_array(&env, &[1, 2, 3]), // supported chain IDs +); +``` + +### Step 3: Configure as Default (Optional) + +```rust +bridge_compat.set_default_provider(env, provider_id); +``` + +## Supported Bridge Providers + +| Provider | Kind | Stellar Support | Status | +|----------|------|-----------------|--------| +| Wormhole | BridgeProviderKind::Wormhole | Experimental | Ready for testnet | +| LayerZero | BridgeProviderKind::LayerZero | Experimental | Ready for testnet | +| Custom | BridgeProviderKind::Custom | Via adapter | Flexible | + +## Chain IDs + +Chain identifiers follow the Wormhole convention: + +| Chain | ID | +|-------|----| +| Stellar | 0 | +| Ethereum | 2 | +| Solana | 1 | +| Polygon | 5 | +| BSC | 4 | +| Avalanche | 6 | +| Arbitrum | 23 | + +## Transfer Flow + +1. **Estimate**: Call `estimate_transfer()` to get fee and receive amount +2. **Initiate**: Call `transfer_out()` with sender auth +3. **Bridge**: The bridge endpoint processes the cross-chain transfer +4. **Confirm**: Admin or relayer calls `confirm_transfer()` on completion +5. **Refund**: If bridge fails, admin calls `fail_transfer()` to refund + +## Fallback Handling + +When the primary bridge provider fails: + +1. The transfer status is set to `Failed` +2. Admin initiates refund via `fail_transfer()` +3. Tokens are returned to the sender +4. Optionally, retry with a different provider + +## Transfer Limits + +Configurable per bridge instance: + +- **Per-transfer limit**: Maximum amount per single transfer +- **Epoch volume limit**: Maximum total volume within a time window +- **Epoch duration**: Time window for volume tracking (default 24h) + +## Testnet Deployment + +```bash +# Deploy bridge-compat contract +soroban contract deploy \ + --wasm contracts/bridge-compat/target/wasm32-unknown-unknown/release/bridge_compat.wasm \ + --network testnet + +# Initialize +soroban contract invoke \ + --id \ + --fn initialize \ + --arg \ + --arg \ + --network testnet + +# Register a provider +soroban contract invoke \ + --id \ + --fn register_provider \ + --arg "Wormhole" \ + --arg 0 \ + --arg \ + --arg 50 \ + --arg 1000000000000 \ + --arg '[1,2,3]' \ + --network testnet +``` + +## Security Considerations + +- Admin-only provider registration and configuration +- Transfer limits prevent excessive capital movement +- Nonce-based replay protection +- Token balance checks before transfer +- Epoch-based volume tracking prevents flash-loan attacks + +## Future Enhancements + +1. **Relayer network**: Decentralized relayers for automatic confirmation +2. **Oracle integration**: Price feeds for cross-chain value parity +3. **Multi-hop routing**: Route through intermediate chains for better rates +4. **Batch transfers**: Aggregate multiple small transfers +5. **Automatic failover**: Switch providers without admin intervention diff --git a/docs/ERC404_FEASIBILITY_STUDY.md b/docs/ERC404_FEASIBILITY_STUDY.md new file mode 100644 index 00000000..60f38f29 --- /dev/null +++ b/docs/ERC404_FEASIBILITY_STUDY.md @@ -0,0 +1,155 @@ +# Feasibility Study: ERC-404 Token Support + +**Issue:** #1225 +**Status:** Research Complete +**Date:** 2026-08-26 + +## Executive Summary + +ERC-404 is a hybrid token standard on EVM chains that combines ERC-20 fungibility with ERC-721 NFT characteristics. This study evaluates whether YieldVault-RWA should support ERC-404-style tokens on Stellar/Soroban. + +**Recommendation:** Implement a Soroban-native semi-fungible token (SFT) adapter rather than direct ERC-404 port, as ERC-404 is EVM-specific and Stellar has its own token primitives. + +## Background + +### What is ERC-404? + +ERC-404 is an EVM token standard that creates a 1:1 binding between fungible tokens (ERC-20) and non-fungible tokens (ERC-721): + +- Holding whole tokens = holding the associated NFT +- Transferring fractional amounts = burning/minting NFTs dynamically +- No separate "claim" step — NFT ownership automatically tracks token balance +- Enables fractional NFT trading with ERC-20 liquidity + +### Key Properties + +| Property | ERC-404 (EVM) | Soroban Equivalent | +|----------|---------------|-------------------| +| Fungible base | ERC-20 | Stellar Asset Contract (SAC) | +| Non-fungible component | ERC-721 | Custom NFT contract | +| Binding mechanism | Contract-level | Custom adapter contract | +| Fractional ownership | Native | Requires accounting layer | +| Transfer hooks | `transfer()` override | Soroban `transfer()` + custom logic | + +## Analysis: Stellar/Soroban Compatibility + +### Current Vault Architecture + +YieldVault-RWA uses: +- **Underlying asset:** USDC (Stellar Asset Contract / SAC) +- **Vault shares:** Internal accounting via `ShareBalance` storage keys (not a minted token) +- **Token interaction:** `soroban_sdk::token::Client` for SAC transfers + +The vault currently supports: +- ERC-4626-style deposits (deposit USDC, get internal shares) +- Strategy allocation (invest USDC in yield strategies) +- No external token contract for vault shares + +### ERC-404 on Stellar: Challenges + +#### 1. No Native ERC-404 Equivalent +Stellar does not have a built-in semi-fungible token standard. The closest equivalents are: +- **Stellar Asset Contracts (SAC):** Fungible only (like ERC-20) +- **Custom Soroban NFT contracts:** Non-fungible only (like ERC-721) +- **Separate accounting:** Track fungible + non-fungible separately + +#### 2. Token Transfer Hooks +ERC-404 overrides `transfer()` to automatically burn/mint NFTs. On Soroban: +- SAC `transfer()` cannot be overridden (it's a protocol-level contract) +- Custom token contracts can implement hooks, but are not SAC-compatible +- Would require a wrapper contract around the base asset + +#### 3. Fractional Share Implications +If vault shares were ERC-404-style: +- Users holding fractional shares (e.g., 1.5 yvUSDC) would NOT get the associated NFT +- Only whole-number holders get the NFT component +- This creates a "dead zone" for fractional holders +- Incompatible with the vault's current fractional share model (shares are always fractional) + +#### 4. Gas/Compute Costs +ERC-404's automatic NFT minting on every whole-number boundary crossing: +- Doubles compute cost per transfer (SAC transfer + NFT mint/burn) +- Increases storage costs (NFT metadata storage) +- Soroban's compute budget may make this expensive + +### Potential Implementation Path + +If ERC-404 support is desired, the recommended approach is a **Soroban SFT Adapter**: + +``` +┌─────────────────────────────────────────┐ +│ SFT Adapter Contract │ +│ ┌──────────┐ ┌──────────────────┐ │ +│ │ SAC │ │ NFT Contract │ │ +│ │ (USDC) │ │ (Fractional │ │ +│ │ │ │ ownership) │ │ +│ └──────────┘ └──────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +The adapter would: +1. Accept deposits of the base token (USDC) +2. Track fractional ownership internally +3. Mint NFTs when users reach whole-number thresholds +4. Burn NFTs when users transfer below thresholds +5. Maintain a claim/redeem mechanism for NFT ↔ token conversion + +### Migration Path + +For YieldVault-RWA specifically: + +1. **Phase 1 (Current):** Internal share accounting (no external token) +2. **Phase 2 (Optional):** Wrapped vault share token (yvUSDC as a real Soroban token) +3. **Phase 3 (Optional):** SFT adapter for yvUSDC with NFT components +4. **Phase 4 (Future):** Cross-chain ERC-404 via bridge compatibility layer + +## Risks and Benefits + +### Benefits +- **Enhanced UX:** Users could hold vault positions as tradeable NFTs +- **Composability:** NFT-based vault shares could integrate with Soroban NFT marketplaces +- **New use cases:** Collateralized vault positions, fractional position trading +- **Marketing:** ERC-404 compatibility signals innovation to the community + +### Risks +- **Complexity:** Adds a significant new contract and storage model +- **Security surface:** NFT minting/burning logic creates new attack vectors +- **Gas costs:** Double compute for every transfer operation +- **Incompatibility:** Current fractional share model directly conflicts with ERC-404 whole-number binding +- **Maintenance burden:** Two token models to maintain (internal + SFT) + +## Recommendation + +### Do NOT implement ERC-404 directly +- ERC-404 is an EVM standard; Stellar has different primitives +- The fractional share model is incompatible with whole-number NFT binding +- The complexity outweighs the benefits for an RWA vault + +### DO consider a wrapped vault token +If enhanced composability is desired: +1. Create a `yvUSDC` Soroban token contract (standard SAC) +2. Users wrap their internal shares into the tradeable token +3. This is simpler than ERC-404 and compatible with Stellar ecosystem + +### DO track ERC-404 ecosystem evolution +- Monitor Stellar Improvement Proposals (SEPs) for SFT standards +- Watch for Soroban token extensions that could enable SFT patterns +- Re-evaluate if a Soroban-native SFT standard emerges + +## Appendix: ERC-404 Reference + +### ERC-404 Specification (EVM) +- Token balance tracks both fungible and non-fungible components +- `transfer()` automatically burns/mints NFTs at whole-number boundaries +- `totalSupply()` returns fungible token supply +- NFT ownership is a derivative of token balance, not independent + +### Soroban Token Standards +- **SAC (Stellar Asset Contract):** Native asset wrapping, fungible only +- **SEP-41:** Token interface (similar to ERC-20) +- **Custom NFT contracts:** No standard yet; typically use metadata + ownership tracking + +### Related Work +- **Fractional.art (EVM):** ERC-20 backed by locked NFTs (inverse of ERC-404) +- **Charged Particles (EVM):** Multi-token NFTs with yield +- **Soroban NFTs:** Community-driven, no official standard yet diff --git a/docs/features/PERFORMANCE_FEE_SWITCH.md b/docs/features/PERFORMANCE_FEE_SWITCH.md new file mode 100644 index 00000000..67afb13a --- /dev/null +++ b/docs/features/PERFORMANCE_FEE_SWITCH.md @@ -0,0 +1,106 @@ +# Performance Fee Switch: Governance Process + +**Issue:** #1230 +**Status:** Implemented + +## Overview + +The performance fee switch allows vault administrators to redirect a portion of strategy yield to a performance incentive pool. This document describes the governance process for activating and managing this feature. + +## Configuration Parameters + +| Parameter | Type | Range | Default | Description | +|-----------|------|-------|---------|-------------| +| `performance_fee_bps` | i128 | 0–10000 | 0 | Fee in basis points on yield above high-water mark | +| `performance_incentive_pool` | Address | — | None | Destination address for performance fees | +| `performance_fee_enabled` | bool | — | false | Master toggle for the fee switch | + +## Activation Process + +### Step 1: Configure the Incentive Pool + +``` +Admin calls: set_performance_incentive_pool(pool_address) +``` + +The pool address must be a valid Stellar address. This is typically a DAO treasury, team multisig, or dedicated incentive contract. + +### Step 2: Set the Performance Fee Rate + +``` +Admin calls: set_performance_fee_bps(bps) +``` + +The fee is expressed in basis points (0–10000). For example: +- 100 bps = 1% of yield above HWM +- 500 bps = 5% of yield above HWM +- 1000 bps = 10% of yield above HWM + +**Recommendation:** Start with a conservative rate (100–200 bps) and adjust based on performance. + +### Step 3: Enable the Fee Switch + +``` +Admin calls: set_performance_fee_enabled(true) +``` + +This will fail if the incentive pool has not been configured. + +## How It Works + +1. **Yield reporting:** When a strategy reports yield (via `report_benji_yield` or `accrue_korean_debt_yield`), the contract checks if the performance fee switch is enabled. + +2. **High-water mark comparison:** The yield amount is compared against the strategy's high-water mark. Only yield ABOVE the high-water mark is subject to the performance fee. + +3. **Fee calculation:** `perf_fee = yield_above_hwm × perf_fee_bps / 10_000` + +4. **Transfer:** The performance fee is transferred to the incentive pool address. + +5. **Event emission:** A `pperffee` event is emitted with the fee amount and yield above HWM. + +## Important Notes + +- **No timelock required:** Unlike protocol fee changes, the performance fee switch can be toggled immediately. This is because it affects only new yield, not existing balances. +- **Protocol fee applies first:** The standard protocol fee (`fee_bps`) is deducted before the performance fee. The performance fee is calculated on the net yield after protocol fees. +- **Watermark-based:** Only yield above the high-water mark is subject to the fee. This means early performance that recovers previous losses is not charged. +- **Admin-only:** All configuration changes require admin authorization. + +## Events + +| Event | Symbol | Data | Description | +|-------|--------|------|-------------| +| Pool set | `pperfpool` | (pool_address) | Incentive pool address was changed | +| Fee rate changed | `pperfchg` | (bps) | Performance fee rate was updated | +| Toggle changed | `pperftog` | (enabled) | Fee switch was toggled | +| Fee charged | `pperffee` | (strategy, amount, yield_above_hwm) | Performance fee was collected | + +## Example Governance Proposal + +``` +Title: Activate Performance Fee for Strategy Incentives +Description: Enable a 2% performance fee on yield above the high-water mark, + directing fees to the team incentive pool for strategy development. + +Actions: +1. set_performance_incentive_pool(TTEAM_MULTISIG_ADDRESS) +2. set_performance_fee_bps(200) +3. set_performance_fee_enabled(true) + +Expected Impact: +- 2% of incremental yield directed to strategy development fund +- No impact on existing depositor balances +- Only applies to new yield above previous peak +``` + +## Risk Considerations + +1. **Admin key compromise:** A compromised admin key could set the fee to 100% (10000 bps). Mitigation: use multi-sig for admin, monitor events. +2. **No depositor governance:** Performance fee changes don't require depositor vote. Mitigation: transparent configuration, event monitoring. +3. **Strategy gaming:** An admin could manipulate watermarks to avoid fees. Mitigation: watermarks are cumulative and cannot be lowered. + +## Future Enhancements + +- Governance vote requirement for fee changes above a threshold +- Maximum fee cap enforced at the contract level +- Automatic fee distribution to multiple recipients +- Time-weighted average performance fee (reduce gaming)