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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions api/db/init/08_yield_optimizer.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
-- Yield Optimizer Schema
-- Issue #607: Auto-compounding vault and yield optimization

CREATE TABLE IF NOT EXISTS yield_vaults (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vault_address TEXT NOT NULL UNIQUE,
share_token TEXT NOT NULL,
underlying_asset TEXT NOT NULL,
reward_asset TEXT NOT NULL,
total_assets NUMERIC NOT NULL DEFAULT 0,
total_shares NUMERIC NOT NULL DEFAULT 0,
share_price NUMERIC NOT NULL DEFAULT 1000000000,
performance_fee_bps INTEGER NOT NULL DEFAULT 1000,
management_fee_bps INTEGER NOT NULL DEFAULT 100,
harvest_interval_secs BIGINT NOT NULL DEFAULT 86400,
apy_base NUMERIC, -- base APY without compounding
apy_compounded NUMERIC, -- APY with auto-compounding
apy_boost_bps INTEGER, -- boost in basis points
last_harvested_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_yield_vaults_asset ON yield_vaults (underlying_asset);

CREATE TABLE IF NOT EXISTS yield_compounding_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vault_address TEXT NOT NULL,
rewards_claimed NUMERIC NOT NULL,
rewards_reinvested NUMERIC NOT NULL,
performance_fee NUMERIC NOT NULL,
gas_cost_stroops BIGINT,
total_assets_before NUMERIC NOT NULL,
total_assets_after NUMERIC NOT NULL,
share_price_before NUMERIC NOT NULL,
share_price_after NUMERIC NOT NULL,
compounded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_compounding_vault ON yield_compounding_events (vault_address);
CREATE INDEX IF NOT EXISTS idx_compounding_timestamp ON yield_compounding_events (compounded_at DESC);

CREATE TABLE IF NOT EXISTS yield_strategies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vault_address TEXT NOT NULL,
strategy_type TEXT NOT NULL, -- 'conservative', 'moderate', 'aggressive'
target_pools JSONB NOT NULL DEFAULT '[]',
min_apy_bps INTEGER NOT NULL DEFAULT 0,
max_allocation_pct NUMERIC NOT NULL DEFAULT 100,
rebalance_threshold_bps INTEGER NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_yield_strategies_vault ON yield_strategies (vault_address);
129 changes: 129 additions & 0 deletions api/src/controllers/yieldOptimizer.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Request, Response, Router } from 'express';

const router = Router();

interface YieldVault {
address: string;
shareToken: string;
underlyingAsset: string;
rewardAsset: string;
totalAssets: number;
totalShares: number;
sharePrice: number;
apyBase: number;
apyCompounded: number;
apyBoostBps: number;
lastHarvestedAt: number | null;
createdAt: number;
}

const vaults: Map<string, YieldVault> = new Map();

// POST /api/yield/vault - Create or update vault info
router.post('/vault', (req: Request, res: Response) => {
const { address, shareToken, underlyingAsset, rewardAsset } = req.body;
const vault: YieldVault = {
address,
shareToken,
underlyingAsset,
rewardAsset,
totalAssets: 0,
totalShares: 0,
sharePrice: 1000000000,
apyBase: 0,
apyCompounded: 0,
apyBoostBps: 0,
lastHarvestedAt: null,
createdAt: Math.floor(Date.now() / 1000),
};
vaults.set(address, vault);
res.status(201).json({ success: true, vault });
});

// GET /api/yield/vault/:address - Get vault details
router.get('/vault/:address', (req: Request, res: Response) => {
const vault = vaults.get(req.params.address);
if (!vault) return res.status(404).json({ error: 'Vault not found' });
res.json({ vault });
});

// GET /api/yield/vaults - Get all vaults
router.get('/vaults', (req: Request, res: Response) => {
res.json({ vaults: Array.from(vaults.values()) });
});

// POST /api/yield/vault/:address/harvest - Execute harvest/compound
router.post('/vault/:address/harvest', (req: Request, res: Response) => {
const vault = vaults.get(req.params.address);
if (!vault) return res.status(404).json({ error: 'Vault not found' });

const { minRewards } = req.body;
const now = Math.floor(Date.now() / 1000);

// Simulate harvest (in production this calls the contract)
const rewardsClaimed = Math.floor(vault.totalAssets * 0.01); // 1% simulation
const performanceFee = Math.floor(rewardsClaimed * 0.1); // 10% perf fee
const rewardsReinvested = rewardsClaimed - performanceFee;

vault.totalAssets += rewardsReinvested;
vault.sharePrice = vault.totalShares > 0
? Math.floor(vault.totalAssets * 1000000000 / vault.totalShares)
: 1000000000;
vault.lastHarvestedAt = now;
vaults.set(req.params.address, vault);

res.json({
success: true,
event: {
rewardsClaimed,
rewardsReinvested,
performanceFee,
newTotalAssets: vault.totalAssets,
timestamp: now,
},
});
});

