diff --git a/contracts/grant_contracts/src/certificate_minter.rs b/contracts/grant_contracts/src/certificate_minter.rs new file mode 100644 index 00000000..86aeb34d --- /dev/null +++ b/contracts/grant_contracts/src/certificate_minter.rs @@ -0,0 +1,72 @@ +use soroban_sdk::{contract, contractimpl, Address, Env, String, Symbol, BytesN, Map}; +use crate::grant_contract::GrantId; // Import from your main grant contract + +#[derive(Clone)] +pub struct CertificateMetadata { + pub grant_id: GrantId, + pub dao: Address, + pub grantee: Address, + pub completion_date: u64, + pub total_streamed: u128, + pub repo_url: String, + pub description: String, +} + +pub trait CertificateMinterTrait { + fn mint_completion_certificate( + env: Env, + grant_id: GrantId, + dao: Address, + grantee: Address, + total_streamed: u128, + repo_url: String, + description: String, + ) -> u32; // Returns token ID + + fn get_certificate_metadata(env: Env, token_id: u32) -> CertificateMetadata; + fn is_completed_certificate(env: Env, token_id: u32) -> bool; +} + +#[contract] +pub struct CertificateMinter; + +#[contractimpl] +impl CertificateMinterTrait for CertificateMinter { + fn mint_completion_certificate( + env: Env, + grant_id: GrantId, + dao: Address, + grantee: Address, + total_streamed: u128, + repo_url: String, + description: String, + ) -> u32 { + // Only callable by the Grant Contract (or DAO admin) + // Add authorization check here (e.g. require_auth from grant contract address) + + let token_id = Self::sequential_mint(&env, &grantee); // Using OZ-style sequential mint + + let metadata: Map = Map::new(&env); + metadata.set(String::from_str(&env, "name"), String::from_str(&env, "Grant Completion Certificate")); + metadata.set(String::from_str(&env, "grant_id"), String::from_str(&env, &grant_id.to_string())); + metadata.set(String::from_str(&env, "dao"), String::from_str(&env, &dao.to_string())); + metadata.set(String::from_str(&env, "grantee"), String::from_str(&env, &grantee.to_string())); + metadata.set(String::from_str(&env, "completion_date"), String::from_str(&env, &env.ledger().timestamp().to_string())); + metadata.set(String::from_str(&env, "total_streamed"), String::from_str(&env, &total_streamed.to_string())); + metadata.set(String::from_str(&env, "repo_url"), repo_url.clone()); + metadata.set(String::from_str(&env, "description"), description); + + // Store metadata (you can use IPFS hash in production for off-chain JSON) + Self::set_token_metadata(&env, token_id, metadata); + + env.events().publish( + (Symbol::new(&env, "certificate_minted"),), + (token_id, grant_id, grantee) + ); + + token_id + } + + // Additional view functions... + fn get_certificate_metadata(env: Env, token_id: u32) -> CertificateMetadata { ... } +} \ No newline at end of file diff --git a/contracts/grant_contracts/src/certificate_test.rs b/contracts/grant_contracts/src/certificate_test.rs new file mode 100644 index 00000000..6ab6dd5c --- /dev/null +++ b/contracts/grant_contracts/src/certificate_test.rs @@ -0,0 +1,12 @@ +#[test] +fn test_mint_completion_certificate_on_full_payout() { + let env = Env::default(); + env.mock_all_auths(); + + // Setup grant that reaches 100% + // Call finish_stream + let token_id = client.mint_completion_certificate(...); + + assert!(token_id > 0); + // Verify metadata contains correct grant_id, repo_url, etc. +} \ No newline at end of file diff --git a/contracts/grant_contracts/src/lib.rs b/contracts/grant_contracts/src/lib.rs index 01a58293..a4ba2c22 100644 --- a/contracts/grant_contracts/src/lib.rs +++ b/contracts/grant_contracts/src/lib.rs @@ -94,8 +94,8 @@ pub mod sub_dao_authority; pub mod grant_appeals; pub mod wasm_hash_verification; pub mod cross_chain_metadata; -pub mod recursive_funding; -pub mod interest_redirection; +pub mod temporal_guard; +pub mod yield_reserve; // --- Test Modules --- #[cfg(test)] @@ -948,6 +948,9 @@ pub enum GrantError { InvalidGrantee, InvalidStreamConfig, InvalidAccelerationConfig, + InsufficientLiquidity, + InvalidAllocation, + YieldHarvestFailed, } #[derive(Clone, Debug)] @@ -2121,7 +2124,7 @@ impl GrantContract { Ok(()) } - /// Task #194: Stellar DEX Direct-to-Grantee Path Payment Hook + /// Task rgb(57, 158, 96): Stellar DEX Direct-to-Grantee Path Payment Hook /// Withdrawals automatically swapped on the Stellar DEX for preferred builder currency. pub fn swap_and_withdraw( env: Env, diff --git a/contracts/grant_contracts/src/lp_staking.rs b/contracts/grant_contracts/src/lp_staking.rs new file mode 100644 index 00000000..1a2ba55c --- /dev/null +++ b/contracts/grant_contracts/src/lp_staking.rs @@ -0,0 +1,91 @@ +use soroban_sdk::{contractimpl, Address, Env, Symbol, Vec, Map}; +use crate::grant_contract::{GrantContract, GrantId, Collateral, GrantError}; + +#[derive(Clone)] +pub struct LpPosition { + pub lp_token: Address, // Address of the LP share token (SAC or custom) + pub amount_staked: u128, + pub pool_address: Option
, // Optional: reference to the actual liquidity pool + pub accrued_fees: u128, // Track fees/rewards earned while staked +} + +pub trait LpStakingTrait { + // Stake LP tokens as collateral for a grant + fn stake_lp_as_collateral(env: Env, grant_id: GrantId, lp_token: Address, amount: u128, pool: Option
); + + // Claim loyalty bonus (LP fees) when milestones are successfully met + fn claim_loyalty_bonus(env: Env, grant_id: GrantId) -> u128; + + // Slash LP collateral (called by DAO or on default) + fn slash_lp_collateral(env: Env, grant_id: GrantId, percentage: u32); // e.g. 100 for full slash + + // View LP positions and accrued rewards + fn get_lp_positions(env: Env, grant_id: GrantId) -> Vec; +} + +#[contractimpl] +impl LpStakingTrait for GrantContract { + fn stake_lp_as_collateral(env: Env, grant_id: GrantId, lp_token: Address, amount: u128, pool: Option
) { + let mut grant = Self::get_grant(&env, grant_id); + // Authorization & validation + grant.admin.require_auth(); // or grantee depending on flow + + // Transfer LP tokens from grantee to this contract (escrow) + let client = soroban_sdk::token::Client::new(&env, &lp_token); + client.transfer(&env.current_contract_address(), &grant.grantee, &(amount as i128)); // adjust types as needed + + let position = LpPosition { + lp_token: lp_token.clone(), + amount_staked: amount, + pool_address: pool, + accrued_fees: 0, + }; + + grant.collateral.lp_positions.push(position); // Extend existing collateral struct + Self::save_grant(&env, grant_id, grant); + env.events().publish((Symbol::new(&env, "lp_staked"), grant_id), amount); + } + + fn claim_loyalty_bonus(env: Env, grant_id: GrantId) -> u128 { + let grant = Self::get_grant(&env, grant_id); + if !grant.is_completed_successfully() { + panic_with_error!(&env, GrantError::GrantNotEligible); + } + + let mut total_bonus = 0u128; + for pos in grant.collateral.lp_positions.iter() { + // In real implementation: query the LP pool or use a reward distributor + // For MVP: simulate or use a simple fee accrual + let bonus = pos.accrued_fees; // or calculate share of pool fees + if bonus > 0 { + let token_client = soroban_sdk::token::Client::new(&env, &pos.lp_token); + token_client.transfer(&env.current_contract_address(), &grant.grantee, &(bonus as i128)); + total_bonus += bonus; + } + } + + env.events().publish((Symbol::new(&env, "loyalty_bonus_claimed"), grant_id), total_bonus); + total_bonus + } + + fn slash_lp_collateral(env: Env, grant_id: GrantId, percentage: u32) { + // Only DAO / admin can slash + // ... authorization check ... + + let mut grant = Self::get_grant(&env, grant_id); + for pos in grant.collateral.lp_positions.iter_mut() { + let slash_amount = (pos.amount_staked * percentage as u128) / 100; + if slash_amount > 0 { + let token_client = soroban_sdk::token::Client::new(&env, &pos.lp_token); + token_client.transfer(&env.current_contract_address(), &grant.dao_treasury, &(slash_amount as i128)); + pos.amount_staked -= slash_amount; + } + } + Self::save_grant(&env, grant_id, grant); + } + + fn get_lp_positions(env: Env, grant_id: GrantId) -> Vec { + let grant = Self::get_grant(&env, grant_id); + grant.collateral.lp_positions.clone() + } +} \ No newline at end of file diff --git a/contracts/grant_contracts/src/yield_reserve.rs b/contracts/grant_contracts/src/yield_reserve.rs new file mode 100644 index 00000000..7e50e7f5 --- /dev/null +++ b/contracts/grant_contracts/src/yield_reserve.rs @@ -0,0 +1,99 @@ +use soroban_sdk::{contractimpl, Address, Env, Symbol, Map, Vec}; +use crate::grant_contract::{GrantContract, GrantId, Grant, GrantError}; + +#[derive(Clone)] +pub struct ReserveAllocation { + pub asset: Address, // e.g. BENJI token address + pub allocated_amount: u128, // Amount moved to yield-bearing asset + pub principal_reserved: u128, // Portion of this that must remain as principal + pub yield_accrued: u128, + pub allocation_timestamp: u64, +} + +pub trait YieldReserveTrait { + // DAO allocates a percentage of unstreamed treasury to yield + fn allocate_to_yield(env: Env, asset: Address, percentage: u32); // e.g. 40 = 40% + + // Withdraw yield (principal stays protected) + fn harvest_yield(env: Env, asset: Address) -> u128; + + // Withdraw principal only in emergency (with DAO approval) + fn emergency_withdraw_principal(env: Env, asset: Address, amount: u128); + + // Get current liquid vs yield-bearing breakdown + fn get_reserve_status(env: Env) -> (u128, u128, u128); // (liquid, in_yield, accrued_yield) + + // Check if enough liquidity for next 30 days of streams + fn check_liquidity_safety(env: Env) -> bool; +} + +#[contractimpl] +impl YieldReserveTrait for GrantContract { + fn allocate_to_yield(env: Env, asset: Address, percentage: u32) { + // Only DAO admin + // ... authorization check (use existing DAO logic) ... + + if percentage > 70 { // Configurable max, e.g. 70% + panic_with_error!(&env, GrantError::InvalidAllocation); + } + + let total_unstreamed = Self::calculate_total_unstreamed(&env); + let amount_to_allocate = (total_unstreamed * percentage as u128) / 100; + + let next_30_days = Self::calculate_next_30_days_obligations(&env); + let minimum_liquid = next_30_days * 120 / 100; // 20% buffer + + if amount_to_allocate + minimum_liquid > total_unstreamed { + panic_with_error!(&env, GrantError::InsufficientLiquidity); + } + + // Transfer to yield asset (assuming it's a standard token client) + let token = soroban_sdk::token::Client::new(&env, &asset); + token.transfer(&env.current_contract_address(), &env.current_contract_address(), &(amount_to_allocate as i128)); + // In production: call deposit function on lending protocol if needed + + let mut allocation = ReserveAllocation { + asset: asset.clone(), + allocated_amount: amount_to_allocate, + principal_reserved: amount_to_allocate, // initially all is principal + yield_accrued: 0, + allocation_timestamp: env.ledger().timestamp(), + }; + + Self::save_allocation(&env, allocation); + env.events().publish((Symbol::new(&env, "yield_allocated"),), (asset, amount_to_allocate)); + } + + fn harvest_yield(env: Env, asset: Address) -> u128 { + // ... auth check ... + + let mut allocation = Self::get_allocation(&env, &asset); + let current_balance = Self::get_current_balance_of(&env, &asset); // query token balance + + let yield_earned = current_balance.saturating_sub(allocation.allocated_amount); + + if yield_earned > 0 { + allocation.yield_accrued += yield_earned; + // Transfer yield to DAO treasury or designated receiver + let token = soroban_sdk::token::Client::new(&env, &asset); + token.transfer(&env.current_contract_address(), &Self::get_dao_treasury(&env), &(yield_earned as i128)); + } + + Self::save_allocation(&env, allocation); + env.events().publish((Symbol::new(&env, "yield_harvested"),), yield_earned); + yield_earned + } + + fn check_liquidity_safety(env: Env) -> bool { + let next_30_days = Self::calculate_next_30_days_obligations(&env); + let liquid_balance = Self::get_liquid_balance(&env); // across supported assets + + liquid_balance >= next_30_days * 110 / 100 // 10% safety buffer + } + + fn get_reserve_status(env: Env) -> (u128, u128, u128) { + // Returns (total_liquid, total_in_yield, total_accrued_yield) + // Implementation aggregates from allocations map + (0, 0, 0) // placeholder - expand with storage map + } +} \ No newline at end of file diff --git a/contracts/tests/lp_staking_test.rs b/contracts/tests/lp_staking_test.rs new file mode 100644 index 00000000..44d25be5 --- /dev/null +++ b/contracts/tests/lp_staking_test.rs @@ -0,0 +1,23 @@ +#[test] +fn test_lp_staking_as_collateral() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, GrantContract); + let client = GrantContractClient::new(&env, &contract_id); + + // Setup test LP token (use mock or deploy SAC) + let lp_token = ...; // Address of test LP token + let grantee = Address::generate(&env); + + // Stake LP + client.stake_lp_as_collateral(&grant_id, &lp_token, &1000u128, &None); + + // Complete milestones successfully + // ... + + let bonus = client.claim_loyalty_bonus(&grant_id); + assert!(bonus > 0); + + // Test slash scenario +} \ No newline at end of file diff --git a/contracts/tests/yield_reserve_test.rs b/contracts/tests/yield_reserve_test.rs new file mode 100644 index 00000000..2fef1b51 --- /dev/null +++ b/contracts/tests/yield_reserve_test.rs @@ -0,0 +1,14 @@ +#[test] +fn test_yield_allocation_with_safety() { + let env = Env::default(); + env.mock_all_auths(); + + // Setup grants with streaming obligations + // Allocate 40% to BENJI + client.allocate_to_yield(&benji_address, &40u32); + + assert!(client.check_liquidity_safety()); + + let yield_earned = client.harvest_yield(&benji_address); + assert!(yield_earned > 0); +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index e8e9f5c9..df8e9ebd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,8 +19,8 @@ pub struct Grant { pub struct SubStream { pub creator: Address, pub subscriber: Address, - pub flow_rate: i128, // tokens per second - pub balance: i128, // available revenue + pub flow_rate: i128, + pub balance: i128, pub last_claim_time: u64, pub is_active: bool, } @@ -28,7 +28,7 @@ pub struct SubStream { #[contracttype] pub enum DataKey { Grant(u64), - SubStream(u64), // New: SubStream storage + SubStream(u64), GrantCount, SubStreamCount, Arbiter, @@ -39,8 +39,6 @@ pub struct GrantContract; #[contractimpl] impl GrantContract { - // ──────────────────────────────────────────────── - // Storage TTL optimization (from previous work) fn ensure_sufficient_ttl(env: &Env) { const THRESHOLD: u32 = 1000; let max_ttl = env.storage().max_ttl(); @@ -59,28 +57,27 @@ impl GrantContract { .get(&DataKey::SubStream(substream_id)) .unwrap_or_else(|| panic!("SubStream not found")); - // Only the grant admin or grantee can initiate the bridge - if !grant.admin == env.invoker() && !grant.grantee == env.invoker() { - panic!("Unauthorized"); + // Authorization: Only admin or grantee can bridge + if env.invoker() != grant.admin && env.invoker() != grant.grantee { + panic!("Unauthorized: only admin or grantee can bridge SubStream"); } - // Query available revenue from SubStream - if substream.balance <= 0 || !substream.is_active { + // Check SubStream is active and has balance + if !substream.is_active || substream.balance <= 0 { panic!("Insufficient or inactive SubStream balance"); } - // Use SubStream balance as collateral (reduce required upfront stake) + // Use SubStream balance as collateral grant.balance += substream.balance; env.storage().instance().set(&DataKey::Grant(grant_id), &grant); - // Optional: Emit event + // Optional: emit event // env.events().publish(("SubStreamBridged", grant_id, substream_id), substream.balance); true } - // Existing functions (cleaned slightly) pub fn set_arbiter(env: Env, admin: Address, arbiter: Address) { Self::ensure_sufficient_ttl(&env); admin.require_auth(); @@ -171,7 +168,7 @@ impl GrantContract { } // ──────────────────────────────────────────────── -// SubStream Contract (New) +// SubStream Contract // ──────────────────────────────────────────────── #[contract] @@ -186,7 +183,7 @@ impl SubStreamContract { flow_rate: i128, token: Address, ) -> u64 { - Self::ensure_sufficient_ttl(&env); // reuse helper if you move it to lib + Self::ensure_sufficient_ttl(&env); creator.require_auth(); @@ -207,11 +204,9 @@ impl SubStreamContract { count } - - // Add more SubStream logic as needed (claim, pause, etc.) } -// Helper for TTL (shared) +// Shared helper fn ensure_sufficient_ttl(env: &Env) { const THRESHOLD: u32 = 1000; let max_ttl = env.storage().max_ttl();