Skip to content
Merged
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
72 changes: 72 additions & 0 deletions contracts/grant_contracts/src/certificate_minter.rs
Original file line number Diff line number Diff line change
@@ -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<String, String> = 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 { ... }
}
12 changes: 12 additions & 0 deletions contracts/grant_contracts/src/certificate_test.rs
Original file line number Diff line number Diff line change
@@ -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.
}
9 changes: 6 additions & 3 deletions contracts/grant_contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -948,6 +948,9 @@ pub enum GrantError {
InvalidGrantee,
InvalidStreamConfig,
InvalidAccelerationConfig,
InsufficientLiquidity,
InvalidAllocation,
YieldHarvestFailed,
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -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,
Expand Down
91 changes: 91 additions & 0 deletions contracts/grant_contracts/src/lp_staking.rs
Original file line number Diff line number Diff line change
@@ -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<Address>, // 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<Address>);

// 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<LpPosition>;
}

#[contractimpl]
impl LpStakingTrait for GrantContract {
fn stake_lp_as_collateral(env: Env, grant_id: GrantId, lp_token: Address, amount: u128, pool: Option<Address>) {
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<LpPosition> {
let grant = Self::get_grant(&env, grant_id);
grant.collateral.lp_positions.clone()
}
}
99 changes: 99 additions & 0 deletions contracts/grant_contracts/src/yield_reserve.rs
Original file line number Diff line number Diff line change
@@ -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
}
}
23 changes: 23 additions & 0 deletions contracts/tests/lp_staking_test.rs
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 14 additions & 0 deletions contracts/tests/yield_reserve_test.rs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading