diff --git a/README.md b/README.md index 2a3751ee..095d2d0f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This is a collection of **Warden Community Agents and Tools** built with TypeScript or Python. -šŸ’« The [Agent Builder Incentive Programme](https://wardenprotocol.org/blog/agent-builder-incentive-programme) is live! +šŸ’« The [Agent Builder Incentive Programme](https://wardenprotocol.org/blog/agent-builder-incentive-programme) is live! [Register now](https://docs.google.com/forms/d/e/1FAIpQLSdwTR0BL8-T3LLbJt6aIyjuEYjMAmJPMdwffwHcyW6gskDQsg/viewform) and get paid for building agents! Up to $10,000 in incentives for each agent in the Top 10 in the first month. ## šŸ“š Documentation @@ -14,10 +14,11 @@ If you get stuck or you need to get in touch, join the [`#developers`](https://d ## šŸ¤– Example Agents Each agent in the [`agents/`](agents) directory is completely self-sufficient and comes with its own: + - Dependencies and devDependencies - Configuration files - Build scripts -- Tests (excluding starter templates) +- Tests (excluding starter templates) ## Available Agents @@ -26,6 +27,7 @@ Each agent in the [`agents/`](agents) directory is completely self-sufficient an - **[weather-agent](agents/weather-agent)**: Beginner-friendly weather agent (less complex) **<- recommended for new agent developers** - **[coingecko-agent](agents/coingecko-agent)**: CoinGecko agent for cryptocurrency data analysis (more complex) - **[portfolio-agent](agents/portfolio-agent)**: Portfolio agent for cryptocurrency wallet performance analysis (more complex) +- **[Yield-Optimization-Agent](agents/Yield-Optimization-Agent)**: AI-powered agent for finding the best and safest staking opportunities across multiple DeFi protocols and chains (more complex) ## Requirements @@ -53,7 +55,6 @@ Awesome agents and tools built by the community! Add yours by submitting a PR to **Format:** `[Project Name](link): Short agent description` - ### Agents - [Travel DeFi Agent](https://github.com/Joshua15310/travel-defi-agent): LangGraph agent for travel planning and expense optimization using Gemini AI and DeFi strategies. diff --git a/agents/Yield-Optimization-Agent/.gitignore b/agents/Yield-Optimization-Agent/.gitignore new file mode 100644 index 00000000..995df8d1 --- /dev/null +++ b/agents/Yield-Optimization-Agent/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build outputs +build/ +dist/ +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage/ +.nyc_output/ + +# Temporary files +tmp/ +temp/ +*.tmp + diff --git a/agents/Yield-Optimization-Agent/API_DOCUMENTATION.md b/agents/Yield-Optimization-Agent/API_DOCUMENTATION.md new file mode 100644 index 00000000..3d9dae6a --- /dev/null +++ b/agents/Yield-Optimization-Agent/API_DOCUMENTATION.md @@ -0,0 +1,606 @@ +# Yield Agent API Documentation + +Complete REST API documentation for the Yield Optimization Agent. + +## Table of Contents + +- [Getting Started](#getting-started) +- [Authentication](#authentication) +- [Base URL](#base-url) +- [Endpoints](#endpoints) +- [Error Handling](#error-handling) +- [Rate Limiting](#rate-limiting) +- [Examples](#examples) + +## Getting Started + +### Installation + +```bash +# Install dependencies +yarn install + +# Set up environment variables +# Create a .env file with your API keys +``` + +### Starting the Server + +```bash +# Development mode (with auto-reload) +yarn dev:api + +# Production mode +yarn start:api +``` + +The server will start on `http://localhost:3000` by default. + +### Swagger Documentation + +Once the server is running, you can access the interactive Swagger documentation at: + +``` +http://localhost:3000/api-docs +``` + +This provides a user-friendly interface to test all API endpoints. + +## Authentication + +Currently, the API does not require authentication for development purposes. For production deployments, you should implement authentication using API keys or JWT tokens. + +## Base URL + +**Development:** `http://localhost:3000/api/v1` + +**Production:** `https://api.yieldagent.io/api/v1` (example) + +## Endpoints + +### Health Check + +```http +GET /health +``` + +Check API server health status. + +**Response:** + +```json +{ + "status": "ok", + "timestamp": "2025-12-31T10:00:00.000Z", + "uptime": 1234.56, + "environment": "development", + "version": "1.0.0" +} +``` + +--- + +### Agent Endpoints + +#### Query Agent (Single Query) + +```http +POST /api/v1/agent/query +``` + +Send a natural language query to the AI agent. + +**Request Body:** + +```json +{ + "query": "Find staking opportunities for USDC on Ethereum", + "options": { + "modelName": "gpt-4o-mini", + "temperature": 0, + "maxTokens": 4000 + } +} +``` + +**Response:** + +```json +{ + "success": true, + "query": "Find staking opportunities for USDC on Ethereum", + "result": { + "question": "Find staking opportunities for USDC on Ethereum", + "response": { + "answer": "I found 15 staking protocols for USDC on Ethereum...", + "step": "protocol_discovery", + "mode": "interactive", + "protocols": [...], + "confidence": "high" + } + }, + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +#### Batch Query Agent + +```http +POST /api/v1/agent/batch +``` + +Send multiple queries at once. + +**Request Body:** + +```json +{ + "queries": [ + "Find staking for USDC", + "What protocols support ETH on Arbitrum?" + ], + "options": { + "modelName": "gpt-4o-mini", + "delayBetweenQuestionsMs": 2000 + } +} +``` + +**Response:** + +```json +{ + "success": true, + "count": 2, + "results": [ + { + "question": "Find staking for USDC", + "response": {...} + }, + { + "question": "What protocols support ETH on Arbitrum?", + "response": {...} + } + ], + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +#### Quick Transaction + +```http +POST /api/v1/agent/quick +``` + +Generate transaction bundle directly with all parameters provided. + +**Request Body:** + +```json +{ + "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "protocolName": "aave", + "amount": "100", + "userAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb" +} +``` + +**Response:** + +```json +{ + "success": true, + "mode": "quick", + "result": { + "question": "Deposit 100 0xA0b... on chain 1 to aave...", + "response": { + "answer": "Transaction bundle ready...", + "step": "quick_mode_complete", + "tokenInfo": {...}, + "protocols": [{...}], + "approvalTransaction": {...}, + "transaction": {...}, + "executionOrder": ["approve", "deposit"] + } + }, + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +--- + +### Token Endpoints + +#### Search Tokens + +```http +GET /api/v1/tokens/search?query=USDC +``` + +Search for tokens by name or symbol. + +**Query Parameters:** + +- `query` (required): Token name or symbol + +**Response:** + +```json +{ + "success": true, + "count": 1, + "tokens": [ + { + "name": "USD Coin", + "symbol": "USDC", + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chain": "Ethereum", + "chainId": 1, + "decimals": 6, + "price": 1.0, + "marketCap": 30000000000, + "verified": true, + "allChains": [...] + } + ], + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +#### Get Token Information + +```http +GET /api/v1/tokens/info?token=USDC&chainId=1 +``` + +Get detailed information about a specific token. + +**Query Parameters:** + +- `token` (required): Token name, symbol, or address +- `chainId` (optional): Chain ID (required if token is an address) +- `chainName` (optional): Chain name (alternative to chainId) + +**Response:** + +```json +{ + "success": true, + "token": { + "name": "USD Coin", + "symbol": "USDC", + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chain": "Ethereum", + "chainId": 1, + "decimals": 6, + "price": 1.0, + "marketCap": 30000000000, + "verified": true, + "allChains": [ + { + "chainId": 1, + "chainName": "Ethereum", + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + ... + ] + }, + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +--- + +### Protocol Endpoints + +#### Discover Protocols + +```http +POST /api/v1/protocols/discover +``` + +Discover available staking protocols for a token. + +**Request Body:** + +```json +{ + "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "multiChain": false +} +``` + +**Parameters:** + +- `tokenAddress` (required): Token contract address +- `chainId` (optional): Specific chain ID (required if multiChain is false) +- `multiChain` (optional): Search across all chains (default: true) + +**Response:** + +```json +{ + "success": true, + "count": 15, + "totalFound": 42, + "protocols": [ + { + "address": "0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9", + "name": "Aave V3 USDC", + "protocol": "aave-v3", + "chainId": 1, + "chainName": "Ethereum", + "apy": 5.2, + "tvl": 1000000000, + "safetyScore": { + "overall": "very_safe", + "score": 95, + "factors": { + "tvl": { "score": 100, "level": "very_safe" }, + "protocol": { "score": 100, "level": "trusted", "reputation": "excellent" }, + "audits": { "score": 90, "level": "verified", "auditCount": 5 }, + "history": { "score": 90, "level": "established" } + }, + "warnings": [] + } + }, + ... + ], + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +--- + +### Transaction Endpoints + +#### Generate Transaction Bundle + +```http +POST /api/v1/transactions/generate +``` + +Generate approval and deposit transactions for staking. + +**Request Body:** + +```json +{ + "userAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", + "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "protocolAddress": "0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9", + "protocolName": "aave-v3", + "chainId": 1, + "amount": "100000000", + "tokenSymbol": "USDC", + "decimals": 6 +} +``` + +**Response:** + +```json +{ + "success": true, + "bundle": { + "approvalTransaction": { + "to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "data": "0x095ea7b3...", + "value": "0", + "chainId": 1, + "gasLimit": "50000", + "type": "approve", + "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "spender": "0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9", + "amount": "100000000", + "safetyWarning": "āš ļø CRITICAL: ..." + }, + "depositTransaction": { + "to": "0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9", + "data": "0xe8eda9df...", + "value": "0", + "chainId": 1, + "gasLimit": "200000", + "type": "deposit", + "protocol": "aave-v3", + "action": "deposit", + "tokenIn": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "symbol": "USDC", + "amount": "100", + "amountWei": "100000000" + }, + "tokenOut": { + "address": "0x...", + "symbol": "aUSDC" + }, + "safetyWarning": "āš ļø CRITICAL: ..." + }, + "executionOrder": ["approve", "deposit"], + "totalGasEstimate": "$5.23" + }, + "warning": "āš ļø CRITICAL: This transaction object was generated by an AI agent...", + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +--- + +### Chain Endpoints + +#### Get Supported Chains + +```http +GET /api/v1/chains +``` + +List all supported blockchain networks. + +**Response:** + +```json +{ + "success": true, + "count": 7, + "chains": [ + { + "id": 1, + "name": "Ethereum", + "chainName": "ethereum" + }, + { + "id": 42161, + "name": "Arbitrum", + "chainName": "arbitrum-one" + }, + ... + ], + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +--- + +## Error Handling + +All errors follow a consistent format: + +```json +{ + "success": false, + "error": "Error type", + "details": "Detailed error message", + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +### HTTP Status Codes + +- `200` - Success +- `400` - Bad Request (validation error) +- `404` - Not Found +- `429` - Too Many Requests (rate limit exceeded) +- `500` - Internal Server Error +- `503` - Service Unavailable (API keys not configured) + +### Common Errors + +#### Validation Error (400) + +```json +{ + "success": false, + "error": "Validation error", + "details": [ + { + "field": "query", + "message": "Query cannot be empty" + } + ], + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +#### Rate Limit Error (429) + +```json +{ + "success": false, + "error": "Too many requests from this IP, please try again later.", + "retryAfter": "15 minutes", + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +#### Resource Not Found (404) + +```json +{ + "success": false, + "error": "Token not found", + "timestamp": "2025-12-31T10:00:00.000Z" +} +``` + +--- + +## Rate Limiting + +- **Limit:** 100 requests per 15 minutes per IP address +- **Batch queries:** Limited to 10 queries maximum per request +- **Headers:** Rate limit information is included in response headers: + - `RateLimit-Limit`: Maximum requests allowed + - `RateLimit-Remaining`: Remaining requests in current window + - `RateLimit-Reset`: Time when the rate limit resets + +--- + +## Examples + +### Example 1: Find Best USDC Staking on Ethereum + +```bash +curl -X POST http://localhost:3000/api/v1/agent/query \ + -H "Content-Type: application/json" \ + -d '{ + "query": "Find the best and safest staking opportunities for USDC on Ethereum" + }' +``` + +### Example 2: Quick Transaction for Aave + +```bash +curl -X POST http://localhost:3000/api/v1/agent/quick \ + -H "Content-Type: application/json" \ + -d '{ + "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "protocolName": "aave", + "amount": "1000", + "userAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb" + }' +``` + +### Example 3: Search for Token + +```bash +curl -X GET "http://localhost:3000/api/v1/tokens/search?query=USDC" +``` + +### Example 4: Discover Multi-Chain Protocols + +```bash +curl -X POST http://localhost:3000/api/v1/protocols/discover \ + -H "Content-Type: application/json" \ + -d '{ + "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "multiChain": true + }' +``` + +--- + +## Safety Warnings + +āš ļø **CRITICAL REMINDERS:** + +1. **Always verify transaction details** before executing +2. **Check token addresses** on a block explorer +3. **Verify protocol addresses** on the official protocol website +4. **Review safety scores** carefully before selecting protocols +5. **Start with small amounts** when trying new protocols +6. **This is not financial advice** - always do your own research + +All transaction objects include safety warnings and should be thoroughly reviewed before execution. + +--- + +## Support + +For issues, questions, or feature requests: + +- GitHub Issues: [link to repo] +- Email: support@yieldagent.io +- Documentation: http://localhost:3000/api-docs diff --git a/agents/Yield-Optimization-Agent/README.md b/agents/Yield-Optimization-Agent/README.md new file mode 100644 index 00000000..d4b08da2 --- /dev/null +++ b/agents/Yield-Optimization-Agent/README.md @@ -0,0 +1,431 @@ +# Yield Optimization Agent with tx data + +An AI-powered agent that helps users find the best and safest staking opportunities for their tokens across multiple DeFi protocols and chains with tx data. + +## šŸš€ Quick Start - REST API + +We now provide a **production-ready REST API** with Swagger documentation! + +```bash +# Install dependencies +yarn install + +# Start the API server +yarn start:api + +# Or with auto-reload for development +yarn dev:api +``` + +**Access the Swagger UI:** `http://localhost:3000/api-docs` + +## Features + +- šŸ” **Token Discovery**: Search tokens by name, symbol, or contract address +- 🌐 **Multi-Chain Support**: Ethereum, Arbitrum, Optimism, Polygon, Base, Avalanche, BNB Chain +- šŸ¦ **Protocol Discovery**: Automatically find all available staking protocols for any token +- šŸ›”ļø **Safety Evaluation**: Comprehensive safety scoring based on TVL, reputation, audits, and history +- šŸ’° **Transaction Generation**: Generate approval and deposit transactions using Enso SDK +- āš ļø **Safety First**: Mandatory safety warnings and comprehensive validation +- ⚔ **Optimized**: Returns top protocols only to minimize API usage and token consumption +- šŸ”„ **Rate Limit Handling**: Automatic retry with exponential backoff for API rate limits +- 🌐 **REST API**: Production-ready HTTP API with Swagger documentation + +## Installation + +```bash +# Install dependencies +yarn install +``` + +## Configuration + +Create a `.env` file in the root directory: + +```env +# Required +OPENAI_API_KEY=your_openai_api_key +ENSO_API_KEY=your_enso_api_key + +# Optional but recommended (for token information) +COINGECKO_API_KEY=your_coingecko_demo_api_key + +# API Server Configuration (for REST API) +PORT=3000 +NODE_ENV=development +CORS_ORIGIN=* +RATE_LIMIT_MAX=100 +``` + +**Note**: The CoinGecko API key is optional. If not provided, the agent will use fallback mock data for common tokens. For production use, a CoinGecko demo API key is recommended. + +## Usage + +### REST API Usage (Recommended) + +```bash +# Start the API server +yarn start:api + +# Make requests to the API +curl -X POST http://localhost:3000/api/v1/agent/query \ + -H "Content-Type: application/json" \ + -d '{"query": "Find staking opportunities for USDC on Ethereum"}' +``` + +**Interactive Documentation:** Visit `http://localhost:3000/api-docs` for the Swagger UI + +### API Endpoints + +The REST API provides the following endpoints: + +- **Agent Endpoints**: + + - `POST /api/v1/agent/query` - Natural language queries to the AI agent + - `POST /api/v1/agent/batch` - Batch processing (up to 10 queries) + - `POST /api/v1/agent/quick` - Quick transaction generation + +- **Token Endpoints**: + + - `GET /api/v1/tokens/search?query=USDC` - Search tokens by name/symbol + - `GET /api/v1/tokens/info?token=USDC&chainId=1` - Get detailed token information + +- **Protocol Endpoints**: + + - `POST /api/v1/protocols/discover` - Discover staking protocols for a token + +- **Transaction Endpoints**: + + - `POST /api/v1/transactions/generate` - Generate transaction bundles + +- **Utility Endpoints**: + - `GET /api/v1/chains` - Get supported chains + - `GET /health` - Health check + +šŸ‘‰ **See [API_DOCUMENTATION.md](./API_DOCUMENTATION.md) for complete API reference** + +### Programmatic Usage (TypeScript/JavaScript) + +```typescript +import { runYieldAgent } from "./src"; + +const questions = [ + "Find staking opportunities for USDC", + "What protocols can I stake ETH on Arbitrum?", +]; + +const results = await runYieldAgent(questions); +``` + +### Interactive Mode + +The agent guides users through a step-by-step process: + +1. **Token Input**: User provides token name or address +2. **Token Confirmation**: Agent fetches and displays token information +3. **Protocol Discovery**: Agent finds all available protocols across chains +4. **Safety Evaluation**: Protocols are ranked by safety and yield +5. **Protocol Selection**: User selects preferred protocol +6. **Transaction Generation**: Agent creates transaction bundle (approval + deposit if needed) + +### Quick Mode + +Users can provide all information in one command: + +``` +"Deposit 100 USDC on Arbitrum to Aave v3" +``` + +The agent will: + +- Parse all inputs +- Validate everything +- Generate transaction bundle immediately +- Include all safety warnings + +## API Reference + +### Core Functions + +#### `runYieldAgent(questions, options)` + +Run the yield optimization agent with a list of questions. + +**Parameters:** + +- `questions: string[]` - Array of user questions +- `options?: AgentOptions` - Optional configuration + - `modelName?: string` - OpenAI model name (default: "gpt-4o-mini") + - `temperature?: number` - Model temperature (default: 0) + - `maxTokens?: number` - Maximum tokens per response (default: 4000) + - `maxRetries?: number` - Maximum retries for rate limits (default: 3) + - `delayBetweenQuestionsMs?: number` - Delay between questions in ms (default: 2000) + +**Returns:** `Promise` + +**Example:** + +```typescript +const results = await runYieldAgent(["Find staking for USDC on Arbitrum"], { + modelName: "gpt-4o-mini", + temperature: 0, + maxTokens: 4000, +}); +``` + +### Services + +#### Token Info API (`src/agent/api.ts`) + +- `getTokenInfo(input, chainId?, chainName?)` - Get token information +- `searchToken(query)` - Search tokens by name/symbol +- `getTokenByAddress(address, chainId)` - Get token by contract address + +#### Enso Service (`src/agent/enso-service.ts`) + +- `discoverProtocols(tokenAddress, chainId)` - Find protocols on a chain +- `discoverProtocolsMultiChain(tokenAddress)` - Find protocols across all chains +- `checkApprovalNeeded(...)` - Check if token approval is needed +- `generateTransactionBundle(...)` - Generate approval + deposit transactions + +#### Safety Service (`src/agent/safety-service.ts`) + +- `evaluateProtocolSafety(protocol)` - Evaluate protocol safety score +- `addSafetyScores(protocols, maxProtocols?)` - Add safety scores to protocols (limits evaluation to top protocols by TVL) +- `sortProtocolsBySafetyAndYield(protocols)` - Sort protocols by safety and yield + +#### Validation (`src/agent/validation.ts`) + +- `validateTokenInput(input)` - Validate token input +- `validateChain(chain)` - Validate chain +- `validateAmount(amount, balance, decimals)` - Validate amount +- `detectQuickMode(input)` - Detect quick mode input +- `parseQuickModeInput(input)` - Parse quick mode input + +## Safety Features + +### Mandatory Safety Warnings + +All transaction objects include this warning: + +``` +āš ļø CRITICAL: This transaction object was generated by an AI agent. +Please verify all details (token address, protocol address, amount, chain) +before executing. Double-check on block explorer and protocol website. +This is not financial advice. +``` + +### Input Validation + +- **Address Validation**: Validates Ethereum address format and checksum +- **Chain Validation**: Ensures chain is supported +- **Amount Validation**: Verifies amount is positive and within balance +- **Address + Chain Requirement**: When address is provided, chain MUST be specified + +### Pre-Transaction Checks + +Before generating any transaction, the agent verifies: + +- Token exists on specified chain +- Protocol exists for token on chain +- User has sufficient balance +- Protocol safety evaluation +- All parameters are valid + +## Supported Chains + +- Ethereum (Chain ID: 1) +- Arbitrum (Chain ID: 42161) +- Optimism (Chain ID: 10) +- Polygon (Chain ID: 137) +- Base (Chain ID: 8453) +- Avalanche (Chain ID: 43114) +- BNB Chain (Chain ID: 56) + +## Safety Scoring + +Protocols are evaluated based on: + +1. **TVL (Total Value Locked)** + + - > $100M: Very Safe + - $10M - $100M: Safe + - < $10M: Risky + +2. **Protocol Reputation** + + - Trusted protocols (Aave, Compound, Lido, etc.) + - Unknown protocols flagged + +3. **Audit Status** + + - Verified audits from DefiLlama + - Audit count and quality + +4. **Historical Performance** + - Protocol age and stability + - Security incident history + +## Transaction Flow + +### With Approval Needed + +1. Generate approval transaction +2. User executes approval transaction +3. Wait for confirmation +4. Generate deposit transaction +5. User executes deposit transaction + +### Without Approval Needed + +1. Generate deposit transaction +2. User executes deposit transaction + +## Performance & Optimization + +### Protocol Limiting + +To optimize API usage and prevent token limit errors, the agent: + +1. **Pre-filters by TVL**: Sorts protocols by Total Value Locked and selects top 20 +2. **Evaluates safety**: Only evaluates safety scores for top 20 protocols (saves API credits) +3. **Returns top results**: Returns top 15 protocols sorted by safety + yield + +This approach ensures: + +- āœ… Reduced API credit consumption (evaluating 20 instead of 100+ protocols) +- āœ… No token limit errors (returning 15 instead of 100+ protocols) +- āœ… Best protocols still shown (top by TVL, then sorted by safety + yield) + +### Rate Limiting & Retries + +The agent includes automatic rate limit handling: + +- **Automatic retry**: Retries on 429 rate limit errors with exponential backoff +- **Smart detection**: Distinguishes between rate limits (retryable) and quota issues (not retryable) +- **Configurable delays**: 2 second delay between questions by default +- **Max retries**: Configurable retry attempts (default: 3) + +## Error Handling + +The agent handles various error cases: + +- Token not found +- No protocols available +- Invalid address format +- Unsupported chain +- Insufficient balance +- Network errors +- API rate limiting (with automatic retry) +- Quota/billing issues (with helpful error messages) + +All errors include clear messages and suggestions. + +## Development + +### Project Structure + +``` +yield-agent/ +ā”œā”€ā”€ src/ +│ ā”œā”€ā”€ agent/ +│ │ ā”œā”€ā”€ index.ts # Main agent orchestration +│ │ ā”œā”€ā”€ tools.ts # LangChain tools +│ │ ā”œā”€ā”€ api.ts # CoinGecko integration +│ │ ā”œā”€ā”€ enso-service.ts # Enso SDK wrapper +│ │ ā”œā”€ā”€ safety-service.ts # Safety evaluation +│ │ ā”œā”€ā”€ validation.ts # Input validation +│ │ ā”œā”€ā”€ types.ts # TypeScript types +│ │ ā”œā”€ā”€ output-structure.ts # Response schema +│ │ └── system-prompt.ts # Agent system prompt +│ ā”œā”€ā”€ api/ +│ │ ā”œā”€ā”€ server.ts # Express server setup +│ │ ā”œā”€ā”€ routes.ts # API route definitions +│ │ ā”œā”€ā”€ controllers.ts # Request handlers +│ │ ā”œā”€ā”€ middleware.ts # Validation & error handling +│ │ ā”œā”€ā”€ validation.ts # Request validation schemas +│ │ ā”œā”€ā”€ swagger.ts # OpenAPI configuration +│ │ └── index.ts # API module exports +│ ā”œā”€ā”€ common/ +│ │ ā”œā”€ā”€ types.ts +│ │ ā”œā”€ā”€ logger.ts +│ │ └── utils.ts +│ └── index.ts # Public API +ā”œā”€ā”€ package.json +ā”œā”€ā”€ tsconfig.json +ā”œā”€ā”€ README.md +ā”œā”€ā”€ API_DOCUMENTATION.md # Complete API reference +└── Yield-Agent-API.postman_collection.json # Postman collection +``` + +### Building + +```bash +# Build TypeScript +yarn build + +# Run linter +yarn lint + +# Format code +yarn prettier +``` + +### Testing + +```bash +# Run unit tests +yarn test + +# Test API endpoints +yarn test:api +yarn test:api:full # Includes AI agent tests +``` + +### API Testing + +The API can be tested using: + +1. **Swagger UI**: `http://localhost:3000/api-docs` - Interactive testing interface +2. **Postman**: Import `Yield-Agent-API.postman_collection.json` +3. **Test Script**: Run `yarn test:api` for automated tests + +## Dependencies + +### Core Agent + +- **LangGraph**: Agent framework +- **Enso SDK**: Protocol discovery and transaction generation +- **CoinGecko API**: Token information (optional, uses mock data if not provided) +- **viem**: Ethereum utilities +- **Zod**: Schema validation +- **OpenAI API**: LLM for agent reasoning + +### REST API + +- **Express.js**: Web framework +- **Swagger UI**: Interactive API documentation +- **Helmet**: Security headers +- **CORS**: Cross-origin resource sharing +- **express-rate-limit**: Rate limiting + +## API Usage Optimization + +The agent is optimized to minimize API usage: + +- **Protocol filtering**: Only evaluates top 20 protocols by TVL +- **Result limiting**: Returns top 15 protocols to user +- **Lazy initialization**: CoinGecko client only initialized when API key is available +- **Batch processing**: Safety evaluations processed in batches of 5 +- **Token limits**: Configurable max tokens (default: 4000) to handle responses efficiently + +## Important Notes + +āš ļø **CRITICAL SAFETY REMINDERS:** + +1. **Always verify transaction details** before executing +2. **Check token addresses** on block explorer +3. **Verify protocol addresses** on protocol website +4. **Review safety scores** before selecting protocols +5. **Start with small amounts** when trying new protocols +6. **This is not financial advice** - do your own research diff --git a/agents/Yield-Optimization-Agent/Yield-Agent-API.postman_collection.json b/agents/Yield-Optimization-Agent/Yield-Agent-API.postman_collection.json new file mode 100644 index 00000000..05e184da --- /dev/null +++ b/agents/Yield-Optimization-Agent/Yield-Agent-API.postman_collection.json @@ -0,0 +1,292 @@ +{ + "info": { + "name": "Yield Agent API", + "description": "Collection of API requests for the Yield Optimization Agent", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Health & Info", + "item": [ + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/health", + "host": ["{{base_url}}"], + "path": ["health"] + } + } + }, + { + "name": "Get Supported Chains", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/chains", + "host": ["{{base_url}}"], + "path": ["api", "v1", "chains"] + } + } + } + ] + }, + { + "name": "Agent", + "item": [ + { + "name": "Agent Query - Find USDC Staking", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"Find staking opportunities for USDC on Ethereum\",\n \"options\": {\n \"modelName\": \"gpt-4o-mini\",\n \"temperature\": 0\n }\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/agent/query", + "host": ["{{base_url}}"], + "path": ["api", "v1", "agent", "query"] + } + } + }, + { + "name": "Agent Query - ETH on Arbitrum", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"What protocols can I stake ETH on Arbitrum?\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/agent/query", + "host": ["{{base_url}}"], + "path": ["api", "v1", "agent", "query"] + } + } + }, + { + "name": "Batch Query", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"queries\": [\n \"Find staking for USDC\",\n \"What protocols support ETH on Arbitrum?\"\n ],\n \"options\": {\n \"delayBetweenQuestionsMs\": 2000\n }\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/agent/batch", + "host": ["{{base_url}}"], + "path": ["api", "v1", "agent", "batch"] + } + } + }, + { + "name": "Quick Transaction - Aave USDC", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"tokenAddress\": \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\",\n \"chainId\": 1,\n \"protocolName\": \"aave\",\n \"amount\": \"100\",\n \"userAddress\": \"0x2a360629a7332e468b2d30dD0f76e5c41D6cEaA9\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/agent/quick", + "host": ["{{base_url}}"], + "path": ["api", "v1", "agent", "quick"] + } + } + } + ] + }, + { + "name": "Tokens", + "item": [ + { + "name": "Search Token - USDC", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/tokens/search?query=USDC", + "host": ["{{base_url}}"], + "path": ["api", "v1", "tokens", "search"], + "query": [ + { + "key": "query", + "value": "USDC" + } + ] + } + } + }, + { + "name": "Search Token - ETH", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/tokens/search?query=ETH", + "host": ["{{base_url}}"], + "path": ["api", "v1", "tokens", "search"], + "query": [ + { + "key": "query", + "value": "ETH" + } + ] + } + } + }, + { + "name": "Get Token Info - USDC", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/tokens/info?token=USDC&chainId=1", + "host": ["{{base_url}}"], + "path": ["api", "v1", "tokens", "info"], + "query": [ + { + "key": "token", + "value": "USDC" + }, + { + "key": "chainId", + "value": "1" + } + ] + } + } + }, + { + "name": "Get Token Info - By Address", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/tokens/info?token=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48&chainId=1", + "host": ["{{base_url}}"], + "path": ["api", "v1", "tokens", "info"], + "query": [ + { + "key": "token", + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + { + "key": "chainId", + "value": "1" + } + ] + } + } + } + ] + }, + { + "name": "Protocols", + "item": [ + { + "name": "Discover Protocols - Single Chain", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"tokenAddress\": \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\",\n \"chainId\": 1,\n \"multiChain\": false\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/protocols/discover", + "host": ["{{base_url}}"], + "path": ["api", "v1", "protocols", "discover"] + } + } + }, + { + "name": "Discover Protocols - Multi Chain", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"tokenAddress\": \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\",\n \"multiChain\": true\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/protocols/discover", + "host": ["{{base_url}}"], + "path": ["api", "v1", "protocols", "discover"] + } + } + } + ] + }, + { + "name": "Transactions", + "item": [ + { + "name": "Generate Transaction Bundle", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"userAddress\": \"0x2a360629a7332e468b2d30dD0f76e5c41D6cEaA9\",\n \"tokenAddress\": \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\",\n \"protocolAddress\": \"0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9\",\n \"protocolName\": \"aave-v3\",\n \"chainId\": 1,\n \"amount\": \"100000000\",\n \"tokenSymbol\": \"USDC\",\n \"decimals\": 6\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/transactions/generate", + "host": ["{{base_url}}"], + "path": ["api", "v1", "transactions", "generate"] + } + } + } + ] + } + ], + "variable": [ + { + "key": "base_url", + "value": "http://localhost:3000", + "type": "string" + } + ] +} diff --git a/agents/Yield-Optimization-Agent/eslint.config.mjs b/agents/Yield-Optimization-Agent/eslint.config.mjs new file mode 100644 index 00000000..8d9a4314 --- /dev/null +++ b/agents/Yield-Optimization-Agent/eslint.config.mjs @@ -0,0 +1,94 @@ +// @ts-check +import eslint from '@eslint/js'; +import vitest from '@vitest/eslint-plugin'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['**/build/**', '**/tmp/**', '**/coverage/**'], + }, + eslint.configs.recommended, + eslintConfigPrettier, + { + extends: [...tseslint.configs.recommended], + + files: ['**/*.ts', '**/*.mts', 'src/**/*.ts'], + + ignores: ['test-api.ts'], + + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + + rules: { + '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/no-explicit-any': 'off', + }, + + languageOptions: { + parser: tseslint.parser, + ecmaVersion: 2020, + sourceType: 'module', + + globals: { + ...globals.node, + }, + + parserOptions: { + project: true, + }, + }, + }, + { + // Separate config for test files without typed linting + files: ['test-api.ts'], + + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + + languageOptions: { + parser: tseslint.parser, + ecmaVersion: 2020, + sourceType: 'module', + + globals: { + ...globals.node, + }, + + // No typed linting for test files + parserOptions: {}, + }, + }, + { + files: ['tests/**'], + + plugins: { + vitest, + }, + + rules: { + ...vitest.configs.recommended.rules, + }, + + settings: { + vitest: { + typecheck: true, + }, + }, + + languageOptions: { + globals: { + ...vitest.environments.env.globals, + }, + }, + }, +); + diff --git a/agents/Yield-Optimization-Agent/package.json b/agents/Yield-Optimization-Agent/package.json new file mode 100644 index 00000000..86da13fb --- /dev/null +++ b/agents/Yield-Optimization-Agent/package.json @@ -0,0 +1,65 @@ +{ + "name": "yield-agent", + "version": "1.0.0", + "description": "Yield optimization agent for finding best staking opportunities across DeFi protocols", + "private": true, + "type": "module", + "main": "build/index.js", + "types": "build/index.d.ts", + "scripts": { + "start": "tsx src/index.ts", + "start:api": "tsx src/api/server.ts", + "dev:api": "tsx watch src/api/server.ts", + "clean": "rimraf build", + "prebuild": "yarn lint", + "build": "tsc -p tsconfig.json", + "lint": "eslint src", + "test": "vitest run unit --config tests/vitest.config.ts", + "test:api": "tsx test-api.ts", + "test:api:full": "tsx test-api.ts --full", + "prettier": "prettier \"src/**/*.{ts,mts}\" --write", + "prettier:check": "prettier \"src/**/*.{ts,mts}\" --check" + }, + "dependencies": { + "tslib": "~2.8", + "@langchain/core": "^0.3.78", + "@langchain/langgraph": "^0.4.9", + "@langchain/openai": "^0.6.16", + "@ensofinance/sdk": "1.0.18", + "@coingecko/coingecko-typescript": "^1.0.0", + "dotenv": "^16.4.7", + "langchain": "^0.3.36", + "zod": "^3.24.1", + "viem": "^2.x", + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "express-rate-limit": "^7.1.5", + "swagger-ui-express": "^5.0.0", + "swagger-jsdoc": "^6.2.8", + "axios": "^1.6.5" + }, + "devDependencies": { + "tsx": "^4.20.6", + "@eslint/js": "~9.17", + "@types/node": "~20", + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/swagger-ui-express": "^4.1.6", + "@types/swagger-jsdoc": "^6.0.4", + "@types/express-rate-limit": "^6.0.0", + "@typescript-eslint/parser": "~8.19", + "@vitest/coverage-v8": "~2.1", + "@vitest/eslint-plugin": "~1.1", + "eslint-config-prettier": "~9.1", + "eslint": "~9.17", + "globals": "~15.14", + "prettier": "~3.4", + "rimraf": "~6.0", + "ts-api-utils": "~2.0", + "typescript-eslint": "~8.19", + "typescript": "~5.7", + "vitest": "~2.1" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} diff --git a/agents/Yield-Optimization-Agent/src/agent/api.ts b/agents/Yield-Optimization-Agent/src/agent/api.ts new file mode 100644 index 00000000..a0895123 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/agent/api.ts @@ -0,0 +1,413 @@ +/** + * Token information API service + * Integrates with CoinGecko API to fetch token metadata + */ + +import Coingecko from "@coingecko/coingecko-typescript"; +import { TokenInfo } from "./types"; +import { Logger } from "../common/logger"; +import { getChainById, getChainByName } from "../common/types"; +import { isAddress } from "viem"; +import { retryWithBackoff } from "../common/utils"; + +const logger = new Logger("TokenInfoAPI"); + +// Lazy initialization of CoinGecko client +let coingeckoClient: Coingecko | null = null; + +function getCoingeckoClient(): Coingecko | null { + const apiKey = process.env.COINGECKO_API_KEY; + + if (!apiKey) { + logger.debug("CoinGecko API key not found in environment variables"); + return null; + } + + if (!coingeckoClient) { + logger.info( + `Initializing CoinGecko client with API key: ${apiKey.substring(0, 7)}...${apiKey.substring(apiKey.length - 4)}` + ); + coingeckoClient = new Coingecko({ + demoAPIKey: apiKey, + environment: "demo", + timeout: 10000, + maxRetries: 3, + }); + } + + return coingeckoClient; +} + +/** + * Mock token data for testing when API key is not available + */ +function getMockTokenData(query: string): TokenInfo[] { + const upperQuery = query.toUpperCase(); + + // USDC mock data + if (upperQuery === "USDC" || upperQuery.includes("USD COIN")) { + return [ + { + name: "USD Coin", + symbol: "USDC", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // Ethereum + chain: "Ethereum", + chainId: 1, + decimals: 6, + marketCap: 30000000000, + price: 1.0, + verified: true, + allChains: [ + { + chainId: 1, + chainName: "Ethereum", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }, + { + chainId: 42161, + chainName: "Arbitrum", + address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + { + chainId: 10, + chainName: "Optimism", + address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + }, + { + chainId: 137, + chainName: "Polygon", + address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + }, + { + chainId: 8453, + chainName: "Base", + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + }, + { + chainId: 43114, + chainName: "Avalanche", + address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + }, + { + chainId: 56, + chainName: "BNB Chain", + address: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + }, + ], + }, + ]; + } + + // ETH mock data + if ( + upperQuery === "ETH" || + upperQuery === "ETHEREUM" || + upperQuery.includes("ETHER") + ) { + return [ + { + name: "Ethereum", + symbol: "ETH", + address: "0x0000000000000000000000000000000000000000", // Native token + chain: "Ethereum", + chainId: 1, + decimals: 18, + marketCap: 400000000000, + price: 3000, + verified: true, + allChains: [ + { + chainId: 1, + chainName: "Ethereum", + address: "0x0000000000000000000000000000000000000000", + }, + { + chainId: 42161, + chainName: "Arbitrum", + address: "0x0000000000000000000000000000000000000000", + }, + { + chainId: 10, + chainName: "Optimism", + address: "0x0000000000000000000000000000000000000000", + }, + ], + }, + ]; + } + + return []; +} + +/** + * Search for tokens by name or symbol + */ +export async function searchToken(query: string): Promise { + try { + logger.info(`Searching for token: ${query}`); + + // Check if API key is available + const client = getCoingeckoClient(); + if (!client) { + logger.warn( + "CoinGecko API key not found. Using fallback token data for testing." + ); + // Return mock data for common tokens when API key is not available + return getMockTokenData(query); + } + + const searchResults = await retryWithBackoff(async () => { + return await client.search.get({ query }); + }); + + if (!searchResults.coins || searchResults.coins.length === 0) { + logger.warn(`No tokens found for query: ${query}`); + return []; + } + + // Get detailed info for top results (limit to 10) + const topResults = searchResults.coins?.slice(0, 10) || []; + const tokenInfos: TokenInfo[] = []; + + for (const coin of topResults) { + // Skip if coin doesn't have an id + const coinId = coin?.id; + if (!coinId) { + continue; + } + + try { + const client = getCoingeckoClient(); + if (!client) { + continue; // Skip if no client available + } + + const coinData = await retryWithBackoff(async () => { + return await client.coins.getID(coinId); + }); + + // Find token on supported chains + const supportedChainEntries = Object.entries( + coinData.platforms || {} + ).filter(([platform, address]) => { + if (!address) return false; + const chain = getChainByName(platform); + return chain !== undefined; + }); + + if (supportedChainEntries.length > 0) { + // Use first supported chain as primary + const [platform, address] = supportedChainEntries[0]; + const chain = getChainByName(platform); + + if (chain && address) { + const marketData = coinData.market_data; + + tokenInfos.push({ + name: coinData.name, + symbol: coinData.symbol.toUpperCase(), + address: address as string, + chain: chain.name, + chainId: chain.id, + marketCap: marketData?.market_cap?.usd, + price: marketData?.current_price?.usd, + decimals: + coinData.detail_platforms?.[platform]?.decimal_place || 18, + logoURI: coinData.image?.large, + description: coinData.description?.en?.substring(0, 500), // Limit description length + priceChange24h: marketData?.price_change_percentage_24h, + volume24h: marketData?.total_volume?.usd, + coingeckoId: coinData.id, + verified: true, // CoinGecko listed tokens are considered verified + allChains: supportedChainEntries.map(([p, addr]) => { + const c = getChainByName(p); + return { + chainId: c?.id || 0, + chainName: c?.name || p, + address: addr as string, + }; + }), + }); + } + } + } catch (error) { + logger.warn(`Failed to fetch details for coin ${coinId}:`, error); + // Continue with next coin + } + } + + logger.info(`Found ${tokenInfos.length} tokens for query: ${query}`); + return tokenInfos; + } catch (error) { + logger.error(`Error searching for token ${query}:`, error); + throw new Error( + `Failed to search for token: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } +} + +/** + * Get token info by contract address and chain + */ +export async function getTokenByAddress( + address: string, + chainId: number +): Promise { + try { + logger.info( + `Fetching token info for address ${address} on chain ${chainId}` + ); + + const chain = getChainById(chainId); + if (!chain) { + throw new Error(`Unsupported chain ID: ${chainId}`); + } + + // CoinGecko uses different platform IDs + const platformMap: Record = { + ethereum: "ethereum", + "arbitrum-one": "arbitrum", + "optimistic-ethereum": "optimism", + "polygon-pos": "polygon", + base: "base", + avalanche: "avalanche", + "binance-smart-chain": "bsc", + }; + + const platformId = platformMap[chain.chainName] || chain.chainName; + + const client = getCoingeckoClient(); + if (!client) { + logger.warn( + "CoinGecko API key not found. Cannot fetch token by address." + ); + return null; + } + + const tokenData = await retryWithBackoff(async () => { + return await client.coins.contract.get(address, { + id: platformId, + }); + }); + + const marketData = tokenData.market_data; + + return { + name: tokenData.name, + symbol: tokenData.symbol.toUpperCase(), + address: address, + chain: chain.name, + chainId: chainId, + marketCap: marketData?.market_cap?.usd, + price: marketData?.current_price?.usd, + decimals: tokenData.detail_platforms?.[platformId]?.decimal_place || 18, + logoURI: tokenData.image?.large, + description: tokenData.description?.en?.substring(0, 500), + priceChange24h: marketData?.price_change_percentage_24h, + volume24h: marketData?.total_volume?.usd, + coingeckoId: tokenData.id, + verified: true, + allChains: Object.entries(tokenData.platforms || {}) + .filter(([p, addr]) => { + const c = getChainByName(p); + return c !== undefined && addr; + }) + .map(([p, addr]) => { + const c = getChainByName(p); + return { + chainId: c?.id || 0, + chainName: c?.name || p, + address: addr as string, + }; + }), + }; + } catch (error) { + logger.error(`Error fetching token by address ${address}:`, error); + return null; + } +} + +/** + * Get token info by name, symbol, or address + */ +export async function getTokenInfo( + input: string, + chainId?: number, + chainName?: string +): Promise { + try { + // If input is an address, fetch directly + if (isAddress(input)) { + if (!chainId && !chainName) { + throw new Error("Chain must be provided when using token address"); + } + + const resolvedChainId = + chainId || (chainName ? getChainByName(chainName)?.id : undefined); + + if (!resolvedChainId) { + throw new Error("Invalid chain specified"); + } + + const tokenInfo = await getTokenByAddress(input, resolvedChainId); + return tokenInfo; + } + + // Otherwise, search by name/symbol + const searchResults = await searchToken(input); + + if (searchResults.length === 0) { + return null; + } + + // If single result, return it with all chains information + // This allows the agent to show all available chains to the user + if (searchResults.length === 1) { + return searchResults[0]; + } + + // Multiple results - return array for user selection + return searchResults; + } catch (error) { + logger.error(`Error getting token info for ${input}:`, error); + throw error; + } +} + +/** + * Get token price data + */ +export async function getTokenPrice(coingeckoId: string): Promise<{ + price: number; + priceChange24h?: number; + marketCap?: number; +} | null> { + try { + const client = getCoingeckoClient(); + if (!client) { + logger.warn("CoinGecko API key not found. Cannot fetch token price."); + return null; + } + + const marketData = await retryWithBackoff(async () => { + return await client.coins.markets.get({ + ids: coingeckoId, + vs_currency: "usd", + }); + }); + + if (marketData.length === 0) { + return null; + } + + const data = marketData[0]; + return { + price: data.current_price, + priceChange24h: data.price_change_percentage_24h, + marketCap: data.market_cap, + }; + } catch (error) { + logger.error(`Error fetching price for ${coingeckoId}:`, error); + return null; + } +} diff --git a/agents/Yield-Optimization-Agent/src/agent/enso-service.ts b/agents/Yield-Optimization-Agent/src/agent/enso-service.ts new file mode 100644 index 00000000..d38070cd --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/agent/enso-service.ts @@ -0,0 +1,434 @@ +/** + * Enso SDK service + * Handles protocol discovery, approval checks, and transaction bundle creation + */ + +import { + EnsoClient, + BundleAction, + BundleParams, + BundleActionType, +} from "@ensofinance/sdk"; +import { isAddress } from "viem"; +import { formatUnits } from "viem"; +import { + ProtocolVault, + ApprovalCheckResult, + ApprovalTransaction, + DepositTransaction, + TransactionBundle, +} from "./types"; +import { Logger } from "../common/logger"; +import { SUPPORTED_CHAINS, getChainById } from "../common/types"; +import { retryWithBackoff } from "../common/utils"; + +const logger = new Logger("EnsoService"); + +// Initialize Enso client +let ensoClient: EnsoClient | null = null; + +/** + * Initialize Enso client with API key + */ +export function initializeEnsoClient(apiKey: string): void { + if (!apiKey) { + throw new Error("Enso API key is required"); + } + ensoClient = new EnsoClient({ apiKey }); + logger.info("Enso client initialized"); +} + +/** + * Get Enso client instance + */ +function getEnsoClient(): EnsoClient { + if (!ensoClient) { + const apiKey = process.env.ENSO_API_KEY; + if (!apiKey) { + throw new Error( + "Enso API key not found. Set ENSO_API_KEY environment variable." + ); + } + initializeEnsoClient(apiKey); + } + return ensoClient!; +} + +/** + * Discover protocols/vaults for a token on a specific chain + */ +export async function discoverProtocols( + tokenAddress: string, + chainId: number +): Promise { + try { + logger.info( + `Discovering protocols for token ${tokenAddress} on chain ${chainId}` + ); + + if (!isAddress(tokenAddress)) { + throw new Error(`Invalid token address: ${tokenAddress}`); + } + + const chain = getChainById(chainId); + if (!chain) { + throw new Error(`Unsupported chain ID: ${chainId}`); + } + + const client = getEnsoClient(); + + const tokenData = await retryWithBackoff(async () => { + return await client.getTokenData({ + underlyingTokensExact: [tokenAddress as `0x${string}`], + chainId: chainId, + includeMetadata: true, + type: "defi", // Only DeFi vaults + }); + }); + + const protocols: ProtocolVault[] = []; + + for (const token of tokenData.data || []) { + // Filter to only include vaults/protocols (not the token itself) + const apyValue = + typeof token.apy === "string" ? parseFloat(token.apy) : token.apy || 0; + if ( + token.address.toLowerCase() !== tokenAddress.toLowerCase() && + apyValue > 0 + ) { + const tvlValue = + typeof token.tvl === "string" + ? parseFloat(token.tvl) + : token.tvl || 0; + + // Log token fields for first protocol to debug + if (protocols.length === 0) { + logger.info( + `First protocol token fields: protocol="${token.protocol}", project="${token.project}", name="${token.name}"` + ); + } + + protocols.push({ + address: token.address, + name: token.name, + symbol: token.symbol, + protocol: token.protocol || token.project || "unknown", // Use token.protocol first (this is what Parifi uses) + chainId: chainId, + chainName: chain.name, + apy: apyValue, + tvl: tvlValue, + underlyingTokens: + token.underlyingTokens?.map((ut) => ({ + address: ut.address, + symbol: ut.symbol, + name: ut.name, + })) || [], + logosUri: token.logosUri, + project: token.project, + }); + } + } + + logger.info( + `Found ${protocols.length} protocols for token ${tokenAddress} on chain ${chainId}` + ); + return protocols; + } catch (error) { + logger.error(`Error discovering protocols:`, error); + throw new Error( + `Failed to discover protocols: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } +} + +/** + * Discover protocols across all supported chains + */ +export async function discoverProtocolsMultiChain( + tokenAddress: string +): Promise { + logger.info( + `Discovering protocols for token ${tokenAddress} across all chains` + ); + + const allProtocols: ProtocolVault[] = []; + + // Query each supported chain in parallel + const promises = SUPPORTED_CHAINS.map((chain) => + discoverProtocols(tokenAddress, chain.id).catch((error) => { + logger.warn(`Failed to discover protocols on ${chain.name}:`, error); + return [] as ProtocolVault[]; + }) + ); + + const results = await Promise.all(promises); + + // Flatten results + for (const protocols of results) { + allProtocols.push(...protocols); + } + + logger.info(`Found ${allProtocols.length} total protocols across all chains`); + return allProtocols; +} + +/** + * Check if token approval is needed + * Note: Enso SDK's getApprovalData returns the spender address, not requires it as input + */ +export async function checkApprovalNeeded( + userAddress: string, + tokenAddress: string, + protocolAddress: string, + chainId: number, + amount: bigint +): Promise { + try { + logger.info( + `Checking approval for token ${tokenAddress}, amount ${amount.toString()} on chain ${chainId}` + ); + + if (!isAddress(userAddress) || !isAddress(tokenAddress)) { + return { + approvalNeeded: false, + error: "Invalid address format", + message: "Invalid address format", + }; + } + + const client = getEnsoClient(); + + try { + // getApprovalData returns the spender address we need to approve + const approvalData = await retryWithBackoff(async () => { + return await client.getApprovalData({ + fromAddress: userAddress as `0x${string}`, + tokenAddress: tokenAddress as `0x${string}`, + chainId: chainId, + amount: amount.toString(), + }); + }); + + // Use the spender address returned by Enso, not the protocol address + const spenderAddress = approvalData.spender || approvalData.tx.to; + + const approvalTransaction: ApprovalTransaction = { + to: approvalData.tx.to, + data: approvalData.tx.data, + value: + typeof approvalData.tx.value === "string" + ? approvalData.tx.value + : approvalData.tx.value?.toString() || "0", + gasLimit: + typeof approvalData.gas === "string" + ? approvalData.gas + : approvalData.gas?.toString() || "0", + gasPrice: undefined, // Not provided by Enso SDK + chainId: chainId, + tokenAddress: tokenAddress, + spender: spenderAddress, + amount: amount.toString(), + type: "approve", + safetyWarning: + "āš ļø CRITICAL: This transaction object was generated by an AI agent. Please verify all details (token address, protocol address, amount, chain) before executing. Double-check on block explorer and protocol website. This is not financial advice.", + }; + + return { + approvalNeeded: true, + requiredAmount: amount, + approvalTransaction, + message: "Approval transaction required before deposit", + }; + } catch (error) { + // If approval data fetch fails, assume approval might not be needed + // or there's an error - log and return error + logger.warn(`Failed to get approval data:`, error); + return { + approvalNeeded: false, + error: `Failed to check approval: ${error instanceof Error ? error.message : "Unknown error"}`, + message: "Could not verify approval status", + }; + } + } catch (error) { + logger.error(`Error checking approval:`, error); + return { + approvalNeeded: false, + error: `Failed to check approval: ${error instanceof Error ? error.message : "Unknown error"}`, + message: "Could not verify approval status", + }; + } +} + +/** + * Generate deposit transaction bundle + */ +export async function generateTransactionBundle( + userAddress: string, + tokenAddress: string, + protocolAddress: string, + protocolName: string, + chainId: number, + amount: bigint, + tokenSymbol: string, + decimals: number +): Promise { + try { + // Normalize protocol name for Enso API (lowercase, remove "Standard" prefix) + const normalizedProtocolName = protocolName + .toLowerCase() + .replace(/^standard\s+/i, "") + .trim(); + + logger.info( + `Generating transaction bundle for ${tokenSymbol} deposit to ${normalizedProtocolName} (original: ${protocolName}) on chain ${chainId}` + ); + + if ( + !isAddress(userAddress) || + !isAddress(tokenAddress) || + !isAddress(protocolAddress) + ) { + throw new Error("Invalid address format"); + } + + const client = getEnsoClient(); + + // Step 1: Check if approval is needed + const approvalCheck = await checkApprovalNeeded( + userAddress, + tokenAddress, + protocolAddress, + chainId, + amount + ); + + // Step 2: Generate deposit transaction + const bundleActions: BundleAction[] = [ + { + protocol: protocolName, + action: BundleActionType.Deposit, + args: { + tokenIn: tokenAddress as `0x${string}`, + tokenOut: protocolAddress as `0x${string}`, + amountIn: amount.toString(), + primaryAddress: protocolAddress as `0x${string}`, + }, + }, + ]; + + const bundleParams: BundleParams = { + chainId: chainId, + fromAddress: userAddress as `0x${string}`, + routingStrategy: "router", + receiver: userAddress as `0x${string}`, + }; + + logger.info(`Bundle params: ${JSON.stringify(bundleParams)}`); + logger.info(`Bundle actions: ${JSON.stringify(bundleActions)}`); + + const depositTxData = await retryWithBackoff(async () => { + const result = await client.getBundleData(bundleParams, bundleActions); + logger.info(`Bundle data received: ${JSON.stringify(result)}`); + return result; + }).catch((err) => { + logger.error(`getBundleData error details:`, err); + logger.error( + `Error response:`, + err.response || err.responseData || "No response data" + ); + throw err; + }); + + const depositTransaction: DepositTransaction = { + to: depositTxData.tx.to, + data: depositTxData.tx.data, + value: + typeof depositTxData.tx.value === "string" + ? depositTxData.tx.value + : depositTxData.tx.value?.toString() || "0", + gasLimit: + typeof depositTxData.gas === "string" + ? depositTxData.gas + : depositTxData.gas?.toString() || "0", + gasPrice: undefined, // Not provided by Enso SDK + chainId: chainId, + protocol: normalizedProtocolName, + action: "deposit", + tokenIn: { + address: tokenAddress, + symbol: tokenSymbol, + amount: formatUnits(amount, decimals), + amountWei: amount.toString(), + }, + tokenOut: { + address: protocolAddress, + symbol: protocolName, + }, + type: "deposit", + safetyWarning: + "āš ļø CRITICAL: This transaction object was generated by an AI agent. Please verify all details (token address, protocol address, amount, chain) before executing. Double-check on block explorer and protocol website. This is not financial advice.", + }; + + // Step 3: Build transaction bundle + const executionOrder: ("approve" | "deposit")[] = []; + + if (approvalCheck.approvalNeeded && approvalCheck.approvalTransaction) { + executionOrder.push("approve", "deposit"); + + // Calculate total gas estimate + const approvalGas = approvalCheck.approvalTransaction.gasLimit + ? BigInt(approvalCheck.approvalTransaction.gasLimit) + : BigInt(0); + const depositGas = depositTransaction.gasLimit + ? BigInt(depositTransaction.gasLimit) + : BigInt(0); + const totalGas = approvalGas + depositGas; + + return { + approvalTransaction: approvalCheck.approvalTransaction, + depositTransaction, + executionOrder, + totalGasEstimate: totalGas.toString(), + }; + } + + executionOrder.push("deposit"); + return { + depositTransaction, + executionOrder, + }; + } catch (error) { + logger.error(`Error generating transaction bundle:`, error); + throw new Error( + `Failed to generate transaction bundle: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } +} + +/** + * Get token price from Enso + */ +export async function getTokenPrice( + tokenAddress: string, + chainId: number +): Promise { + try { + const client = getEnsoClient(); + const priceData = await retryWithBackoff(async () => { + return await client.getPriceData({ + address: tokenAddress as `0x${string}`, + chainId: chainId, + }); + }); + + const priceValue = priceData.price + ? typeof priceData.price === "string" + ? parseFloat(priceData.price) + : priceData.price + : null; + return priceValue; + } catch (error) { + logger.warn(`Failed to get token price:`, error); + return null; + } +} diff --git a/agents/Yield-Optimization-Agent/src/agent/index.ts b/agents/Yield-Optimization-Agent/src/agent/index.ts new file mode 100644 index 00000000..4b3647eb --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/agent/index.ts @@ -0,0 +1,216 @@ +/** + * Main agent orchestration + * Creates and runs the yield optimization agent + */ + +import { ChatOpenAI } from "@langchain/openai"; +import { createReactAgent } from "@langchain/langgraph/prebuilt"; +import zod from "zod"; +import { AgentResponse, Logger } from "../common"; +import { retryOnRateLimit } from "../common/utils"; +import { SystemPrompt } from "./system-prompt"; +import { ResponseSchema, ensureSafetyWarning } from "./output-structure"; +import { getYieldAgentTools } from "./tools"; + +export interface AgentOptions { + modelName?: string; + temperature?: number; + systemPrompt?: string; + responseSchema?: zod.Schema; + delayBetweenQuestionsMs?: number; + maxTokens?: number; + maxRetries?: number; +} + +const DEFAULT_OPTIONS: Required = { + modelName: "gpt-4o-mini", + temperature: 0, + systemPrompt: SystemPrompt, + responseSchema: ResponseSchema, + delayBetweenQuestionsMs: 2000, // Increased from 500ms to 2s to avoid rate limits + maxTokens: 4000, // Increased to handle longer responses (was 2000) + maxRetries: 3, +}; + +/** + * Create the yield optimization agent with configured LLM and tools + */ +function createAgent( + options: Required +): ReturnType { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY environment variable is required"); + } + + const model = new ChatOpenAI({ + modelName: options.modelName, + temperature: options.temperature, + apiKey: apiKey, + maxTokens: options.maxTokens, + maxRetries: options.maxRetries, + timeout: 60000, // 60 second timeout + }); + + return createReactAgent({ + llm: model, + tools: getYieldAgentTools(), + responseFormat: options.responseSchema as any, + }); +} + +/** + * Process a single question through the agent with retry logic for rate limits + */ +async function processQuestion( + agent: ReturnType, + question: string, + systemPrompt: string, + maxRetries: number = 3, + logger?: Logger +): Promise { + // Retry logic specifically for 429 rate limit errors + const response = await retryOnRateLimit( + async () => { + return await agent.invoke({ + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: question }, + ], + }); + }, + maxRetries, + 2000, // Start with 2 second delay, exponential backoff + logger + ); + + // Ensure safety warnings are included + if (response && typeof response === "object" && "response" in response) { + const responseData = response.response as any; + if (responseData && typeof responseData === "object") { + ensureSafetyWarning(responseData); + } + } + + return { question, response }; +} + +/** + * Run the yield optimization agent with a list of questions + */ +export async function runYieldAgent( + questions: string[], + options: AgentOptions = {} +): Promise { + const config = { ...DEFAULT_OPTIONS, ...options }; + const logger = new Logger("YieldAgent"); + + logger.info("Starting Yield Optimization Agent..."); + + // Validate environment variables + if (!process.env.OPENAI_API_KEY) { + throw new Error("OPENAI_API_KEY environment variable is required"); + } + + // Log API key status (without exposing the full key) + const apiKey = process.env.OPENAI_API_KEY; + logger.info( + `OpenAI API Key loaded: ${apiKey.substring(0, 7)}...${apiKey.substring(apiKey.length - 4)}` + ); + + if (!process.env.ENSO_API_KEY) { + logger.warn("ENSO_API_KEY not set - Enso SDK features will not work"); + } + + const agent = createAgent(config); + + logger.info("Running question processing"); + + const results: AgentResponse[] = []; + + for (let i = 0; i < questions.length; i++) { + const question = questions[i]; + const questionNum = `[${i + 1}/${questions.length}]`; + + logger.info(`${questionNum} New question to answer: '${question}'`); + + try { + const result = await processQuestion( + agent, + question, + config.systemPrompt, + config.maxRetries, + logger + ); + logger.info("Result:", result); + results.push(result); + logger.info(`${questionNum} Question answered successfully`); + } catch (error) { + const errorMessage = (error as Error).message; + logger.error("Agent response error:", errorMessage); + + // Provide more helpful error message + let userFriendlyError = errorMessage; + if ( + errorMessage.includes("exceeded your current quota") || + errorMessage.includes("check your plan and billing") + ) { + userFriendlyError = + "Quota/Billing Issue: Your OpenAI account has exceeded its quota or has billing issues. Please check:\n" + + "1. https://platform.openai.com/account/billing - Verify you have available credits\n" + + "2. Ensure a payment method is added if required\n" + + "3. Check if the API key belongs to the correct account/project\n" + + "4. Verify your account has spending limits configured correctly"; + } else if ( + errorMessage.includes("429") || + errorMessage.includes("rate limit") + ) { + userFriendlyError = + "Rate limit exceeded. Please wait a few minutes and try again."; + } + + results.push({ + question, + response: { + answer: `ERROR: ${userFriendlyError}`, + step: "error", + mode: "interactive", + confidence: "low", + validationErrors: [errorMessage], + }, + } as any); + } + + // Add delay between questions (except for the last one) + if (i < questions.length - 1 && config.delayBetweenQuestionsMs > 0) { + logger.info( + `${questionNum} Delaying for ${config.delayBetweenQuestionsMs}ms` + ); + await new Promise((resolve) => + setTimeout(resolve, config.delayBetweenQuestionsMs) + ); + } + } + + logger.info("Finished Agent"); + return results; +} + +export * from "./types"; +export { + searchToken, + getTokenByAddress, + getTokenInfo, + getTokenPrice, +} from "./api"; +export { + initializeEnsoClient, + discoverProtocols, + discoverProtocolsMultiChain, + checkApprovalNeeded, + generateTransactionBundle, + getTokenPrice as getTokenPriceFromEnso, +} from "./enso-service"; +export * from "./safety-service"; +export * from "./validation"; +export { getYieldAgentTools } from "./tools"; diff --git a/agents/Yield-Optimization-Agent/src/agent/output-structure.ts b/agents/Yield-Optimization-Agent/src/agent/output-structure.ts new file mode 100644 index 00000000..79201f24 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/agent/output-structure.ts @@ -0,0 +1,195 @@ +/** + * Output schema for agent responses + * Defines the structure of agent responses using Zod + */ + +import { z } from "zod"; + +export const ResponseSchema = z.object({ + answer: z + .string() + .describe( + "The main response to the user. MUST include safety warnings when transaction is present." + ), + step: z + .enum([ + "token_lookup", + "token_confirmation", + "protocol_discovery", + "protocol_selection", + "amount_input", + "transaction_ready", + "quick_mode_complete", + "error", + ]) + .describe("Current step in the workflow"), + + mode: z + .enum(["interactive", "quick"]) + .default("interactive") + .describe("Agent mode"), + + tokenInfo: z + .object({ + name: z.string(), + symbol: z.string(), + address: z.string(), + chain: z.string(), + chainId: z.number(), + marketCap: z.number().nullable().optional(), + price: z.number().nullable().optional(), + verified: z.boolean().nullable().optional(), + warnings: z.array(z.string()).nullable().optional(), + }) + .nullable() + .optional(), + + protocols: z + .array( + z.object({ + address: z.string(), + name: z.string(), + protocol: z.string(), + chainId: z.number(), + chainName: z.string(), + apy: z.number(), + tvl: z.number(), + safetyScore: z.object({ + overall: z.enum(["very_safe", "safe", "moderate", "risky"]), + score: z.number(), + warnings: z.array(z.string()).nullable().optional(), + }), + }) + ) + .nullable() + .optional(), + + approvalTransaction: z + .object({ + to: z.string().describe("Token contract address (for approval)"), + data: z.string().describe("Encoded approve() transaction data"), + value: z.string().describe("ETH value (always 0 for ERC20 approvals)"), + gasLimit: z + .string() + .nullable() + .optional() + .describe("Estimated gas limit"), + gasPrice: z.string().nullable().optional().describe("Gas price"), + chainId: z.number().describe("Chain ID"), + tokenAddress: z.string().describe("Token address being approved"), + spender: z.string().describe("Protocol/vault address to approve"), + amount: z.string().describe("Amount to approve (in wei)"), + type: z.literal("approve").describe("Transaction type"), + safetyWarning: z + .string() + .describe("Mandatory safety warning about verifying transaction"), + }) + .nullable() + .optional() + .describe("Approval transaction if token approval is needed"), + + transaction: z + .object({ + to: z.string().describe("Contract address to interact with"), + data: z.string().describe("Encoded transaction data"), + value: z.string().describe("ETH value (if native token)"), + gasLimit: z + .string() + .nullable() + .optional() + .describe("Estimated gas limit"), + gasPrice: z.string().nullable().optional().describe("Gas price"), + chainId: z.number().describe("Chain ID"), + protocol: z.string().describe("Protocol name"), + action: z.enum(["deposit", "stake"]).describe("Action type"), + tokenIn: z.object({ + address: z.string(), + symbol: z.string(), + amount: z.string().describe("Human-readable amount"), + amountWei: z.string().describe("Amount in wei"), + }), + tokenOut: z.object({ + address: z.string(), + symbol: z.string(), + }), + estimatedGas: z + .string() + .nullable() + .optional() + .describe("Estimated gas cost in USD"), + slippage: z.number().nullable().optional().describe("Slippage tolerance"), + type: z.literal("deposit").describe("Transaction type"), + safetyWarning: z + .string() + .describe("Mandatory safety warning about verifying transaction"), + }) + .nullable() + .optional() + .describe("Deposit/stake transaction"), + + executionOrder: z + .array(z.enum(["approve", "deposit"])) + .nullable() + .optional() + .describe("Order in which transactions should be executed"), + + totalGasEstimate: z + .string() + .nullable() + .optional() + .describe("Total estimated gas for all transactions"), + + validationErrors: z + .array(z.string()) + .nullable() + .optional() + .describe("Input validation errors"), + + warnings: z + .array(z.string()) + .nullable() + .optional() + .describe("General warnings"), + + confidence: z.enum(["high", "medium", "low"]).describe("Confidence level"), +}); + +export type AgentResponseSchema = z.infer; + +/** + * Helper function to ensure safety warning is always included + */ +export function ensureSafetyWarning( + response: AgentResponseSchema +): AgentResponseSchema { + const safetyWarning = + "āš ļø CRITICAL: This transaction object was generated by an AI agent. Please verify all details (token address, protocol address, amount, chain) before executing. Double-check on block explorer and protocol website. This is not financial advice."; + + // Ensure approval transaction has warning + if ( + response.approvalTransaction && + !response.approvalTransaction.safetyWarning + ) { + response.approvalTransaction.safetyWarning = safetyWarning; + } + + // Ensure deposit transaction has warning + if (response.transaction && !response.transaction.safetyWarning) { + response.transaction.safetyWarning = safetyWarning; + } + + // Also ensure answer includes warning + if ( + (response.approvalTransaction || response.transaction) && + !response.answer.includes("CRITICAL") + ) { + const executionNote = response.approvalTransaction + ? "\n\nāš ļø IMPORTANT: You must execute the approval transaction FIRST, then wait for confirmation before executing the deposit transaction." + : ""; + response.answer += + "\n\nāš ļø CRITICAL: This transaction object was generated by an AI agent. Please verify all details before executing. This is not financial advice." + + executionNote; + } + + return response; +} diff --git a/agents/Yield-Optimization-Agent/src/agent/safety-service.ts b/agents/Yield-Optimization-Agent/src/agent/safety-service.ts new file mode 100644 index 00000000..6efd14a0 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/agent/safety-service.ts @@ -0,0 +1,332 @@ +/** + * Safety evaluation service + * Evaluates protocol safety based on TVL, reputation, audits, and history + */ + +import { SafetyScore, ProtocolVault, ProtocolWithSafety } from './types'; +import { Logger } from '../common/logger'; +import { retryWithBackoff } from '../common/utils'; + +const logger = new Logger('SafetyService'); + +/** + * Well-known trusted protocols + */ +const TRUSTED_PROTOCOLS = [ + 'aave', + 'compound', + 'lido', + 'yearn', + 'uniswap', + 'curve', + 'balancer', + 'makerdao', + 'convex', + 'frax', + 'morpho', + 'spark', +]; + +/** + * Evaluate protocol safety score + */ +export async function evaluateProtocolSafety( + protocol: ProtocolVault, +): Promise { + logger.info(`Evaluating safety for protocol ${protocol.protocol} on ${protocol.chainName}`); + + const factors = { + tvl: evaluateTVL(protocol.tvl), + protocol: evaluateProtocolReputation(protocol.protocol), + audits: await evaluateAudits(protocol.protocol), + history: evaluateHistory(protocol.protocol), + }; + + // Calculate overall score (weighted average) + const weights = { + tvl: 0.4, + protocol: 0.3, + audits: 0.2, + history: 0.1, + }; + + const overallScore = + factors.tvl.score * weights.tvl + + factors.protocol.score * weights.protocol + + factors.audits.score * weights.audits + + factors.history.score * weights.history; + + // Determine overall safety level + let overall: 'very_safe' | 'safe' | 'moderate' | 'risky'; + if (overallScore >= 80) { + overall = 'very_safe'; + } else if (overallScore >= 60) { + overall = 'safe'; + } else if (overallScore >= 40) { + overall = 'moderate'; + } else { + overall = 'risky'; + } + + const warnings: string[] = []; + const recommendations: string[] = []; + + // Add warnings based on factors + if (factors.tvl.level === 'low') { + warnings.push(`Low TVL ($${formatTVL(protocol.tvl)}) - protocol may be less established`); + } + + if (factors.protocol.level === 'unknown') { + warnings.push('Protocol not in trusted list - exercise caution'); + recommendations.push('Research the protocol thoroughly before investing'); + } + + if (factors.audits.auditCount === 0) { + warnings.push('No audit information found'); + recommendations.push('Verify protocol has been audited before investing'); + } + + if (overall === 'risky') { + warnings.push('Overall safety score indicates high risk'); + recommendations.push('Consider using smaller amounts or more established protocols'); + } + + return { + overall, + score: Math.round(overallScore), + factors, + warnings: warnings.length > 0 ? warnings : undefined, + recommendations: recommendations.length > 0 ? recommendations : undefined, + }; +} + +/** + * Evaluate TVL factor + */ +function evaluateTVL(tvl: number): { score: number; level: string } { + if (tvl >= 100_000_000) { + // > $100M + return { score: 100, level: 'very_high' }; + } else if (tvl >= 10_000_000) { + // $10M - $100M + return { score: 75, level: 'high' }; + } else if (tvl >= 1_000_000) { + // $1M - $10M + return { score: 50, level: 'medium' }; + } else if (tvl >= 100_000) { + // $100K - $1M + return { score: 30, level: 'low' }; + } else { + // < $100K + return { score: 10, level: 'very_low' }; + } +} + +/** + * Evaluate protocol reputation + */ +function evaluateProtocolReputation(protocolName: string): { + score: number; + level: string; + reputation: string; +} { + const normalizedName = protocolName.toLowerCase().replace(/[^a-z0-9]/g, ''); + + // Check if protocol is in trusted list + const isTrusted = TRUSTED_PROTOCOLS.some( + (trusted) => normalizedName.includes(trusted) || trusted.includes(normalizedName), + ); + + if (isTrusted) { + return { + score: 100, + level: 'trusted', + reputation: 'Well-established and trusted protocol', + }; + } + + // Check for known risky patterns + const riskyPatterns = ['test', 'demo', 'experimental']; + const hasRiskyPattern = riskyPatterns.some((pattern) => normalizedName.includes(pattern)); + + if (hasRiskyPattern) { + return { + score: 20, + level: 'risky', + reputation: 'Protocol name suggests experimental or test version', + }; + } + + // Unknown protocol + return { + score: 50, + level: 'unknown', + reputation: 'Protocol not in trusted list - research recommended', + }; +} + +/** + * Evaluate audit status + * Note: In production, this would fetch from DefiLlama or audit databases + */ +async function evaluateAudits(protocolName: string): Promise<{ + score: number; + level: string; + auditCount: number; +}> { + try { + // Try to fetch from DefiLlama API + const protocolId = protocolName.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const url = `https://api.llama.fi/protocol/${protocolId}`; + + try { + const response = await retryWithBackoff(async () => { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + return res.json(); + }); + + // DefiLlama protocol data might have audit information + // For now, we'll assume if protocol exists in DefiLlama, it has some level of verification + const hasData = response && typeof response === 'object'; + + if (hasData) { + return { + score: 80, + level: 'verified', + auditCount: 1, // Assume at least one audit if in DefiLlama + }; + } + } catch (error) { + logger.debug(`Failed to fetch audit data from DefiLlama: ${error}`); + } + + // Default: no audit information found + return { + score: 30, + level: 'unknown', + auditCount: 0, + }; + } catch (error) { + logger.warn(`Error evaluating audits for ${protocolName}:`, error); + return { + score: 30, + level: 'unknown', + auditCount: 0, + }; + } +} + +/** + * Evaluate historical performance + * Note: In production, this would check for security incidents, hacks, etc. + */ +function evaluateHistory(protocolName: string): { score: number; level: string } { + // Known protocols with good history get higher scores + const normalizedName = protocolName.toLowerCase(); + const trustedProtocols = ['aave', 'compound', 'lido', 'yearn', 'uniswap']; + + const isTrusted = trustedProtocols.some((trusted) => normalizedName.includes(trusted)); + + if (isTrusted) { + return { + score: 90, + level: 'excellent', + }; + } + + // Unknown protocols get medium score (no negative history known) + return { + score: 60, + level: 'unknown', + }; +} + +/** + * Add safety scores to protocols + * @param protocols - Protocols to evaluate + * @param maxProtocols - Maximum number of protocols to evaluate (default: 30) + */ +export async function addSafetyScores( + protocols: ProtocolVault[], + maxProtocols: number = 30, +): Promise { + // Pre-filter by TVL before evaluation to save API credits + const protocolsToEvaluate = protocols + .sort((a, b) => b.tvl - a.tvl) + .slice(0, maxProtocols); + + logger.info(`Adding safety scores to ${protocolsToEvaluate.length} protocols (filtered from ${protocols.length} total)`); + + const protocolsWithSafety: ProtocolWithSafety[] = []; + + // Evaluate protocols in parallel (with limit to avoid rate limits) + const batchSize = 5; + for (let i = 0; i < protocolsToEvaluate.length; i += batchSize) { + const batch = protocolsToEvaluate.slice(i, i + batchSize); + const evaluations = await Promise.all( + batch.map((protocol) => + evaluateProtocolSafety(protocol).catch((error) => { + logger.warn(`Failed to evaluate safety for ${protocol.protocol}:`, error); + // Return default risky score on error + return { + overall: 'risky' as const, + score: 20, + factors: { + tvl: { score: 20, level: 'unknown' }, + protocol: { score: 20, level: 'unknown', reputation: 'unknown' }, + audits: { score: 20, level: 'unknown', auditCount: 0 }, + history: { score: 20, level: 'unknown' }, + }, + warnings: ['Could not evaluate safety - exercise caution'], + }; + }), + ), + ); + + for (let j = 0; j < batch.length; j++) { + protocolsWithSafety.push({ + ...batch[j], + safetyScore: evaluations[j], + }); + } + } + + return protocolsWithSafety; +} + +/** + * Sort protocols by safety and yield + */ +export function sortProtocolsBySafetyAndYield( + protocols: ProtocolWithSafety[], +): ProtocolWithSafety[] { + return [...protocols].sort((a, b) => { + // First sort by safety score (higher is better) + const safetyDiff = b.safetyScore.score - a.safetyScore.score; + + // If safety scores are close (within 10 points), prefer higher APY + if (Math.abs(safetyDiff) < 10) { + return b.apy - a.apy; + } + + return safetyDiff; + }); +} + +/** + * Format TVL for display + */ +function formatTVL(tvl: number): string { + if (tvl >= 1_000_000_000) { + return `$${(tvl / 1_000_000_000).toFixed(2)}B`; + } else if (tvl >= 1_000_000) { + return `$${(tvl / 1_000_000).toFixed(2)}M`; + } else if (tvl >= 1_000) { + return `$${(tvl / 1_000).toFixed(2)}K`; + } + return `$${tvl.toFixed(2)}`; +} + diff --git a/agents/Yield-Optimization-Agent/src/agent/system-prompt.ts b/agents/Yield-Optimization-Agent/src/agent/system-prompt.ts new file mode 100644 index 00000000..8139454b --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/agent/system-prompt.ts @@ -0,0 +1,119 @@ +/** + * System prompt for the yield optimization agent + */ + +export const SystemPrompt = `You are a yield optimization agent that helps users find the best and safest staking opportunities for their tokens. + +CRITICAL SAFETY RULES: +1. MONEY IS INVOLVED - Always prioritize safety over yield +2. ALL transaction objects MUST include this warning: "āš ļø CRITICAL: This transaction object was generated by an AI agent. Please verify all details (token address, protocol address, amount, chain) before executing. Double-check on block explorer and protocol website. This is not financial advice." +3. If user provides a token address, you MUST require chain information - never proceed without it +4. Always validate addresses, chain IDs, and amounts before generating transactions +5. When in doubt, ask for clarification rather than making assumptions + +INPUT VALIDATION: +- If user provides token address WITHOUT chain: "Error: Chain must be provided when using token address. Please specify the chain (e.g., ethereum, arbitrum, base)." +- If user provides token address WITH chain: Process directly (quick mode) +- If user provides only token name/symbol: Show token information for ALL supported chains and wait for user confirmation +- Validate all addresses are valid Ethereum addresses (checksum, length) +- Validate chain IDs are in supported list +- Validate amounts are positive numbers + +QUICK MODE DETECTION: +If user provides token address + chain + protocol + amount + user address in one command, use QUICK MODE: +- **MUST use the quick_transaction tool** (NOT discover_protocols or generate_transaction separately) +- The quick_transaction tool will: + - Get token info + - Find the specific protocol vault + - Evaluate safety score + - Generate transaction bundle (approval + deposit if needed) + - Return everything in one response +- Skip all confirmation steps +- Return vault info and transaction objects directly +- Still include all safety warnings and protocol safety scores +- **CRITICAL**: If quick_transaction fails, DO NOT fall back to discover_protocols with multiChain=true +- If quick_transaction returns an error with failed:true, return the error to the user and ask them to verify inputs +- Do NOT automatically search other chains when quick mode fails + +Example quick mode inputs: +- "Deposit 100 USDC (0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) on Ethereum to Aave for user 0x1234..." +- "Stake 50 USDC on Ethereum to morpho protocol, amount 50, user address 0x1234..." +- "Generate transaction for 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 on Ethereum to aave, amount 100, user 0x1234..." + +Your workflow (Interactive Mode): +1. When a user provides a token name or symbol (NO address): + - **MUST FIRST call the get_token_info tool** with ONLY the token name/symbol (do NOT provide chainId or chainName) + - The tool will return token information with: + - requiresConfirmation: true flag indicating you need to show all chains + - An "allChains" array showing ALL supported chains where the token exists + - Token metadata (name, symbol, marketCap, price, etc.) + - Format your response to clearly display: + - Token name and symbol (from token.name and token.symbol) + - For EACH chain in the allChains array, show: + - Chain name (chainName) + - Chain ID (chainId) + - Contract address (address) + - Token metadata: Market cap, price, verification status (if available) + - Present chains in a numbered list or table format like: + "Found USDC on 5 chains: + 1. Ethereum (Chain ID: 1) - Address: 0x... + 2. Arbitrum (Chain ID: 42161) - Address: 0x... + 3. Base (Chain ID: 8453) - Address: 0x... + ..." + - Ask user: "Please confirm which chain and address you want to use for [token name]" + - Include: "āš ļø Please verify token details and select the correct chain before proceeding" + - DO NOT proceed to protocol discovery until user confirms chain and address + - **CRITICAL: You MUST call get_token_info tool first - do not ask for chain without showing token information** + +2. When a user provides a token address: + - If NO chain provided: Call get_token_info tool which will return an error requiring chain + - If chain IS provided: Call get_token_info tool with both address and chain to fetch token information for that specific chain and proceed directly + +3. Once token and chain are confirmed (either from step 1 confirmation or step 2 direct input): + +4. Discover all available staking protocols/vaults for the confirmed token on the confirmed chain: + - Ethereum (1), Arbitrum (42161), Optimism (10), Polygon (137), Base (8453), Avalanche (43114), BNB Chain (56) + +5. For each protocol, evaluate safety based on: + - Total Value Locked (TVL) + - Protocol reputation and audit status + - Historical performance + - Any security incidents + +6. Present protocols ranked by a combination of yield (APY) and safety, clearly indicating: + - Protocol name and chain + - APY percentage + - Safety score and level (very_safe, safe, moderate, risky) + - TVL + - Any safety warnings + - āš ļø Highlight risky protocols prominently + +7. Once the user selects a protocol: + - Validate protocol exists on selected chain + - Ask for amount (or use max balance) + - Verify user has sufficient balance + - Check if token approval is needed + - Generate transaction bundle (approval + deposit if needed, or just deposit) + +8. ALWAYS include this warning with ALL transaction objects (approval and deposit): + "āš ļø CRITICAL: This transaction object was generated by an AI agent. Please verify all details (token address, protocol address, amount, chain) before executing. Double-check on block explorer and protocol website. This is not financial advice." + +9. If approval is required, clearly indicate execution order: + - First: Execute approval transaction + - Wait for confirmation + - Then: Execute deposit transaction + +EDGE CASES TO HANDLE: +- Token not found: "Token not found. Please check spelling or provide contract address with chain." +- No protocols found: "No staking protocols found for this token on supported chains." +- Invalid address: "Invalid address format. Please provide a valid Ethereum address." +- Chain not supported: "Chain not supported. Supported chains: Ethereum, Arbitrum, Optimism, Polygon, Base, Avalanche, BNB Chain" +- Protocol not found on chain: "Protocol not available on selected chain. Available protocols: [list]" +- Insufficient balance: "Insufficient balance. Your balance: X, Required: Y" +- Amount exceeds balance: "Amount exceeds available balance" +- Network errors: "Network error. Please try again." +- Rate limiting: "API rate limit reached. Please wait a moment." +- Quick mode failures: If quick_transaction fails, return the error message to user. DO NOT automatically fall back to multi-chain protocol discovery. Only search other chains if user explicitly requests it. + +Always prioritize user safety and provide clear warnings about risks. Include "This is not financial advice" in ALL responses.`; + diff --git a/agents/Yield-Optimization-Agent/src/agent/tools.ts b/agents/Yield-Optimization-Agent/src/agent/tools.ts new file mode 100644 index 00000000..be77986f --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/agent/tools.ts @@ -0,0 +1,747 @@ +/** + * LangChain tools for the yield agent + * Wraps service functions as tools that the agent can use + */ + +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { isAddress } from "viem"; +import { getTokenInfo, searchToken } from "./api"; +import { + discoverProtocols, + discoverProtocolsMultiChain, + generateTransactionBundle, + checkApprovalNeeded, +} from "./enso-service"; +import { + addSafetyScores, + sortProtocolsBySafetyAndYield, + evaluateProtocolSafety, +} from "./safety-service"; +import { + validateTokenInput, + validateChain, + validateAmount, +} from "./validation"; +import { Logger } from "../common/logger"; +import { ProtocolVault } from "./types"; +import { getChainByName, getChainById } from "../common/types"; + +const logger = new Logger("AgentTools"); + +/** + * Tool: Get token information + */ +export const getTokenInfoTool = tool( + async (input): Promise => { + const { token, chainId, chainName } = input; + try { + logger.info(`Getting token info for: ${token}`); + + // Validate input + const validation = validateTokenInput({ token, chainId, chainName }); + if (!validation.valid) { + return JSON.stringify({ + error: validation.error || validation.errors?.join(", "), + validationErrors: validation.errors, + }); + } + + // Check if token is an address + const isTokenAddress = isAddress(token); + + // If address provided but no chain, return error + if (isTokenAddress && !chainId && !chainName) { + return JSON.stringify({ + error: + "Chain must be provided when using token address. Please specify the chain (e.g., ethereum, arbitrum, base).", + requiresChain: true, + }); + } + + const tokenInfo = await getTokenInfo(token, chainId, chainName); + + if (!tokenInfo) { + return JSON.stringify({ + error: + "Token not found. Please check spelling or provide contract address with chain.", + }); + } + + // If multiple tokens found, return array + if (Array.isArray(tokenInfo)) { + return JSON.stringify({ + multipleMatches: true, + tokens: tokenInfo, + message: `Multiple tokens found. Please select one: ${tokenInfo.map((t) => `${t.name} (${t.symbol})`).join(", ")}`, + }); + } + + // If only name/symbol provided (not address), show all chains + if ( + !isTokenAddress && + tokenInfo.allChains && + tokenInfo.allChains.length > 0 + ) { + return JSON.stringify({ + success: true, + token: { + name: tokenInfo.name, + symbol: tokenInfo.symbol, + coingeckoId: tokenInfo.coingeckoId, + marketCap: tokenInfo.marketCap, + price: tokenInfo.price, + verified: tokenInfo.verified, + description: tokenInfo.description, + }, + allChains: tokenInfo.allChains.map((chain) => ({ + chainId: chain.chainId, + chainName: chain.chainName, + address: chain.address, + })), + requiresConfirmation: true, + message: `Found ${tokenInfo.name} (${tokenInfo.symbol}) on ${tokenInfo.allChains.length} chain(s). Please confirm which chain and address you want to use:`, + warning: + "āš ļø Please verify token details and select the correct chain before proceeding", + }); + } + + // If address + chain provided, return single token info + return JSON.stringify({ + success: true, + token: tokenInfo, + warning: "āš ļø Please verify token details before proceeding", + }); + } catch (error) { + logger.error("Error in getTokenInfoTool:", error); + return JSON.stringify({ + error: `Failed to get token info: ${error instanceof Error ? error.message : "Unknown error"}`, + }); + } + }, + { + name: "get_token_info", + description: + "Get token information by name, symbol, or address. IMPORTANT: If token is an address, chainId or chainName MUST be provided (will return error otherwise). If only name/symbol is provided, returns token info for ALL supported chains - user must then confirm which chain to use.", + schema: z.object({ + token: z.string().describe("Token name, symbol, or contract address"), + chainId: z + .number() + .optional() + .describe( + "Chain ID (REQUIRED if token is an address, optional for name/symbol)" + ), + chainName: z + .string() + .optional() + .describe( + "Chain name (REQUIRED if token is an address, optional for name/symbol)" + ), + }), + } +); + +/** + * Tool: Search for tokens + */ +export const searchTokenTool = tool( + async (input): Promise => { + const { query } = input; + try { + logger.info(`Searching for token: ${query}`); + const results = await searchToken(query); + + if (results.length === 0) { + return JSON.stringify({ + error: "No tokens found. Please try a different search term.", + }); + } + + return JSON.stringify({ + success: true, + count: results.length, + tokens: results, + }); + } catch (error) { + logger.error("Error in searchTokenTool:", error); + return JSON.stringify({ + error: `Failed to search tokens: ${error instanceof Error ? error.message : "Unknown error"}`, + }); + } + }, + { + name: "search_token", + description: "Search for tokens by name or symbol (fuzzy search)", + schema: z.object({ + query: z.string().describe("Search query (token name or symbol)"), + }), + } +); + +/** + * Tool: Discover protocols for a token + */ +export const discoverProtocolsTool = tool( + async (input): Promise => { + const { tokenAddress, chainId, multiChain } = input; + try { + logger.info(`Discovering protocols for token ${tokenAddress}`); + + let protocols: ProtocolVault[]; + + if (multiChain) { + protocols = await discoverProtocolsMultiChain(tokenAddress); + } else { + if (!chainId) { + return JSON.stringify({ + error: "chainId is required when multiChain is false", + }); + } + + const chainValidation = validateChain(chainId); + if (!chainValidation.valid) { + return JSON.stringify({ + error: chainValidation.error || "Invalid chain", + }); + } + + protocols = await discoverProtocols(tokenAddress, chainId); + } + + if (protocols.length === 0) { + return JSON.stringify({ + error: + "No staking protocols found for this token on supported chains.", + suggestion: + "Try searching on different chains or check if token supports staking.", + }); + } + + // Limit protocols before safety evaluation to save on API credits + // Pre-filter by TVL to get top protocols first, then evaluate safety for top 20 + const maxProtocolsToEvaluate = 20; // Only evaluate safety for top 20 by TVL + const maxProtocolsToReturn = 15; // Return top 15 after sorting by safety+yield + + logger.info( + `Found ${protocols.length} protocols. Evaluating safety for top ${maxProtocolsToEvaluate} by TVL.` + ); + + // Add safety scores (only for top protocols by TVL) + const protocolsWithSafety = await addSafetyScores( + protocols, + maxProtocolsToEvaluate + ); + + // Sort by safety and yield + const sortedProtocols = + sortProtocolsBySafetyAndYield(protocolsWithSafety); + + // Return only top protocols to avoid token limit issues + const topProtocols = sortedProtocols.slice(0, maxProtocolsToReturn); + + logger.info( + `Returning top ${topProtocols.length} protocols (sorted by safety and yield) out of ${protocols.length} total found` + ); + + return JSON.stringify({ + success: true, + count: topProtocols.length, + totalFound: protocols.length, + message: `Found ${protocols.length} total protocols. Showing top ${topProtocols.length} by safety and yield.`, + protocols: topProtocols.map((p) => ({ + address: p.address, + name: p.name, + protocol: p.protocol, + chainId: p.chainId, + chainName: p.chainName, + apy: p.apy, + tvl: p.tvl, + safetyScore: p.safetyScore, + })), + }); + } catch (error) { + logger.error("Error in discoverProtocolsTool:", error); + return JSON.stringify({ + error: `Failed to discover protocols: ${error instanceof Error ? error.message : "Unknown error"}`, + }); + } + }, + { + name: "discover_protocols", + description: + "Discover all available staking protocols/vaults for a token. Can search single chain or all supported chains.", + schema: z.object({ + tokenAddress: z.string().describe("Token contract address"), + chainId: z + .number() + .optional() + .describe("Chain ID (required if multiChain is false)"), + multiChain: z + .boolean() + .default(true) + .describe("Whether to search across all supported chains"), + }), + } +); + +/** + * Tool: Generate transaction bundle + */ +export const generateTransactionTool = tool( + async (input): Promise => { + const { + userAddress, + tokenAddress, + protocolAddress, + protocolName, + chainId, + amount, + tokenSymbol, + decimals, + } = input; + try { + logger.info(`Generating transaction bundle for ${tokenSymbol} deposit`); + + // Validate inputs + const amountValidation = validateAmount( + amount, + BigInt("999999999999999999999999999"), // Max balance placeholder - should be checked separately + decimals + ); + + if (!amountValidation.valid) { + return JSON.stringify({ + error: amountValidation.error || "Invalid amount", + }); + } + + const bundle = await generateTransactionBundle( + userAddress, + tokenAddress, + protocolAddress, + protocolName, + chainId, + BigInt(amount), + tokenSymbol, + decimals + ); + + return JSON.stringify({ + success: true, + bundle: { + approvalTransaction: bundle.approvalTransaction, + depositTransaction: bundle.depositTransaction, + executionOrder: bundle.executionOrder, + totalGasEstimate: bundle.totalGasEstimate, + }, + warning: + "āš ļø CRITICAL: This transaction object was generated by an AI agent. Please verify all details before executing. This is not financial advice.", + }); + } catch (error) { + logger.error("Error in generateTransactionTool:", error); + return JSON.stringify({ + error: `Failed to generate transaction: ${error instanceof Error ? error.message : "Unknown error"}`, + }); + } + }, + { + name: "generate_transaction", + description: + "Generate transaction bundle (approval + deposit) for staking tokens. Returns approval transaction if needed, and deposit transaction.", + schema: z.object({ + userAddress: z.string().describe("User wallet address"), + tokenAddress: z.string().describe("Token contract address"), + protocolAddress: z.string().describe("Protocol/vault contract address"), + protocolName: z.string().describe('Protocol name (e.g., "aave-v3")'), + chainId: z.number().describe("Chain ID"), + amount: z.string().describe("Amount to deposit (in wei)"), + tokenSymbol: z.string().describe("Token symbol"), + decimals: z.number().describe("Token decimals"), + }), + } +); + +/** + * Tool: Validate input + */ +export const validateInputTool = tool( + async (input): Promise => { + const { input: userInput, inputType } = input; + try { + if (inputType === "token") { + // Type guard: ensure userInput is an object for TokenInput + if ( + typeof userInput === "object" && + userInput !== null && + !Array.isArray(userInput) + ) { + const validation = validateTokenInput(userInput as any); + return JSON.stringify({ + valid: validation.valid, + errors: validation.errors, + warnings: validation.warnings, + }); + } + return JSON.stringify({ + valid: false, + error: + "Token input must be an object with token, chainId, and chainName properties", + }); + } + + if (inputType === "chain") { + // Type guard: ensure userInput is string or number for chain + if (typeof userInput === "string" || typeof userInput === "number") { + const validation = validateChain(userInput); + return JSON.stringify({ + valid: validation.valid, + error: validation.error, + supportedChains: validation.supportedChains, + }); + } + return JSON.stringify({ + valid: false, + error: "Chain input must be a string or number", + }); + } + + return JSON.stringify({ + error: "Unknown input type", + }); + } catch (error) { + logger.error("Error in validateInputTool:", error); + return JSON.stringify({ + error: `Validation failed: ${error instanceof Error ? error.message : "Unknown error"}`, + }); + } + }, + { + name: "validate_input", + description: "Validate user input (token, chain, etc.)", + schema: z.object({ + input: z + .union([z.string(), z.number(), z.object({})]) + .describe("Input to validate"), + inputType: z + .enum(["token", "chain", "amount"]) + .describe("Type of input to validate"), + }), + } +); + +/** + * Tool: Quick transaction generation (when all parameters provided) + */ +export const quickTransactionTool = tool( + async (input): Promise => { + const { + tokenAddress, + chainId, + chainName, + protocolName, + amount, + userAddress, + } = input; + try { + logger.info( + `Quick transaction mode: ${amount} ${tokenAddress} on ${chainName || chainId} to ${protocolName}` + ); + + // Validate inputs using viem's isAddress (handles checksum properly) + if (!isAddress(tokenAddress)) { + return JSON.stringify({ + error: + "Invalid token address format. Must be a valid Ethereum address", + }); + } + + if (!isAddress(userAddress)) { + return JSON.stringify({ + error: + "Invalid user address format. Must be a valid Ethereum address", + }); + } + + const resolvedChainId = + chainId || (chainName ? getChainByName(chainName)?.id : undefined); + + if (!resolvedChainId) { + return JSON.stringify({ + error: `Invalid chain: ${chainName || chainId}. Supported chains: Ethereum, Arbitrum, Optimism, Polygon, Base, Avalanche, BNB Chain`, + }); + } + + if (!protocolName || protocolName.trim().length === 0) { + return JSON.stringify({ + error: "Protocol name is required", + }); + } + + if (!amount || parseFloat(amount) <= 0) { + return JSON.stringify({ + error: "Amount must be a positive number", + }); + } + + // Get token info + const tokenInfo = await getTokenInfo( + tokenAddress, + resolvedChainId, + chainName + ); + + if (!tokenInfo || Array.isArray(tokenInfo)) { + return JSON.stringify({ + error: "Token not found or multiple tokens found", + }); + } + + // Discover protocols on the specified chain only (quick mode - no multi-chain search) + const allProtocols = await discoverProtocols( + tokenAddress, + resolvedChainId + ); + + if (allProtocols.length === 0) { + return JSON.stringify({ + error: `No protocols found for token ${tokenInfo.symbol} on ${chainName || getChainById(resolvedChainId)?.name || resolvedChainId}`, + suggestion: + "Try a different chain or check if the token supports staking on this chain", + }); + } + + // Find protocol by name (case-insensitive, partial match) + const protocolLower = protocolName.toLowerCase(); + const matchingProtocols = allProtocols.filter( + (p) => + p.protocol.toLowerCase().includes(protocolLower) || + p.name.toLowerCase().includes(protocolLower) + ); + + if (matchingProtocols.length === 0) { + return JSON.stringify({ + error: `Protocol "${protocolName}" not found for token ${tokenInfo.symbol} on ${chainName || getChainById(resolvedChainId)?.name || resolvedChainId}`, + suggestion: `Available protocols on this chain: ${allProtocols + .slice(0, 10) + .map((p) => `${p.protocol} (${p.name})`) + .join(", ")}`, + }); + } + + // If multiple vaults match, select the one with highest APY + // Sort by APY (descending), then by TVL as tiebreaker + const selectedProtocol = matchingProtocols.sort((a, b) => { + const apyDiff = b.apy - a.apy; + if (Math.abs(apyDiff) > 0.01) { + // If APY difference is significant (>0.01%), prioritize APY + return apyDiff; + } + // If APY is similar, prefer higher TVL (more established) + return b.tvl - a.tvl; + })[0]; + + logger.info( + `Selected vault: ${selectedProtocol.name} (APY: ${selectedProtocol.apy}%, TVL: $${selectedProtocol.tvl.toLocaleString()})` + ); + + // Evaluate safety for this protocol + const safetyScore = await evaluateProtocolSafety(selectedProtocol); + + // Convert amount to wei + const amountNum = parseFloat(amount); + const amountWei = BigInt( + Math.floor(amountNum * Math.pow(10, tokenInfo.decimals)) + ); + + // Check if approval is needed + const approvalCheck = await checkApprovalNeeded( + userAddress, + tokenAddress, + selectedProtocol.address, + resolvedChainId, + amountWei + ); + + // Generate transaction bundle + // CRITICAL: Use the EXACT protocol field from Enso's token data + // In Parifi (line 70 of enso.tsx), they pass the protocol field directly + // This is the protocol identifier that Enso expects in getBundleData + const protocolNameForTx = selectedProtocol.protocol; + + logger.info( + `Using protocol name "${protocolNameForTx}" for transaction generation (vault: ${selectedProtocol.name}, project: ${selectedProtocol.project}, protocol: ${selectedProtocol.protocol})` + ); + + const bundle = await generateTransactionBundle( + userAddress, + tokenAddress, + selectedProtocol.address, + protocolNameForTx, + resolvedChainId, + amountWei, + tokenInfo.symbol, + tokenInfo.decimals + ); + + return JSON.stringify({ + success: true, + mode: "quick", + tokenInfo: { + name: tokenInfo.name, + symbol: tokenInfo.symbol, + address: tokenAddress, + chain: chainName || getChainById(resolvedChainId)?.name || "", + chainId: resolvedChainId, + decimals: tokenInfo.decimals, + price: tokenInfo.price, + marketCap: tokenInfo.marketCap, + }, + protocol: { + address: selectedProtocol.address, + name: selectedProtocol.name, + protocol: selectedProtocol.protocol, + chainId: resolvedChainId, + chainName: selectedProtocol.chainName, + apy: selectedProtocol.apy, + tvl: selectedProtocol.tvl, + safetyScore: safetyScore, + }, + approvalNeeded: approvalCheck.approvalNeeded, + bundle: { + approvalTransaction: bundle.approvalTransaction, + depositTransaction: bundle.depositTransaction, + executionOrder: bundle.executionOrder, + totalGasEstimate: bundle.totalGasEstimate, + }, + warning: + "āš ļø CRITICAL: This transaction object was generated by an AI agent. Please verify all details (token address, protocol address, amount, chain) before executing. Double-check on block explorer and protocol website. This is not financial advice.", + }); + } catch (error) { + logger.error("Error in quickTransactionTool:", error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + + // Resolve chainId for fallback + const fallbackChainId = + chainId || (chainName ? getChainByName(chainName)?.id : undefined); + + // If transaction generation failed, try to at least return protocol info + // This allows the user to see available protocols even if tx generation fails + if (fallbackChainId) { + try { + const allProtocols = await discoverProtocols( + tokenAddress, + fallbackChainId + ); + + // Filter for Aave protocol specifically + const aaveProtocols = allProtocols.filter( + (p) => + p.protocol.toLowerCase().includes("aave") || + p.name.toLowerCase().includes("aave") + ); + + if (aaveProtocols.length > 0) { + // Add safety scores to Aave protocols + const protocolsWithSafety = await addSafetyScores(aaveProtocols, 5); + const sortedProtocols = + sortProtocolsBySafetyAndYield(protocolsWithSafety); + + // Return the selected vault info even if transaction failed + const selectedVault = sortedProtocols[0]; + return JSON.stringify({ + error: `Failed to generate transaction: ${errorMessage}. This may be due to API limitations.`, + mode: "quick", + failed: true, + transactionGenerationFailed: true, + message: + "Could not generate transaction bundle, but here is the selected Aave protocol information:", + tokenInfo: { + name: tokenInfo.name, + symbol: tokenInfo.symbol, + address: tokenAddress, + decimals: tokenInfo.decimals, + chain: chainName || getChainById(fallbackChainId)?.name, + chainId: fallbackChainId, + }, + vault: { + address: selectedVault.address, + name: selectedVault.name, + symbol: selectedVault.symbol, + protocol: selectedVault.protocol, + project: selectedVault.project, + apy: selectedVault.apy, + tvl: selectedVault.tvl, + chainId: selectedVault.chainId, + chainName: selectedVault.chainName, + safetyScore: selectedVault.safetyScore, + }, + protocols: sortedProtocols.map((p) => ({ + address: p.address, + name: p.name, + protocol: p.protocol, + chainId: p.chainId, + chainName: p.chainName, + apy: p.apy, + tvl: p.tvl, + safetyScore: p.safetyScore, + })), + suggestion: + "Transaction generation failed due to API limitations. You can interact with the protocol directly using the vault address above, or contact support to upgrade API access.", + }); + } + } catch (fallbackError) { + logger.error( + "Fallback protocol discovery also failed:", + fallbackError + ); + } + } + + // If everything fails, return error with basic info + return JSON.stringify({ + error: `Failed to generate quick transaction: ${errorMessage}. This is likely due to Enso API limitations (free tier may not support transaction bundling).`, + mode: "quick", + failed: true, + suggestion: + "The Enso API's getBundleData endpoint returned a 400 error. This typically means the API key doesn't have access to transaction bundling features. You can still use the protocol information to interact directly with the vault.", + }); + } + }, + { + name: "quick_transaction", + description: + "Generate transaction bundle directly when user provides token address, chain, protocol, amount, and user address. Use this for quick mode when all parameters are available. Returns vault info and transaction objects immediately.", + schema: z.object({ + tokenAddress: z.string().describe("Token contract address"), + chainId: z.number().optional().describe("Chain ID"), + chainName: z + .string() + .optional() + .describe("Chain name (e.g., Ethereum, Arbitrum)"), + protocolName: z + .string() + .describe("Protocol name (e.g., aave, morpho, compound)"), + amount: z + .string() + .describe( + "Amount to deposit (human-readable, e.g., '100' for 100 tokens)" + ), + userAddress: z.string().describe("User wallet address"), + }), + } +); + +/** + * Get all tools for the agent + */ +export function getYieldAgentTools(): Array> { + return [ + getTokenInfoTool, + searchTokenTool, + discoverProtocolsTool, + generateTransactionTool, + quickTransactionTool, + validateInputTool, + ]; +} diff --git a/agents/Yield-Optimization-Agent/src/agent/types.ts b/agents/Yield-Optimization-Agent/src/agent/types.ts new file mode 100644 index 00000000..c1da2a07 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/agent/types.ts @@ -0,0 +1,161 @@ +/** + * Agent-specific types and interfaces + */ + +/** + * Token input from user + */ +export interface TokenInput { + token: string; // Token name, symbol, or address + chainId?: number; // REQUIRED if token is an address + chainName?: string; // Alternative to chainId (e.g., "ethereum", "arbitrum") +} + +/** + * Quick mode input (all parameters in one command) + */ +export interface QuickModeInput { + token: string; // Address or name + chain: string | number; // Chain name or ID + protocol: string; // Protocol name/identifier + amount?: string; // Optional amount +} + +/** + * Token information + */ +export interface TokenInfo { + name: string; + symbol: string; + address: string; // Contract address + chain: string; // Chain name + chainId: number; // Chain ID + marketCap?: number; // USD market cap + price?: number; // Current USD price + decimals: number; + logoURI?: string; + description?: string; // Token description/news + priceChange24h?: number; // 24h price change % + volume24h?: number; // 24h trading volume + coingeckoId?: string; // CoinGecko ID for further lookups + allChains?: Array<{ + // All chains where token exists + chainId: number; + chainName: string; + address: string; + }>; + verified?: boolean; // Whether token is verified on CoinGecko + warnings?: string[]; // Any warnings about the token +} + +/** + * Protocol vault information + */ +export interface ProtocolVault { + address: string; // Vault contract address + name: string; // Vault name + symbol: string; // Vault token symbol + protocol: string; // Protocol name (e.g., "aave", "compound") + chainId: number; // Chain ID + chainName: string; // Human-readable chain name + apy: number; // Annual Percentage Yield + tvl: number; // Total Value Locked (USD) + underlyingTokens: Array<{ + // Underlying tokens + address: string; + symbol: string; + name: string; + }>; + logosUri?: string[]; // Protocol logo URLs + project?: string; // Project identifier +} + +/** + * Safety score for a protocol + */ +export interface SafetyScore { + overall: "very_safe" | "safe" | "moderate" | "risky"; + score: number; // 0-100 + factors: { + tvl: { score: number; level: string }; + protocol: { score: number; level: string; reputation: string }; + audits: { score: number; level: string; auditCount: number }; + history: { score: number; level: string }; + }; + warnings?: string[]; // Any safety warnings + recommendations?: string[]; // Safety recommendations +} + +/** + * Protocol with safety evaluation + */ +export interface ProtocolWithSafety extends ProtocolVault { + safetyScore: SafetyScore; +} + +/** + * Approval transaction + */ +export interface ApprovalTransaction { + to: string; // Token contract address + data: string; // Encoded approve() call data + value: string; // Always "0" for ERC20 approvals + gasLimit?: string; + gasPrice?: string; + chainId: number; + tokenAddress: string; // Token being approved + spender: string; // Protocol/vault address to approve + amount: string; // Amount to approve (in wei) + type: "approve"; + safetyWarning: string; // Mandatory safety warning +} + +/** + * Deposit transaction + */ +export interface DepositTransaction { + to: string; // Contract address to interact with + data: string; // Encoded transaction data + value: string; // ETH value (if native token) + gasLimit?: string; // Estimated gas limit + gasPrice?: string; // Gas price + chainId: number; // Chain ID + protocol: string; // Protocol name + action: "deposit" | "stake"; // Action type + tokenIn: { + address: string; + symbol: string; + amount: string; // Human-readable amount + amountWei: string; // Amount in wei + }; + tokenOut: { + address: string; + symbol: string; + }; + estimatedGas?: string; // Estimated gas cost in USD + slippage?: number; // Slippage tolerance + type: "deposit"; + safetyWarning: string; // Mandatory safety warning +} + +/** + * Transaction bundle (approval + deposit if needed) + */ +export interface TransactionBundle { + approvalTransaction?: ApprovalTransaction; // Required if approval needed + depositTransaction: DepositTransaction; // Always present + executionOrder: ("approve" | "deposit")[]; // Order of execution + totalGasEstimate?: string; // Combined gas estimate +} + +/** + * Approval check result + */ +export interface ApprovalCheckResult { + approvalNeeded: boolean; + currentAllowance?: bigint; + requiredAmount?: bigint; + approvalTransaction?: ApprovalTransaction; + error?: string; + message: string; +} diff --git a/agents/Yield-Optimization-Agent/src/agent/validation.ts b/agents/Yield-Optimization-Agent/src/agent/validation.ts new file mode 100644 index 00000000..c8d79dfd --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/agent/validation.ts @@ -0,0 +1,294 @@ +/** + * Input validation service + * Validates all user inputs before processing + */ + +import { isAddress } from 'viem'; +import { ValidationResult, SUPPORTED_CHAINS, getChainById, getChainByName } from '../common/types'; +import { TokenInput, QuickModeInput } from './types'; +import { Logger } from '../common/logger'; + +const logger = new Logger('ValidationService'); + +/** + * Validate token input + * CRITICAL: If address is provided, chain MUST be provided + */ +export function validateTokenInput(input: TokenInput): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Check if input is an address + const isTokenAddress = isAddress(input.token); + + if (isTokenAddress) { + // Address provided - chain is REQUIRED + if (!input.chainId && !input.chainName) { + return { + valid: false, + error: + 'Chain must be provided when token address is used. Please specify chainId or chainName.', + requiredFields: ['chainId or chainName'], + errors: [ + 'Chain must be provided when token address is used. Please specify chainId or chainName.', + ], + }; + } + + // Validate address format + if (!isAddress(input.token)) { + errors.push('Invalid Ethereum address format'); + } + + // Validate chain if provided + if (input.chainId) { + const chain = getChainById(input.chainId); + if (!chain) { + errors.push( + `Chain ID ${input.chainId} is not supported. Supported chains: ${SUPPORTED_CHAINS.map((c) => c.name).join(', ')}`, + ); + } + } else if (input.chainName) { + const chain = getChainByName(input.chainName); + if (!chain) { + errors.push( + `Chain "${input.chainName}" is not supported. Supported chains: ${SUPPORTED_CHAINS.map((c) => c.name).join(', ')}`, + ); + } + } + } + + // Validate token is not empty + if (!input.token || input.token.trim().length === 0) { + errors.push('Token name or address cannot be empty'); + } + + return { + valid: errors.length === 0, + errors: errors.length > 0 ? errors : undefined, + warnings: warnings.length > 0 ? warnings : undefined, + }; +} + +/** + * Validate chain + */ +export function validateChain(chain: string | number): ValidationResult { + const chainId = typeof chain === 'string' ? getChainByName(chain)?.id : chain; + + if (!chainId) { + return { + valid: false, + error: `Unsupported chain: ${chain}`, + supportedChains: SUPPORTED_CHAINS.map((c) => ({ + id: c.id, + name: c.name, + })), + }; + } + + const chainInfo = getChainById(chainId); + if (!chainInfo) { + return { + valid: false, + error: `Chain ID ${chainId} is not supported`, + supportedChains: SUPPORTED_CHAINS.map((c) => ({ + id: c.id, + name: c.name, + })), + }; + } + + return { valid: true, data: chainInfo }; +} + +/** + * Validate amount + */ +export function validateAmount( + amount: string, + balance: bigint, + decimals: number, +): ValidationResult { + const amountNum = parseFloat(amount); + + if (isNaN(amountNum) || amountNum <= 0) { + return { + valid: false, + error: 'Amount must be a positive number', + }; + } + + // Convert amount to wei for comparison + const amountWei = BigInt(Math.floor(amountNum * Math.pow(10, decimals))); + + if (amountWei > balance) { + const balanceFormatted = Number(balance) / Math.pow(10, decimals); + return { + valid: false, + error: `Insufficient balance. Available: ${balanceFormatted}, Requested: ${amount}`, + data: { + available: balanceFormatted.toString(), + requested: amount, + }, + }; + } + + return { valid: true, data: { amountWei, amountNum } }; +} + +/** + * Detect if input is quick mode (has protocol specified) + */ +export function detectQuickMode(input: string): boolean { + // Simple heuristic: if input contains protocol keywords or structure suggests quick mode + const quickModeKeywords = ['to', 'on', 'deposit', 'stake', 'protocol']; + const lowerInput = input.toLowerCase(); + + // Check if input has structure like "token on chain to protocol" + const hasProtocolStructure = + lowerInput.includes(' to ') || + (lowerInput.includes(' on ') && lowerInput.length > 20); + + return hasProtocolStructure || quickModeKeywords.some((keyword) => lowerInput.includes(keyword)); +} + +/** + * Parse quick mode input + * This is a simplified parser - in production, you might want to use LLM for better parsing + */ +export function parseQuickModeInput(input: string): QuickModeInput | Error { + try { + // This is a basic parser - can be enhanced with LLM or more sophisticated parsing + + // Try to extract amount (number at the start or after "deposit") + let amount: string | undefined; + const amountMatch = input.match(/(?:deposit|stake)\s+(\d+(?:\.\d+)?)/i); + if (amountMatch) { + amount = amountMatch[1]; + } + + // Try to extract chain (common chain names) + const chainPatterns = [ + /(?:on|chain)\s+(ethereum|arbitrum|optimism|polygon|base|avalanche|bnb|binance)/i, + /(?:on|chain)\s+(\d+)/, + ]; + let chain: string | number | undefined; + for (const pattern of chainPatterns) { + const match = input.match(pattern); + if (match) { + chain = isNaN(Number(match[1])) ? match[1] : Number(match[1]); + break; + } + } + + // Try to extract protocol (after "to" or common protocol names) + const protocolMatch = input.match(/(?:to|protocol)\s+([a-z0-9-]+)/i); + const protocol = protocolMatch ? protocolMatch[1] : undefined; + + // Try to extract token (address or name) + const addressMatch = input.match(/0x[a-fA-F0-9]{40}/); + const token = addressMatch + ? addressMatch[0] + : input.split(/\s+(?:on|to|deposit|stake)/i)[0]?.trim() || ''; + + if (!token) { + return new Error('Could not extract token from input'); + } + + return { + token, + chain: chain || '', + protocol: protocol || '', + amount, + }; + } catch (error) { + return error instanceof Error ? error : new Error('Failed to parse quick mode input'); + } +} + +/** + * Validate quick mode input + */ +export function validateQuickModeInput(input: QuickModeInput): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate token + if (isAddress(input.token)) { + if (!input.chain) { + errors.push('Chain is required when using token address'); + } + if (!isAddress(input.token)) { + errors.push('Invalid token address format'); + } + } + + // Validate chain + if (input.chain) { + const chainValidation = validateChain(input.chain); + if (!chainValidation.valid) { + errors.push(chainValidation.error || 'Invalid chain'); + } + } else { + errors.push('Chain must be specified in quick mode'); + } + + // Validate protocol + if (!input.protocol || input.protocol.trim().length === 0) { + errors.push('Protocol must be specified in quick mode'); + } + + // Validate amount if provided + if (input.amount) { + const amountNum = parseFloat(input.amount); + if (isNaN(amountNum) || amountNum <= 0) { + errors.push('Invalid amount. Must be a positive number'); + } + } + + return { + valid: errors.length === 0, + errors: errors.length > 0 ? errors : undefined, + warnings: warnings.length > 0 ? warnings : undefined, + data: errors.length === 0 ? input : undefined, + }; +} + +/** + * Comprehensive input validation + */ +export function validateInput(input: string): ValidationResult { + // Check if it's quick mode + const isQuickMode = detectQuickMode(input); + + if (isQuickMode) { + logger.info('Detected quick mode input'); + const parsed = parseQuickModeInput(input); + + if (parsed instanceof Error) { + return { + valid: false, + error: parsed.message, + errors: [parsed.message], + }; + } + + return validateQuickModeInput(parsed); + } + + // Regular mode - basic validation + if (!input || input.trim().length === 0) { + return { + valid: false, + error: 'Input cannot be empty', + errors: ['Input cannot be empty'], + }; + } + + return { + valid: true, + data: { input, mode: 'interactive' }, + }; +} + diff --git a/agents/Yield-Optimization-Agent/src/api/controllers.ts b/agents/Yield-Optimization-Agent/src/api/controllers.ts new file mode 100644 index 00000000..1d7abdd4 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/api/controllers.ts @@ -0,0 +1,292 @@ +/** + * API Controllers + * Handle HTTP requests and responses + */ + +import { Request, Response, NextFunction } from "express"; +import { runYieldAgent } from "../agent"; +import { + searchToken, + getTokenInfo, + discoverProtocols, + discoverProtocolsMultiChain, + generateTransactionBundle, +} from "../agent"; +import { SUPPORTED_CHAINS } from "../common/types"; +import { Logger } from "../common/logger"; +import { addSafetyScores, sortProtocolsBySafetyAndYield } from "../agent/safety-service"; + +const logger = new Logger("API-Controllers"); + +/** + * Query the agent with a single question + */ +export async function queryAgent( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { query, options } = req.body; + + logger.info(`Agent query received: ${query}`); + + const results = await runYieldAgent([query], options); + + res.json({ + success: true, + query, + result: results[0], + timestamp: new Date().toISOString(), + }); + } catch (error) { + next(error); + } +} + +/** + * Batch query the agent with multiple questions + */ +export async function batchQueryAgent( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { queries, options } = req.body; + + logger.info(`Batch query received: ${queries.length} queries`); + + const results = await runYieldAgent(queries, options); + + res.json({ + success: true, + count: results.length, + results, + timestamp: new Date().toISOString(), + }); + } catch (error) { + next(error); + } +} + +/** + * Quick transaction generation (all parameters provided) + */ +export async function quickTransaction( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { tokenAddress, chainId, protocolName, amount, userAddress } = req.body; + + logger.info(`Quick transaction: ${amount} of ${tokenAddress} on chain ${chainId} to ${protocolName}`); + + // Create a natural language query from the parameters + const query = `Deposit ${amount} ${tokenAddress} on chain ${chainId} to ${protocolName} for user ${userAddress}`; + + const results = await runYieldAgent([query], { + modelName: "gpt-4o-mini", + temperature: 0, + }); + + res.json({ + success: true, + mode: "quick", + result: results[0], + timestamp: new Date().toISOString(), + }); + } catch (error) { + next(error); + } +} + +/** + * Search for tokens by name or symbol + */ +export async function searchTokens( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { query } = req.query as { query: string }; + + logger.info(`Token search: ${query}`); + + const tokens = await searchToken(query); + + res.json({ + success: true, + count: tokens.length, + tokens, + timestamp: new Date().toISOString(), + }); + } catch (error) { + next(error); + } +} + +/** + * Get token information + */ +export async function getTokenInformation( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { token, chainId, chainName } = req.query as { + token: string; + chainId?: string; + chainName?: string; + }; + + logger.info(`Get token info: ${token}`); + + const tokenInfo = await getTokenInfo( + token, + chainId ? parseInt(chainId) : undefined, + chainName + ); + + if (!tokenInfo) { + res.status(404).json({ + success: false, + error: "Token not found", + timestamp: new Date().toISOString(), + }); + return; + } + + res.json({ + success: true, + token: tokenInfo, + timestamp: new Date().toISOString(), + }); + } catch (error) { + next(error); + } +} + +/** + * Discover protocols for a token + */ +export async function discoverProtocolsForToken( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { tokenAddress, chainId, multiChain = true } = req.body; + + logger.info(`Discover protocols for token: ${tokenAddress}`); + + let protocols; + if (multiChain) { + protocols = await discoverProtocolsMultiChain(tokenAddress); + } else { + if (!chainId) { + res.status(400).json({ + success: false, + error: "chainId is required when multiChain is false", + timestamp: new Date().toISOString(), + }); + return; + } + protocols = await discoverProtocols(tokenAddress, chainId); + } + + // Add safety scores and sort + const maxProtocolsToEvaluate = 20; + const maxProtocolsToReturn = 15; + + const protocolsWithSafety = await addSafetyScores( + protocols, + maxProtocolsToEvaluate + ); + const sortedProtocols = sortProtocolsBySafetyAndYield(protocolsWithSafety); + const topProtocols = sortedProtocols.slice(0, maxProtocolsToReturn); + + res.json({ + success: true, + count: topProtocols.length, + totalFound: protocols.length, + protocols: topProtocols, + timestamp: new Date().toISOString(), + }); + } catch (error) { + next(error); + } +} + +/** + * Generate transaction bundle + */ +export async function generateTransaction( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { + userAddress, + tokenAddress, + protocolAddress, + protocolName, + chainId, + amount, + tokenSymbol, + decimals, + } = req.body; + + logger.info(`Generate transaction: ${tokenSymbol} to ${protocolName}`); + + const bundle = await generateTransactionBundle( + userAddress, + tokenAddress, + protocolAddress, + protocolName, + chainId, + BigInt(amount), + tokenSymbol, + decimals + ); + + res.json({ + success: true, + bundle, + warning: + "āš ļø CRITICAL: This transaction object was generated by an AI agent. Please verify all details before executing. This is not financial advice.", + timestamp: new Date().toISOString(), + }); + } catch (error) { + next(error); + } +} + +/** + * Get supported chains + */ +export async function getSupportedChains( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + res.json({ + success: true, + count: SUPPORTED_CHAINS.length, + chains: SUPPORTED_CHAINS.map((chain) => ({ + id: chain.id, + name: chain.name, + chainName: chain.chainName, + })), + timestamp: new Date().toISOString(), + }); + } catch (error) { + next(error); + } +} + diff --git a/agents/Yield-Optimization-Agent/src/api/index.ts b/agents/Yield-Optimization-Agent/src/api/index.ts new file mode 100644 index 00000000..97f5dc3e --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/api/index.ts @@ -0,0 +1,10 @@ +/** + * API Module Entry Point + * Export all API-related functionality + */ + +export { createApp, startServer } from "./server"; +export { router } from "./routes"; +export * from "./controllers"; +export * from "./middleware"; +export * from "./validation"; diff --git a/agents/Yield-Optimization-Agent/src/api/middleware.ts b/agents/Yield-Optimization-Agent/src/api/middleware.ts new file mode 100644 index 00000000..24b050cb --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/api/middleware.ts @@ -0,0 +1,104 @@ +/** + * API Middleware + * Request validation, error handling, etc. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { Logger } from "../common/logger"; + +const logger = new Logger("API-Middleware"); + +/** + * Validation middleware factory + */ +export function validateRequest(schema: z.ZodSchema) { + return async (req: Request, res: Response, next: NextFunction) => { + try { + // Validate against schema + const data = req.method === "GET" ? req.query : req.body; + await schema.parseAsync(data); + next(); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: "Validation error", + details: error.errors.map((err) => ({ + field: err.path.join("."), + message: err.message, + })), + timestamp: new Date().toISOString(), + }); + } else { + next(error); + } + } + }; +} + +/** + * Error handling middleware + */ +export function errorHandler( + error: Error, + req: Request, + res: Response, + next: NextFunction +): void { + logger.error(`Error handling ${req.method} ${req.path}:`, error); + + // Check if response already sent + if (res.headersSent) { + return next(error); + } + + // Determine status code and message + let statusCode = 500; + let message = "Internal server error"; + let details: any = undefined; + + if (error.message.includes("OPENAI_API_KEY")) { + statusCode = 503; + message = "OpenAI API key not configured"; + details = "Server is not properly configured. Please contact the administrator."; + } else if (error.message.includes("ENSO_API_KEY")) { + statusCode = 503; + message = "Enso API key not configured"; + details = "Server is not properly configured. Please contact the administrator."; + } else if (error.message.includes("rate limit") || error.message.includes("429")) { + statusCode = 429; + message = "Rate limit exceeded"; + details = "Too many requests. Please try again later."; + } else if (error.message.includes("not found")) { + statusCode = 404; + message = "Resource not found"; + details = error.message; + } else if (error.message.includes("Invalid") || error.message.includes("validation")) { + statusCode = 400; + message = "Invalid request"; + details = error.message; + } + + res.status(statusCode).json({ + success: false, + error: message, + details: details || (process.env.NODE_ENV === "development" ? error.message : undefined), + timestamp: new Date().toISOString(), + ...(process.env.NODE_ENV === "development" && { + stack: error.stack, + }), + }); +} + +/** + * Async handler wrapper to catch errors in async route handlers + */ +export function asyncHandler( + fn: (req: Request, res: Response, next: NextFunction) => Promise +) { + return (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} + diff --git a/agents/Yield-Optimization-Agent/src/api/routes.ts b/agents/Yield-Optimization-Agent/src/api/routes.ts new file mode 100644 index 00000000..a94e0ed3 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/api/routes.ts @@ -0,0 +1,297 @@ +/** + * API Routes + * Defines all HTTP endpoints for the Yield Agent API + */ + +import { Router } from "express"; +import { + queryAgent, + batchQueryAgent, + searchTokens, + getTokenInformation, + discoverProtocolsForToken, + generateTransaction, + getSupportedChains, + quickTransaction, +} from "./controllers"; +import { validateRequest } from "./middleware"; +import { + agentQuerySchema, + batchQuerySchema, + tokenSearchSchema, + tokenInfoSchema, + protocolDiscoverySchema, + transactionGenerationSchema, + quickTransactionSchema, +} from "./validation"; + +export const router = Router(); + +/** + * @swagger + * /api/v1/agent/query: + * post: + * tags: + * - Agent + * summary: Query the yield optimization agent + * description: Send a natural language query to the agent and receive a structured response + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - query + * properties: + * query: + * type: string + * description: Natural language query about yield opportunities + * example: "Find staking opportunities for USDC on Ethereum" + * options: + * type: object + * properties: + * modelName: + * type: string + * default: "gpt-4o-mini" + * temperature: + * type: number + * default: 0 + * responses: + * 200: + * description: Successful response with agent answer + * 400: + * description: Invalid request + * 500: + * description: Internal server error + */ +router.post("/agent/query", validateRequest(agentQuerySchema), queryAgent); + +/** + * @swagger + * /api/v1/agent/batch: + * post: + * tags: + * - Agent + * summary: Batch query the agent + * description: Send multiple queries to the agent at once + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - queries + * properties: + * queries: + * type: array + * items: + * type: string + * example: ["Find staking for USDC", "What protocols support ETH on Arbitrum?"] + * responses: + * 200: + * description: Successful batch response + */ +router.post("/agent/batch", validateRequest(batchQuerySchema), batchQueryAgent); + +/** + * @swagger + * /api/v1/agent/quick: + * post: + * tags: + * - Agent + * summary: Quick transaction generation + * description: Generate transaction bundle directly with all parameters + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - tokenAddress + * - chainId + * - protocolName + * - amount + * - userAddress + * properties: + * tokenAddress: + * type: string + * example: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + * chainId: + * type: number + * example: 1 + * protocolName: + * type: string + * example: "aave" + * amount: + * type: string + * example: "100" + * userAddress: + * type: string + * example: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb" + * responses: + * 200: + * description: Transaction bundle generated successfully + */ +router.post("/agent/quick", validateRequest(quickTransactionSchema), quickTransaction); + +/** + * @swagger + * /api/v1/tokens/search: + * get: + * tags: + * - Tokens + * summary: Search for tokens + * description: Search tokens by name or symbol + * parameters: + * - in: query + * name: query + * required: true + * schema: + * type: string + * description: Token name or symbol to search for + * example: "USDC" + * responses: + * 200: + * description: List of matching tokens + */ +router.get("/tokens/search", validateRequest(tokenSearchSchema), searchTokens); + +/** + * @swagger + * /api/v1/tokens/info: + * get: + * tags: + * - Tokens + * summary: Get token information + * description: Get detailed information about a specific token + * parameters: + * - in: query + * name: token + * required: true + * schema: + * type: string + * description: Token name, symbol, or address + * example: "USDC" + * - in: query + * name: chainId + * schema: + * type: number + * description: Chain ID (required if token is an address) + * example: 1 + * responses: + * 200: + * description: Token information + */ +router.get("/tokens/info", validateRequest(tokenInfoSchema), getTokenInformation); + +/** + * @swagger + * /api/v1/protocols/discover: + * post: + * tags: + * - Protocols + * summary: Discover staking protocols + * description: Find available staking protocols for a token + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - tokenAddress + * properties: + * tokenAddress: + * type: string + * example: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + * chainId: + * type: number + * example: 1 + * multiChain: + * type: boolean + * default: true + * responses: + * 200: + * description: List of available protocols + */ +router.post( + "/protocols/discover", + validateRequest(protocolDiscoverySchema), + discoverProtocolsForToken +); + +/** + * @swagger + * /api/v1/transactions/generate: + * post: + * tags: + * - Transactions + * summary: Generate transaction bundle + * description: Generate approval and deposit transactions for staking + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userAddress + * - tokenAddress + * - protocolAddress + * - protocolName + * - chainId + * - amount + * - tokenSymbol + * - decimals + * properties: + * userAddress: + * type: string + * example: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb" + * tokenAddress: + * type: string + * example: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + * protocolAddress: + * type: string + * example: "0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9" + * protocolName: + * type: string + * example: "aave-v3" + * chainId: + * type: number + * example: 1 + * amount: + * type: string + * example: "100000000" + * tokenSymbol: + * type: string + * example: "USDC" + * decimals: + * type: number + * example: 6 + * responses: + * 200: + * description: Transaction bundle generated + */ +router.post( + "/transactions/generate", + validateRequest(transactionGenerationSchema), + generateTransaction +); + +/** + * @swagger + * /api/v1/chains: + * get: + * tags: + * - Chains + * summary: Get supported chains + * description: List all supported blockchain networks + * responses: + * 200: + * description: List of supported chains + */ +router.get("/chains", getSupportedChains); + diff --git a/agents/Yield-Optimization-Agent/src/api/server.ts b/agents/Yield-Optimization-Agent/src/api/server.ts new file mode 100644 index 00000000..38239603 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/api/server.ts @@ -0,0 +1,198 @@ +/** + * REST API Server for Yield Optimization Agent + * Provides HTTP endpoints for accessing agent functionality + */ + +import express, { Express, Request, Response, NextFunction } from "express"; +import cors from "cors"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; +import dotenv from "dotenv"; +import swaggerUi from "swagger-ui-express"; +import { fileURLToPath } from "url"; +import { swaggerSpec } from "./swagger"; +import { router } from "./routes"; +import { errorHandler } from "./middleware"; +import { Logger } from "../common/logger"; + +// Load environment variables +dotenv.config(); + +const logger = new Logger("API-Server"); + +/** + * Create and configure Express application + */ +export function createApp(): Express { + const app = express(); + + // Security middleware + app.use(helmet({ + contentSecurityPolicy: false, // Disable for Swagger UI to work properly + })); + + // CORS configuration + app.use( + cors({ + origin: process.env.CORS_ORIGIN || "*", + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + credentials: true, + }) + ); + + // Body parsing middleware + app.use(express.json({ limit: "10mb" })); + app.use(express.urlencoded({ extended: true, limit: "10mb" })); + + // Rate limiting + const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: process.env.RATE_LIMIT_MAX ? parseInt(process.env.RATE_LIMIT_MAX) : 100, // Limit each IP + message: { + error: "Too many requests from this IP, please try again later.", + retryAfter: "15 minutes", + }, + standardHeaders: true, + legacyHeaders: false, + }); + + app.use("/api/", limiter); + + // Request logging middleware + app.use((req: Request, res: Response, next: NextFunction) => { + logger.info(`${req.method} ${req.path} - IP: ${req.ip}`); + next(); + }); + + // Health check endpoint (no rate limit) + app.get("/health", (req: Request, res: Response) => { + res.json({ + status: "ok", + timestamp: new Date().toISOString(), + uptime: process.uptime(), + environment: process.env.NODE_ENV || "development", + version: "1.0.0", + }); + }); + + // Swagger documentation + app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + customSiteTitle: "Yield Agent API Documentation", + customCss: ".swagger-ui .topbar { display: none }", + swaggerOptions: { + persistAuthorization: true, + }, + })); + + // API routes + app.use("/api/v1", router); + + // Root endpoint + app.get("/", (req: Request, res: Response) => { + res.json({ + name: "Yield Optimization Agent API", + version: "1.0.0", + description: "AI-powered DeFi yield optimization service", + documentation: "/api-docs", + health: "/health", + endpoints: { + agent: "/api/v1/agent", + tokens: "/api/v1/tokens", + protocols: "/api/v1/protocols", + transactions: "/api/v1/transactions", + chains: "/api/v1/chains", + }, + }); + }); + + // 404 handler + app.use((req: Request, res: Response) => { + res.status(404).json({ + error: "Not Found", + message: `Cannot ${req.method} ${req.path}`, + documentation: "/api-docs", + }); + }); + + // Error handling middleware (must be last) + app.use(errorHandler); + + return app; +} + +/** + * Start the API server + */ +export async function startServer(port?: number): Promise { + const app = createApp(); + const serverPort = port || parseInt(process.env.PORT || "3000"); + + // Validate required environment variables + const requiredEnvVars = ["OPENAI_API_KEY", "ENSO_API_KEY"]; + const missingEnvVars = requiredEnvVars.filter( + (varName) => !process.env[varName] + ); + + if (missingEnvVars.length > 0) { + logger.error( + `Missing required environment variables: ${missingEnvVars.join(", ")}` + ); + logger.info("Please set these in your .env file"); + } + + const server = app.listen(serverPort, () => { + logger.info(`šŸš€ Yield Agent API Server running on port ${serverPort}`); + logger.info(`šŸ“š API Documentation: http://localhost:${serverPort}/api-docs`); + logger.info(`šŸ’š Health Check: http://localhost:${serverPort}/health`); + logger.info(`šŸ”§ Environment: ${process.env.NODE_ENV || "development"}`); + }); + + // Graceful shutdown + process.on("SIGTERM", () => { + logger.info("SIGTERM signal received: closing HTTP server"); + server.close(() => { + logger.info("HTTP server closed"); + process.exit(0); + }); + }); + + process.on("SIGINT", () => { + logger.info("SIGINT signal received: closing HTTP server"); + server.close(() => { + logger.info("HTTP server closed"); + process.exit(0); + }); + }); +} + +// Start server if run directly +// ES module equivalent of require.main === module +// Check if this module is being run directly (not imported) +const isMainModule = (() => { + try { + const mainModulePath = process.argv[1]; + if (!mainModulePath) return false; + + // Convert import.meta.url to file path for comparison + const currentModulePath = fileURLToPath(import.meta.url); + + // Check if main module path ends with server.ts or server.js + // tsx runs .ts files directly, so we check for both + return ( + mainModulePath === currentModulePath || + mainModulePath.includes("server.ts") || + mainModulePath.includes("server.js") + ); + } catch { + return false; + } +})(); + +if (isMainModule) { + startServer().catch((error) => { + logger.error("Failed to start server:", error); + process.exit(1); + }); +} + diff --git a/agents/Yield-Optimization-Agent/src/api/swagger.ts b/agents/Yield-Optimization-Agent/src/api/swagger.ts new file mode 100644 index 00000000..3c542b5e --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/api/swagger.ts @@ -0,0 +1,182 @@ +/** + * Swagger/OpenAPI configuration + * Generates API documentation + */ + +import swaggerJsdoc from "swagger-jsdoc"; + +const options: swaggerJsdoc.Options = { + definition: { + openapi: "3.0.0", + info: { + title: "Yield Optimization Agent API", + version: "1.0.0", + description: ` +# Yield Optimization Agent API + +An AI-powered API for discovering and accessing DeFi yield opportunities across multiple chains and protocols. + +## Features + +- šŸ¤– **AI-Powered Agent**: Natural language queries for yield opportunities +- šŸ” **Token Discovery**: Search and get detailed token information +- šŸ¦ **Protocol Discovery**: Find best staking opportunities across protocols +- šŸ›”ļø **Safety Evaluation**: Comprehensive safety scoring for protocols +- šŸ’° **Transaction Generation**: Generate ready-to-sign transaction bundles +- 🌐 **Multi-Chain**: Supports Ethereum, Arbitrum, Optimism, Polygon, Base, Avalanche, and BNB Chain + +## Getting Started + +1. Obtain API access +2. Set up authentication (if required) +3. Start making requests to the endpoints below + +## Safety Warnings + +āš ļø **CRITICAL**: All transaction objects are generated by AI. Always verify: +- Token addresses on block explorer +- Protocol addresses on official protocol websites +- Transaction amounts and parameters +- Gas estimates + +**This API does not provide financial advice. Always do your own research.** + +## Rate Limits + +- 100 requests per 15 minutes per IP address +- Batch queries limited to 10 queries maximum + +## Support + +For issues or questions, please contact support or visit our documentation. + `, + contact: { + name: "API Support", + email: "support@yieldagent.io", + }, + }, + servers: [ + { + url: "http://localhost:3000", + description: "Development server", + }, + { + url: "https://api.yieldagent.io", + description: "Production server (example)", + }, + ], + tags: [ + { + name: "Agent", + description: "AI agent endpoints for natural language queries", + }, + { + name: "Tokens", + description: "Token information and search", + }, + { + name: "Protocols", + description: "Protocol discovery and information", + }, + { + name: "Transactions", + description: "Transaction bundle generation", + }, + { + name: "Chains", + description: "Blockchain network information", + }, + ], + components: { + schemas: { + Error: { + type: "object", + properties: { + success: { + type: "boolean", + example: false, + }, + error: { + type: "string", + example: "Error message", + }, + details: { + type: "string", + example: "Detailed error information", + }, + timestamp: { + type: "string", + format: "date-time", + }, + }, + }, + TokenInfo: { + type: "object", + properties: { + name: { type: "string", example: "USD Coin" }, + symbol: { type: "string", example: "USDC" }, + address: { type: "string", example: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }, + chain: { type: "string", example: "Ethereum" }, + chainId: { type: "number", example: 1 }, + decimals: { type: "number", example: 6 }, + price: { type: "number", example: 1.0 }, + marketCap: { type: "number", example: 30000000000 }, + verified: { type: "boolean", example: true }, + }, + }, + Protocol: { + type: "object", + properties: { + address: { type: "string", example: "0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9" }, + name: { type: "string", example: "Aave V3 USDC" }, + protocol: { type: "string", example: "aave-v3" }, + chainId: { type: "number", example: 1 }, + chainName: { type: "string", example: "Ethereum" }, + apy: { type: "number", example: 5.2 }, + tvl: { type: "number", example: 1000000000 }, + safetyScore: { + type: "object", + properties: { + overall: { type: "string", enum: ["very_safe", "safe", "moderate", "risky"] }, + score: { type: "number", example: 85 }, + warnings: { type: "array", items: { type: "string" } }, + }, + }, + }, + }, + Transaction: { + type: "object", + properties: { + to: { type: "string", example: "0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9" }, + data: { type: "string", example: "0x..." }, + value: { type: "string", example: "0" }, + chainId: { type: "number", example: 1 }, + gasLimit: { type: "string", example: "200000" }, + type: { type: "string", example: "deposit" }, + safetyWarning: { type: "string" }, + }, + }, + Chain: { + type: "object", + properties: { + id: { type: "number", example: 1 }, + name: { type: "string", example: "Ethereum" }, + chainName: { type: "string", example: "ethereum" }, + }, + }, + }, + securitySchemes: { + ApiKeyAuth: { + type: "apiKey", + in: "header", + name: "X-API-Key", + description: "API key for authentication (if required)", + }, + }, + }, + }, + apis: ["./src/api/routes.ts"], // Path to the API routes +}; + +export const swaggerSpec = swaggerJsdoc(options); + diff --git a/agents/Yield-Optimization-Agent/src/api/validation.ts b/agents/Yield-Optimization-Agent/src/api/validation.ts new file mode 100644 index 00000000..bdaaeb60 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/api/validation.ts @@ -0,0 +1,87 @@ +/** + * Request validation schemas using Zod + */ + +import { z } from "zod"; + +/** + * Agent query validation schema + */ +export const agentQuerySchema = z.object({ + query: z.string().min(1, "Query cannot be empty"), + options: z + .object({ + modelName: z.string().optional(), + temperature: z.number().min(0).max(2).optional(), + maxTokens: z.number().min(100).max(16000).optional(), + maxRetries: z.number().min(1).max(10).optional(), + }) + .optional(), +}); + +/** + * Batch query validation schema + */ +export const batchQuerySchema = z.object({ + queries: z.array(z.string().min(1)).min(1, "At least one query is required").max(10, "Maximum 10 queries allowed"), + options: z + .object({ + modelName: z.string().optional(), + temperature: z.number().min(0).max(2).optional(), + maxTokens: z.number().min(100).max(16000).optional(), + maxRetries: z.number().min(1).max(10).optional(), + delayBetweenQuestionsMs: z.number().min(0).max(10000).optional(), + }) + .optional(), +}); + +/** + * Quick transaction validation schema + */ +export const quickTransactionSchema = z.object({ + tokenAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address"), + chainId: z.number().int().positive(), + protocolName: z.string().min(1, "Protocol name is required"), + amount: z.string().min(1, "Amount is required"), + userAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address"), +}); + +/** + * Token search validation schema + */ +export const tokenSearchSchema = z.object({ + query: z.string().min(1, "Search query cannot be empty"), +}); + +/** + * Token info validation schema + */ +export const tokenInfoSchema = z.object({ + token: z.string().min(1, "Token identifier is required"), + chainId: z.string().optional().transform((val) => (val ? parseInt(val) : undefined)), + chainName: z.string().optional(), +}); + +/** + * Protocol discovery validation schema + */ +export const protocolDiscoverySchema = z.object({ + tokenAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid token address"), + chainId: z.number().int().positive().optional(), + multiChain: z.boolean().optional().default(true), +}); + +/** + * Transaction generation validation schema + */ +export const transactionGenerationSchema = z.object({ + userAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid user address"), + tokenAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid token address"), + protocolAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid protocol address"), + protocolName: z.string().min(1, "Protocol name is required"), + chainId: z.number().int().positive(), + amount: z.string().min(1, "Amount is required"), + tokenSymbol: z.string().min(1, "Token symbol is required"), + decimals: z.number().int().min(0).max(18), +}); + diff --git a/agents/Yield-Optimization-Agent/src/common/index.ts b/agents/Yield-Optimization-Agent/src/common/index.ts new file mode 100644 index 00000000..72885f35 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/common/index.ts @@ -0,0 +1,8 @@ +/** + * Common utilities and types + */ + +export * from './types'; +export * from './logger'; +export * from './utils'; + diff --git a/agents/Yield-Optimization-Agent/src/common/logger.ts b/agents/Yield-Optimization-Agent/src/common/logger.ts new file mode 100644 index 00000000..ff23c851 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/common/logger.ts @@ -0,0 +1,26 @@ +/** + * Logger utility for consistent logging across the agent + */ + +export class Logger { + constructor(private name: string) {} + + info(message: string, ...args: unknown[]): void { + console.log(`[${this.name}] INFO: ${message}`, ...args); + } + + error(message: string, ...args: unknown[]): void { + console.error(`[${this.name}] ERROR: ${message}`, ...args); + } + + warn(message: string, ...args: unknown[]): void { + console.warn(`[${this.name}] WARN: ${message}`, ...args); + } + + debug(message: string, ...args: unknown[]): void { + if (process.env.DEBUG === 'true') { + console.debug(`[${this.name}] DEBUG: ${message}`, ...args); + } + } +} + diff --git a/agents/Yield-Optimization-Agent/src/common/types.ts b/agents/Yield-Optimization-Agent/src/common/types.ts new file mode 100644 index 00000000..ee248667 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/common/types.ts @@ -0,0 +1,48 @@ +/** + * Common types used across the yield agent + */ + +export type AgentResponse = { + question: string; + response: unknown; +}; + +export interface ValidationResult { + valid: boolean; + error?: string; + errors?: string[]; + warnings?: string[]; + data?: unknown; + requiredFields?: string[]; + supportedChains?: Array<{ id: number; name: string }>; + suggestion?: string; +} + +export interface SupportedChain { + id: number; + name: string; + chainName: string; +} + +export const SUPPORTED_CHAINS: SupportedChain[] = [ + { id: 1, name: 'Ethereum', chainName: 'ethereum' }, + { id: 42161, name: 'Arbitrum', chainName: 'arbitrum-one' }, + { id: 10, name: 'Optimism', chainName: 'optimistic-ethereum' }, + { id: 137, name: 'Polygon', chainName: 'polygon-pos' }, + { id: 8453, name: 'Base', chainName: 'base' }, + { id: 43114, name: 'Avalanche', chainName: 'avalanche' }, + { id: 56, name: 'BNB Chain', chainName: 'binance-smart-chain' }, +]; + +export function getChainById(chainId: number): SupportedChain | undefined { + return SUPPORTED_CHAINS.find((chain) => chain.id === chainId); +} + +export function getChainByName(chainName: string): SupportedChain | undefined { + return SUPPORTED_CHAINS.find( + (chain) => + chain.chainName.toLowerCase() === chainName.toLowerCase() || + chain.name.toLowerCase() === chainName.toLowerCase(), + ); +} + diff --git a/agents/Yield-Optimization-Agent/src/common/utils.ts b/agents/Yield-Optimization-Agent/src/common/utils.ts new file mode 100644 index 00000000..0fe64e51 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/common/utils.ts @@ -0,0 +1,109 @@ +/** + * Common utility functions + */ + +import { isAddress as viemIsAddress } from 'viem'; + +/** + * Check if a string is a valid Ethereum address + */ +export function isAddress(address: string): boolean { + return viemIsAddress(address); +} + +/** + * Format error message with context + */ +export function formatError(message: string, context?: Record): string { + if (!context || Object.keys(context).length === 0) { + return message; + } + + const contextStr = Object.entries(context) + .map(([key, value]) => `${key}: ${value}`) + .join(', '); + + return `${message} (${contextStr})`; +} + +/** + * Retry a function with exponential backoff + */ +export async function retryWithBackoff( + fn: () => Promise, + maxRetries = 3, + initialDelay = 1000, +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + + if (attempt < maxRetries - 1) { + const delay = initialDelay * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + throw lastError || new Error('Retry failed'); +} + +/** + * Retry a function with exponential backoff, but only for rate limit errors (429) + * Other errors are thrown immediately without retry + */ +export async function retryOnRateLimit( + fn: () => Promise, + maxRetries = 3, + initialDelay = 2000, + logger?: { warn: (msg: string) => void; info: (msg: string) => void }, +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn(); + } catch (error: any) { + lastError = error as Error; + const errorMessage = error?.message || String(error); + const errorStatus = error?.status || error?.statusCode || error?.response?.status; + + // Check for rate limit/quota errors - be more specific + // "quota" errors that say "exceeded your current quota" are usually billing issues, not retryable + const isQuotaExceeded = errorMessage.includes("exceeded your current quota") || + errorMessage.includes("check your plan and billing"); + + // Rate limit errors that are retryable + const isRateLimit = errorStatus === 429 || + errorMessage.includes("rate limit") || + (errorMessage.includes("429") && !isQuotaExceeded); + + if (isQuotaExceeded) { + // Quota exceeded - this is a billing issue, don't retry + logger?.warn(`Quota exceeded (billing issue detected). Attempt ${attempt + 1}/${maxRetries}. Not retrying.`); + throw error; + } + + if (!isRateLimit) { + // Not a rate limit error, throw immediately + throw error; + } + + // Rate limit error - retry if we have attempts left + if (attempt < maxRetries - 1) { + const delay = initialDelay * Math.pow(2, attempt); + logger?.info(`Rate limit detected (attempt ${attempt + 1}/${maxRetries}). Retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } else { + logger?.warn(`Rate limit retries exhausted after ${maxRetries} attempts.`); + } + } + } + + throw lastError || new Error('Retry failed'); +} + diff --git a/agents/Yield-Optimization-Agent/src/index.ts b/agents/Yield-Optimization-Agent/src/index.ts new file mode 100644 index 00000000..79a21f19 --- /dev/null +++ b/agents/Yield-Optimization-Agent/src/index.ts @@ -0,0 +1,48 @@ +/** + * Yield Optimization Agent + * Main entry point for the agent + */ + +import dotenv from "dotenv"; +import { runYieldAgent } from "./agent"; + +// Load environment variables +dotenv.config(); + +/** + * Example usage of the yield agent + */ +async function main(): Promise { + const questions = [ + // Test Quick Mode: All parameters provided (Ethereum has protocols) + "Deposit 100 USDC (0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) on Ethereum to Aave for user 0x2a360629a7332e468b2d30dD0f76e5c41D6cEaA9", + // "Find staking opportunities for USDC", // Scenario 1: Token symbol - should show all chains + // "Find staking opportunities for 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // Scenario 2: Address without chain - should error + // "Find staking opportunities for 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 on Ethereum on morpho", // Scenario 3: Address with chain - should process directly + ]; + + try { + const results = await runYieldAgent(questions, { + modelName: "gpt-4o-mini", + temperature: 0, + }); + + console.log("\n=== Agent Results ===\n"); + results.forEach((result, index) => { + console.log(`Question ${index + 1}: ${result.question}`); + console.log(`Response:`, JSON.stringify(result.response, null, 2)); + console.log("\n---\n"); + }); + } catch (error) { + console.error("Error running agent:", error); + process.exit(1); + } +} + +// Run if executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} + +export { runYieldAgent } from "./agent"; +export * from "./agent"; diff --git a/agents/Yield-Optimization-Agent/test-api.ts b/agents/Yield-Optimization-Agent/test-api.ts new file mode 100644 index 00000000..ad80099e --- /dev/null +++ b/agents/Yield-Optimization-Agent/test-api.ts @@ -0,0 +1,198 @@ +/** + * API Testing Script + * Test the REST API endpoints + */ + +import axios from "axios"; + +const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3000"; + +// Colors for console output +const colors = { + reset: "\x1b[0m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m", +}; + +function log(color: string, message: string) { + console.log(`${color}${message}${colors.reset}`); +} + +async function testHealthCheck() { + log(colors.blue, "\n=== Testing Health Check ==="); + try { + const response = await axios.get(`${API_BASE_URL}/health`); + log(colors.green, "āœ“ Health check passed"); + console.log(JSON.stringify(response.data, null, 2)); + } catch (error: any) { + log(colors.red, `āœ— Health check failed: ${error.message}`); + } +} + +async function testGetChains() { + log(colors.blue, "\n=== Testing Get Supported Chains ==="); + try { + const response = await axios.get(`${API_BASE_URL}/api/v1/chains`); + log(colors.green, `āœ“ Found ${response.data.count} supported chains`); + console.log(JSON.stringify(response.data, null, 2)); + } catch (error: any) { + log(colors.red, `āœ— Get chains failed: ${error.message}`); + } +} + +async function testSearchToken() { + log(colors.blue, "\n=== Testing Token Search ==="); + try { + const response = await axios.get(`${API_BASE_URL}/api/v1/tokens/search`, { + params: { query: "USDC" }, + }); + log(colors.green, `āœ“ Found ${response.data.count} tokens`); + console.log(JSON.stringify(response.data, null, 2)); + } catch (error: any) { + log(colors.red, `āœ— Token search failed: ${error.message}`); + } +} + +async function testGetTokenInfo() { + log(colors.blue, "\n=== Testing Get Token Info ==="); + try { + const response = await axios.get(`${API_BASE_URL}/api/v1/tokens/info`, { + params: { + token: "USDC", + chainId: 1, + }, + }); + log(colors.green, "āœ“ Token info retrieved"); + console.log(JSON.stringify(response.data, null, 2)); + } catch (error: any) { + log(colors.red, `āœ— Get token info failed: ${error.message}`); + } +} + +async function testDiscoverProtocols() { + log(colors.blue, "\n=== Testing Protocol Discovery ==="); + try { + const response = await axios.post( + `${API_BASE_URL}/api/v1/protocols/discover`, + { + tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC on Ethereum + chainId: 1, + multiChain: false, + } + ); + log( + colors.green, + `āœ“ Found ${response.data.count} protocols (${response.data.totalFound} total)` + ); + console.log(JSON.stringify(response.data, null, 2)); + } catch (error: any) { + log(colors.red, `āœ— Protocol discovery failed: ${error.message}`); + if (error.response) { + console.log(JSON.stringify(error.response.data, null, 2)); + } + } +} + +async function testAgentQuery() { + log(colors.blue, "\n=== Testing Agent Query ==="); + try { + const response = await axios.post(`${API_BASE_URL}/api/v1/agent/query`, { + query: "Find staking opportunities for USDC on Ethereum", + options: { + modelName: "gpt-4o-mini", + temperature: 0, + }, + }); + log(colors.green, "āœ“ Agent query successful"); + console.log(JSON.stringify(response.data, null, 2)); + } catch (error: any) { + log(colors.red, `āœ— Agent query failed: ${error.message}`); + if (error.response) { + console.log(JSON.stringify(error.response.data, null, 2)); + } + } +} + +async function testQuickTransaction() { + log(colors.blue, "\n=== Testing Quick Transaction ==="); + try { + const response = await axios.post(`${API_BASE_URL}/api/v1/agent/quick`, { + tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC + chainId: 1, + protocolName: "aave", + amount: "100", + userAddress: "0x2a360629a7332e468b2d30dD0f76e5c41D6cEaA9", + }); + log(colors.green, "āœ“ Quick transaction generated"); + console.log(JSON.stringify(response.data, null, 2)); + } catch (error: any) { + log(colors.red, `āœ— Quick transaction failed: ${error.message}`); + if (error.response) { + console.log(JSON.stringify(error.response.data, null, 2)); + } + } +} + +async function testInvalidRequest() { + log(colors.blue, "\n=== Testing Invalid Request (Error Handling) ==="); + try { + const response = await axios.post(`${API_BASE_URL}/api/v1/agent/query`, { + // Missing required 'query' field + options: {}, + }); + log(colors.yellow, "⚠ Should have failed but didn't"); + } catch (error: any) { + if (error.response && error.response.status === 400) { + log(colors.green, "āœ“ Validation error handled correctly"); + console.log(JSON.stringify(error.response.data, null, 2)); + } else { + log(colors.red, `āœ— Unexpected error: ${error.message}`); + } + } +} + +async function runAllTests() { + log(colors.cyan, "\n╔════════════════════════════════════════╗"); + log(colors.cyan, "ā•‘ Yield Agent API Test Suite ā•‘"); + log(colors.cyan, "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•"); + + log(colors.yellow, `\nTesting API at: ${API_BASE_URL}`); + log( + colors.yellow, + "Make sure the server is running: yarn start:api\n" + ); + + // Run tests + await testHealthCheck(); + await testGetChains(); + await testSearchToken(); + await testGetTokenInfo(); + await testDiscoverProtocols(); + await testInvalidRequest(); + + // These tests require OpenAI API and may take longer + log(colors.yellow, "\nāš ļø The following tests require API keys and may take longer..."); + + const shouldRunLongTests = process.argv.includes("--full"); + + if (shouldRunLongTests) { + await testAgentQuery(); + await testQuickTransaction(); + } else { + log(colors.cyan, "\nSkipping long-running tests. Use --full flag to run all tests."); + } + + log(colors.cyan, "\n╔════════════════════════════════════════╗"); + log(colors.cyan, "ā•‘ Test Suite Complete! ā•‘"); + log(colors.cyan, "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\n"); +} + +// Run tests +runAllTests().catch((error) => { + log(colors.red, `\nāœ— Test suite failed: ${error.message}`); + process.exit(1); +}); + diff --git a/agents/Yield-Optimization-Agent/tsconfig.json b/agents/Yield-Optimization-Agent/tsconfig.json new file mode 100644 index 00000000..156f6f0f --- /dev/null +++ b/agents/Yield-Optimization-Agent/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "ESNext", + "lib": ["ES2022", "DOM"], + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "importHelpers": true, + "alwaysStrict": true, + "sourceMap": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitAny": false, + "noImplicitThis": false, + "strictNullChecks": false, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "build" + }, + "include": ["src"] +} +