diff --git a/packages/bot/src/adapters/discord.ts b/packages/bot/src/adapters/discord.ts index 57adb559..9eb424ca 100644 --- a/packages/bot/src/adapters/discord.ts +++ b/packages/bot/src/adapters/discord.ts @@ -6,6 +6,7 @@ export class DiscordAdapter { private client: Client; private userChannels: Map = new Map(); // userId -> channelId private token: string; + private backendUrl: string = process.env.BACKEND_URL || 'http://localhost:2333'; constructor(token: string) { this.token = token; @@ -38,13 +39,19 @@ export class DiscordAdapter { ); } + if (message.content === "!help") { + await message.reply( + "**Commands:**\n`!start`, `!help`, `!sponsor`, `!trustline`, `!amm`" + ); + } + if (message.content === "!sponsor") { const userId = message.author.id; await message.reply("⏳ Requesting account sponsorship..."); try { const response = await fetch( - `${BACKEND_URL}/api/account/${userId}/sponsor`, + `${this.backendUrl}/api/account/${userId}/sponsor`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -99,6 +106,130 @@ export class DiscordAdapter { await message.reply(`❌ Error: ${error instanceof Error ? error.message : String(error)}`); } } + + if (message.content.startsWith('!amm')) { + const args = message.content.split(' ').slice(1); + if (args.length < 1) { + let helpMsg = '🔍 **AMM Explorer**\n\n'; + helpMsg += 'Use this command to search for liquidity pools and view their metrics.\n\n'; + helpMsg += '**Usage:**\n'; + helpMsg += '• `!amm search `\n'; + helpMsg += '• `!amm stats `\n\n'; + helpMsg += '**Examples:**\n'; + helpMsg += '• `!amm search XLM USDC`\n'; + helpMsg += '• `!amm search XLM yXLM:GBSH...`\n'; + helpMsg += '• `!amm stats 65f...a1b`'; + + return message.reply(helpMsg); + } + + const subCommand = args[0].toLowerCase(); + const horizonUrl = process.env.STELLAR_HORIZON_URL || 'https://horizon.stellar.org'; + + try { + if (subCommand === 'search') { + if (args.length < 3) { + return message.reply('❌ Usage: `!amm search `'); + } + const assetA = args[1]; + const assetB = args[2]; + await message.reply(`🔍 Searching for **${assetA}/${assetB}** pools...`); + + const formatAsset = (a: string) => { + const upper = a.toUpperCase(); + if (upper === 'XLM' || upper === 'NATIVE') return 'native'; + if (a.includes(':')) { + const [code, issuer] = a.split(':'); + return `${code.toUpperCase()}:${issuer}`; + } + return a; + }; + + const response = await fetch(`${horizonUrl}/liquidity_pools?assets=${formatAsset(assetA)},${formatAsset(assetB)}`); + const data = await response.json() as any; + const pools = data._embedded?.records || []; + + if (pools.length === 0) { + return message.reply(`❌ No liquidity pools found for **${assetA}/${assetB}**.`); + } + + let responseMsg = `✅ **Found ${pools.length} pool(s) for ${assetA}/${assetB}:**\n\n`; + pools.slice(0, 5).forEach((p: any) => { + const resA = p.reserves[0]; + const resB = p.reserves[1]; + const shortId = `${p.id.slice(0, 8)}...${p.id.slice(-8)}`; + + // APR calculation + const reserveA = parseFloat(resA.amount); + const reserveB = parseFloat(resB.amount); + const volumeEntry = p.volume ? p.volume[Object.keys(p.volume)[0]] : null; + const volume24h = volumeEntry ? parseFloat(volumeEntry.base_volume) + parseFloat(volumeEntry.counter_volume) : 0; + const totalLiquidity = reserveA + reserveB; + const apr = totalLiquidity > 0 ? ((volume24h * 0.003 * 365) / totalLiquidity * 100).toFixed(2) : '0.00'; + + responseMsg += `🔹 **Pool** \`${shortId}\`\n`; + responseMsg += `💰 **Reserves:**\n`; + responseMsg += ` • ${reserveA.toLocaleString()} ${resA.asset.split(':')[0]}\n`; + responseMsg += ` • ${reserveB.toLocaleString()} ${resB.asset.split(':')[0]}\n`; + responseMsg += `📊 **Fee:** ${(p.fee_bp / 100).toFixed(2)}% | **APR:** ${apr}%\n`; + responseMsg += `🔗 \`!amm stats ${p.id}\`\n\n`; + }); + + if (pools.length > 5) { + responseMsg += `*...and ${pools.length - 5} more.*`; + } + + await message.reply(responseMsg); + } else if (subCommand === 'stats') { + if (args.length < 2) { + return message.reply('❌ Usage: `!amm stats `'); + } + const poolId = args[1]; + await message.reply(`📊 Fetching stats for pool \`${poolId.slice(0, 8)}...\``); + + const response = await fetch(`${horizonUrl}/liquidity_pools/${poolId}`); + if (response.status === 404) { + return message.reply('❌ Liquidity pool not found.'); + } + const p = await response.json() as any; + + const resA = p.reserves[0]; + const resB = p.reserves[1]; + + // APR calculation + const reserveA = parseFloat(resA.amount); + const reserveB = parseFloat(resB.amount); + const volumeEntry = p.volume ? p.volume[Object.keys(p.volume)[0]] : null; + const volume24h = volumeEntry ? parseFloat(volumeEntry.base_volume) + parseFloat(volumeEntry.counter_volume) : 0; + const totalLiquidity = reserveA + reserveB; + const apr = totalLiquidity > 0 ? ((volume24h * 0.003 * 365) / totalLiquidity * 100).toFixed(2) : '0.00'; + + let responseMsg = `📊 **Liquidity Pool Metrics**\n\n`; + responseMsg += `🆔 **ID:** \`${p.id}\`\n\n`; + + responseMsg += `**Assets:**\n`; + responseMsg += `• ${resA.asset}\n`; + responseMsg += `• ${resB.asset}\n\n`; + + responseMsg += `**Reserves:**\n`; + responseMsg += `• **${reserveA.toLocaleString()}** ${resA.asset.split(':')[0]}\n`; + responseMsg += `• **${reserveB.toLocaleString()}** ${resB.asset.split(':')[0]}\n\n`; + + responseMsg += `**Statistics:**\n`; + responseMsg += `• **Shares:** ${parseFloat(p.total_shares).toLocaleString()}\n`; + responseMsg += `• **Trustlines:** ${p.total_trustlines}\n`; + responseMsg += `• **Fee:** ${(p.fee_bp / 100).toFixed(2)}%\n`; + responseMsg += `• **APR:** ${apr}%\n`; + + await message.reply(responseMsg); + } else { + await message.reply('❓ Unknown subcommand. Use `search` or `stats`.'); + } + } catch (error) { + console.error('AMM Explorer error:', error); + await message.reply(`❌ **Error:** ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } }); await this.client.login(token); diff --git a/packages/bot/src/adapters/telegram.ts b/packages/bot/src/adapters/telegram.ts index 27ac4804..8742d51e 100644 --- a/packages/bot/src/adapters/telegram.ts +++ b/packages/bot/src/adapters/telegram.ts @@ -20,7 +20,131 @@ export class TelegramAdapter { this.bot = new Telegraf(this.token); this.bot.start((ctx) => ctx.reply('Welcome to Chen Pilot! I am your AI-powered Stellar DeFi assistant.')); - this.bot.help((ctx) => ctx.reply('Commands: /start, /balance, /swap, /trustline')); + this.bot.help((ctx) => ctx.reply('Commands: /start, /balance, /swap, /trustline, /amm')); + + this.bot.command('amm', async (ctx) => { + const args = ctx.message.text.split(' ').slice(1); + if (args.length < 1) { + let helpMsg = '🔍 AMM Explorer\n\n'; + helpMsg += 'Use this command to search for liquidity pools and view their metrics.\n\n'; + helpMsg += 'Usage:\n'; + helpMsg += '• /amm search <assetA> <assetB>\n'; + helpMsg += '• /amm stats <poolId>\n\n'; + helpMsg += 'Examples:\n'; + helpMsg += '• /amm search XLM USDC\n'; + helpMsg += '• /amm search XLM yXLM:GBSH...\n'; + helpMsg += '• /amm stats 65f...a1b'; + + return ctx.reply(helpMsg, { parse_mode: 'HTML' }); + } + + const subCommand = args[0].toLowerCase(); + const horizonUrl = process.env.STELLAR_HORIZON_URL || 'https://horizon.stellar.org'; + + try { + if (subCommand === 'search') { + if (args.length < 3) { + return ctx.reply('❌ Usage: /amm search <assetA> <assetB>', { parse_mode: 'HTML' }); + } + const assetA = args[1]; + const assetB = args[2]; + const loadingMsg = await ctx.reply(`🔍 Searching for ${assetA}/${assetB} pools...`, { parse_mode: 'HTML' }); + + const formatAsset = (a: string) => { + const upper = a.toUpperCase(); + if (upper === 'XLM' || upper === 'NATIVE') return 'native'; + if (a.includes(':')) { + const [code, issuer] = a.split(':'); + return `${code.toUpperCase()}:${issuer}`; + } + return a; + }; + + const response = await fetch(`${horizonUrl}/liquidity_pools?assets=${formatAsset(assetA)},${formatAsset(assetB)}`); + const data = await response.json() as any; + const pools = data._embedded?.records || []; + + if (pools.length === 0) { + return ctx.reply(`❌ No liquidity pools found for ${assetA}/${assetB}.`, { parse_mode: 'HTML' }); + } + + let message = `✅ Found ${pools.length} pool(s) for ${assetA}/${assetB}:\n\n`; + pools.slice(0, 5).forEach((p: any) => { + const resA = p.reserves[0]; + const resB = p.reserves[1]; + const shortId = `${p.id.slice(0, 8)}...${p.id.slice(-8)}`; + + // APR calculation + const reserveA = parseFloat(resA.amount); + const reserveB = parseFloat(resB.amount); + const volumeEntry = p.volume ? p.volume[Object.keys(p.volume)[0]] : null; + const volume24h = volumeEntry ? parseFloat(volumeEntry.base_volume) + parseFloat(volumeEntry.counter_volume) : 0; + const totalLiquidity = reserveA + reserveB; + const apr = totalLiquidity > 0 ? ((volume24h * 0.003 * 365) / totalLiquidity * 100).toFixed(2) : '0.00'; + + message += `🔹 Pool ${shortId}\n`; + message += `💰 Reserves:\n`; + message += ` • ${reserveA.toLocaleString()} ${resA.asset.split(':')[0]}\n`; + message += ` • ${reserveB.toLocaleString()} ${resB.asset.split(':')[0]}\n`; + message += `📊 Fee: ${(p.fee_bp / 100).toFixed(2)}% | APR: ${apr}%\n`; + message += `🔗 /amm stats ${p.id}\n\n`; + }); + + if (pools.length > 5) { + message += `...and ${pools.length - 5} more.`; + } + + await ctx.reply(message, { parse_mode: 'HTML' }); + } else if (subCommand === 'stats') { + if (args.length < 2) { + return ctx.reply('❌ Usage: /amm stats <poolId>', { parse_mode: 'HTML' }); + } + const poolId = args[1]; + await ctx.reply(`📊 Fetching stats for pool ${poolId.slice(0, 8)}...`, { parse_mode: 'HTML' }); + + const response = await fetch(`${horizonUrl}/liquidity_pools/${poolId}`); + if (response.status === 404) { + return ctx.reply('❌ Liquidity pool not found.'); + } + const p = await response.json() as any; + + const resA = p.reserves[0]; + const resB = p.reserves[1]; + + // APR calculation + const reserveA = parseFloat(resA.amount); + const reserveB = parseFloat(resB.amount); + const volumeEntry = p.volume ? p.volume[Object.keys(p.volume)[0]] : null; + const volume24h = volumeEntry ? parseFloat(volumeEntry.base_volume) + parseFloat(volumeEntry.counter_volume) : 0; + const totalLiquidity = reserveA + reserveB; + const apr = totalLiquidity > 0 ? ((volume24h * 0.003 * 365) / totalLiquidity * 100).toFixed(2) : '0.00'; + + let message = `📊 Liquidity Pool Metrics\n\n`; + message += `🆔 ID: ${p.id}\n\n`; + + message += `Assets:\n`; + message += `• ${resA.asset}\n`; + message += `• ${resB.asset}\n\n`; + + message += `Reserves:\n`; + message += `• ${reserveA.toLocaleString()} ${resA.asset.split(':')[0]}\n`; + message += `• ${reserveB.toLocaleString()} ${resB.asset.split(':')[0]}\n\n`; + + message += `Statistics:\n`; + message += `• Shares: ${parseFloat(p.total_shares).toLocaleString()}\n`; + message += `• Trustlines: ${p.total_trustlines}\n`; + message += `• Fee: ${(p.fee_bp / 100).toFixed(2)}%\n`; + message += `• APR: ${apr}%\n`; + + await ctx.reply(message, { parse_mode: 'HTML' }); + } else { + await ctx.reply('❓ Unknown subcommand. Use search or stats.', { parse_mode: 'HTML' }); + } + } catch (error) { + console.error('AMM Explorer error:', error); + await ctx.reply(`❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`, { parse_mode: 'HTML' }); + } + }); this.bot.command('trustline', async (ctx) => { const args = ctx.message.text.split(' ').slice(1); diff --git a/src/Agents/registry/ToolAutoDiscovery.ts b/src/Agents/registry/ToolAutoDiscovery.ts index f744ca01..d48f897c 100644 --- a/src/Agents/registry/ToolAutoDiscovery.ts +++ b/src/Agents/registry/ToolAutoDiscovery.ts @@ -50,6 +50,9 @@ export class ToolAutoDiscovery { const { strategyRegistryTool } = await import("../tools/strategyRegistry"); toolRegistry.register(strategyRegistryTool); + const { ammExplorerTool } = await import("../tools/ammExplorer"); + toolRegistry.register(ammExplorerTool); + // todo // await this.discoverToolsFromDirectory(); diff --git a/src/Agents/tools/ammExplorer.ts b/src/Agents/tools/ammExplorer.ts new file mode 100644 index 00000000..c6d3ff56 --- /dev/null +++ b/src/Agents/tools/ammExplorer.ts @@ -0,0 +1,192 @@ +import { BaseTool } from "./base/BaseTool"; +import { ToolMetadata, ToolResult } from "../registry/ToolMetadata"; +import config from "../../config/config"; +import logger from "../../config/logger"; +import { horizonProxyService } from "../../Gateway/horizonProxy.service"; + +interface AmmExplorerPayload extends Record { + operation: "get_stats" | "search_pools"; + poolId?: string; + assetA?: string; + assetB?: string; +} + +interface HorizonPoolRecord { + id: string; + reserves: Array<{ asset: string; amount: string }>; + total_shares: string; + total_trustlines: string; + fee_bp: number; + volume?: { [key: string]: { base_volume: string; counter_volume: string } }; +} + +const POOL_ID_REGEX = /^[0-9a-f]{64}$/i; +const FEE_PERCENTAGE = 0.003; // 0.30% standard Stellar AMM fee + +export class AmmExplorerTool extends BaseTool { + metadata: ToolMetadata = { + name: "amm_explorer", + description: + "Search for Stellar AMM liquidity pools and view their latest metrics like reserves, volume, and APR", + parameters: { + operation: { + type: "string", + description: "The operation to perform: 'get_stats' or 'search_pools'", + required: true, + enum: ["get_stats", "search_pools"], + }, + poolId: { + type: "string", + description: "64-character hexadecimal Stellar AMM liquidity pool ID (required for 'get_stats')", + required: false, + pattern: "^[0-9a-f]{64}$", + }, + assetA: { + type: "string", + description: "First asset in the pair (e.g., 'XLM' or 'USDC:GA...') (required for 'search_pools')", + required: false, + }, + assetB: { + type: "string", + description: "Second asset in the pair (e.g., 'XLM' or 'USDC:GA...') (required for 'search_pools')", + required: false, + }, + }, + examples: [ + "Search for XLM/USDC liquidity pools", + "Get metrics for pool abc123...", + "Explore AMM pools for native XLM and yXLM", + ], + category: "stellar", + version: "1.0.0", + }; + + validate(payload: AmmExplorerPayload): { valid: boolean; errors: string[] } { + const baseValidation = super.validate ? super.validate(payload) : { valid: true, errors: [] }; + const errors = [...(baseValidation.errors || [])]; + + if (payload.operation === "get_stats" && !payload.poolId) { + errors.push("poolId is required for get_stats operation"); + } else if (payload.operation === "search_pools" && (!payload.assetA || !payload.assetB)) { + errors.push("Both assetA and assetB are required for search_pools operation"); + } + + return { + valid: errors.length === 0, + errors, + }; + } + + async execute(payload: AmmExplorerPayload, userId: string): Promise { + const validation = this.validate(payload); + if (!validation.valid) { + return this.createErrorResult("amm_explorer", validation.errors.join(", ")); + } + + try { + switch (payload.operation) { + case "get_stats": + return await this.getStats(payload.poolId); + case "search_pools": + return await this.searchPools(payload.assetA, payload.assetB); + default: + return this.createErrorResult("amm_explorer", `Unknown operation: ${payload.operation}`); + } + } catch (error) { + logger.error("AmmExplorerTool error", { payload, error }); + return this.createErrorResult( + "amm_explorer", + error instanceof Error ? error.message : "Unknown error" + ); + } + } + + private async getStats(poolId?: string): Promise { + try { + const path = `/liquidity_pools/${poolId}`; + const pool = (await horizonProxyService.proxyGet(path, {})) as HorizonPoolRecord; + return this.createSuccessResult("amm_explorer", this.formatPoolData(pool)); + } catch (error) { + if (error instanceof Error && error.message.includes("404")) { + return this.createErrorResult("amm_explorer", "Liquidity pool not found."); + } + throw error; + } + } + + private async searchPools(assetA?: string, assetB?: string): Promise { + const formattedA = this.formatAsset(assetA!); + const formattedB = this.formatAsset(assetB!); + + const path = "/liquidity_pools"; + const data = (await horizonProxyService.proxyGet(path, { + assets: `${formattedA},${formattedB}`, + })) as any; + + const pools = data._embedded.records as HorizonPoolRecord[]; + + if (pools.length === 0) { + return this.createSuccessResult("amm_explorer", { + message: `No liquidity pools found for ${assetA}/${assetB}`, + pools: [], + }); + } + + return this.createSuccessResult("amm_explorer", { + message: `Found ${pools.length} liquidity pool(s) for ${assetA}/${assetB}`, + pools: pools.map((p) => this.formatPoolData(p)), + }); + } + + private formatAsset(assetStr: string): string { + const upper = assetStr.toUpperCase(); + if (upper === "XLM" || upper === "NATIVE") { + return "native"; + } + // If it's already in CODE:ISSUER format, return as is (but uppercase code) + if (assetStr.includes(":")) { + const [code, issuer] = assetStr.split(":"); + return `${code.toUpperCase()}:${issuer}`; + } + return assetStr; // Return as is, might be just the code (Horizon might not like it without issuer) + } + + private formatPoolData(pool: HorizonPoolRecord) { + const reserveA = parseFloat(pool.reserves[0]?.amount ?? "0"); + const reserveB = parseFloat(pool.reserves[1]?.amount ?? "0"); + const assetA = pool.reserves[0]?.asset ?? "unknown"; + const assetB = pool.reserves[1]?.asset ?? "unknown"; + + const volumeEntry = pool.volume + ? (pool.volume as Record)[ + Object.keys(pool.volume)[0] + ] + : null; + + const volume24h = volumeEntry + ? parseFloat(volumeEntry.base_volume) + parseFloat(volumeEntry.counter_volume) + : 0; + + const totalLiquidity = reserveA + reserveB; + const apr = + totalLiquidity > 0 + ? parseFloat(((volume24h * FEE_PERCENTAGE * 365) / totalLiquidity * 100).toFixed(2)) + : 0; + + return { + poolId: pool.id, + assetA, + assetB, + reserveA: parseFloat(reserveA.toFixed(7)), + reserveB: parseFloat(reserveB.toFixed(7)), + totalShares: pool.total_shares, + totalTrustlines: pool.total_trustlines, + fee: `${(pool.fee_bp / 100).toFixed(2)}%`, + volume24h: parseFloat(volume24h.toFixed(7)), + apr, + timestamp: new Date().toISOString(), + }; + } +} + +export const ammExplorerTool = new AmmExplorerTool(); diff --git a/src/Agents/tools/base/BaseTool.ts b/src/Agents/tools/base/BaseTool.ts index 29160a8f..e01a30a8 100644 --- a/src/Agents/tools/base/BaseTool.ts +++ b/src/Agents/tools/base/BaseTool.ts @@ -38,6 +38,25 @@ export abstract class BaseTool }, got ${typeof value}` ); } + + // enum validation + if (paramDef.enum && typeof value === "string" && !paramDef.enum.includes(value)) { + errors.push( + `Invalid value for parameter '${paramName}': must be one of ${paramDef.enum.join( + ", " + )}` + ); + } + + // pattern validation + if (paramDef.pattern && typeof value === "string") { + const regex = new RegExp(paramDef.pattern); + if (!regex.test(value)) { + errors.push( + `Invalid format for parameter '${paramName}': must match pattern ${paramDef.pattern}` + ); + } + } } }); diff --git a/tests/unit/ammExplorerTool.test.ts b/tests/unit/ammExplorerTool.test.ts new file mode 100644 index 00000000..3ad88907 --- /dev/null +++ b/tests/unit/ammExplorerTool.test.ts @@ -0,0 +1,80 @@ +import { ammExplorerTool } from "../../src/Agents/tools/ammExplorer"; +import { horizonProxyService } from "../../src/Gateway/horizonProxy.service"; + +// Mock horizonProxyService +jest.mock("../../src/Gateway/horizonProxy.service", () => ({ + horizonProxyService: { + proxyGet: jest.fn(), + }, +})); + +describe("AmmExplorerTool", () => { + beforeEach(() => { + (horizonProxyService.proxyGet as jest.Mock).mockReset(); + }); + + it("search_pools: should return success result with pools", async () => { + const mockPools = { + _embedded: { + records: [ + { + id: "pool1", + reserves: [ + { asset: "native", amount: "100.0000000" }, + { asset: "USDC:GABC", amount: "10.0000000" } + ], + total_shares: "50", + total_trustlines: "10", + fee_bp: 30 + } + ] + } + }; + + (horizonProxyService.proxyGet as jest.Mock).mockResolvedValue(mockPools); + + const result = await ammExplorerTool.execute({ + operation: "search_pools", + assetA: "XLM", + assetB: "USDC:GABC" + }, "user123"); + + expect(result.status).toBe("success"); + expect(result.data.pools).toHaveLength(1); + expect(result.data.pools[0].poolId).toBe("pool1"); + expect(result.data.pools[0].assetA).toBe("native"); + expect(horizonProxyService.proxyGet).toHaveBeenCalledWith("/liquidity_pools", expect.any(Object)); + }); + + it("get_stats: should return pool details", async () => { + const mockPool = { + id: "0".repeat(64), + reserves: [ + { asset: "native", amount: "100.0000000" }, + { asset: "USDC:GABC", amount: "10.0000000" } + ], + total_shares: "50", + total_trustlines: "10", + fee_bp: 30 + }; + const poolId = "0".repeat(64); + + (horizonProxyService.proxyGet as jest.Mock).mockResolvedValue(mockPool); + + const result = await ammExplorerTool.execute({ + operation: "get_stats", + poolId: poolId + }, "user123"); + + expect(result.status).toBe("success"); + expect(result.data.poolId).toBe(mockPool.id); + expect(result.data.reserveA).toBe(100); + expect(result.data.fee).toBe("0.30%"); + expect(horizonProxyService.proxyGet).toHaveBeenCalledWith(`/liquidity_pools/${poolId}`, {}); + }); + + it("should return error if operation is missing", async () => { + const result = await ammExplorerTool.execute({} as any, "user123"); + expect(result.status).toBe("error"); + }); +});