// GET /api/yield/vault/:address/preview-deposit
router.get('/vault/:address/preview-deposit', (req: Request, res: Response) => {
const vault = vaults.get(req.params.address);
if (!vault) return res.status(404).json({ error: 'Vault not found' });

const amount = parseInt(req.query.amount as string) || 0;
let shares = amount;
if (vault.totalShares > 0 && vault.totalAssets > 0) {
shares = Math.floor(amount * vault.totalShares / vault.totalAssets);
}
res.json({ shares });
});

// GET /api/yield/vault/:address/preview-withdraw
router.get('/vault/:address/preview-withdraw', (req: Request, res: Response) => {
const vault = vaults.get(req.params.address);
if (!vault) return res.status(404).json({ error: 'Vault not found' });

const shares = parseInt(req.query.shares as string) || 0;
let assets = 0;
if (vault.totalShares > 0) {
assets = Math.floor(shares * vault.totalAssets / vault.totalShares);
}
res.json({ assets });
});

// POST /api/yield/strategy - Set yield strategy
router.post('/strategy', (req: Request, res: Response) => {
const { vaultAddress, strategyType, targetPools, minApyBps, maxAllocationPct } = req.body;
res.json({
success: true,
strategy: {
vaultAddress,
strategyType,
targetPools: targetPools || [],
minApyBps: minApyBps || 0,
maxAllocationPct: maxAllocationPct || 100,
isActive: true,
},
});
});

export default router;
106 changes: 106 additions & 0 deletions api/src/services/yield/auto-compound.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import axios from 'axios';

interface VaultConfig {
performanceFeeBps: number;
managementFeeBps: number;
harvestIntervalSecs: number;
slippageToleranceBps: number;
depositPaused: boolean;
withdrawPaused: boolean;
active: boolean;
}

interface VaultSnapshot {
totalAssets: number;
totalShares: number;
sharePrice: number;
lastHarvestedAt: number;
accruedManagementFees: number;
accruedPerformanceFees: number;
}

interface CompoundingEvent {
rewardsClaimed: number;
rewardsReinvested: number;
performanceFee: number;
newTotalAssets: number;
timestamp: number;
}

type YieldStrategy = 'conservative' | 'moderate' | 'aggressive';

export class AutoCompoundService {
private contractEndpoint: string;

constructor(contractEndpoint: string) {
this.contractEndpoint = contractEndpoint;
}

async getVaultSnapshot(vault: string): Promise<VaultSnapshot> {
const res = await axios.get(`${this.contractEndpoint}/vault/${vault}/snapshot`);
return res.data.snapshot;
}

async getCompoundSchedule(vault: string): Promise<{
canCompound: boolean;
nextCompoundAt: number;
estimatedGasStroops: number;
}> {
const res = await axios.get(`${this.contractEndpoint}/vault/${vault}/compound-schedule`);
return res.data;
}

async executeCompound(vault: string, caller: string, minRewards: number): Promise<CompoundingEvent> {
const res = await axios.post(`${this.contractEndpoint}/vault/${vault}/harvest`, {
caller,
minRewards,
});
return res.data.event;
}

async previewDeposit(vault: string, amount: number): Promise<{ shares: number }> {
const res = await axios.get(`${this.contractEndpoint}/vault/${vault}/preview-deposit`, {
params: { amount },
});
return res.data;
}

async previewWithdraw(vault: string, shares: number): Promise<{ assets: number }> {
const res = await axios.get(`${this.contractEndpoint}/vault/${vault}/preview-withdraw`, {
params: { shares },
});
return res.data;
}

calculateApyBoost(baseApy: number, compoundFrequency: number, compoundIntervalDays: number): number {
if (baseApy <= 0) return 0;
const ratePerPeriod = baseApy / compoundFrequency;
const compoundedApy = (1 + ratePerPeriod) ** compoundFrequency - 1;
return compoundedApy - baseApy;
}

estimateOptimalCompoundInterval(
gasCostStroops: number,
rewardAmount: number,
assetPrice: number,
baseApy: number,
): number {
if (rewardAmount <= 0 || gasCostStroops <= 0) return 86400; // default 1 day
const profitPerCompound = rewardAmount * assetPrice;
const gasCostUsd = gasCostStroops * 0.00001; // approximate
const minProfitRatio = gasCostUsd / profitPerCompound;
if (minProfitRatio <= 0) return 3600; // min 1 hour
const optimalDays = Math.ceil(1 / (minProfitRatio * 365));
return Math.max(3600, Math.min(604800, optimalDays * 86400)); // between 1 hour and 7 days
}

selectBestStrategy(poolApys: { pool: string; apy: number; risk: number }[], riskTolerance: 'low' | 'medium' | 'high'): YieldStrategy {
if (riskTolerance === 'low') return 'conservative';
if (riskTolerance === 'high') return 'aggressive';
return 'moderate';
}
}

export const autoCompoundService = new AutoCompoundService(
process.env.CONTRACT_API_URL || 'http://localhost:3001/api'
);
Loading