From a9c1d4b5e0bd0f023590a59e0486212b0b06c423 Mon Sep 17 00:00:00 2001 From: Harbduls Date: Tue, 2 Jun 2026 13:16:53 +0100 Subject: [PATCH] Implement Flash Loan Standard Interface --- contracts/token/src/events.rs | 7 + contracts/token/src/lib.rs | 348 ++++++++++++++++++++++++---------- 2 files changed, 254 insertions(+), 101 deletions(-) diff --git a/contracts/token/src/events.rs b/contracts/token/src/events.rs index 1ccd7ed..c3fcaf9 100644 --- a/contracts/token/src/events.rs +++ b/contracts/token/src/events.rs @@ -78,3 +78,10 @@ pub fn emit_unpaused(env: &Env, admin: &Address) { env.events() .publish((symbol_short!("unpause"),), (admin.clone(),)); } + +pub fn emit_flash_loan(env: &Env, receiver: &Address, amount: i128, fee: i128) { + env.events().publish( + (symbol_short!("fl_loan"),), + (receiver.clone(), amount, fee), + ); +} diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 222d5b0..975b939 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -1,6 +1,7 @@ //! # bc-forge Token Contract //! -//! A compact SEP-41-compatible token used by the vesting contract tests. +//! A SEP-41-compatible token with admin controls, pausable lifecycle, +//! reentrancy protection, and native flash loan capabilities. #![no_std] @@ -12,32 +13,37 @@ mod rate_limit; mod test; use bc_forge_admin as admin; +use bc_forge_ttl as ttl; use soroban_sdk::token::TokenInterface; use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, Address, Env, String, + contract, contracterror, contractimpl, contracttype, Address, Env, String, Val, Vec, }; -use reentrancy_guard::ReentrancyGuard; -use rate_limit::BcForgeRateLimit; + +// ─── Storage Keys ──────────────────────────────────────────────────────────── #[derive(Clone)] #[contracttype] pub enum DataKey { - /// The contract admin address (singular). - Admin, - PendingAdmin, - /// Spending allowance: (owner, spender) → amount and expiration. - Allowance(Address, Address), - AllowanceExp(Address, Address), /// Token balance for an address. -enum DataKey { Balance(Address), + /// Spending allowance: (owner, spender) → amount and expiration. Allowance(Address, Address), + /// Token decimals (stored as u32). Decimals, + /// Token name. Name, + /// Token symbol. Symbol, + /// Total token supply. Supply, + /// Flash loan fee in basis points (e.g. 5 = 0.05%). + FlashLoanFeeBps, + /// Reentrancy lock for the flash loan function. + FlashLoanActive, } +// ─── Internal Types ────────────────────────────────────────────────────────── + #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] struct AllowanceData { @@ -45,6 +51,8 @@ struct AllowanceData { expiration_ledger: u32, } +// ─── Errors ────────────────────────────────────────────────────────────────── + #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[contracterror] #[repr(u32)] @@ -55,15 +63,26 @@ pub enum TokenError { InsufficientBalance = 4, InsufficientAllowance = 5, ContractPaused = 6, - FeeNotConfigured = 7, - InsufficientFeeBalance = 8, - FeeExemptionNotFound = 9, + /// Flash loan repayment (principal + fee) was not satisfied. + FlashLoanRepaymentFailed = 7, + /// A flash loan is already in progress (reentrancy blocked). + FlashLoanReentrant = 8, + /// Requested borrow amount exceeds the contract's own token balance. + FlashLoanAmountExceedsBalance = 9, } +// ─── Contract ──────────────────────────────────────────────────────────────── + #[contract] pub struct BcForgeToken; +// ─── Internal helpers ──────────────────────────────────────────────────────── + impl BcForgeToken { + fn extend_instance_ttl(env: &Env) { + ttl::extend_instance_ttl(env); + } + fn ensure_initialized(env: &Env) -> Result<(), TokenError> { if admin::has_admin(env) { Ok(()) @@ -87,6 +106,8 @@ impl BcForgeToken { } } + // ── Balance storage ──────────────────────────────────────────────────── + fn read_balance(env: &Env, address: &Address) -> i128 { env.storage() .persistent() @@ -100,19 +121,23 @@ impl BcForgeToken { .set(&DataKey::Balance(address.clone()), &amount); } + // ── Supply storage ───────────────────────────────────────────────────── + fn read_supply(env: &Env) -> i128 { let key = DataKey::Supply; if env.storage().instance().has(&key) { - ttl::extend_instance_ttl(env); + Self::extend_instance_ttl(env); } env.storage().instance().get(&key).unwrap_or(0) } fn write_supply(env: &Env, supply: i128) { env.storage().instance().set(&DataKey::Supply, &supply); - ttl::extend_instance_ttl(env); + Self::extend_instance_ttl(env); } + // ── Allowance storage ────────────────────────────────────────────────── + fn read_allowance_data(env: &Env, from: &Address, spender: &Address) -> AllowanceData { env.storage() .persistent() @@ -132,12 +157,6 @@ impl BcForgeToken { } } - /// Reads the full allowance info for (owner → spender), defaulting to zero allowance with no expiration. - fn read_allowance_info(env: &Env, from: &Address, spender: &Address) -> AllowanceInfo { - env.storage() - .persistent() - .get(&DataKey::Allowance(from.clone(), spender.clone())) - .unwrap_or(AllowanceInfo { amount: 0, exp_ledger: 0 }) fn write_allowance(env: &Env, from: &Address, spender: &Address, amount: i128, exp: u32) { let data = AllowanceData { amount, @@ -148,12 +167,18 @@ impl BcForgeToken { .set(&DataKey::Allowance(from.clone(), spender.clone()), &data); } - fn move_balance(env: &Env, from: &Address, to: &Address, amount: i128) -> Result<(), TokenError> { + // ── Balance movement ─────────────────────────────────────────────────── + + fn move_balance( + env: &Env, + from: &Address, + to: &Address, + amount: i128, + ) -> Result<(), TokenError> { let from_balance = Self::read_balance(env, from); if from_balance < amount { return Err(TokenError::InsufficientBalance); } - if from != to { let to_balance = Self::read_balance(env, to); Self::write_balance(env, from, from_balance - amount); @@ -162,11 +187,17 @@ impl BcForgeToken { Ok(()) } - fn internal_mint(env: &Env, admin_address: &Address, to: &Address, amount: i128) -> Result<(), TokenError> { + // ── Mint helper ──────────────────────────────────────────────────────── + + fn internal_mint( + env: &Env, + admin_address: &Address, + to: &Address, + amount: i128, + ) -> Result<(), TokenError> { if amount <= 0 { return Err(TokenError::InvalidAmount); } - let new_balance = Self::read_balance(env, to) + amount; let new_supply = Self::read_supply(env) + amount; Self::write_balance(env, to, new_balance); @@ -174,10 +205,47 @@ impl BcForgeToken { events::emit_mint(env, admin_address, to, amount, new_balance, new_supply); Ok(()) } + + // ── Flash loan fee ───────────────────────────────────────────────────── + + /// Returns the configured fee in basis points (default: 5 bps = 0.05%). + fn read_flash_loan_fee_bps(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::FlashLoanFeeBps) + .unwrap_or(5u32) + } + + /// Calculates the fee for `amount` given a rate in basis points. + /// Uses ceiling division so that sub-bps amounts still incur at least 1 unit of fee. + fn calculate_flash_loan_fee(amount: i128, fee_bps: u32) -> i128 { + // fee = ceil(amount * fee_bps / 10_000) + let numerator = amount * (fee_bps as i128); + (numerator + 9_999) / 10_000 + } + + // ── Flash loan reentrancy lock ───────────────────────────────────────── + + fn flash_loan_lock(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::FlashLoanActive) + .unwrap_or(false) + } + + fn flash_loan_set_lock(env: &Env, locked: bool) { + env.storage() + .instance() + .set(&DataKey::FlashLoanActive, &locked); + } } +// ─── Public contractimpl ───────────────────────────────────────────────────── + #[contractimpl] impl BcForgeToken { + // ── Initialisation ───────────────────────────────────────────────────── + pub fn initialize( env: Env, admin_address: Address, @@ -188,7 +256,6 @@ impl BcForgeToken { if admin::has_admin(&env) { return Err(TokenError::AlreadyInitialized); } - admin::set_admin(&env, &admin_address); env.storage().instance().set(&DataKey::Decimals, &decimal); env.storage().instance().set(&DataKey::Name, &name); @@ -198,54 +265,13 @@ impl BcForgeToken { Ok(()) } + // ── Admin helpers ────────────────────────────────────────────────────── + pub fn admin(env: Env) -> Address { Self::panic_on_err(&env, Self::ensure_initialized(&env)); admin::get_admin(&env) } - pub fn mint(env: Env, to: Address, amount: i128) -> Result<(), TokenError> { - reentrancy_guard!(&env, "mint_guard", { - Self::ensure_initialized(&env)?; - Self::ensure_not_paused(&env)?; - let current_admin = Self::read_admin(&env)?; - current_admin.require_auth(); - - // Check rate limits for mint operation - if !crate::rate_limit::check_mint_rate_limit(&env, ¤t_admin, amount) { - return Err(TokenError::InvalidAmount); - } - - Self::internal_mint(&env, ¤t_admin, &to, amount) - }) - } - - pub fn batch_mint(env: Env, recipients: Vec) -> Result<(), TokenError> { - reentrancy_guard!(&env, "batch_mint_guard", { - Self::ensure_initialized(&env)?; - Self::ensure_not_paused(&env)?; - let current_admin = Self::read_admin(&env)?; - current_admin.require_auth(); - - for i in 0..recipients.len() { - let recipient = recipients.get(i).expect("recipient should exist"); - if recipient.amount <= 0 { - return Err(TokenError::InvalidAmount); - } - } - Self::extend_instance_ttl_for_call(&env); - Self::ensure_initialized(&env)?; - Self::ensure_not_paused(&env)?; - let admin_address = admin::get_admin(&env); - admin_address.require_auth(); - Self::internal_mint(&env, &admin_address, &to, amount) - } - - pub fn supply(env: Env) -> i128 { - Self::extend_instance_ttl_for_call(&env); - Self::panic_on_err(&env, Self::ensure_initialized(&env)); - Self::read_supply(&env) - } - pub fn transfer_ownership(env: Env, new_admin: Address) -> Result<(), TokenError> { Self::ensure_initialized(&env)?; let current_admin = admin::get_admin(&env); @@ -270,48 +296,165 @@ impl BcForgeToken { events::emit_unpaused(&env, &admin_address); Ok(()) } + + pub fn supply(env: Env) -> i128 { + Self::extend_instance_ttl(&env); + Self::panic_on_err(&env, Self::ensure_initialized(&env)); + Self::read_supply(&env) + } + + // ── Mint ─────────────────────────────────────────────────────────────── + + pub fn mint(env: Env, to: Address, amount: i128) -> Result<(), TokenError> { + Self::ensure_initialized(&env)?; + Self::ensure_not_paused(&env)?; + let admin_address = admin::get_admin(&env); + admin_address.require_auth(); + Self::internal_mint(&env, &admin_address, &to, amount) + } + + // ── Flash loan fee configuration ─────────────────────────────────────── + + /// Sets the flash loan fee in basis points. Admin only. + /// Example: `set_flash_loan_fee_bps(5)` → 0.05% fee. + pub fn set_flash_loan_fee_bps(env: Env, fee_bps: u32) -> Result<(), TokenError> { + Self::ensure_initialized(&env)?; + let admin_address = admin::get_admin(&env); + admin_address.require_auth(); + env.storage() + .instance() + .set(&DataKey::FlashLoanFeeBps, &fee_bps); + Self::extend_instance_ttl(&env); + Ok(()) + } + + /// Returns the current flash loan fee in basis points. + pub fn flash_loan_fee_bps(env: Env) -> u32 { + Self::extend_instance_ttl(&env); + Self::read_flash_loan_fee_bps(&env) + } + + // ── Flash loan ───────────────────────────────────────────────────────── + + /// Executes a native flash loan. + /// + /// # Flow + /// 1. Snapshot the contract's own token balance. + /// 2. Calculate `fee = ceil(amount × fee_bps / 10_000)`. + /// 3. Transfer `amount` tokens from the contract's balance to `receiver`. + /// 4. Invoke `receiver.on_flash_loan(initiator, amount, fee, calldata)`. + /// 5. Assert the contract's balance ≥ snapshot + fee; panic and roll back otherwise. + /// + /// # Reentrancy + /// A per-instance lock prevents a malicious receiver from re-entering `flash_loan` + /// mid-execution. + /// + /// # Errors / Panics + /// - [`TokenError::NotInitialized`] – contract not yet initialised. + /// - [`TokenError::ContractPaused`] – contract is paused. + /// - [`TokenError::InvalidAmount`] – `amount` is ≤ 0. + /// - [`TokenError::FlashLoanAmountExceedsBalance`] – borrow > contract's balance. + /// - [`TokenError::FlashLoanReentrant`] – another flash loan is already in flight. + /// - [`TokenError::FlashLoanRepaymentFailed`] – receiver did not repay principal + fee. + pub fn flash_loan( + env: Env, + receiver: Address, + amount: i128, + calldata: Vec, + ) -> Result<(), TokenError> { + Self::ensure_initialized(&env)?; + Self::ensure_not_paused(&env)?; + + // ── Guard: reentrancy ────────────────────────────────────────────── + if Self::flash_loan_lock(&env) { + return Err(TokenError::FlashLoanReentrant); + } + Self::flash_loan_set_lock(&env, true); + + // ── Validate amount ──────────────────────────────────────────────── + if amount <= 0 { + Self::flash_loan_set_lock(&env, false); + return Err(TokenError::InvalidAmount); + } + + // ── Snapshot & fee ───────────────────────────────────────────────── + let contract_address = env.current_contract_address(); + let balance_before = Self::read_balance(&env, &contract_address); + + if amount > balance_before { + Self::flash_loan_set_lock(&env, false); + return Err(TokenError::FlashLoanAmountExceedsBalance); + } + + let fee_bps = Self::read_flash_loan_fee_bps(&env); + let fee = Self::calculate_flash_loan_fee(amount, fee_bps); + let required_repayment = balance_before + fee; // balance must reach at least this + + // ── Transfer funds to receiver ───────────────────────────────────── + // Direct balance manipulation — no auth required for the contract + // moving its own funds, and no `from.require_auth()` path is triggered. + Self::write_balance(&env, &contract_address, balance_before - amount); + let receiver_bal = Self::read_balance(&env, &receiver); + Self::write_balance(&env, &receiver, receiver_bal + amount); + + // ── Invoke receiver callback ─────────────────────────────────────── + // on_flash_loan(initiator: Address, amount: i128, fee: i128, calldata: Vec) -> Val + let _: Val = env.invoke_contract( + &receiver, + &soroban_sdk::Symbol::new(&env, "on_flash_loan"), + soroban_sdk::vec![ + &env, + contract_address.into_val(&env), + amount.into_val(&env), + fee.into_val(&env), + calldata.into_val(&env), + ], + ); + + // ── Invariant check ──────────────────────────────────────────────── + let balance_after = Self::read_balance(&env, &contract_address); + if balance_after < required_repayment { + // Soroban will roll back the entire transaction when we panic. + soroban_sdk::panic_with_error!(&env, TokenError::FlashLoanRepaymentFailed); + } + + // ── Release lock & emit event ────────────────────────────────────── + Self::flash_loan_set_lock(&env, false); + events::emit_flash_loan(&env, &receiver, amount, fee); + + Ok(()) + } } +// ─── SEP-41 TokenInterface impl ────────────────────────────────────────────── + #[contractimpl] impl TokenInterface for BcForgeToken { fn allowance(env: Env, from: Address, spender: Address) -> i128 { - Self::extend_instance_ttl_for_call(&env); + Self::extend_instance_ttl(&env); Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::allowance_amount(&env, &from, &spender) } - fn approve(env: Env, from: Address, spender: Address, amount: i128, exp: u32) { - reentrancy_guard!(&env, "approve_guard", { - Self::panic_on_err(&env, Self::ensure_initialized(&env)); - from.require_auth(); - if amount < 0 { - soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); - } - Self::write_allowance(&env, &from, &spender, amount, exp); - events::emit_approve(&env, &from, &spender, amount); - }) - Self::extend_instance_ttl_for_call(&env); + fn approve(env: Env, from: Address, spender: Address, amount: i128, expiration_ledger: u32) { + Self::extend_instance_ttl(&env); Self::panic_on_err(&env, Self::ensure_initialized(&env)); from.require_auth(); if amount < 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); } - Self::write_allowance(&env, &from, &spender, amount, exp); - events::emit_approve(&env, &from, &spender, amount, exp); + Self::write_allowance(&env, &from, &spender, amount, expiration_ledger); + events::emit_approve(&env, &from, &spender, amount, expiration_ledger); } fn balance(env: Env, id: Address) -> i128 { - Self::extend_instance_ttl_for_call(&env); + Self::extend_instance_ttl(&env); Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::read_balance(&env, &id) } fn transfer(env: Env, from: Address, to: Address, amount: i128) { - reentrancy_guard!(&env, "transfer_guard", { - Self::panic_on_err(&env, Self::ensure_initialized(&env)); - Self::panic_on_err(&env, Self::ensure_not_paused(&env)); - from.require_auth(); - Self::extend_instance_ttl_for_call(&env); + Self::extend_instance_ttl(&env); Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::panic_on_err(&env, Self::ensure_not_paused(&env)); from.require_auth(); @@ -323,7 +466,7 @@ impl TokenInterface for BcForgeToken { } fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) { - Self::extend_instance_ttl_for_call(&env); + Self::extend_instance_ttl(&env); Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::panic_on_err(&env, Self::ensure_not_paused(&env)); spender.require_auth(); @@ -349,7 +492,7 @@ impl TokenInterface for BcForgeToken { } fn burn(env: Env, from: Address, amount: i128) { - Self::extend_instance_ttl_for_call(&env); + Self::extend_instance_ttl(&env); Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::panic_on_err(&env, Self::ensure_not_paused(&env)); from.require_auth(); @@ -357,10 +500,10 @@ impl TokenInterface for BcForgeToken { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); } - // Check rate limits for burn operation - if !crate::rate_limit::check_burn_rate_limit(&env, &from, amount) { - soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); - } + let balance = Self::read_balance(&env, &from); + if balance < amount { + soroban_sdk::panic_with_error!(&env, TokenError::InsufficientBalance); + } let new_balance = balance - amount; let new_supply = Self::read_supply(&env) - amount; @@ -370,7 +513,7 @@ impl TokenInterface for BcForgeToken { } fn burn_from(env: Env, spender: Address, from: Address, amount: i128) { - Self::extend_instance_ttl_for_call(&env); + Self::extend_instance_ttl(&env); Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::panic_on_err(&env, Self::ensure_not_paused(&env)); spender.require_auth(); @@ -404,13 +547,16 @@ impl TokenInterface for BcForgeToken { } fn decimals(env: Env) -> u32 { - Self::extend_instance_ttl_for_call(&env); + Self::extend_instance_ttl(&env); Self::panic_on_err(&env, Self::ensure_initialized(&env)); - env.storage().instance().get(&DataKey::Decimals).unwrap_or(7) + env.storage() + .instance() + .get(&DataKey::Decimals) + .unwrap_or(7) } fn name(env: Env) -> String { - Self::extend_instance_ttl_for_call(&env); + Self::extend_instance_ttl(&env); Self::panic_on_err(&env, Self::ensure_initialized(&env)); env.storage() .instance() @@ -419,7 +565,7 @@ impl TokenInterface for BcForgeToken { } fn symbol(env: Env) -> String { - Self::extend_instance_ttl_for_call(&env); + Self::extend_instance_ttl(&env); Self::panic_on_err(&env, Self::ensure_initialized(&env)); env.storage() .instance()