diff --git a/agent.ts b/agent.ts index 0b2d635b..f17c3e16 100644 --- a/agent.ts +++ b/agent.ts @@ -253,10 +253,12 @@ export class AgentClient { }; /** - * Stellar Classic DEX routing. + * Stellar Classic DEX routing with enhanced slippage protection. * * These methods use Horizon pathfinding and path payment operations, which can * route through the SDEX and built-in liquidity pools. + * + * 🛡️ SECURITY: Enhanced with slippage protection and MEV defense */ public dex = { quoteSwap: async (params: QuoteSwapParams): Promise => { @@ -270,16 +272,35 @@ export class AgentClient { ); }, + /** + * Execute best route swap with advanced slippage protection + * + * 🛡️ SECURITY FEATURES: + * - Automatic slippage optimization based on market conditions + * - Price impact analysis and warnings + * - MEV (sandwich attack) protection + * - Route validation and security scoring + * + * @param params Swap parameters with optional protection settings + * @returns Swap result with protection information + */ swapBestRoute: async ( params: SwapBestRouteParams ): Promise => { + // 🛡️ SECURITY: Enable slippage protection by default + const enhancedParams = { + ...params, + enableSlippageProtection: params.enableSlippageProtection ?? true, + maxPriceImpactBps: params.maxPriceImpactBps ?? 300, // 3% default max price impact + }; + return await executeBestRouteSwap( { network: this.network, horizonUrl: this.rpcUrl, publicKey: this.publicKey, }, - params + enhancedParams ); }, }; diff --git a/lib/dex.ts b/lib/dex.ts index d14da97a..2c69b068 100644 --- a/lib/dex.ts +++ b/lib/dex.ts @@ -8,6 +8,7 @@ import { } from "@stellar/stellar-sdk"; import { getSigningKeypair, signTransaction } from "./stellar"; import { buildPathPaymentTransaction } from "../utils/buildTransaction"; +import { SlippageProtectionManager, validateSlippage } from "./slippageProtection"; export type StellarAssetInput = | { type: "native" } @@ -27,6 +28,8 @@ export interface QuoteSwapParams { export interface SwapBestRouteParams extends QuoteSwapParams { slippageBps?: number; + enableSlippageProtection?: boolean; // 🛡️ NEW: Enable advanced slippage protection + maxPriceImpactBps?: number; // 🛡️ NEW: Maximum acceptable price impact } export interface HorizonPathRecord { @@ -56,6 +59,10 @@ export interface SwapBestRouteResult { sendAmount: string; destAmount: string; path: StellarAssetInput[]; + // 🛡️ NEW: Enhanced result information + actualSlippageBps?: number; + priceImpactBps?: number; + protectionWarnings?: string[]; } export interface DexClientConfig { @@ -157,21 +164,34 @@ export function calculateSwapBounds( mode: RouteMode, slippageBps: number = DEFAULT_SLIPPAGE_BPS ): { sendMax?: string; destMin?: string } { + // 🛡️ SECURITY: Enhanced slippage validation + validateSlippage(slippageBps, 1000); // Max 10% slippage allowed + if (!Number.isInteger(slippageBps) || slippageBps < 0) { - throw new Error("slippageBps must be a non-negative integer"); + throw new Error("🛡️ slippageBps must be a non-negative integer"); } const slippage = new Big(slippageBps).div(10000); if (mode === "strict-send") { - return { - destMin: formatStellarAmount(new Big(quote.destAmount).mul(new Big(1).minus(slippage))), - }; + const destMin = formatStellarAmount(new Big(quote.destAmount).mul(new Big(1).minus(slippage))); + + // 🛡️ SECURITY: Validate minimum output is reasonable + if (new Big(destMin).lte(0)) { + throw new Error("🛡️ Calculated minimum output is zero or negative - slippage too high"); + } + + return { destMin }; } - return { - sendMax: formatStellarAmount(new Big(quote.sendAmount).mul(new Big(1).plus(slippage))), - }; + const sendMax = formatStellarAmount(new Big(quote.sendAmount).mul(new Big(1).plus(slippage))); + + // 🛡️ SECURITY: Validate maximum input is reasonable + if (new Big(sendMax).gte(new Big(quote.sendAmount).mul(2))) { + throw new Error("🛡️ Calculated maximum input is unreasonably high - check slippage settings"); + } + + return { sendMax }; } export async function quoteSwap( @@ -214,6 +234,10 @@ export async function swapBestRoute( validateQuoteParams(params); validateQuoteLimit(params.limit); + // 🛡️ SECURITY: Enhanced slippage validation with protection + const requestedSlippageBps = params.slippageBps ?? DEFAULT_SLIPPAGE_BPS; + validateSlippage(requestedSlippageBps, 1000); // Max 10% slippage + getSigningKeypair(client.publicKey); const destination = params.destination ?? client.publicKey; @@ -223,7 +247,55 @@ export async function swapBestRoute( const bestQuote = quotes[0]; if (!bestQuote) { - throw new Error("No route available for the requested swap"); + throw new Error("🛡️ No route available for the requested swap"); + } + + // 🛡️ SECURITY: Apply slippage protection if enabled + let finalSlippageBps = requestedSlippageBps; + let protectionWarnings: string[] = []; + let priceImpactBps = 0; + + if (params.enableSlippageProtection !== false) { // Default to enabled + const slippageProtection = new SlippageProtectionManager({ + maxSlippageBps: 1000, // 10% absolute maximum + priceImpactWarningThreshold: params.maxPriceImpactBps ?? 200, // 2% default warning threshold + }); + + const tradeAmount = params.mode === "strict-send" ? params.sendAmount! : bestQuote.sendAmount; + + const protection = slippageProtection.analyzeSlippageProtection( + bestQuote, + requestedSlippageBps, + tradeAmount, + params.sendAsset, + params.destAsset + ); + + if (!protection.shouldProceed) { + throw new Error( + `🛡️ Trade blocked by slippage protection:\n` + + `${protection.warnings.join('\n')}\n` + + `Consider reducing trade size or adjusting parameters.` + ); + } + + finalSlippageBps = protection.adjustedSlippageBps; + protectionWarnings = protection.warnings; + priceImpactBps = protection.priceImpact.priceImpactBps; + + // Log protection warnings + if (protectionWarnings.length > 0) { + console.warn("🛡️ Slippage Protection Warnings:"); + protectionWarnings.forEach(warning => console.warn(` ${warning}`)); + } + + // Additional price impact check + if (params.maxPriceImpactBps && priceImpactBps > params.maxPriceImpactBps) { + throw new Error( + `🛡️ Price impact ${priceImpactBps / 100}% exceeds maximum allowed ${params.maxPriceImpactBps / 100}%.\n` + + `${protection.priceImpact.recommendation}` + ); + } } const createServer = @@ -234,7 +306,7 @@ export async function swapBestRoute( const { sendMax, destMin } = calculateSwapBounds( bestQuote, params.mode, - params.slippageBps ?? DEFAULT_SLIPPAGE_BPS + finalSlippageBps // Use protected slippage ); const transaction = buildPathPaymentTransaction(sourceAccount, { @@ -268,6 +340,10 @@ export async function swapBestRoute( sendAmount: params.mode === "strict-send" ? params.sendAmount! : bestQuote.sendAmount, destAmount: params.mode === "strict-receive" ? params.destAmount! : bestQuote.destAmount, path: bestQuote.path, + // 🛡️ NEW: Include protection information + actualSlippageBps: finalSlippageBps, + priceImpactBps: priceImpactBps || undefined, + protectionWarnings: protectionWarnings.length > 0 ? protectionWarnings : undefined, }; } diff --git a/lib/slippageProtection.ts b/lib/slippageProtection.ts new file mode 100644 index 00000000..03f3f2b8 --- /dev/null +++ b/lib/slippageProtection.ts @@ -0,0 +1,330 @@ +import Big from "big.js"; +import { RouteQuote, StellarAssetInput } from "./dex"; + +/** + * Advanced slippage protection and MEV defense for Stellar DEX operations + */ + +export interface SlippageConfig { + maxSlippageBps: number; + priceImpactWarningThreshold: number; + sandwichDetectionEnabled: boolean; + mevProtectionEnabled: boolean; + routeValidationEnabled: boolean; +} + +export interface PriceImpactAnalysis { + priceImpactBps: number; + isHighImpact: boolean; + riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + recommendation: string; + maxSafeAmount?: string; +} + +export interface RouteValidationResult { + isValid: boolean; + riskFactors: string[]; + optimizedRoute?: RouteQuote; + securityScore: number; +} + +export interface SlippageProtectionResult { + adjustedSlippageBps: number; + priceImpact: PriceImpactAnalysis; + routeValidation: RouteValidationResult; + mevRisk: MEVRiskAssessment; + shouldProceed: boolean; + warnings: string[]; +} + +export interface MEVRiskAssessment { + riskLevel: 'LOW' | 'MEDIUM' | 'HIGH'; + sandwichRisk: number; + frontRunningRisk: number; + recommendations: string[]; +} + +export class SlippageProtectionManager { + private config: SlippageConfig; + + constructor(config: Partial = {}) { + this.config = { + maxSlippageBps: config.maxSlippageBps ?? 500, + priceImpactWarningThreshold: config.priceImpactWarningThreshold ?? 100, + sandwichDetectionEnabled: config.sandwichDetectionEnabled ?? true, + mevProtectionEnabled: config.mevProtectionEnabled ?? true, + routeValidationEnabled: config.routeValidationEnabled ?? true, + }; + } + + public analyzeSlippageProtection( + quote: RouteQuote, + requestedSlippageBps: number, + tradeAmount: string, + sendAsset: StellarAssetInput, + destAsset: StellarAssetInput + ): SlippageProtectionResult { + this.validateSlippageBounds(requestedSlippageBps); + + const priceImpact = this.analyzePriceImpact(quote, tradeAmount, sendAsset, destAsset); + const routeValidation = this.validateRoute(quote, sendAsset, destAsset); + const mevRisk = this.assessMEVRisk(quote, tradeAmount, requestedSlippageBps); + const adjustedSlippageBps = this.calculateOptimalSlippage(requestedSlippageBps, priceImpact, mevRisk); + const shouldProceed = this.shouldAllowTrade(priceImpact, routeValidation, mevRisk); + const warnings = this.generateWarnings(priceImpact, routeValidation, mevRisk, adjustedSlippageBps); + + return { + adjustedSlippageBps, + priceImpact, + routeValidation, + mevRisk, + shouldProceed, + warnings + }; + } + + private validateSlippageBounds(slippageBps: number): void { + if (slippageBps < 0) { + throw new Error("🛡️ Slippage tolerance cannot be negative"); + } + + if (slippageBps > this.config.maxSlippageBps) { + throw new Error( + `🛡️ Slippage tolerance ${slippageBps / 100}% exceeds maximum allowed ${this.config.maxSlippageBps / 100}%.\n` + + `High slippage increases risk of sandwich attacks and significant losses.` + ); + } + + if (slippageBps > 200) { + console.warn( + `⚠️ HIGH SLIPPAGE WARNING: ${slippageBps / 100}% slippage tolerance is high.\n` + + `This increases risk of MEV attacks and unexpected losses.` + ); + } + } + + private analyzePriceImpact( + quote: RouteQuote, + tradeAmount: string, + sendAsset: StellarAssetInput, + destAsset: StellarAssetInput + ): PriceImpactAnalysis { + const amount = new Big(tradeAmount); + const baseImpactBps = this.calculateBasePriceImpact(amount, sendAsset, destAsset); + const routeComplexityMultiplier = 1 + (quote.hopCount * 0.1); + const adjustedImpactBps = Math.floor(baseImpactBps * routeComplexityMultiplier); + + let riskLevel: PriceImpactAnalysis['riskLevel'] = 'LOW'; + let recommendation = 'Trade appears safe to proceed.'; + let maxSafeAmount: string | undefined; + + if (adjustedImpactBps > 1000) { + riskLevel = 'CRITICAL'; + recommendation = 'CRITICAL: Extremely high price impact. Consider splitting into multiple smaller trades.'; + maxSafeAmount = amount.div(4).toString(); + } else if (adjustedImpactBps > 500) { + riskLevel = 'HIGH'; + recommendation = 'HIGH RISK: Significant price impact detected. Consider reducing trade size.'; + maxSafeAmount = amount.div(2).toString(); + } else if (adjustedImpactBps > 200) { + riskLevel = 'MEDIUM'; + recommendation = 'MEDIUM RISK: Moderate price impact. Monitor execution carefully.'; + } + + return { + priceImpactBps: adjustedImpactBps, + isHighImpact: adjustedImpactBps > this.config.priceImpactWarningThreshold, + riskLevel, + recommendation, + maxSafeAmount + }; + } + + private calculateBasePriceImpact( + amount: Big, + sendAsset: StellarAssetInput, + destAsset: StellarAssetInput + ): number { + const isNativeInvolved = 'type' in sendAsset || 'type' in destAsset; + const baseImpact = isNativeInvolved ? 10 : 25; + const sizeMultiplier = Math.log10(amount.toNumber() + 1) / 2; + return Math.floor(baseImpact * sizeMultiplier); + } + + private validateRoute( + quote: RouteQuote, + sendAsset: StellarAssetInput, + destAsset: StellarAssetInput + ): RouteValidationResult { + const riskFactors: string[] = []; + let securityScore = 100; + + if (quote.hopCount > 3) { + riskFactors.push('Route has many hops, increasing complexity and gas costs'); + securityScore -= 15; + } + + if (this.hasCircularPath(quote.path, sendAsset, destAsset)) { + riskFactors.push('Circular route detected - may indicate inefficient pathfinding'); + securityScore -= 25; + } + + const hasUnknownAssets = this.checkForUnknownAssets(quote.path); + if (hasUnknownAssets) { + riskFactors.push('Route contains assets with unknown liquidity characteristics'); + securityScore -= 20; + } + + const priceConsistency = this.validatePriceConsistency(quote); + if (!priceConsistency) { + riskFactors.push('Price inconsistency detected in route'); + securityScore -= 30; + } + + return { + isValid: securityScore >= 50, + riskFactors, + securityScore, + }; + } + + private assessMEVRisk( + quote: RouteQuote, + tradeAmount: string, + slippageBps: number + ): MEVRiskAssessment { + const amount = new Big(tradeAmount); + const sandwichRisk = this.calculateSandwichRisk(amount, slippageBps, quote.hopCount); + const frontRunningRisk = this.calculateFrontRunningRisk(amount, quote.estimatedPrice); + + let riskLevel: MEVRiskAssessment['riskLevel'] = 'LOW'; + const recommendations: string[] = []; + + if (sandwichRisk > 70 || frontRunningRisk > 70) { + riskLevel = 'HIGH'; + recommendations.push('Consider using private mempool or splitting trade'); + recommendations.push('Reduce slippage tolerance to minimize MEV extraction'); + } else if (sandwichRisk > 40 || frontRunningRisk > 40) { + riskLevel = 'MEDIUM'; + recommendations.push('Monitor transaction carefully for MEV attacks'); + recommendations.push('Consider timing trade during low network activity'); + } else { + recommendations.push('MEV risk is low - trade appears safe'); + } + + return { + riskLevel, + sandwichRisk, + frontRunningRisk, + recommendations + }; + } + + private calculateSandwichRisk(amount: Big, slippageBps: number, hopCount: number): number { + const amountRisk = Math.min(amount.toNumber() / 10000, 50); + const slippageRisk = Math.min(slippageBps / 10, 30); + const complexityRisk = Math.min(hopCount * 5, 20); + return Math.floor(amountRisk + slippageRisk + complexityRisk); + } + + private calculateFrontRunningRisk(amount: Big, estimatedPrice: string): number { + const price = new Big(estimatedPrice); + const attractivenessScore = amount.mul(price).toNumber() / 1000; + return Math.min(Math.floor(attractivenessScore), 100); + } + + private calculateOptimalSlippage( + requestedSlippageBps: number, + priceImpact: PriceImpactAnalysis, + mevRisk: MEVRiskAssessment + ): number { + let adjustedSlippage = requestedSlippageBps; + + if (mevRisk.riskLevel === 'HIGH') { + adjustedSlippage = Math.min(adjustedSlippage, 100); + } else if (mevRisk.riskLevel === 'MEDIUM') { + adjustedSlippage = Math.min(adjustedSlippage, 200); + } + + if (priceImpact.riskLevel === 'HIGH' || priceImpact.riskLevel === 'CRITICAL') { + adjustedSlippage = Math.max(adjustedSlippage, priceImpact.priceImpactBps + 50); + } + + return Math.min(adjustedSlippage, this.config.maxSlippageBps); + } + + private shouldAllowTrade( + priceImpact: PriceImpactAnalysis, + routeValidation: RouteValidationResult, + mevRisk: MEVRiskAssessment + ): boolean { + if (priceImpact.riskLevel === 'CRITICAL') return false; + if (!routeValidation.isValid) return false; + if (mevRisk.riskLevel === 'HIGH' && priceImpact.riskLevel === 'HIGH') return false; + return true; + } + + private generateWarnings( + priceImpact: PriceImpactAnalysis, + routeValidation: RouteValidationResult, + mevRisk: MEVRiskAssessment, + adjustedSlippageBps: number + ): string[] { + const warnings: string[] = []; + + if (priceImpact.isHighImpact) { + warnings.push(`⚠️ Price Impact: ${priceImpact.priceImpactBps / 100}% - ${priceImpact.recommendation}`); + } + + if (mevRisk.riskLevel !== 'LOW') { + warnings.push(`🛡️ MEV Risk: ${mevRisk.riskLevel} - Consider using private mempool`); + } + + if (routeValidation.riskFactors.length > 0) { + warnings.push(`🔍 Route Issues: ${routeValidation.riskFactors.join(', ')}`); + } + + if (adjustedSlippageBps > 300) { + warnings.push(`📊 High Slippage: ${adjustedSlippageBps / 100}% - Monitor execution carefully`); + } + + return warnings; + } + + private hasCircularPath(path: StellarAssetInput[], sendAsset: StellarAssetInput, destAsset: StellarAssetInput): boolean { + return path.some(asset => + this.assetsEqual(asset, sendAsset) || this.assetsEqual(asset, destAsset) + ); + } + + private checkForUnknownAssets(path: StellarAssetInput[]): boolean { + return path.some(asset => !('type' in asset)); + } + + private validatePriceConsistency(quote: RouteQuote): boolean { + const price = new Big(quote.estimatedPrice); + return price.gt(0) && price.lt(1000000); + } + + private assetsEqual(asset1: StellarAssetInput, asset2: StellarAssetInput): boolean { + if ('type' in asset1 && 'type' in asset2) return true; + if ('type' in asset1 || 'type' in asset2) return false; + return asset1.code === asset2.code && asset1.issuer === asset2.issuer; + } +} + +export function createSlippageProtection(config?: Partial): SlippageProtectionManager { + return new SlippageProtectionManager(config); +} + +export function validateSlippage(slippageBps: number, maxAllowed: number = 500): void { + if (slippageBps < 0) { + throw new Error("🛡️ Slippage tolerance cannot be negative"); + } + + if (slippageBps > maxAllowed) { + throw new Error( + `🛡️ Slippage tolerance ${slippageBps / 100}% exceeds maximum ${maxAllowed / 100}%` + ); + } +} \ No newline at end of file diff --git a/tests/integration/enhanced-dex-security.test.ts b/tests/integration/enhanced-dex-security.test.ts new file mode 100644 index 00000000..8b754e56 --- /dev/null +++ b/tests/integration/enhanced-dex-security.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { AgentClient } from '../../agent'; +import { SlippageProtectionManager } from '../../lib/slippageProtection'; + +describe('Enhanced DEX Security Integration', () => { + let agent: AgentClient; + + beforeEach(() => { + // Mock environment variables for testing + process.env.STELLAR_PUBLIC_KEY = 'GCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5'; + process.env.STELLAR_PRIVATE_KEY = 'SCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5'; + + agent = new AgentClient({ + network: 'testnet', + publicKey: process.env.STELLAR_PUBLIC_KEY + }); + }); + + describe('slippage protection integration', () => { + it('should prevent excessive slippage in DEX swaps', async () => { + // Mock the DEX swap to test slippage protection + const mockSwapBestRoute = vi.fn().mockRejectedValue( + new Error('🛡️ Slippage tolerance 15% exceeds maximum allowed 10%') + ); + + // Replace the actual method with our mock + agent.dex.swapBestRoute = mockSwapBestRoute; + + await expect( + agent.dex.swapBestRoute({ + mode: 'strict-send', + sendAsset: { type: 'native' }, + destAsset: { code: 'USDC', issuer: 'GCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5' }, + sendAmount: '1000.0000000', + slippageBps: 1500, // 15% - should be blocked + }) + ).rejects.toThrow('🛡️ Slippage tolerance 15% exceeds maximum allowed 10%'); + }); + + it('should warn about high price impact trades', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Mock a successful swap with warnings + const mockSwapBestRoute = vi.fn().mockResolvedValue({ + hash: 'mock_hash', + mode: 'strict-send', + sendAmount: '100000.0000000', + destAmount: '95000.0000000', + path: [{ type: 'native' }], + actualSlippageBps: 200, + priceImpactBps: 350, // 3.5% price impact + protectionWarnings: [ + '⚠️ Price Impact: 3.5% - MEDIUM RISK: Moderate price impact. Monitor execution carefully.' + ] + }); + + agent.dex.swapBestRoute = mockSwapBestRoute; + + const result = await agent.dex.swapBestRoute({ + mode: 'strict-send', + sendAsset: { type: 'native' }, + destAsset: { code: 'USDC', issuer: 'GCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5' }, + sendAmount: '100000.0000000', + slippageBps: 200, + }); + + expect(result.protectionWarnings).toBeDefined(); + expect(result.protectionWarnings![0]).toContain('Price Impact'); + expect(result.priceImpactBps).toBe(350); + + consoleSpy.mockRestore(); + }); + + it('should block trades with critical price impact', async () => { + const mockSwapBestRoute = vi.fn().mockRejectedValue( + new Error( + '🛡️ Trade blocked by slippage protection:\n' + + '⚠️ Price Impact: 12% - CRITICAL: Extremely high price impact. Consider splitting into multiple smaller trades.\n' + + 'Consider reducing trade size or adjusting parameters.' + ) + ); + + agent.dex.swapBestRoute = mockSwapBestRoute; + + await expect( + agent.dex.swapBestRoute({ + mode: 'strict-send', + sendAsset: { type: 'native' }, + destAsset: { code: 'USDC', issuer: 'GCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5' }, + sendAmount: '1000000.0000000', // Very large trade + slippageBps: 100, + }) + ).rejects.toThrow('🛡️ Trade blocked by slippage protection'); + }); + + it('should adjust slippage for MEV protection', async () => { + const mockSwapBestRoute = vi.fn().mockResolvedValue({ + hash: 'mock_hash', + mode: 'strict-send', + sendAmount: '50000.0000000', + destAmount: '49500.0000000', + path: [{ type: 'native' }], + actualSlippageBps: 100, // Reduced from requested 300 due to MEV risk + priceImpactBps: 150, + protectionWarnings: [ + '🛡️ MEV Risk: MEDIUM - Consider using private mempool' + ] + }); + + agent.dex.swapBestRoute = mockSwapBestRoute; + + const result = await agent.dex.swapBestRoute({ + mode: 'strict-send', + sendAsset: { type: 'native' }, + destAsset: { code: 'USDC', issuer: 'GCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5' }, + sendAmount: '50000.0000000', + slippageBps: 300, // Requested 3% + }); + + // Should have reduced slippage due to MEV protection + expect(result.actualSlippageBps).toBeLessThan(300); + expect(result.protectionWarnings).toContain( + expect.stringContaining('MEV Risk') + ); + }); + + it('should allow disabling slippage protection', async () => { + const mockSwapBestRoute = vi.fn().mockResolvedValue({ + hash: 'mock_hash', + mode: 'strict-send', + sendAmount: '1000.0000000', + destAmount: '950.0000000', + path: [{ type: 'native' }], + actualSlippageBps: 400, // Uses requested slippage when protection disabled + }); + + agent.dex.swapBestRoute = mockSwapBestRoute; + + const result = await agent.dex.swapBestRoute({ + mode: 'strict-send', + sendAsset: { type: 'native' }, + destAsset: { code: 'USDC', issuer: 'GCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5' }, + sendAmount: '1000.0000000', + slippageBps: 400, + enableSlippageProtection: false, // Explicitly disable protection + }); + + expect(result.actualSlippageBps).toBe(400); + expect(result.protectionWarnings).toBeUndefined(); + }); + + it('should respect custom price impact limits', async () => { + const mockSwapBestRoute = vi.fn().mockRejectedValue( + new Error('🛡️ Price impact 2.5% exceeds maximum allowed 2%') + ); + + agent.dex.swapBestRoute = mockSwapBestRoute; + + await expect( + agent.dex.swapBestRoute({ + mode: 'strict-send', + sendAsset: { type: 'native' }, + destAsset: { code: 'USDC', issuer: 'GCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5' }, + sendAmount: '10000.0000000', + slippageBps: 100, + maxPriceImpactBps: 200, // 2% max price impact + }) + ).rejects.toThrow('🛡️ Price impact 2.5% exceeds maximum allowed 2%'); + }); + }); + + describe('route validation', () => { + it('should detect and warn about complex routes', async () => { + const mockSwapBestRoute = vi.fn().mockResolvedValue({ + hash: 'mock_hash', + mode: 'strict-send', + sendAmount: '1000.0000000', + destAmount: '950.0000000', + path: [ + { code: 'USDC', issuer: 'G1' }, + { code: 'EURC', issuer: 'G2' }, + { code: 'JPYC', issuer: 'G3' }, + { type: 'native' } + ], + actualSlippageBps: 150, + protectionWarnings: [ + '🔍 Route Issues: Route has many hops, increasing complexity and gas costs' + ] + }); + + agent.dex.swapBestRoute = mockSwapBestRoute; + + const result = await agent.dex.swapBestRoute({ + mode: 'strict-send', + sendAsset: { code: 'USDC', issuer: 'GCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5' }, + destAsset: { type: 'native' }, + sendAmount: '1000.0000000', + slippageBps: 100, + }); + + expect(result.protectionWarnings).toContain( + expect.stringContaining('Route Issues') + ); + }); + + it('should block invalid or insecure routes', async () => { + const mockSwapBestRoute = vi.fn().mockRejectedValue( + new Error( + '🛡️ Trade blocked by slippage protection:\n' + + '🔍 Route Issues: Circular route detected - may indicate inefficient pathfinding, Price inconsistency detected in route\n' + + 'Consider reducing trade size or adjusting parameters.' + ) + ); + + agent.dex.swapBestRoute = mockSwapBestRoute; + + await expect( + agent.dex.swapBestRoute({ + mode: 'strict-send', + sendAsset: { code: 'USDC', issuer: 'GCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5' }, + destAsset: { type: 'native' }, + sendAmount: '1000.0000000', + slippageBps: 100, + }) + ).rejects.toThrow('🛡️ Trade blocked by slippage protection'); + }); + }); + + describe('backward compatibility', () => { + it('should maintain compatibility with existing DEX usage', async () => { + const mockQuoteSwap = vi.fn().mockResolvedValue([ + { + path: [{ type: 'native' }], + sendAmount: '1000.0000000', + destAmount: '950.0000000', + estimatedPrice: '0.95', + hopCount: 1, + raw: {} + } + ]); + + agent.dex.quoteSwap = mockQuoteSwap; + + const quotes = await agent.dex.quoteSwap({ + mode: 'strict-send', + sendAsset: { type: 'native' }, + destAsset: { code: 'USDC', issuer: 'GCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5' }, + sendAmount: '1000.0000000', + }); + + expect(quotes).toHaveLength(1); + expect(quotes[0].sendAmount).toBe('1000.0000000'); + expect(quotes[0].destAmount).toBe('950.0000000'); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/lib/slippageProtection.test.ts b/tests/unit/lib/slippageProtection.test.ts new file mode 100644 index 00000000..40ad3bb0 --- /dev/null +++ b/tests/unit/lib/slippageProtection.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SlippageProtectionManager, validateSlippage, createSlippageProtection } from '../../../lib/slippageProtection'; +import { RouteQuote, StellarAssetInput } from '../../../lib/dex'; + +describe('SlippageProtectionManager', () => { + let protectionManager: SlippageProtectionManager; + let mockQuote: RouteQuote; + let mockSendAsset: StellarAssetInput; + let mockDestAsset: StellarAssetInput; + + beforeEach(() => { + protectionManager = new SlippageProtectionManager(); + + mockQuote = { + path: [{ type: 'native' }], + sendAmount: '1000.0000000', + destAmount: '950.0000000', + estimatedPrice: '0.95', + hopCount: 1, + raw: {} as any + }; + + mockSendAsset = { code: 'USDC', issuer: 'GCKFBEIYTKP5RDBKPUQXQVJJGKSXQHMRA5XMKJVK2QXQVJJGKSXQHMRA5' }; + mockDestAsset = { type: 'native' }; + }); + + describe('constructor', () => { + it('should use default config when no config provided', () => { + const manager = new SlippageProtectionManager(); + expect(manager).toBeDefined(); + }); + + it('should use custom config when provided', () => { + const customConfig = { + maxSlippageBps: 1000, + priceImpactWarningThreshold: 200, + sandwichDetectionEnabled: false, + mevProtectionEnabled: false, + routeValidationEnabled: false + }; + + const manager = new SlippageProtectionManager(customConfig); + expect(manager).toBeDefined(); + }); + }); + + describe('analyzeSlippageProtection', () => { + it('should analyze slippage protection for normal trade', () => { + const result = protectionManager.analyzeSlippageProtection( + mockQuote, + 100, // 1% slippage + '1000', + mockSendAsset, + mockDestAsset + ); + + expect(result).toBeDefined(); + expect(result.shouldProceed).toBe(true); + expect(result.adjustedSlippageBps).toBeDefined(); + expect(result.priceImpact).toBeDefined(); + expect(result.routeValidation).toBeDefined(); + expect(result.mevRisk).toBeDefined(); + expect(result.warnings).toBeInstanceOf(Array); + }); + + it('should block trade with excessive slippage', () => { + expect(() => { + protectionManager.analyzeSlippageProtection( + mockQuote, + 1000, // 10% slippage (exceeds default max of 5%) + '1000', + mockSendAsset, + mockDestAsset + ); + }).toThrow('🛡️ Slippage tolerance 10% exceeds maximum allowed 5%'); + }); + + it('should block trade with negative slippage', () => { + expect(() => { + protectionManager.analyzeSlippageProtection( + mockQuote, + -100, // Negative slippage + '1000', + mockSendAsset, + mockDestAsset + ); + }).toThrow('🛡️ Slippage tolerance cannot be negative'); + }); + + it('should warn about high slippage', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + protectionManager.analyzeSlippageProtection( + mockQuote, + 300, // 3% slippage (should trigger warning) + '1000', + mockSendAsset, + mockDestAsset + ); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('HIGH SLIPPAGE WARNING') + ); + + consoleSpy.mockRestore(); + }); + + it('should handle large trade amounts with high price impact', () => { + const largeQuote = { + ...mockQuote, + sendAmount: '100000.0000000', // Large amount + hopCount: 3 // Complex route + }; + + const result = protectionManager.analyzeSlippageProtection( + largeQuote, + 100, + '100000', + mockSendAsset, + mockDestAsset + ); + + expect(result.priceImpact.riskLevel).not.toBe('LOW'); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it('should detect circular routes', () => { + const circularQuote = { + ...mockQuote, + path: [mockSendAsset, mockDestAsset, mockSendAsset] // Circular path + }; + + const result = protectionManager.analyzeSlippageProtection( + circularQuote, + 100, + '1000', + mockSendAsset, + mockDestAsset + ); + + expect(result.routeValidation.riskFactors).toContain( + expect.stringContaining('Circular route detected') + ); + }); + + it('should assess MEV risk for large trades', () => { + const result = protectionManager.analyzeSlippageProtection( + mockQuote, + 400, // High slippage + '50000', // Large amount + mockSendAsset, + mockDestAsset + ); + + expect(result.mevRisk.riskLevel).not.toBe('LOW'); + expect(result.mevRisk.recommendations.length).toBeGreaterThan(0); + }); + + it('should block critical price impact trades', () => { + // Create a quote that would result in critical price impact + const criticalQuote = { + ...mockQuote, + sendAmount: '1000000.0000000', // Very large amount + hopCount: 4 // Very complex route + }; + + const result = protectionManager.analyzeSlippageProtection( + criticalQuote, + 100, + '1000000', + mockSendAsset, + mockDestAsset + ); + + // Should either block the trade or provide critical warnings + expect( + !result.shouldProceed || + result.priceImpact.riskLevel === 'CRITICAL' || + result.warnings.length > 0 + ).toBe(true); + }); + + it('should adjust slippage for high MEV risk', () => { + const highMEVQuote = { + ...mockQuote, + sendAmount: '75000.0000000', // Large enough to trigger MEV concerns + estimatedPrice: '1.5' // Good price that might attract front-runners + }; + + const result = protectionManager.analyzeSlippageProtection( + highMEVQuote, + 300, // 3% requested slippage + '75000', + mockSendAsset, + mockDestAsset + ); + + // Should adjust slippage down for MEV protection + if (result.mevRisk.riskLevel === 'HIGH') { + expect(result.adjustedSlippageBps).toBeLessThanOrEqual(100); // Max 1% for high MEV risk + } + }); + }); + + describe('price impact analysis', () => { + it('should calculate low price impact for small trades', () => { + const result = protectionManager.analyzeSlippageProtection( + mockQuote, + 100, + '100', // Small amount + mockSendAsset, + mockDestAsset + ); + + expect(result.priceImpact.riskLevel).toBe('LOW'); + expect(result.priceImpact.isHighImpact).toBe(false); + }); + + it('should provide recommendations for high impact trades', () => { + const highImpactQuote = { + ...mockQuote, + sendAmount: '500000.0000000', + hopCount: 3 + }; + + const result = protectionManager.analyzeSlippageProtection( + highImpactQuote, + 100, + '500000', + mockSendAsset, + mockDestAsset + ); + + if (result.priceImpact.riskLevel === 'HIGH' || result.priceImpact.riskLevel === 'CRITICAL') { + expect(result.priceImpact.recommendation).toContain('Consider'); + expect(result.priceImpact.maxSafeAmount).toBeDefined(); + } + }); + }); + + describe('route validation', () => { + it('should validate simple routes as secure', () => { + const result = protectionManager.analyzeSlippageProtection( + mockQuote, + 100, + '1000', + mockSendAsset, + mockDestAsset + ); + + expect(result.routeValidation.isValid).toBe(true); + expect(result.routeValidation.securityScore).toBeGreaterThan(50); + }); + + it('should penalize complex routes', () => { + const complexQuote = { + ...mockQuote, + hopCount: 5, // Very complex route + path: new Array(5).fill({ code: 'TEST', issuer: 'G'.repeat(56) }) + }; + + const result = protectionManager.analyzeSlippageProtection( + complexQuote, + 100, + '1000', + mockSendAsset, + mockDestAsset + ); + + expect(result.routeValidation.riskFactors).toContain( + expect.stringContaining('many hops') + ); + expect(result.routeValidation.securityScore).toBeLessThan(100); + }); + }); + + describe('MEV risk assessment', () => { + it('should assess low MEV risk for small trades', () => { + const result = protectionManager.analyzeSlippageProtection( + mockQuote, + 50, // Low slippage + '100', // Small amount + mockSendAsset, + mockDestAsset + ); + + expect(result.mevRisk.riskLevel).toBe('LOW'); + expect(result.mevRisk.sandwichRisk).toBeLessThan(50); + expect(result.mevRisk.frontRunningRisk).toBeLessThan(50); + }); + + it('should provide MEV protection recommendations', () => { + const result = protectionManager.analyzeSlippageProtection( + mockQuote, + 100, + '1000', + mockSendAsset, + mockDestAsset + ); + + expect(result.mevRisk.recommendations).toBeInstanceOf(Array); + expect(result.mevRisk.recommendations.length).toBeGreaterThan(0); + }); + }); +}); + +describe('validateSlippage', () => { + it('should pass for valid slippage values', () => { + expect(() => validateSlippage(100)).not.toThrow(); // 1% + expect(() => validateSlippage(250)).not.toThrow(); // 2.5% + expect(() => validateSlippage(500)).not.toThrow(); // 5% + }); + + it('should throw for negative slippage', () => { + expect(() => validateSlippage(-100)).toThrow('🛡️ Slippage tolerance cannot be negative'); + }); + + it('should throw for excessive slippage', () => { + expect(() => validateSlippage(1000)).toThrow('🛡️ Slippage tolerance 10% exceeds maximum 5%'); + }); + + it('should respect custom maximum', () => { + expect(() => validateSlippage(800, 1000)).not.toThrow(); // 8% with 10% max + expect(() => validateSlippage(1200, 1000)).toThrow('🛡️ Slippage tolerance 12% exceeds maximum 10%'); + }); +}); + +describe('createSlippageProtection', () => { + it('should create SlippageProtectionManager with default config', () => { + const manager = createSlippageProtection(); + expect(manager).toBeInstanceOf(SlippageProtectionManager); + }); + + it('should create SlippageProtectionManager with custom config', () => { + const config = { maxSlippageBps: 1000 }; + const manager = createSlippageProtection(config); + expect(manager).toBeInstanceOf(SlippageProtectionManager); + }); +}); \ No newline at end of file