diff --git a/api/db/init/06_liquidation_integration.sql b/api/db/init/06_liquidation_integration.sql new file mode 100644 index 00000000..85165c8a --- /dev/null +++ b/api/db/init/06_liquidation_integration.sql @@ -0,0 +1,5 @@ +ALTER TABLE auction_events ADD COLUMN IF NOT EXISTS pool_address TEXT; +ALTER TABLE auction_events ADD COLUMN IF NOT EXISTS borrower TEXT; +ALTER TABLE auction_events ADD COLUMN IF NOT EXISTS liquidation_type TEXT DEFAULT 'dutch_auction'; +ALTER TABLE auction_events ADD COLUMN IF NOT EXISTS discount_floor_bps INTEGER; +ALTER TABLE auction_events ADD COLUMN IF NOT EXISTS min_price_bps INTEGER; diff --git a/api/src/controllers/liquidationAuction.controller.ts b/api/src/controllers/liquidationAuction.controller.ts new file mode 100644 index 00000000..8157407f --- /dev/null +++ b/api/src/controllers/liquidationAuction.controller.ts @@ -0,0 +1,121 @@ +import { Request, Response, Router } from 'express'; + +const router = Router(); + +interface AuctionData { + id: number; + pool: string; + borrower: string; + collateralAsset: string; + debtAsset: string; + collateralAmount: string; + debtAmount: string; + oraclePrice: string; + currentPrice: string; + startTime: number; + endTime: number; + status: 'Active' | 'Settled' | 'Expired'; + highestBidder: string | null; + highestBidAmount: string | null; +} + +const auctions: Map = new Map(); +let nextId = 1; + +router.post('/', (req: Request, res: Response) => { + const { pool, borrower, collateralAsset, debtAsset, collateralAmount, debtAmount, oraclePrice, durationSecs } = req.body; + + const id = nextId++; + const now = Math.floor(Date.now() / 1000); + + const auction: AuctionData = { + id, + pool, + borrower, + collateralAsset, + debtAsset, + collateralAmount, + debtAmount, + oraclePrice, + currentPrice: oraclePrice, + startTime: now, + endTime: now + (durationSecs || 3600), + status: 'Active', + highestBidder: null, + highestBidAmount: null, + }; + + auctions.set(id, auction); + + res.status(201).json({ success: true, auction }); +}); + +router.get('/active', (req: Request, res: Response) => { + const active = Array.from(auctions.values()).filter(a => a.status === 'Active'); + res.json({ auctions: active }); +}); + +router.get('/pool/:poolAddress', (req: Request, res: Response) => { + const poolAuctions = Array.from(auctions.values()).filter( + a => a.pool === req.params.poolAddress + ); + res.json({ auctions: poolAuctions }); +}); + +router.get('/:id', (req: Request, res: Response) => { + const auction = auctions.get(parseInt(req.params.id)); + if (!auction) return res.status(404).json({ error: 'Auction not found' }); + res.json({ auction }); +}); + +router.post('/bid', (req: Request, res: Response) => { + const { auctionId, bidder, repayAmount } = req.body; + const auction = auctions.get(auctionId); + + if (!auction) return res.status(404).json({ error: 'Auction not found' }); + if (auction.status !== 'Active') return res.status(400).json({ error: 'Auction not active' }); + + const now = Math.floor(Date.now() / 1000); + if (now >= auction.endTime) { + return res.status(400).json({ error: 'Auction has ended' }); + } + + const elapsed = now - auction.startTime; + const duration = auction.endTime - auction.startTime; + const oraclePrice = parseFloat(auction.oraclePrice); + const minPrice = oraclePrice * 0.7; + const priceRange = oraclePrice - minPrice; + const priceDrop = (priceRange * elapsed) / duration; + const currentPrice = (oraclePrice - priceDrop).toFixed(7); + + const repayNum = parseFloat(repayAmount); + const collateralRatio = parseFloat(auction.collateralAmount) / parseFloat(auction.debtAmount); + const collateralReceived = (repayNum * collateralRatio).toFixed(7); + + auction.status = 'Settled'; + auction.highestBidder = bidder; + auction.highestBidAmount = repayAmount; + auction.currentPrice = currentPrice; + + res.json({ + success: true, + collateralReceived, + debtRepaid: repayAmount, + currentPrice, + }); +}); + +router.post('/:id/expire', (req: Request, res: Response) => { + const auction = auctions.get(parseInt(req.params.id)); + if (!auction) return res.status(404).json({ error: 'Auction not found' }); + + const now = Math.floor(Date.now() / 1000); + const originalDuration = auction.endTime - auction.startTime; + + auction.endTime = now + originalDuration * 2; + auction.currentPrice = (parseFloat(auction.oraclePrice) * 0.5).toFixed(7); + + res.json({ success: true, auction }); +}); + +export default router; diff --git a/api/src/services/liquidationAuction.service.ts b/api/src/services/liquidationAuction.service.ts new file mode 100644 index 00000000..d42bb417 --- /dev/null +++ b/api/src/services/liquidationAuction.service.ts @@ -0,0 +1,96 @@ +import axios from 'axios'; + +interface AuctionConfig { + durationSecs: number; + minPriceBps: number; + discountFloorBps: number; + maxParallelAuctions: number; +} + +interface LiquidationAuction { + id: number; + pool: string; + borrower: string; + collateralAsset: string; + debtAsset: string; + collateralAmount: string; + debtAmount: string; + oraclePrice: string; + currentPrice: string; + startTime: number; + endTime: number; + status: 'Active' | 'Settled' | 'Expired'; + highestBidder: string | null; + highestBidAmount: string | null; +} + +export class LiquidationAuctionService { + private contractEndpoint: string; + + constructor(contractEndpoint: string) { + this.contractEndpoint = contractEndpoint; + } + + async createAuction(params: { + pool: string; + borrower: string; + collateralAsset: string; + debtAsset: string; + collateralAmount: string; + debtAmount: string; + oraclePrice: string; + durationSecs?: number; + }): Promise { + const res = await axios.post(`${this.contractEndpoint}/auctions`, params); + return res.data.auction.id; + } + + async getActiveAuctions(): Promise { + const res = await axios.get(`${this.contractEndpoint}/auctions/active`); + return res.data.auctions || []; + } + + async getCurrentPrice(auctionId: number): Promise { + const res = await axios.get(`${this.contractEndpoint}/auctions/${auctionId}`); + const auction = res.data.auction; + const now = Math.floor(Date.now() / 1000); + + if (now >= auction.endTime) { + return (parseFloat(auction.oraclePrice) * 0.7).toFixed(7); + } + + const elapsed = now - auction.startTime; + const duration = auction.endTime - auction.startTime; + const priceRange = parseFloat(auction.oraclePrice) * 0.3; + const priceDrop = (priceRange * elapsed) / duration; + return (parseFloat(auction.oraclePrice) - priceDrop).toFixed(7); + } + + async placeBid(auctionId: number, bidder: string, repayAmount: string): Promise<{ + collateralReceived: string; + debtRepaid: string; + }> { + const res = await axios.post(`${this.contractEndpoint}/auctions/bid`, { + auctionId, + bidder, + repayAmount, + }); + return res.data; + } + + calculateDiscountBps(oraclePrice: string, currentPrice: string): number { + const oracle = parseFloat(oraclePrice); + const current = parseFloat(currentPrice); + if (oracle <= 0) return 0; + return Math.round(((oracle - current) * 10000) / oracle); + } + + estimateProfit(auction: LiquidationAuction): number { + const collateralVal = parseFloat(auction.collateralAmount) * parseFloat(auction.currentPrice) / 10000; + return collateralVal - parseFloat(auction.debtAmount); + } +} + +export const liquidationAuctionService = new LiquidationAuctionService( + process.env.CONTRACT_API_URL || 'http://localhost:3001/api' +); diff --git a/bots/liquidation-bot/.env.example b/bots/liquidation-bot/.env.example index 3e5ece80..d6a9a154 100644 --- a/bots/liquidation-bot/.env.example +++ b/bots/liquidation-bot/.env.example @@ -18,6 +18,13 @@ POLL_INTERVAL_MS=5000 DRY_RUN=true MAX_CONCURRENT_LIQUIDATIONS=3 +# Dutch Auction Strategy +BID_STRATEGY=earliest_discount +# Options: earliest_discount, highest_premium, fastest_fill +MIN_DISCOUNT_BPS=500 +MAX_AUCTION_BID_AMOUNT=1000000000 +ANALYTICS_ENABLED=true + # WebSocket WS_URL=ws://localhost:3000/api/ws/health-updates diff --git a/bots/liquidation-bot/src/auctionBidder.service.ts b/bots/liquidation-bot/src/auctionBidder.service.ts index 9ee2eec6..146c7a54 100644 --- a/bots/liquidation-bot/src/auctionBidder.service.ts +++ b/bots/liquidation-bot/src/auctionBidder.service.ts @@ -42,6 +42,12 @@ interface AuctionAnalytics { type BidStrategy = 'earliest_discount' | 'highest_premium' | 'fastest_fill'; +interface DutchAuctionPrice { + currentPrice: number; + discountBps: number; + elapsedRatio: number; +} + export class AuctionBidderService { private config: BotConfig; private logger: Logger; @@ -50,6 +56,13 @@ export class AuctionBidderService { private results: BidResult[] = []; private ws: WebSocket | null = null; private watchedAuctions: Map = new Map(); + private analyticsData: AuctionAnalytics = { + totalAuctions: 0, + settledAuctions: 0, + avgPremiumBps: 0, + avgTimeToFillSecs: 0, + totalCollateralLiquidated: 0, + }; constructor() { this.config = botConfig; @@ -61,6 +74,7 @@ export class AuctionBidderService { this.logger.info('Auction bidder starting', { dryRun: this.config.dryRun, minProfit: this.config.minProfitThresholdXlm, + bidStrategy: this.config.bidStrategy, }); this.connectWebSocket(); @@ -131,43 +145,88 @@ export class AuctionBidderService { setTimeout(poll, this.config.pollIntervalMs); } + private computeDutchPrice(auction: Auction): DutchAuctionPrice { + const now = Math.floor(Date.now() / 1000); + if (now >= auction.endTime) { + const minPrice = auction.oraclePrice * 0.7; + const discountBps = Math.round(((auction.oraclePrice - minPrice) * 10000) / auction.oraclePrice); + return { currentPrice: Math.floor(minPrice), discountBps, elapsedRatio: 1.0 }; + } + + const elapsed = now - auction.startTime; + const duration = auction.endTime - auction.startTime; + const elapsedRatio = duration > 0 ? elapsed / duration : 0; + + const minPrice = auction.oraclePrice * 0.7; + const priceRange = auction.oraclePrice - minPrice; + const priceDrop = priceRange * elapsedRatio; + const currentPrice = auction.oraclePrice - priceDrop; + const discountBps = Math.round((priceDrop * 10000) / auction.oraclePrice); + + return { currentPrice: Math.floor(currentPrice), discountBps, elapsedRatio }; + } + private handleAuctionUpdate(auctions: Auction[]): void { + const profitableAuctions: Array<{ auction: Auction; score: number }> = []; + for (const auction of auctions) { if (auction.status !== 'Active') { this.watchedAuctions.delete(auction.id); continue; } + const computed = this.computeDutchPrice(auction); + auction.currentPrice = computed.currentPrice; this.watchedAuctions.set(auction.id, auction); - const discountBps = this.calculateDiscountBps(auction); const profitPotential = this.estimateProfit(auction); if ( - discountBps >= this.config.minDiscountBps && + computed.discountBps >= this.config.minDiscountBps && profitPotential >= this.config.minProfitThresholdXlm * 10_000_000 ) { + const score = this.scoreAuction(auction, computed); + profitableAuctions.push({ auction, score }); + this.logger.info('Profitable auction detected', { auctionId: auction.id, - discountBps, + discountBps: computed.discountBps, currentPrice: auction.currentPrice, profitPotential, + strategy: this.config.bidStrategy, }); + } + } + + if (profitableAuctions.length > 0) { + profitableAuctions.sort((a, b) => b.score - a.score); + for (const { auction } of profitableAuctions) { this.placeBid(auction); } } } + private scoreAuction(auction: Auction, computed: DutchAuctionPrice): number { + switch (this.config.bidStrategy) { + case 'earliest_discount': + return computed.discountBps * (1 - computed.elapsedRatio); + case 'highest_premium': + return computed.discountBps; + case 'fastest_fill': + return (1 - computed.elapsedRatio) * 10000; + default: + return computed.discountBps; + } + } + private calculateDiscountBps(auction: Auction): number { if (auction.oraclePrice <= 0) return 0; return Math.round(((auction.oraclePrice - auction.currentPrice) * 10_000) / auction.oraclePrice); } private estimateProfit(auction: Auction): number { - const collateralValueAtOracle = (auction.collateralAmount * auction.oraclePrice) / 10_000; const collateralValueAtCurrent = (auction.collateralAmount * auction.currentPrice) / 10_000; - const debtRepay = auction.debtAmount; - return collateralValueAtCurrent - debtRepay; + return collateralValueAtCurrent - auction.debtAmount; } private async placeBid(auction: Auction): Promise { @@ -176,32 +235,39 @@ export class AuctionBidderService { } this.activeBids++; - const maxRepay = Math.floor(auction.debtAmount * 0.5); + const maxRepay = Math.min( + Math.floor(auction.debtAmount * 0.5), + this.config.maxAuctionBidAmount + ); + + const computed = this.computeDutchPrice(auction); + const collateralEstimate = Math.floor( + (maxRepay * computed.currentPrice) / auction.oraclePrice + ); this.logger.info('Placing auction bid', { auctionId: auction.id, maxRepay, - currentPrice: auction.currentPrice, + currentPrice: computed.currentPrice, + collateralEstimate, + strategy: this.config.bidStrategy, }); if (this.config.dryRun) { this.logger.info('DRY RUN - Would bid on auction', { auctionId: auction.id, - estimatedCollateral: Math.floor( - (maxRepay * auction.currentPrice) / auction.oraclePrice - ), + estimatedCollateral: collateralEstimate, }); this.results.push({ auctionId: auction.id, success: true, - collateralReceived: Math.floor( - (maxRepay * auction.currentPrice) / auction.oraclePrice - ), + collateralReceived: collateralEstimate, debtRepaid: maxRepay, - premiumBps: this.calculateDiscountBps(auction), + premiumBps: computed.discountBps, timestamp: Date.now(), }); + this.updateAnalytics(true, computed.discountBps, 0, collateralEstimate); this.activeBids--; return; } @@ -211,7 +277,8 @@ export class AuctionBidderService { `${this.config.apiBaseUrl}/auctions/bid`, { auctionId: auction.id, - debtRepayAmount: maxRepay, + bidder: this.config.botPublicKey, + repayAmount: maxRepay.toString(), }, { timeout: 30000 } ); @@ -220,15 +287,18 @@ export class AuctionBidderService { auctionId: auction.id, success: true, txHash: res.data?.txHash, - collateralReceived: res.data?.collateralReceived, + collateralReceived: res.data?.collateralReceived ? parseFloat(res.data.collateralReceived) : collateralEstimate, debtRepaid: maxRepay, - premiumBps: this.calculateDiscountBps(auction), + premiumBps: computed.discountBps, timestamp: Date.now(), }); + this.updateAnalytics(true, computed.discountBps, 0, collateralEstimate); + this.logger.info('Auction bid successful', { auctionId: auction.id, txHash: res.data?.txHash, + collateralReceived: res.data?.collateralReceived, }); } catch (err: any) { this.results.push({ @@ -246,6 +316,20 @@ export class AuctionBidderService { } } + private updateAnalytics(success: boolean, premiumBps: number, timeToFillSecs: number, collateralLiquidated: number): void { + if (!this.config.analyticsEnabled) return; + + this.analyticsData.totalAuctions++; + if (success) { + this.analyticsData.settledAuctions++; + const totalPremium = this.analyticsData.avgPremiumBps * (this.analyticsData.settledAuctions - 1) + premiumBps; + this.analyticsData.avgPremiumBps = Math.round(totalPremium / this.analyticsData.settledAuctions); + const totalTime = this.analyticsData.avgTimeToFillSecs * (this.analyticsData.settledAuctions - 1) + timeToFillSecs; + this.analyticsData.avgTimeToFillSecs = Math.round(totalTime / this.analyticsData.settledAuctions); + this.analyticsData.totalCollateralLiquidated += collateralLiquidated; + } + } + public getStats(): { totalBidAttempted: number; totalBidSuccessful: number; @@ -269,6 +353,10 @@ export class AuctionBidderService { }; } + public getAnalytics(): AuctionAnalytics { + return { ...this.analyticsData }; + } + public getResults(): BidResult[] { return [...this.results]; } diff --git a/bots/liquidation-bot/src/config.ts b/bots/liquidation-bot/src/config.ts index b232a649..5c63eb3d 100644 --- a/bots/liquidation-bot/src/config.ts +++ b/bots/liquidation-bot/src/config.ts @@ -16,6 +16,10 @@ export interface BotConfig { wsUrl: string; apiBaseUrl: string; logLevel: string; + minDiscountBps: number; + bidStrategy: 'earliest_discount' | 'highest_premium' | 'fastest_fill'; + maxAuctionBidAmount: number; + analyticsEnabled: boolean; } export function loadConfig(): BotConfig { @@ -34,6 +38,10 @@ export function loadConfig(): BotConfig { wsUrl: process.env.WS_URL || 'ws://localhost:3000/api/ws/health-updates', apiBaseUrl: process.env.API_BASE_URL || 'http://localhost:3000/api', logLevel: process.env.LOG_LEVEL || 'info', + minDiscountBps: parseInt(process.env.MIN_DISCOUNT_BPS || '500', 10), + bidStrategy: (process.env.BID_STRATEGY || 'earliest_discount') as BotConfig['bidStrategy'], + maxAuctionBidAmount: parseInt(process.env.MAX_AUCTION_BID_AMOUNT || '1000000000', 10), + analyticsEnabled: process.env.ANALYTICS_ENABLED !== 'false', }; } diff --git a/stellar-lend/Cargo.toml b/stellar-lend/Cargo.toml index 0cf88783..9a594dd0 100644 --- a/stellar-lend/Cargo.toml +++ b/stellar-lend/Cargo.toml @@ -52,6 +52,7 @@ members = [ "contracts/yield-router", "contracts/shared-events", "contracts/yield-router", "contracts/dutch-auction", + "contracts/liquidation-integration", "contracts/debt-token", "contracts/shared-math", "contracts/shared-storage", diff --git a/stellar-lend/contracts/dutch-auction/src/lib.rs b/stellar-lend/contracts/dutch-auction/src/lib.rs index 78b6eff4..edb0bc81 100644 --- a/stellar-lend/contracts/dutch-auction/src/lib.rs +++ b/stellar-lend/contracts/dutch-auction/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env}; +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Vec}; const BPS_BASE: i128 = 10_000; @@ -26,6 +26,16 @@ pub struct AuctionConfig { pub discount_floor_bps: i128, } +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct LiquidationAuctionConfig { + pub pool: Address, + pub default_duration_secs: u64, + pub default_min_price_bps: i128, + pub default_discount_floor_bps: i128, + pub max_parallel_auctions: u32, +} + #[derive(Clone, Debug, PartialEq)] #[contracttype] pub struct Auction { @@ -71,6 +81,9 @@ enum DataKey { TotalPremiumBps, TotalTimeToFill, TotalCollateralLiquidated, + PoolConfig(Address), + PoolLiquidations(Address), + ActiveCount, } #[contract] @@ -79,44 +92,122 @@ pub struct DutchAuctionContract; #[contractimpl] impl DutchAuctionContract { pub fn initialize(env: Env, admin: Address) { + admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::AuctionCount, &0u64); - env.storage() - .instance() - .set(&DataKey::TotalPremiumBps, &0i128); - env.storage() - .instance() - .set(&DataKey::TotalTimeToFill, &0u64); - env.storage() - .instance() - .set(&DataKey::TotalCollateralLiquidated, &0i128); + env.storage().instance().set(&DataKey::TotalPremiumBps, &0i128); + env.storage().instance().set(&DataKey::TotalTimeToFill, &0u64); + env.storage().instance().set(&DataKey::TotalCollateralLiquidated, &0i128); + env.storage().instance().set(&DataKey::ActiveCount, &0u32); + } + + pub fn initialize_pool(env: Env, admin: Address, config: LiquidationAuctionConfig) { + admin.require_auth(); + assert!(config.default_duration_secs > 0, "duration must be positive"); + assert!(config.default_min_price_bps > 0 && config.default_min_price_bps <= BPS_BASE, "invalid min_price_bps"); + assert!(config.default_discount_floor_bps >= 0 && config.default_discount_floor_bps < BPS_BASE, "invalid discount_floor_bps"); + assert!(config.max_parallel_auctions > 0, "max_parallel_auctions must be positive"); + env.storage().persistent().set(&DataKey::PoolConfig(config.pool.clone()), &config); + env.events().publish( + (soroban_sdk::symbol_short!("PoolInit"), config.pool.clone()), + (config.default_duration_secs, config.default_min_price_bps, config.default_discount_floor_bps), + ); + } + + pub fn create_liquidation_auction( + env: Env, + pool: Address, + borrower: Address, + collateral_asset: Address, + debt_asset: Address, + collateral_amount: i128, + debt_amount: i128, + oracle_price: i128, + ) -> u64 { + pool.require_auth(); + + let pool_config: LiquidationAuctionConfig = env.storage() + .persistent() + .get(&DataKey::PoolConfig(pool.clone())) + .expect("pool not configured"); + + let active: u32 = env.storage().instance().get(&DataKey::ActiveCount).unwrap_or(0); + assert!((active as u32) < pool_config.max_parallel_auctions, "max parallel auctions reached"); + + assert!(collateral_amount > 0, "collateral must be positive"); + assert!(debt_amount > 0, "debt must be positive"); + assert!(oracle_price > 0, "oracle price must be positive"); + + let count: u64 = env.storage().instance().get(&DataKey::AuctionCount).unwrap_or(0); + let auction_id = count + 1; + + let start_time = env.ledger().timestamp(); + let end_time = start_time + pool_config.default_duration_secs; + let min_price = (oracle_price * pool_config.default_min_price_bps) / BPS_BASE; + + let config = AuctionConfig { + pool: pool.clone(), + collateral_asset: collateral_asset.clone(), + debt_asset: debt_asset.clone(), + collateral_amount, + debt_amount, + oracle_price, + duration_secs: pool_config.default_duration_secs, + min_price_bps: pool_config.default_min_price_bps, + discount_floor_bps: pool_config.default_discount_floor_bps, + }; + + let auction = Auction { + id: auction_id, + config: config.clone(), + start_price: oracle_price, + current_price: oracle_price, + start_time, + end_time, + status: AuctionStatus::Active, + borrower: borrower.clone(), + highest_bidder: None, + highest_bid_amount: None, + }; + + env.storage().persistent().set(&DataKey::Auction(auction_id), &auction); + env.storage().instance().set(&DataKey::AuctionCount, &auction_id); + env.storage().instance().set(&DataKey::ActiveCount, &(active + 1)); + + let mut pool_liquidations: Vec = env.storage() + .persistent() + .get(&DataKey::PoolLiquidations(pool.clone())) + .unwrap_or_else(|| Vec::new(&env)); + pool_liquidations.push_back(auction_id); + env.storage().persistent().set(&DataKey::PoolLiquidations(pool), &pool_liquidations); + + env.events().publish( + (soroban_sdk::symbol_short!("AucCreate"), auction_id), + ( + &pool, + &borrower, + &collateral_asset, + &debt_asset, + collateral_amount, + debt_amount, + oracle_price, + ), + ); + + auction_id } pub fn create_auction(env: Env, borrower: Address, config: AuctionConfig) -> u64 { - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .expect("not initialized"); + let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized"); admin.require_auth(); assert!(config.duration_secs > 0, "duration must be positive"); - assert!( - config.min_price_bps > 0 && config.min_price_bps <= BPS_BASE, - "invalid min_price_bps" - ); - assert!( - config.discount_floor_bps >= 0 && config.discount_floor_bps < BPS_BASE, - "invalid discount_floor_bps" - ); + assert!(config.min_price_bps > 0 && config.min_price_bps <= BPS_BASE, "invalid min_price_bps"); + assert!(config.discount_floor_bps >= 0 && config.discount_floor_bps < BPS_BASE, "invalid discount_floor_bps"); assert!(config.collateral_amount > 0, "collateral must be positive"); assert!(config.debt_amount > 0, "debt must be positive"); - let count: u64 = env - .storage() - .instance() - .get(&DataKey::AuctionCount) - .unwrap_or(0); + let count: u64 = env.storage().instance().get(&DataKey::AuctionCount).unwrap_or(0); let auction_id = count + 1; let start_time = env.ledger().timestamp(); @@ -135,33 +226,22 @@ impl DutchAuctionContract { highest_bid_amount: None, }; - env.storage() - .persistent() - .set(&DataKey::Auction(auction_id), &auction); - env.storage() - .instance() - .set(&DataKey::AuctionCount, &auction_id); + env.storage().persistent().set(&DataKey::Auction(auction_id), &auction); + env.storage().instance().set(&DataKey::AuctionCount, &auction_id); + + let mut active: u32 = env.storage().instance().get(&DataKey::ActiveCount).unwrap_or(0); + env.storage().instance().set(&DataKey::ActiveCount, &(active + 1)); env.events().publish( (soroban_sdk::symbol_short!("AucCreate"), auction_id), - ( - &config.pool, - &config.collateral_asset, - &config.debt_asset, - config.oracle_price, - config.duration_secs, - ), + (&config.pool, &config.collateral_asset, &config.debt_asset, config.oracle_price, config.duration_secs), ); auction_id } pub fn get_current_price(env: Env, auction_id: u64) -> i128 { - let auction: Auction = env - .storage() - .persistent() - .get(&DataKey::Auction(auction_id)) - .expect("auction not found"); + let auction: Auction = env.storage().persistent().get(&DataKey::Auction(auction_id)).expect("auction not found"); if auction.status != AuctionStatus::Active { return auction.current_price; @@ -169,66 +249,45 @@ impl DutchAuctionContract { let now = env.ledger().timestamp(); if now >= auction.end_time { - let min_price = - (auction.config.oracle_price * auction.config.min_price_bps) / BPS_BASE; + let min_price = (auction.config.oracle_price * auction.config.min_price_bps) / BPS_BASE; return min_price; } let elapsed = now - auction.start_time; - let min_price = - (auction.config.oracle_price * auction.config.min_price_bps) / BPS_BASE; + let min_price = (auction.config.oracle_price * auction.config.min_price_bps) / BPS_BASE; let total_discount = auction.config.oracle_price - min_price; - let price_reduction = - (total_discount * (elapsed as i128)) / (auction.config.duration_secs as i128); + let price_reduction = (total_discount * (elapsed as i128)) / (auction.config.duration_secs as i128); auction.config.oracle_price - price_reduction } - pub fn place_bid( - env: Env, - auction_id: u64, - bidder: Address, - debt_repay_amount: i128, - ) -> AuctionBid { + pub fn place_bid(env: Env, auction_id: u64, bidder: Address, debt_repay_amount: i128) -> AuctionBid { bidder.require_auth(); - let mut auction: Auction = env - .storage() - .persistent() - .get(&DataKey::Auction(auction_id)) - .expect("auction not found"); + let mut auction: Auction = env.storage().persistent().get(&DataKey::Auction(auction_id)).expect("auction not found"); assert!(auction.status == AuctionStatus::Active, "auction not active"); let now = env.ledger().timestamp(); assert!(now < auction.end_time, "auction ended"); assert!(debt_repay_amount > 0, "repay amount must be positive"); + assert!(debt_repay_amount <= auction.config.debt_amount, "exceeds debt amount"); let current_price = Self::get_current_price(env.clone(), auction_id); let collateral_received = (debt_repay_amount * BPS_BASE) / current_price; + assert!(collateral_received <= auction.config.collateral_amount, "exceeds collateral"); let debt_client = token::Client::new(&env, &auction.config.debt_asset); - debt_client.transfer_from( - &env.current_contract_address(), - &bidder, - &env.current_contract_address(), - &debt_repay_amount, - ); + debt_client.transfer(&bidder, &auction.config.pool, &debt_repay_amount); let collateral_client = token::Client::new(&env, &auction.config.collateral_asset); - collateral_client.transfer( - &env.current_contract_address(), - &bidder, - &collateral_received, - ); + collateral_client.transfer(&env.current_contract_address(), &bidder, &collateral_received); auction.highest_bidder = Some(bidder.clone()); auction.highest_bid_amount = Some(debt_repay_amount); auction.current_price = current_price; auction.status = AuctionStatus::Settled; - env.storage() - .persistent() - .set(&DataKey::Auction(auction_id), &auction); + env.storage().persistent().set(&DataKey::Auction(auction_id), &auction); let bid = AuctionBid { auction_id, @@ -238,45 +297,27 @@ impl DutchAuctionContract { timestamp: now, }; - env.storage() - .persistent() - .set(&DataKey::AuctionBid(auction_id), &bid); + env.storage().persistent().set(&DataKey::AuctionBid(auction_id), &bid); - let premium_bps = - ((auction.config.oracle_price - current_price) * BPS_BASE) / auction.config.oracle_price; + let premium_bps = ((auction.config.oracle_price - current_price) * BPS_BASE) / auction.config.oracle_price; - let mut total_premium: i128 = env - .storage() - .instance() - .get(&DataKey::TotalPremiumBps) - .unwrap_or(0); + let mut total_premium: i128 = env.storage().instance().get(&DataKey::TotalPremiumBps).unwrap_or(0); total_premium += premium_bps; - env.storage() - .instance() - .set(&DataKey::TotalPremiumBps, &total_premium); - - let mut total_time: u64 = env - .storage() - .instance() - .get(&DataKey::TotalTimeToFill) - .unwrap_or(0); + env.storage().instance().set(&DataKey::TotalPremiumBps, &total_premium); + + let mut total_time: u64 = env.storage().instance().get(&DataKey::TotalTimeToFill).unwrap_or(0); total_time += now - auction.start_time; - env.storage() - .instance() - .set(&DataKey::TotalTimeToFill, &total_time); - - let mut total_collateral: i128 = env - .storage() - .instance() - .get(&DataKey::TotalCollateralLiquidated) - .unwrap_or(0); + env.storage().instance().set(&DataKey::TotalTimeToFill, &total_time); + + let mut total_collateral: i128 = env.storage().instance().get(&DataKey::TotalCollateralLiquidated).unwrap_or(0); total_collateral += collateral_received; - env.storage() - .instance() - .set(&DataKey::TotalCollateralLiquidated, &total_collateral); + env.storage().instance().set(&DataKey::TotalCollateralLiquidated, &total_collateral); + + let mut active: u32 = env.storage().instance().get(&DataKey::ActiveCount).unwrap_or(0); + if active > 0 { env.storage().instance().set(&DataKey::ActiveCount, &(active - 1)); } env.events().publish( - (soroban_sdk::symbol_short!("BidPlaced"), auction_id), + (soroban_sdk::symbol_short!("BidPlace"), auction_id), (&bidder, debt_repay_amount, collateral_received, current_price), ); @@ -284,153 +325,90 @@ impl DutchAuctionContract { } pub fn settle_auction(env: Env, auction_id: u64) { - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .expect("not initialized"); + let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized"); admin.require_auth(); - let auction: Auction = env - .storage() - .persistent() - .get(&DataKey::Auction(auction_id)) - .expect("auction not found"); - - assert!( - auction.status == AuctionStatus::Settled, - "auction not settled" - ); + let auction: Auction = env.storage().persistent().get(&DataKey::Auction(auction_id)).expect("auction not found"); + assert!(auction.status == AuctionStatus::Settled, "auction not settled"); env.events().publish( (soroban_sdk::symbol_short!("AucSettle"), auction_id), - ( - &auction.highest_bidder, - auction.highest_bid_amount, - auction.current_price, - ), + (&auction.highest_bidder, auction.highest_bid_amount, auction.current_price), ); } pub fn expire_auction(env: Env, auction_id: u64) { - let mut auction: Auction = env - .storage() - .persistent() - .get(&DataKey::Auction(auction_id)) - .expect("auction not found"); + let mut auction: Auction = env.storage().persistent().get(&DataKey::Auction(auction_id)).expect("auction not found"); assert!(auction.status == AuctionStatus::Active, "auction not active"); let now = env.ledger().timestamp(); assert!(now >= auction.end_time, "auction not yet ended"); - auction.status = AuctionStatus::Expired; - let min_price = - (auction.config.oracle_price * auction.config.min_price_bps) / BPS_BASE; - auction.current_price = min_price; - - env.storage() - .persistent() - .set(&DataKey::Auction(auction_id), &auction); - let new_duration = auction.config.duration_secs * 2; let new_end_time = auction.start_time + new_duration; - let mut extended_auction = auction.clone(); - extended_auction.end_time = new_end_time; - extended_auction.status = AuctionStatus::Active; - env.storage() - .persistent() - .set(&DataKey::Auction(auction_id), &extended_auction); + let floor_price = (auction.config.oracle_price * (BPS_BASE - auction.config.discount_floor_bps)) / BPS_BASE; + auction.current_price = floor_price; + auction.end_time = new_end_time; + + env.storage().persistent().set(&DataKey::Auction(auction_id), &auction); env.events().publish( (soroban_sdk::symbol_short!("AucExpire"), auction_id), - ( - min_price, - new_duration, - auction.config.discount_floor_bps, - ), + (floor_price, new_duration, auction.config.discount_floor_bps), ); } pub fn get_auction(env: Env, auction_id: u64) -> Auction { - env.storage() - .persistent() - .get(&DataKey::Auction(auction_id)) - .expect("auction not found") + env.storage().persistent().get(&DataKey::Auction(auction_id)).expect("auction not found") } - pub fn get_active_auctions(env: Env) -> soroban_sdk::Vec { - let count: u64 = env - .storage() - .instance() - .get(&DataKey::AuctionCount) - .unwrap_or(0); + pub fn get_auction_bid(env: Env, auction_id: u64) -> Option { + env.storage().persistent().get(&DataKey::AuctionBid(auction_id)) + } - let mut active = soroban_sdk::Vec::new(&env); + pub fn get_active_auctions(env: Env) -> Vec { + let count: u64 = env.storage().instance().get(&DataKey::AuctionCount).unwrap_or(0); + let mut active = Vec::new(&env); let mut i = 1u64; while i <= count { - let auction: Auction = env - .storage() - .persistent() - .get(&DataKey::Auction(i)) - .unwrap(); + let auction: Auction = env.storage().persistent().get(&DataKey::Auction(i)).unwrap(); if auction.status == AuctionStatus::Active { active.push_back(auction); } i += 1; } - active } + pub fn get_pool_liquidations(env: Env, pool: Address) -> Vec { + let ids: Vec = env.storage().persistent().get(&DataKey::PoolLiquidations(pool)).unwrap_or_else(|| Vec::new(&env)); + let mut auctions = Vec::new(&env); + for i in 0..ids.len() { + let auction: Auction = env.storage().persistent().get(&DataKey::Auction(ids.get(i).unwrap())).unwrap(); + auctions.push_back(auction); + } + auctions + } + pub fn get_analytics(env: Env) -> AuctionAnalytics { - let total: u64 = env - .storage() - .instance() - .get(&DataKey::AuctionCount) - .unwrap_or(0); - - let total_premium_bps: i128 = env - .storage() - .instance() - .get(&DataKey::TotalPremiumBps) - .unwrap_or(0); - let total_time: u64 = env - .storage() - .instance() - .get(&DataKey::TotalTimeToFill) - .unwrap_or(0); - let total_collateral: i128 = env - .storage() - .instance() - .get(&DataKey::TotalCollateralLiquidated) - .unwrap_or(0); + let total: u64 = env.storage().instance().get(&DataKey::AuctionCount).unwrap_or(0); + let total_premium_bps: i128 = env.storage().instance().get(&DataKey::TotalPremiumBps).unwrap_or(0); + let total_time: u64 = env.storage().instance().get(&DataKey::TotalTimeToFill).unwrap_or(0); + let total_collateral: i128 = env.storage().instance().get(&DataKey::TotalCollateralLiquidated).unwrap_or(0); let mut settled: u64 = 0; let mut i = 1u64; while i <= total { - let auction: Auction = env - .storage() - .persistent() - .get(&DataKey::Auction(i)) - .unwrap(); + let auction: Auction = env.storage().persistent().get(&DataKey::Auction(i)).unwrap(); if auction.status == AuctionStatus::Settled { settled += 1; } i += 1; } - let avg_premium = if settled > 0 { - total_premium_bps / (settled as i128) - } else { - 0 - }; - - let avg_time = if settled > 0 { - total_time / settled - } else { - 0 - }; + let avg_premium = if settled > 0 { total_premium_bps / (settled as i128) } else { 0 }; + let avg_time = if settled > 0 { total_time / settled } else { 0 }; AuctionAnalytics { total_auctions: total, @@ -440,6 +418,10 @@ impl DutchAuctionContract { total_collateral_liquidated: total_collateral, } } + + pub fn get_pool_config(env: Env, pool: Address) -> Option { + env.storage().persistent().get(&DataKey::PoolConfig(pool)) + } } #[cfg(test)] @@ -453,7 +435,9 @@ mod tests { contract_id: Address, admin: Address, borrower: Address, + pool: Address, config: AuctionConfig, + pool_config: LiquidationAuctionConfig, } impl TestEnv { @@ -461,12 +445,13 @@ mod tests { let env = Env::default(); let admin = Address::generate(&env); let borrower = Address::generate(&env); + let pool = Address::generate(&env); let contract_id = env.register(DutchAuctionContract, ()); let client = DutchAuctionContractClient::new(&env, &contract_id); client.initialize(&admin); let config = AuctionConfig { - pool: Address::generate(&env), + pool: pool.clone(), collateral_asset: Address::generate(&env), debt_asset: Address::generate(&env), collateral_amount: 1_000_000_000, @@ -477,7 +462,15 @@ mod tests { discount_floor_bps: 3000, }; - TestEnv { env, contract_id, admin, borrower, config } + let pool_config = LiquidationAuctionConfig { + pool: pool.clone(), + default_duration_secs: 3600, + default_min_price_bps: 7000, + default_discount_floor_bps: 3000, + max_parallel_auctions: 5, + }; + + TestEnv { env, contract_id, admin, borrower, pool, config, pool_config } } fn client(&self) -> DutchAuctionContractClient<'_> { @@ -500,36 +493,55 @@ mod tests { #[test] fn test_initialize() { let t = TestEnv::new(); - let stored: Address = t.env - .as_contract(&t.contract_id, || t.env.storage().instance().get(&DataKey::Admin)) - .unwrap(); + let stored: Address = t.env.as_contract(&t.contract_id, || t.env.storage().instance().get(&DataKey::Admin)).unwrap(); assert_eq!(stored, t.admin); - - let count: u64 = t.env - .as_contract(&t.contract_id, || t.env.storage().instance().get(&DataKey::AuctionCount)) - .unwrap(); + let count: u64 = t.env.as_contract(&t.contract_id, || t.env.storage().instance().get(&DataKey::AuctionCount)).unwrap(); assert_eq!(count, 0); } + #[test] + fn test_initialize_pool() { + let t = TestEnv::new(); + let client = t.client(); + client.initialize_pool(&t.admin, &t.pool_config); + let stored: LiquidationAuctionConfig = client.get_pool_config(&t.pool).unwrap(); + assert_eq!(stored.default_duration_secs, 3600); + assert_eq!(stored.default_min_price_bps, 7000); + assert_eq!(stored.max_parallel_auctions, 5); + } + #[test] fn test_create_auction() { let t = TestEnv::new(); t.mock_admin_auth(); - let id = t.client().create_auction(&t.borrower, &t.config); assert_eq!(id, 1); - let auction = t.client().get_auction(&id); assert_eq!(auction.status, AuctionStatus::Active); assert_eq!(auction.start_price, 20_000); assert_eq!(auction.borrower, t.borrower); } + #[test] + fn test_create_liquidation_auction() { + let t = TestEnv::new(); + t.env.mock_all_auths(); + t.client().initialize_pool(&t.admin, &t.pool_config); + + let id = t.client().create_liquidation_auction( + &t.pool, &t.borrower, &t.config.collateral_asset, &t.config.debt_asset, + 1_000_000_000, 500_000_000, 20_000, + ); + assert_eq!(id, 1); + let auction = t.client().get_auction(&id); + assert_eq!(auction.status, AuctionStatus::Active); + assert_eq!(auction.start_price, 20_000); + } + #[test] fn test_price_decay() { let t = TestEnv::new(); t.mock_admin_auth(); - let id = t.client().create_auction(&t.borrower, &t.config); let p0 = t.client().get_current_price(&id); @@ -548,7 +560,6 @@ mod tests { fn test_get_active_auctions() { let t = TestEnv::new(); t.mock_admin_auth(); - t.client().create_auction(&t.borrower, &t.config); assert_eq!(t.client().get_active_auctions().len(), 1); } @@ -568,7 +579,6 @@ mod tests { fn test_create_multiple_auctions() { let t = TestEnv::new(); t.env.mock_all_auths(); - let id1 = t.client().create_auction(&t.borrower, &t.config); let id2 = t.client().create_auction(&t.borrower, &t.config); assert_eq!(id1, 1); @@ -582,4 +592,71 @@ mod tests { let t = TestEnv::new(); t.client().get_auction(&999); } + + #[test] + fn test_get_auction_bid_none() { + let t = TestEnv::new(); + let bid = t.client().get_auction_bid(&1); + assert!(bid.is_none()); + } + + #[test] + fn test_expire_auction() { + let t = TestEnv::new(); + t.env.mock_all_auths(); + let id = t.client().create_auction(&t.borrower, &t.config); + + t.env.ledger().set_timestamp(t.env.ledger().timestamp() + 7200); + t.client().expire_auction(&id); + + let auction = t.client().get_auction(&id); + assert!(auction.end_time > auction.start_time + 3600); + assert!(auction.current_price < 20_000); + } + + #[test] + fn test_get_pool_liquidations_empty() { + let t = TestEnv::new(); + let pool = Address::generate(&t.env); + let auctions = t.client().get_pool_liquidations(&pool); + assert_eq!(auctions.len(), 0); + } + + #[test] + fn test_get_pool_liquidations() { + let t = TestEnv::new(); + t.env.mock_all_auths(); + t.client().initialize_pool(&t.admin, &t.pool_config); + + t.client().create_liquidation_auction( + &t.pool, &t.borrower, &t.config.collateral_asset, &t.config.debt_asset, + 1_000_000_000, 500_000_000, 20_000, + ); + t.client().create_liquidation_auction( + &t.pool, &t.borrower, &t.config.collateral_asset, &t.config.debt_asset, + 2_000_000_000, 1_000_000_000, 20_000, + ); + + let auctions = t.client().get_pool_liquidations(&t.pool); + assert_eq!(auctions.len(), 2); + } + + #[test] + #[should_panic(expected = "max parallel auctions reached")] + fn test_max_parallel_auctions() { + let t = TestEnv::new(); + t.env.mock_all_auths(); + let mut limited_config = t.pool_config.clone(); + limited_config.max_parallel_auctions = 1; + t.client().initialize_pool(&t.admin, &limited_config); + + t.client().create_liquidation_auction( + &t.pool, &t.borrower, &t.config.collateral_asset, &t.config.debt_asset, + 1_000_000_000, 500_000_000, 20_000, + ); + t.client().create_liquidation_auction( + &t.pool, &t.borrower, &t.config.collateral_asset, &t.config.debt_asset, + 2_000_000_000, 1_000_000_000, 20_000, + ); + } } diff --git a/stellar-lend/contracts/liquidation-integration/Cargo.toml b/stellar-lend/contracts/liquidation-integration/Cargo.toml new file mode 100644 index 00000000..5e1ceb16 --- /dev/null +++ b/stellar-lend/contracts/liquidation-integration/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "stellarlend-liquidation-integration" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } +stellarlend-dutch-auction = { path = "../dutch-auction" } +stellarlend-lending-types = { path = "../lending-types" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/stellar-lend/contracts/liquidation-integration/src/lib.rs b/stellar-lend/contracts/liquidation-integration/src/lib.rs new file mode 100644 index 00000000..756ce13a --- /dev/null +++ b/stellar-lend/contracts/liquidation-integration/src/lib.rs @@ -0,0 +1,377 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Vec}; + +use stellarlend_dutch_auction::LiquidationAuctionConfig; +use stellarlend_lending_types::Position; + +const BPS_BASE: i128 = 10_000; + +#[contracttype] +#[derive(Clone, Debug)] +pub struct LiquidationAuction { + pub auction_id: u64, + pub pool: Address, + pub borrower: Address, + pub collateral_asset: Address, + pub debt_asset: Address, + pub collateral_amount: i128, + pub debt_amount: i128, + pub oracle_price: i128, + pub start_price: i128, + pub current_price: i128, + pub start_time: u64, + pub end_time: u64, + pub status: u32, + pub highest_bidder: Option
, + pub highest_bid_amount: Option, +} + +#[contracttype] +#[derive(Clone)] +enum DataKey { + Admin, + Config(Address), + LiquidationCount, + Liquidation(u64), + PoolLiquidations(Address), + ActiveCount, +} + +#[contract] +pub struct LiquidationIntegration; + +#[contractimpl] +impl LiquidationIntegration { + pub fn initialize(env: Env, admin: Address) { + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::LiquidationCount, &0u64); + env.storage().instance().set(&DataKey::ActiveCount, &0u32); + } + + pub fn set_pool_config(env: Env, admin: Address, pool: Address, config: LiquidationAuctionConfig) { + admin.require_auth(); + env.storage().persistent().set(&DataKey::Config(pool), &config); + } + + pub fn from_liquidation( + env: Env, + pool: Address, + borrower: Address, + position: Position, + collateral_asset: Address, + debt_asset: Address, + oracle_price: i128, + ) -> u64 { + pool.require_auth(); + + assert!(position.collateral_amount > 0, "no collateral"); + assert!(position.debt_amount > 0, "no debt"); + assert!(oracle_price > 0, "invalid price"); + + Self::create_liquidation_auction( + env, + pool, + borrower, + collateral_asset, + debt_asset, + position.collateral_amount, + position.debt_amount, + oracle_price, + ) + } + + pub fn create_liquidation_auction( + env: Env, + pool: Address, + borrower: Address, + collateral_asset: Address, + debt_asset: Address, + collateral_amount: i128, + debt_amount: i128, + oracle_price: i128, + ) -> u64 { + pool.require_auth(); + + let config: LiquidationAuctionConfig = env.storage() + .persistent() + .get(&DataKey::Config(pool.clone())) + .expect("pool not configured"); + + let count: u64 = env.storage().instance().get(&DataKey::LiquidationCount).unwrap_or(0); + let auction_id = count + 1; + let now = env.ledger().timestamp(); + + let start_price = oracle_price; + let end_time = now + config.default_duration_secs; + + let auction = LiquidationAuction { + auction_id, + pool: pool.clone(), + borrower: borrower.clone(), + collateral_asset: collateral_asset.clone(), + debt_asset: debt_asset.clone(), + collateral_amount, + debt_amount, + oracle_price, + start_price, + current_price: start_price, + start_time: now, + end_time, + status: 0, + highest_bidder: None, + highest_bid_amount: None, + }; + + env.storage().persistent().set(&DataKey::Liquidation(auction_id), &auction); + env.storage().instance().set(&DataKey::LiquidationCount, &auction_id); + + let mut pool_liquidations: Vec = env.storage() + .persistent() + .get(&DataKey::PoolLiquidations(pool.clone())) + .unwrap_or_else(|| Vec::new(&env)); + pool_liquidations.push_back(auction_id); + env.storage().persistent().set(&DataKey::PoolLiquidations(pool), &pool_liquidations); + + let mut active: u32 = env.storage().instance().get(&DataKey::ActiveCount).unwrap_or(0); + env.storage().instance().set(&DataKey::ActiveCount, &(active + 1)); + + env.events().publish( + (soroban_sdk::symbol_short!("LiqCreated"), auction_id), + (&pool, &borrower, collateral_amount, debt_amount, oracle_price), + ); + + auction_id + } + + pub fn get_current_price(env: Env, auction_id: u64) -> i128 { + let auction: LiquidationAuction = env.storage() + .persistent() + .get(&DataKey::Liquidation(auction_id)) + .expect("auction not found"); + + if auction.status != 0 { + return auction.current_price; + } + + let now = env.ledger().timestamp(); + if now >= auction.end_time { + return (auction.oracle_price * 7000) / BPS_BASE; + } + + let elapsed = now - auction.start_time; + let total_duration = auction.end_time - auction.start_time; + if total_duration == 0 { + return auction.start_price; + } + + let min_price = (auction.oracle_price * 7000) / BPS_BASE; + let price_range = auction.start_price - min_price; + let price_drop = (price_range * (elapsed as i128)) / (total_duration as i128); + + auction.start_price - price_drop + } + + pub fn place_bid(env: Env, auction_id: u64, bidder: Address, repay_amount: i128) -> i128 { + bidder.require_auth(); + + let mut auction: LiquidationAuction = env.storage() + .persistent() + .get(&DataKey::Liquidation(auction_id)) + .expect("auction not found"); + + assert_eq!(auction.status, 0, "auction not active"); + assert!(env.ledger().timestamp() < auction.end_time, "auction ended"); + assert!(repay_amount > 0, "invalid amount"); + assert!(repay_amount <= auction.debt_amount, "exceeds debt"); + + let current_price = Self::get_current_price(env.clone(), auction_id); + let collateral_ratio = (auction.collateral_amount * BPS_BASE) / auction.debt_amount; + let collateral_to_transfer = (repay_amount * collateral_ratio) / BPS_BASE; + + let debt_client = token::Client::new(&env, &auction.debt_asset); + debt_client.transfer(&bidder, &auction.pool, &repay_amount); + + let collateral_client = token::Client::new(&env, &auction.collateral_asset); + collateral_client.transfer(&env.current_contract_address(), &bidder, &collateral_to_transfer); + + auction.highest_bidder = Some(bidder.clone()); + auction.highest_bid_amount = Some(repay_amount); + auction.current_price = current_price; + auction.status = 1; + + env.storage().persistent().set(&DataKey::Liquidation(auction_id), &auction); + + let mut active: u32 = env.storage().instance().get(&DataKey::ActiveCount).unwrap_or(0); + if active > 0 { env.storage().instance().set(&DataKey::ActiveCount, &(active - 1)); } + + env.events().publish( + (soroban_sdk::symbol_short!("BidPlaced"), auction_id), + (&bidder, repay_amount, collateral_to_transfer, current_price), + ); + + collateral_to_transfer + } + + pub fn expire_auction(env: Env, auction_id: u64) { + let mut auction: LiquidationAuction = env.storage() + .persistent() + .get(&DataKey::Liquidation(auction_id)) + .expect("auction not found"); + + assert_eq!(auction.status, 0, "auction not active"); + assert!(env.ledger().timestamp() >= auction.end_time, "auction not yet ended"); + + let new_duration = (auction.end_time - auction.start_time) * 2; + auction.end_time = auction.start_time + new_duration; + auction.current_price = (auction.oracle_price * 5000) / BPS_BASE; + + env.storage().persistent().set(&DataKey::Liquidation(auction_id), &auction); + + env.events().publish( + (soroban_sdk::symbol_short!("AucExpired"), auction_id), + (auction.current_price, new_duration), + ); + } + + pub fn get_auction(env: Env, auction_id: u64) -> LiquidationAuction { + env.storage().persistent().get(&DataKey::Liquidation(auction_id)).expect("not found") + } + + pub fn get_active_liquidations(env: Env) -> Vec { + let count: u64 = env.storage().instance().get(&DataKey::LiquidationCount).unwrap_or(0); + let mut active = Vec::new(&env); + for i in 1u64..=count { + let a: LiquidationAuction = env.storage().persistent().get(&DataKey::Liquidation(i)).unwrap(); + if a.status == 0 { + active.push_back(a); + } + } + active + } + + pub fn get_pool_config(env: Env, pool: Address) -> Option { + env.storage().persistent().get(&DataKey::Config(pool)) + } + + pub fn get_pool_liquidations(env: Env, pool: Address) -> Vec { + let ids: Vec = env.storage() + .persistent() + .get(&DataKey::PoolLiquidations(pool)) + .unwrap_or_else(|| Vec::new(&env)); + let mut auctions = Vec::new(&env); + for i in 0..ids.len() { + let a: LiquidationAuction = env.storage().persistent().get(&DataKey::Liquidation(ids.get(i).unwrap())).unwrap(); + auctions.push_back(a); + } + auctions + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Ledger as _}; + use soroban_sdk::Env; + + fn setup() -> (Env, LiquidationIntegrationClient<'static>, Address, Address, LiquidationAuctionConfig) { + let env = Env::default(); + let admin = Address::generate(&env); + let pool = Address::generate(&env); + + let contract_id = env.register(LiquidationIntegration, ()); + let client = LiquidationIntegrationClient::new(&env, &contract_id); + client.initialize(&admin); + + let config = LiquidationAuctionConfig { + pool: pool.clone(), + default_duration_secs: 3600, + default_min_price_bps: 7000, + default_discount_floor_bps: 3000, + max_parallel_auctions: 5, + }; + + (env, client, admin, pool, config) + } + + #[test] + fn test_initialize() { + let (env, _client, admin, _pool, _config) = setup(); + let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + assert_eq!(stored, admin); + } + + #[test] + fn test_set_pool_config() { + let (_env, client, admin, pool, config) = setup(); + client.set_pool_config(&admin, &pool, &config); + let stored: LiquidationAuctionConfig = client.get_pool_config(&pool).unwrap(); + assert_eq!(stored.default_duration_secs, 3600); + } + + #[test] + fn test_create_liquidation_auction() { + let (env, client, admin, pool, config) = setup(); + env.mock_all_auths(); + client.set_pool_config(&admin, &pool, &config); + + let borrower = Address::generate(&env); + let coll_asset = Address::generate(&env); + let debt_asset = Address::generate(&env); + + let id = client.create_liquidation_auction( + &pool, &borrower, &coll_asset, &debt_asset, + &1_000_000_000, &500_000_000, &20_000, + ); + assert_eq!(id, 1); + } + + #[test] + fn test_from_liquidation() { + let (env, client, admin, pool, config) = setup(); + env.mock_all_auths(); + client.set_pool_config(&admin, &pool, &config); + + let borrower = Address::generate(&env); + let coll_asset = Address::generate(&env); + let debt_asset = Address::generate(&env); + let position = Position { + collateral_amount: 2_000_000_000, + debt_amount: 1_000_000_000, + last_updated: 0, + }; + + let id = client.from_liquidation( + &pool, &borrower, &position, &coll_asset, &debt_asset, &20_000, + ); + assert_eq!(id, 1); + let auction = client.get_auction(&id); + assert_eq!(auction.collateral_amount, 2_000_000_000); + } + + #[test] + fn test_get_current_price() { + let (env, client, admin, pool, config) = setup(); + env.mock_all_auths(); + client.set_pool_config(&admin, &pool, &config); + + let borrower = Address::generate(&env); + let coll_asset = Address::generate(&env); + let debt_asset = Address::generate(&env); + + let id = client.create_liquidation_auction( + &pool, &borrower, &coll_asset, &debt_asset, + &1_000_000_000, &500_000_000, &20_000, + ); + + let price = client.get_current_price(&id); + assert_eq!(price, 20_000); + } + + #[test] + fn test_get_active_liquidations_empty() { + let (_env, client, _admin, _pool, _config) = setup(); + let active = client.get_active_liquidations(); + assert_eq!(active.len(), 0); + } +}