From 4d5eb8a91b15c9905a485ad412e7f9089b367c63 Mon Sep 17 00:00:00 2001 From: "J. Brandon Johnson" Date: Mon, 23 Dec 2024 16:15:52 -0800 Subject: [PATCH 1/5] feat: add birdeye plugin --- agent/src/index.ts | 10 +- packages/plugin-birdeye/.npmignore | 6 + packages/plugin-birdeye/README.md | 77 ++++ packages/plugin-birdeye/eslint.config.mjs | 3 + packages/plugin-birdeye/package.json | 31 ++ packages/plugin-birdeye/src/index.ts | 57 +++ .../src/providers/__tests__/utils.test.ts | 145 +++++++ .../defi/__tests__/price-provider.test.ts | 218 ++++++++++ .../src/providers/defi/index.ts | 12 + .../src/providers/defi/networks-provider.ts | 140 +++++++ .../defi/ohlcv-base-quote-provider.ts | 244 +++++++++++ .../src/providers/defi/ohlcv-pair-provider.ts | 210 ++++++++++ .../src/providers/defi/ohlcv-provider.ts | 256 ++++++++++++ .../providers/defi/pair-trades-provider.ts | 245 +++++++++++ .../defi/pair-trades-seek-provider.ts | 266 ++++++++++++ .../providers/defi/price-history-provider.ts | 230 +++++++++++ .../providers/defi/price-multiple-provider.ts | 200 +++++++++ .../src/providers/defi/price-provider.ts | 175 ++++++++ .../providers/defi/price-volume-provider.ts | 234 +++++++++++ .../providers/defi/token-trades-provider.ts | 236 +++++++++++ .../providers/defi/trades-seek-provider.ts | 210 ++++++++++ .../plugin-birdeye/src/providers/index.ts | 148 +++++++ .../src/providers/pair/index.ts | 1 + .../providers/pair/pair-overview-provider.ts | 286 +++++++++++++ .../src/providers/search/index.ts | 1 + .../search/token-market-data-provider.ts | 214 ++++++++++ .../__tests__/token-overview-provider.test.ts | 189 +++++++++ .../token/all-market-list-provider.ts | 114 ++++++ .../src/providers/token/index.ts | 13 + .../providers/token/new-listing-provider.ts | 113 ++++++ .../token/token-creation-provider.ts | 199 +++++++++ .../providers/token/token-holder-provider.ts | 220 ++++++++++ .../providers/token/token-list-provider.ts | 198 +++++++++ .../providers/token/token-market-provider.ts | 217 ++++++++++ .../token/token-metadata-provider.ts | 197 +++++++++ .../token/token-mint-burn-provider.ts | 203 ++++++++++ .../token/token-overview-provider.ts | 266 ++++++++++++ .../token/token-security-provider.ts | 238 +++++++++++ .../providers/token/token-trade-provider.ts | 327 +++++++++++++++ .../providers/token/top-traders-provider.ts | 104 +++++ .../token/trending-tokens-provider.ts | 270 +++++++++++++ .../trader/gainers-losers-provider.ts | 228 +++++++++++ .../src/providers/trader/index.ts | 2 + .../providers/trader/trades-seek-provider.ts | 247 ++++++++++++ .../plugin-birdeye/src/providers/utils.ts | 298 ++++++++++++++ .../src/providers/wallet/index.ts | 6 + .../wallet/portfolio-multichain-provider.ts | 159 ++++++++ .../wallet/supported-networks-provider.ts | 131 ++++++ .../wallet/token-balance-provider.ts | 135 +++++++ ...transaction-history-multichain-provider.ts | 174 ++++++++ .../wallet/transaction-history-provider.ts | 381 ++++++++++++++++++ .../wallet/wallet-portfolio-provider.ts | 335 +++++++++++++++ packages/plugin-birdeye/tsconfig.json | 10 + packages/plugin-birdeye/tsup.config.ts | 29 ++ 54 files changed, 8855 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-birdeye/.npmignore create mode 100644 packages/plugin-birdeye/README.md create mode 100644 packages/plugin-birdeye/eslint.config.mjs create mode 100644 packages/plugin-birdeye/package.json create mode 100644 packages/plugin-birdeye/src/index.ts create mode 100644 packages/plugin-birdeye/src/providers/__tests__/utils.test.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/__tests__/price-provider.test.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/index.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/networks-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/ohlcv-base-quote-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/ohlcv-pair-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/ohlcv-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/pair-trades-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/pair-trades-seek-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/price-history-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/price-multiple-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/price-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/price-volume-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/token-trades-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/defi/trades-seek-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/index.ts create mode 100644 packages/plugin-birdeye/src/providers/pair/index.ts create mode 100644 packages/plugin-birdeye/src/providers/pair/pair-overview-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/search/index.ts create mode 100644 packages/plugin-birdeye/src/providers/search/token-market-data-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/__tests__/token-overview-provider.test.ts create mode 100644 packages/plugin-birdeye/src/providers/token/all-market-list-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/index.ts create mode 100644 packages/plugin-birdeye/src/providers/token/new-listing-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/token-creation-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/token-holder-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/token-list-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/token-market-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/token-metadata-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/token-mint-burn-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/token-overview-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/token-security-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/token-trade-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/top-traders-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/token/trending-tokens-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/trader/gainers-losers-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/trader/index.ts create mode 100644 packages/plugin-birdeye/src/providers/trader/trades-seek-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/utils.ts create mode 100644 packages/plugin-birdeye/src/providers/wallet/index.ts create mode 100644 packages/plugin-birdeye/src/providers/wallet/portfolio-multichain-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/wallet/supported-networks-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/wallet/token-balance-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/wallet/transaction-history-multichain-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/wallet/transaction-history-provider.ts create mode 100644 packages/plugin-birdeye/src/providers/wallet/wallet-portfolio-provider.ts create mode 100644 packages/plugin-birdeye/tsconfig.json create mode 100644 packages/plugin-birdeye/tsup.config.ts diff --git a/agent/src/index.ts b/agent/src/index.ts index 1e49bae84f..11419d5790 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -1,4 +1,5 @@ import { PostgresDatabaseAdapter } from "@elizaos/adapter-postgres"; +import { RedisClient } from "@elizaos/adapter-redis"; import { SqliteDatabaseAdapter } from "@elizaos/adapter-sqlite"; import { AutoClientInterface } from "@elizaos/client-auto"; import { DiscordClientInterface } from "@elizaos/client-discord"; @@ -10,6 +11,7 @@ import { TwitterClientInterface } from "@elizaos/client-twitter"; import { AgentRuntime, CacheManager, + CacheStore, Character, Clients, DbCacheAdapter, @@ -24,15 +26,14 @@ import { settings, stringToUuid, validateCharacterConfig, - CacheStore, } from "@elizaos/core"; -import { RedisClient } from "@elizaos/adapter-redis"; import { zgPlugin } from "@elizaos/plugin-0g"; import { bootstrapPlugin } from "@elizaos/plugin-bootstrap"; import createGoatPlugin from "@elizaos/plugin-goat"; // import { intifacePlugin } from "@elizaos/plugin-intiface"; import { DirectClient } from "@elizaos/client-direct"; import { aptosPlugin } from "@elizaos/plugin-aptos"; +import { birdeyePlugin } from "@elizaos/plugin-birdeye"; import { advancedTradePlugin, coinbaseCommercePlugin, @@ -43,7 +44,6 @@ import { } from "@elizaos/plugin-coinbase"; import { confluxPlugin } from "@elizaos/plugin-conflux"; import { evmPlugin } from "@elizaos/plugin-evm"; -import { storyPlugin } from "@elizaos/plugin-story"; import { flowPlugin } from "@elizaos/plugin-flow"; import { imageGenerationPlugin } from "@elizaos/plugin-image-generation"; import { multiversxPlugin } from "@elizaos/plugin-multiversx"; @@ -51,6 +51,7 @@ import { nearPlugin } from "@elizaos/plugin-near"; import { nftGenerationPlugin } from "@elizaos/plugin-nft-generation"; import { createNodePlugin } from "@elizaos/plugin-node"; import { solanaPlugin } from "@elizaos/plugin-solana"; +import { storyPlugin } from "@elizaos/plugin-story"; import { suiPlugin } from "@elizaos/plugin-sui"; import { TEEMode, teePlugin } from "@elizaos/plugin-tee"; import { tonPlugin } from "@elizaos/plugin-ton"; @@ -508,6 +509,9 @@ export async function createAgent( ? confluxPlugin : null, nodePlugin, + getSecret(character, "BIRDEYE_API_KEY") + ? birdeyePlugin + : null, getSecret(character, "SOLANA_PUBLIC_KEY") || (getSecret(character, "WALLET_PUBLIC_KEY") && !getSecret(character, "WALLET_PUBLIC_KEY")?.startsWith("0x")) diff --git a/packages/plugin-birdeye/.npmignore b/packages/plugin-birdeye/.npmignore new file mode 100644 index 0000000000..078562ecea --- /dev/null +++ b/packages/plugin-birdeye/.npmignore @@ -0,0 +1,6 @@ +* + +!dist/** +!package.json +!readme.md +!tsup.config.ts \ No newline at end of file diff --git a/packages/plugin-birdeye/README.md b/packages/plugin-birdeye/README.md new file mode 100644 index 0000000000..3da190d956 --- /dev/null +++ b/packages/plugin-birdeye/README.md @@ -0,0 +1,77 @@ +# Eliza Birdeye Plugin + +A powerful plugin for Eliza that integrates with Birdeye's comprehensive DeFi and token analytics API. This plugin provides real-time access to blockchain data, token metrics, and DeFi analytics across multiple networks. + +## Features + +- **DeFi Analytics** + + - Real-time price and trading data + - Historical price tracking + - OHLCV (Open, High, Low, Close, Volume) data + - Trade analysis for tokens and pairs + +- **Token Intelligence** + + - Comprehensive token metadata + - Security information + - Token holder analytics + - Mint and burn tracking + - Market trends and new listings + +- **Wallet Analysis** + + - Multi-chain portfolio tracking + - Token balance monitoring + - Transaction history analysis + - Cross-chain analytics + +- **Market Research** + - Gainers and losers tracking + - Trending tokens + - Top trader analysis + - Market pair analytics + +## Installation + +```bash +npm install @eliza/plugin-birdeye +``` + +## Configuration + +Add the following to your Eliza configuration: + +```typescript +import { BirdeyePlugin } from "@eliza/plugin-birdeye"; + +export default { + plugins: [ + new BirdeyePlugin({ + apiKey: "YOUR_BIRDEYE_API_KEY", + }), + ], +}; +``` + +## Environment Variables + +``` +BIRDEYE_API_KEY=your_api_key_here +``` + +## Usage + +Once configured, the plugin provides access to Birdeye data through Eliza's interface. + +## API Reference + +The plugin provides access to all Birdeye API endpoints through structured interfaces. For detailed API documentation, visit [Birdeye's API Documentation](https://public-api.birdeye.so). + +## License + +MIT + +## Contributing + +Contributions are welcome! Please read our contributing guidelines before submitting pull requests. diff --git a/packages/plugin-birdeye/eslint.config.mjs b/packages/plugin-birdeye/eslint.config.mjs new file mode 100644 index 0000000000..92fe5bbebe --- /dev/null +++ b/packages/plugin-birdeye/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintGlobalConfig from "../../eslint.config.mjs"; + +export default [...eslintGlobalConfig]; diff --git a/packages/plugin-birdeye/package.json b/packages/plugin-birdeye/package.json new file mode 100644 index 0000000000..4e4edb3fb9 --- /dev/null +++ b/packages/plugin-birdeye/package.json @@ -0,0 +1,31 @@ +{ + "name": "@elizaos/plugin-birdeye", + "version": "0.1.7-alpha.1", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "dependencies": { + "@elizaos/core": "workspace:*", + "@coral-xyz/anchor": "0.30.1", + "@solana/spl-token": "0.4.9", + "@solana/web3.js": "1.95.8", + "bignumber": "1.1.0", + "bignumber.js": "9.1.2", + "bs58": "6.0.0", + "fomo-sdk-solana": "1.3.2", + "node-cache": "5.1.2", + "pumpdotfun-sdk": "1.3.2", + "tsup": "8.3.5", + "vitest": "2.1.4" + }, + "scripts": { + "build": "tsup --format esm --dts", + "dev": "tsup --format esm --dts --watch", + "lint": "eslint --fix --cache .", + "test": "vitest run" + }, + "peerDependencies": { + "form-data": "4.0.1", + "whatwg-url": "7.1.0" + } +} \ No newline at end of file diff --git a/packages/plugin-birdeye/src/index.ts b/packages/plugin-birdeye/src/index.ts new file mode 100644 index 0000000000..c73b2d36dd --- /dev/null +++ b/packages/plugin-birdeye/src/index.ts @@ -0,0 +1,57 @@ +import { Plugin } from "@elizaos/core"; +import { + gainersLosersProvider, + ohlcvProvider, + pairOverviewProvider, + priceMultipleProvider, + priceProvider, + priceVolumeProvider, + tokenCreationProvider, + tokenListProvider, + tokenMarketDataProvider, + tokenOverviewProvider, + tokenSecurityProvider, + tokenTradeProvider, + tradesSeekProvider, + transactionHistoryProvider, + trendingTokensProvider, + walletPortfolioProvider, +} from "./providers"; + +export const birdeyePlugin: Plugin = { + name: "birdeye", + description: "Birdeye Plugin for token data and analytics", + actions: [], + evaluators: [], + providers: [ + // DeFi providers + priceProvider, + priceMultipleProvider, + ohlcvProvider, + priceVolumeProvider, + + // Pair providers + pairOverviewProvider, + + // Search providers + tokenMarketDataProvider, + + // Token providers + tokenOverviewProvider, + tokenSecurityProvider, + tokenListProvider, + trendingTokensProvider, + tokenCreationProvider, + tokenTradeProvider, + + // Trader providers + gainersLosersProvider, + tradesSeekProvider, + + // Wallet providers + transactionHistoryProvider, + walletPortfolioProvider, + ], +}; + +export default birdeyePlugin; diff --git a/packages/plugin-birdeye/src/providers/__tests__/utils.test.ts b/packages/plugin-birdeye/src/providers/__tests__/utils.test.ts new file mode 100644 index 0000000000..cdc8922733 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/__tests__/utils.test.ts @@ -0,0 +1,145 @@ +import { + extractChain, + extractContractAddresses, + extractLimit, + extractTimeframe, + extractTimeRange, + formatPercentChange, + formatPrice, + formatTimestamp, + formatValue, + shortenAddress, + TIME_UNITS, +} from "../utils"; + +describe("Chain Extraction", () => { + test("extracts chain from text correctly", () => { + expect(extractChain("Check price on Solana")).toBe("solana"); + expect(extractChain("Look up Ethereum token")).toBe("ethereum"); + expect(extractChain("No chain mentioned")).toBe("solana"); // default + }); +}); + +describe("Contract Address Extraction", () => { + test("extracts Ethereum addresses correctly", () => { + const text = + "Token address is 0x1234567890123456789012345678901234567890"; + expect(extractContractAddresses(text)).toEqual([ + "0x1234567890123456789012345678901234567890", + ]); + }); + + test("extracts Solana addresses correctly", () => { + const text = + "Token address is TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + expect(extractContractAddresses(text)).toEqual([ + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + ]); + }); + + test("extracts multiple addresses correctly", () => { + const text = + "0x1234567890123456789012345678901234567890 TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + expect(extractContractAddresses(text)).toHaveLength(2); + }); +}); + +describe("Timeframe Extraction", () => { + test("extracts explicit timeframes correctly", () => { + expect(extractTimeframe("Show 1h chart")).toBe("1h"); + expect(extractTimeframe("Display 15m data")).toBe("15m"); + expect(extractTimeframe("Get 1d overview")).toBe("1d"); + }); + + test("extracts semantic timeframes correctly", () => { + expect(extractTimeframe("Show short term analysis")).toBe("15m"); + expect(extractTimeframe("Get medium term view")).toBe("1h"); + expect(extractTimeframe("Display long term data")).toBe("1d"); + }); + + test("returns default timeframe for unclear input", () => { + expect(extractTimeframe("Show me the data")).toBe("1h"); + }); +}); + +describe("Time Range Extraction", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-01-01T00:00:00Z")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("extracts specific date ranges", () => { + const result = extractTimeRange("from 2023-12-01 to 2023-12-31"); + expect(result.start).toBe(new Date("2023-12-01").getTime() / 1000); + expect(result.end).toBe(new Date("2023-12-31").getTime() / 1000); + }); + + test("extracts relative time ranges", () => { + const now = Math.floor(Date.now() / 1000); + const result = extractTimeRange("24 hours ago"); + expect(result.end).toBe(now); + expect(result.start).toBe(now - TIME_UNITS.day); + }); + + test("handles semantic time ranges", () => { + const now = Math.floor(Date.now() / 1000); + const result = extractTimeRange("show me today's data"); + expect(result.end).toBe(now); + expect(result.start).toBe(now - TIME_UNITS.day); + }); +}); + +describe("Limit Extraction", () => { + test("extracts explicit limits", () => { + expect(extractLimit("show 20 results")).toBe(20); + expect(extractLimit("display 5 items")).toBe(5); + expect(extractLimit("fetch 200 records")).toBe(100); // clamped to max + }); + + test("extracts semantic limits", () => { + expect(extractLimit("show me everything")).toBe(100); + expect(extractLimit("give me a brief overview")).toBe(5); + expect(extractLimit("provide detailed analysis")).toBe(50); + }); + + test("returns default limit for unclear input", () => { + expect(extractLimit("show me the data")).toBe(10); + }); +}); + +describe("Formatting Functions", () => { + test("formats values correctly", () => { + expect(formatValue(1500000000)).toBe("$1.50B"); + expect(formatValue(1500000)).toBe("$1.50M"); + expect(formatValue(1500)).toBe("$1.50K"); + expect(formatValue(150)).toBe("$150.00"); + }); + + test("formats percent changes correctly", () => { + expect(formatPercentChange(10.5)).toBe("šŸ“ˆ 10.50%"); + expect(formatPercentChange(-5.25)).toBe("šŸ“‰ 5.25%"); + expect(formatPercentChange(undefined)).toBe("N/A"); + }); + + test("shortens addresses correctly", () => { + expect( + shortenAddress("0x1234567890123456789012345678901234567890") + ).toBe("0x1234...7890"); + expect(shortenAddress("short")).toBe("short"); + expect(shortenAddress("")).toBe("Unknown"); + }); + + test("formats timestamps correctly", () => { + const timestamp = 1704067200; // 2024-01-01 00:00:00 UTC + expect(formatTimestamp(timestamp)).toMatch(/2024/); + }); + + test("formats prices correctly", () => { + expect(formatPrice(123.456)).toBe("123.46"); + expect(formatPrice(0.000123)).toBe("1.23e-4"); + }); +}); diff --git a/packages/plugin-birdeye/src/providers/defi/__tests__/price-provider.test.ts b/packages/plugin-birdeye/src/providers/defi/__tests__/price-provider.test.ts new file mode 100644 index 0000000000..5c364f5848 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/__tests__/price-provider.test.ts @@ -0,0 +1,218 @@ +import { IAgentRuntime, Memory, State } from "@elizaos/core"; +import { priceProvider } from "../price-provider"; + +// Mock data +const mockPriceData = { + price: 1.23, + timestamp: 1704067200, // 2024-01-01 00:00:00 UTC + token: "TEST", + priceChange24h: 0.05, + priceChange24hPercent: 4.23, +}; + +// Mock fetch globally +global.fetch = jest.fn(); + +describe("Price Provider", () => { + let mockRuntime: IAgentRuntime; + let mockMessage: Memory; + let mockState: State; + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Mock runtime + mockRuntime = { + getSetting: jest.fn().mockReturnValue("mock-api-key"), + } as unknown as IAgentRuntime; + + // Mock message + mockMessage = { + content: { + text: "What is the price of 0x1234567890123456789012345678901234567890 on ethereum", + }, + } as Memory; + + // Mock state + mockState = {} as State; + + // Mock successful fetch response + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ data: mockPriceData }), + }); + }); + + test("returns null when API key is missing", async () => { + (mockRuntime.getSetting as jest.Mock).mockReturnValue(null); + const result = await priceProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).toBeNull(); + }); + + test("returns null when message does not contain price keywords", async () => { + mockMessage.content.text = "random message without price keywords"; + const result = await priceProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).toBeNull(); + }); + + test("returns null when no contract address is found", async () => { + mockMessage.content.text = "what is the price of invalid-address"; + const result = await priceProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).toBeNull(); + }); + + test("handles API error gracefully", async () => { + (global.fetch as jest.Mock).mockRejectedValue(new Error("API Error")); + const result = await priceProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).toBeNull(); + }); + + test("handles 404 response gracefully", async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 404, + }); + const result = await priceProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).toBeNull(); + }); + + test("formats price response correctly with all data", async () => { + const result = await priceProvider.get( + mockRuntime, + mockMessage, + mockState + ); + + expect(result).toContain( + `Price for ${mockPriceData.token} on Ethereum` + ); + expect(result).toContain( + `Current Price: $${mockPriceData.price.toFixed(2)}` + ); + expect(result).toContain( + `24h Change: $${mockPriceData.priceChange24h.toFixed(2)}` + ); + expect(result).toContain( + `24h Change %: ${mockPriceData.priceChange24hPercent.toFixed(2)}%` + ); + expect(result).toContain("Last Updated:"); + }); + + test("formats price response correctly with minimal data", async () => { + const minimalPriceData = { + price: 0.000123, + timestamp: 1704067200, + token: "TEST", + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ data: minimalPriceData }), + }); + + const result = await priceProvider.get( + mockRuntime, + mockMessage, + mockState + ); + + expect(result).toContain( + `Price for ${minimalPriceData.token} on Ethereum` + ); + expect(result).toContain("Current Price: $1.23e-4"); // Scientific notation for small numbers + expect(result).not.toContain("24h Change:"); + expect(result).not.toContain("24h Change %:"); + expect(result).toContain("Last Updated:"); + }); + + test("extracts chain correctly", async () => { + mockMessage.content.text = + "what is the price of 0x1234567890123456789012345678901234567890 on ethereum"; + await priceProvider.get(mockRuntime, mockMessage, mockState); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + "x-chain": "ethereum", + }), + }) + ); + }); + + test("defaults to solana chain when not specified", async () => { + mockMessage.content.text = + "what is the price of 0x1234567890123456789012345678901234567890"; + await priceProvider.get(mockRuntime, mockMessage, mockState); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + "x-chain": "solana", + }), + }) + ); + }); + + test("recognizes various price keywords", async () => { + const priceKeywords = [ + "price", + "cost", + "worth", + "value", + "rate", + "quote", + "how much", + ]; + + for (const keyword of priceKeywords) { + mockMessage.content.text = `${keyword} of 0x1234567890123456789012345678901234567890`; + const result = await priceProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).not.toBeNull(); + } + }); + + test("handles different address formats", async () => { + // Test Ethereum address + mockMessage.content.text = + "price of 0x1234567890123456789012345678901234567890"; + let result = await priceProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).not.toBeNull(); + + // Test Solana address + mockMessage.content.text = + "price of TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + result = await priceProvider.get(mockRuntime, mockMessage, mockState); + expect(result).not.toBeNull(); + }); +}); diff --git a/packages/plugin-birdeye/src/providers/defi/index.ts b/packages/plugin-birdeye/src/providers/defi/index.ts new file mode 100644 index 0000000000..af593176d4 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/index.ts @@ -0,0 +1,12 @@ +export * from "./networks-provider"; +export * from "./ohlcv-base-quote-provider"; +export * from "./ohlcv-pair-provider"; +export * from "./ohlcv-provider"; +export * from "./pair-trades-provider"; +export * from "./pair-trades-seek-provider"; +export * from "./price-history-provider"; +export * from "./price-multiple-provider"; +export * from "./price-provider"; +export * from "./price-volume-provider"; +export * from "./token-trades-provider"; +export * from "./trades-seek-provider"; diff --git a/packages/plugin-birdeye/src/providers/defi/networks-provider.ts b/packages/plugin-birdeye/src/providers/defi/networks-provider.ts new file mode 100644 index 0000000000..458cbb881b --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/networks-provider.ts @@ -0,0 +1,140 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { BASE_URL, makeApiRequest } from "../utils"; + +// Types +interface NetworkInfo { + name: string; + chainId: string; + rpcUrl: string; + explorerUrl: string; + status: "active" | "maintenance" | "deprecated"; + features: string[]; +} + +interface NetworksResponse { + networks: NetworkInfo[]; +} + +// Constants +const NETWORK_KEYWORDS = [ + "supported networks", + "available networks", + "supported chains", + "available chains", + "which networks", + "which chains", + "list networks", + "list chains", + "show networks", + "show chains", + "network support", + "chain support", +] as const; + +// Helper functions +const containsNetworkKeyword = (text: string): boolean => { + return NETWORK_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getNetworks = async ( + apiKey: string +): Promise => { + try { + const url = `${BASE_URL}/defi/networks`; + + elizaLogger.info("Fetching supported networks from:", url); + + return await makeApiRequest(url, { + apiKey, + chain: "solana", + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching networks:", error.message); + } + return null; + } +}; + +const formatNetworkResponse = (data: NetworksResponse): string => { + let response = "Supported Networks on Birdeye\n\n"; + + // Group networks by status + const activeNetworks = data.networks.filter((n) => n.status === "active"); + const maintenanceNetworks = data.networks.filter( + (n) => n.status === "maintenance" + ); + const deprecatedNetworks = data.networks.filter( + (n) => n.status === "deprecated" + ); + + // Format active networks + if (activeNetworks.length > 0) { + response += "šŸŸ¢ Active Networks\n"; + activeNetworks.forEach((network) => { + response += `ā€¢ ${network.name}\n`; + response += ` - Chain ID: ${network.chainId}\n`; + response += ` - Features: ${network.features.join(", ")}\n`; + response += ` - Explorer: ${network.explorerUrl}\n\n`; + }); + } + + // Format maintenance networks + if (maintenanceNetworks.length > 0) { + response += "šŸŸ” Networks Under Maintenance\n"; + maintenanceNetworks.forEach((network) => { + response += `ā€¢ ${network.name}\n`; + response += ` - Chain ID: ${network.chainId}\n`; + response += ` - Features: ${network.features.join(", ")}\n\n`; + }); + } + + // Format deprecated networks + if (deprecatedNetworks.length > 0) { + response += "šŸ”“ Deprecated Networks\n"; + deprecatedNetworks.forEach((network) => { + response += `ā€¢ ${network.name}\n`; + response += ` - Chain ID: ${network.chainId}\n\n`; + }); + } + + return response.trim(); +}; + +export const networksProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsNetworkKeyword(messageText)) { + return null; + } + + elizaLogger.info("NETWORKS provider activated"); + + const networksData = await getNetworks(apiKey); + + if (!networksData) { + return null; + } + + return formatNetworkResponse(networksData); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/ohlcv-base-quote-provider.ts b/packages/plugin-birdeye/src/providers/defi/ohlcv-base-quote-provider.ts new file mode 100644 index 0000000000..0a07937ade --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/ohlcv-base-quote-provider.ts @@ -0,0 +1,244 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + Timeframe, + extractChain, + extractContractAddresses, + extractLimit, + extractTimeRange, + extractTimeframe, + formatTimestamp, + formatValue, + makeApiRequest, +} from "../utils"; + +// Types +interface OHLCVData { + timestamp: number; + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +interface BaseQuoteOHLCVResponse { + data: OHLCVData[]; + pair: { + baseToken: string; + quoteToken: string; + }; +} + +// Constants +const BASE_QUOTE_OHLCV_KEYWORDS = [ + "base quote ohlcv", + "base quote candlestick", + "base quote candles", + "base quote chart", + "base quote price history", + "base quote historical data", + "base quote market data", + "base quote trading data", + "base/quote chart", + "base/quote price", + "base/quote history", + "base/quote movement", + "token pair chart", + "token pair price", + "token pair history", + "token pair movement", +] as const; + +// Helper functions +const containsBaseQuoteOHLCVKeyword = (text: string): boolean => { + return BASE_QUOTE_OHLCV_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getBaseQuoteOHLCV = async ( + apiKey: string, + baseAddress: string, + quoteAddress: string, + timeframe: Timeframe, + chain: Chain, + limit: number +): Promise => { + try { + const params = new URLSearchParams({ + base_address: baseAddress, + quote_address: quoteAddress, + timeframe, + limit: limit.toString(), + }); + const url = `${BASE_URL}/defi/ohlcv_base_quote?${params.toString()}`; + + elizaLogger.info( + `Fetching base/quote OHLCV data for ${baseAddress}/${quoteAddress} with ${timeframe} timeframe on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { + apiKey, + chain, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error( + "Error fetching base/quote OHLCV data:", + error.message + ); + } + return null; + } +}; + +const formatOHLCVData = (data: OHLCVData): string => { + const timestamp = formatTimestamp(data.timestamp); + const change = ((data.close - data.open) / data.open) * 100; + const trend = change >= 0 ? "šŸŸ¢" : "šŸ”“"; + + let response = `${trend} ${timestamp}\n`; + response += `ā€¢ Open: ${formatValue(data.open)}\n`; + response += `ā€¢ High: ${formatValue(data.high)}\n`; + response += `ā€¢ Low: ${formatValue(data.low)}\n`; + response += `ā€¢ Close: ${formatValue(data.close)}\n`; + response += `ā€¢ Volume: ${formatValue(data.volume)}\n`; + response += `ā€¢ Change: ${change >= 0 ? "+" : ""}${change.toFixed(2)}%`; + + return response; +}; + +const formatBaseQuoteOHLCVResponse = ( + data: BaseQuoteOHLCVResponse, + timeframe: Timeframe, + chain: Chain, + timeRange: { start: number; end: number } +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + const startDate = formatTimestamp(timeRange.start); + const endDate = formatTimestamp(timeRange.end); + + let response = `Base/Quote OHLCV Data for ${data.pair.baseToken}/${data.pair.quoteToken} on ${chainName}\n`; + response += `Timeframe: ${timeframe} (${startDate} to ${endDate})\n\n`; + + if (data.data.length === 0) { + return response + "No OHLCV data found for this base/quote pair."; + } + + // Calculate summary statistics + const latestPrice = data.data[data.data.length - 1].close; + const earliestPrice = data.data[0].open; + const priceChange = ((latestPrice - earliestPrice) / earliestPrice) * 100; + const totalVolume = data.data.reduce((sum, d) => sum + d.volume, 0); + const highestPrice = Math.max(...data.data.map((d) => d.high)); + const lowestPrice = Math.min(...data.data.map((d) => d.low)); + const averageVolume = totalVolume / data.data.length; + const volatility = ((highestPrice - lowestPrice) / lowestPrice) * 100; + + response += `šŸ“Š Summary\n`; + response += `ā€¢ Current Price: ${formatValue(latestPrice)}\n`; + response += `ā€¢ Period Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; + response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; + response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; + response += `ā€¢ Highest Price: ${formatValue(highestPrice)}\n`; + response += `ā€¢ Lowest Price: ${formatValue(lowestPrice)}\n`; + response += `ā€¢ Volatility: ${volatility.toFixed(2)}%\n\n`; + + // Add trend analysis + const trendStrength = Math.abs(priceChange); + let trendAnalysis = ""; + if (trendStrength < 1) { + trendAnalysis = "Sideways movement with low volatility"; + } else if (trendStrength < 5) { + trendAnalysis = + priceChange > 0 ? "Slight upward trend" : "Slight downward trend"; + } else if (trendStrength < 10) { + trendAnalysis = + priceChange > 0 + ? "Moderate upward trend" + : "Moderate downward trend"; + } else { + trendAnalysis = + priceChange > 0 ? "Strong upward trend" : "Strong downward trend"; + } + + response += `šŸ“ˆ Trend Analysis\n`; + response += `ā€¢ ${trendAnalysis}\n`; + response += `ā€¢ Volatility is ${volatility < 5 ? "low" : volatility < 15 ? "moderate" : "high"}\n\n`; + + response += `šŸ“Š Recent Data\n`; + // Show only the last 5 entries + const recentData = data.data.slice(-5); + recentData.forEach((candle, index) => { + response += `${index + 1}. ${formatOHLCVData(candle)}\n\n`; + }); + + if (data.data.length > 5) { + response += `Showing last 5 of ${data.data.length} candles.`; + } + + return response; +}; + +export const baseQuoteOHLCVProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsBaseQuoteOHLCVKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length !== 2) { + return null; + } + + const chain = extractChain(messageText); + const timeframe = extractTimeframe(messageText); + const timeRange = extractTimeRange(messageText); + const limit = extractLimit(messageText); + + elizaLogger.info( + `BASE/QUOTE OHLCV provider activated for base ${addresses[0]} and quote ${addresses[1]} with ${timeframe} timeframe on ${chain}` + ); + + const ohlcvData = await getBaseQuoteOHLCV( + apiKey, + addresses[0], + addresses[1], + timeframe, + chain, + limit + ); + + if (!ohlcvData) { + return null; + } + + return formatBaseQuoteOHLCVResponse( + ohlcvData, + timeframe, + chain, + timeRange + ); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/ohlcv-pair-provider.ts b/packages/plugin-birdeye/src/providers/defi/ohlcv-pair-provider.ts new file mode 100644 index 0000000000..8cfa6f9fac --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/ohlcv-pair-provider.ts @@ -0,0 +1,210 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + Timeframe, + extractChain, + extractContractAddresses, + extractLimit, + extractTimeRange, + extractTimeframe, + formatTimestamp, + formatValue, + makeApiRequest, +} from "../utils"; + +// Types +interface OHLCVData { + timestamp: number; + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +interface PairOHLCVResponse { + data: OHLCVData[]; + pair: { + baseToken: string; + quoteToken: string; + }; +} + +// Constants +const PAIR_OHLCV_KEYWORDS = [ + "pair ohlcv", + "pair candlestick", + "pair candles", + "pair chart", + "pair price history", + "pair historical data", + "pair market data", + "pair trading data", + "trading chart", + "price chart", + "market chart", + "candlestick chart", + "price action", + "market action", + "price movement", + "market movement", +] as const; + +// Helper functions +const containsPairOHLCVKeyword = (text: string): boolean => { + return PAIR_OHLCV_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getPairOHLCV = async ( + apiKey: string, + baseAddress: string, + quoteAddress: string, + timeframe: Timeframe, + chain: Chain, + limit: number +): Promise => { + try { + const params = new URLSearchParams({ + base_address: baseAddress, + quote_address: quoteAddress, + timeframe, + limit: limit.toString(), + }); + const url = `${BASE_URL}/defi/ohlcv_pair?${params.toString()}`; + + elizaLogger.info( + `Fetching OHLCV data for pair ${baseAddress}/${quoteAddress} with ${timeframe} timeframe on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching pair OHLCV data:", error.message); + } + return null; + } +}; + +const formatOHLCVData = (data: OHLCVData): string => { + const timestamp = formatTimestamp(data.timestamp); + const change = ((data.close - data.open) / data.open) * 100; + const trend = change >= 0 ? "šŸŸ¢" : "šŸ”“"; + + let response = `${trend} ${timestamp}\n`; + response += `ā€¢ Open: ${formatValue(data.open)}\n`; + response += `ā€¢ High: ${formatValue(data.high)}\n`; + response += `ā€¢ Low: ${formatValue(data.low)}\n`; + response += `ā€¢ Close: ${formatValue(data.close)}\n`; + response += `ā€¢ Volume: ${formatValue(data.volume)}\n`; + response += `ā€¢ Change: ${change >= 0 ? "+" : ""}${change.toFixed(2)}%`; + + return response; +}; + +const formatPairOHLCVResponse = ( + data: PairOHLCVResponse, + timeframe: Timeframe, + chain: Chain, + timeRange: { start: number; end: number } +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + const startDate = formatTimestamp(timeRange.start); + const endDate = formatTimestamp(timeRange.end); + + let response = `OHLCV Data for ${data.pair.baseToken}/${data.pair.quoteToken} pair on ${chainName}\n`; + response += `Timeframe: ${timeframe} (${startDate} to ${endDate})\n\n`; + + if (data.data.length === 0) { + return response + "No OHLCV data found for this pair."; + } + + // Calculate summary statistics + const latestPrice = data.data[data.data.length - 1].close; + const earliestPrice = data.data[0].open; + const priceChange = ((latestPrice - earliestPrice) / earliestPrice) * 100; + const totalVolume = data.data.reduce((sum, d) => sum + d.volume, 0); + const highestPrice = Math.max(...data.data.map((d) => d.high)); + const lowestPrice = Math.min(...data.data.map((d) => d.low)); + const averageVolume = totalVolume / data.data.length; + + response += `šŸ“Š Summary\n`; + response += `ā€¢ Current Price: ${formatValue(latestPrice)}\n`; + response += `ā€¢ Period Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; + response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; + response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; + response += `ā€¢ Highest Price: ${formatValue(highestPrice)}\n`; + response += `ā€¢ Lowest Price: ${formatValue(lowestPrice)}\n`; + response += `ā€¢ Price Range: ${(((highestPrice - lowestPrice) / lowestPrice) * 100).toFixed(2)}%\n\n`; + + response += `šŸ“ˆ Recent Data\n`; + // Show only the last 5 entries + const recentData = data.data.slice(-5); + recentData.forEach((candle, index) => { + response += `${index + 1}. ${formatOHLCVData(candle)}\n\n`; + }); + + if (data.data.length > 5) { + response += `Showing last 5 of ${data.data.length} candles.`; + } + + return response; +}; + +export const pairOHLCVProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsPairOHLCVKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length !== 2) { + return null; + } + + const chain = extractChain(messageText); + const timeframe = extractTimeframe(messageText); + const timeRange = extractTimeRange(messageText); + const limit = extractLimit(messageText); + + elizaLogger.info( + `PAIR OHLCV provider activated for base ${addresses[0]} and quote ${addresses[1]} with ${timeframe} timeframe on ${chain}` + ); + + const ohlcvData = await getPairOHLCV( + apiKey, + addresses[0], + addresses[1], + timeframe, + chain, + limit + ); + + if (!ohlcvData) { + return null; + } + + return formatPairOHLCVResponse(ohlcvData, timeframe, chain, timeRange); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/ohlcv-provider.ts b/packages/plugin-birdeye/src/providers/defi/ohlcv-provider.ts new file mode 100644 index 0000000000..259e1b4a0d --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/ohlcv-provider.ts @@ -0,0 +1,256 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +// Types +interface OHLCVData { + timestamp: number; + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +// Constants +const OHLCV_KEYWORDS = [ + "ohlc", + "ohlcv", + "candlestick", + "candle", + "chart", + "price history", + "historical", +] as const; + +const TIME_INTERVAL_KEYWORDS = { + "1m": ["1 minute", "1min", "1m"], + "3m": ["3 minutes", "3min", "3m"], + "5m": ["5 minutes", "5min", "5m"], + "15m": ["15 minutes", "15min", "15m"], + "30m": ["30 minutes", "30min", "30m"], + "1h": ["1 hour", "1hr", "1h"], + "2h": ["2 hours", "2hr", "2h"], + "4h": ["4 hours", "4hr", "4h"], + "6h": ["6 hours", "6hr", "6h"], + "12h": ["12 hours", "12hr", "12h"], + "1d": ["1 day", "daily", "1d"], + "1w": ["1 week", "weekly", "1w"], + "1mo": ["1 month", "monthly", "1mo"], +} as const; + +const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +] as const; + +const BASE_URL = "https://public-api.birdeye.so"; + +// Helper functions +const containsOHLCVKeyword = (text: string): boolean => { + return OHLCV_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractTimeInterval = (text: string): string => { + const lowerText = text.toLowerCase(); + for (const [interval, keywords] of Object.entries(TIME_INTERVAL_KEYWORDS)) { + if (keywords.some((keyword) => lowerText.includes(keyword))) { + return interval; + } + } + return "1d"; // Default to daily if no interval specified +}; + +const extractChain = (text: string): string => { + const chain = CHAIN_KEYWORDS.find((chain) => + text.toLowerCase().includes(chain.toLowerCase()) + ); + return chain || "solana"; +}; + +const extractContractAddress = (text: string): string | null => { + const words = text.split(/\s+/); + + for (const word of words) { + // Ethereum-like addresses (0x...) + if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { + return word; + } + // Solana addresses (base58, typically 32-44 chars) + if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { + return word; + } + } + return null; +}; + +const getOHLCVData = async ( + apiKey: string, + contractAddress: string, + interval: string = "1d", + chain: string = "solana" +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + interval, + limit: "24", // Get last 24 periods + }); + const url = `${BASE_URL}/defi/ohlcv?${params.toString()}`; + + elizaLogger.info( + `Fetching OHLCV data for address ${contractAddress} on ${chain} with interval ${interval} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + elizaLogger.warn( + `Token not found: ${contractAddress} on ${chain}` + ); + return null; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching OHLCV data:", error); + return null; + } +}; + +const formatNumber = (num: number): string => { + if (!num && num !== 0) return "N/A"; + return num < 0.01 + ? num.toExponential(2) + : num.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 6, + }); +}; + +const formatVolume = (volume: number): string => { + if (volume >= 1_000_000_000) { + return `$${(volume / 1_000_000_000).toFixed(2)}B`; + } + if (volume >= 1_000_000) { + return `$${(volume / 1_000_000).toFixed(2)}M`; + } + if (volume >= 1_000) { + return `$${(volume / 1_000).toFixed(2)}K`; + } + return `$${volume.toFixed(2)}`; +}; + +const formatOHLCVResponse = ( + data: OHLCVData[], + interval: string, + chain: string +): string => { + if (data.length === 0) { + return "No OHLCV data available for the specified period."; + } + + // Sort data by timestamp in ascending order + const sortedData = [...data].sort((a, b) => a.timestamp - b.timestamp); + const latestData = sortedData[sortedData.length - 1]; + + let response = `OHLCV Data (${interval}) on ${chain.charAt(0).toUpperCase() + chain.slice(1)}:\n\n`; + + // Latest price information + response += `šŸ“Š Latest Candle (${new Date(latestData.timestamp * 1000).toLocaleString()})\n`; + response += `ā€¢ Open: $${formatNumber(latestData.open)}\n`; + response += `ā€¢ High: $${formatNumber(latestData.high)}\n`; + response += `ā€¢ Low: $${formatNumber(latestData.low)}\n`; + response += `ā€¢ Close: $${formatNumber(latestData.close)}\n`; + response += `ā€¢ Volume: ${formatVolume(latestData.volume)}\n`; + + // Price change statistics + const priceChange = latestData.close - latestData.open; + const priceChangePercent = (priceChange / latestData.open) * 100; + const trend = priceChange >= 0 ? "šŸ“ˆ" : "šŸ“‰"; + + response += `\n${trend} Period Change\n`; + response += `ā€¢ Price Change: $${formatNumber(priceChange)} (${priceChangePercent.toFixed(2)}%)\n`; + + // Volume analysis + const totalVolume = sortedData.reduce( + (sum, candle) => sum + candle.volume, + 0 + ); + const avgVolume = totalVolume / sortedData.length; + + response += `\nšŸ“Š Volume Analysis\n`; + response += `ā€¢ Total Volume: ${formatVolume(totalVolume)}\n`; + response += `ā€¢ Average Volume: ${formatVolume(avgVolume)}\n`; + + return response; +}; + +export const ohlcvProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsOHLCVKeyword(messageText)) { + return null; + } + + const contractAddress = extractContractAddress(messageText); + if (!contractAddress) { + return null; + } + + const chain = extractChain(messageText); + const interval = extractTimeInterval(messageText); + + elizaLogger.info( + `OHLCV provider activated for address ${contractAddress} on ${chain} with interval ${interval}` + ); + + const ohlcvData = await getOHLCVData( + apiKey, + contractAddress, + interval, + chain + ); + + if (!ohlcvData) { + return null; + } + + return formatOHLCVResponse(ohlcvData, interval, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/pair-trades-provider.ts b/packages/plugin-birdeye/src/providers/defi/pair-trades-provider.ts new file mode 100644 index 0000000000..d0eefb6da9 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/pair-trades-provider.ts @@ -0,0 +1,245 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + extractLimit, + formatTimestamp, + formatValue, + makeApiRequest, + shortenAddress, +} from "../utils"; + +// Types +interface PairTrade { + timestamp: number; + price: number; + volume: number; + side: "buy" | "sell"; + source: string; + txHash: string; + buyer?: string; + seller?: string; + baseToken: string; + quoteToken: string; +} + +interface PairTradesResponse { + trades: PairTrade[]; + totalCount: number; + pair: { + baseToken: string; + quoteToken: string; + }; +} + +// Constants +const PAIR_TRADES_KEYWORDS = [ + "pair trades", + "pair swaps", + "pair transactions", + "pair activity", + "pair orders", + "pair executions", + "pair trading", + "pair market activity", + "pair exchange activity", + "pair trading history", + "pair market history", + "pair exchange history", + "trading pair activity", + "trading pair history", + "base/quote trades", + "base/quote activity", +] as const; + +// Helper functions +const containsPairTradesKeyword = (text: string): boolean => { + return PAIR_TRADES_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getPairTrades = async ( + apiKey: string, + baseAddress: string, + quoteAddress: string, + chain: Chain, + limit: number +): Promise => { + try { + const params = new URLSearchParams({ + base_address: baseAddress, + quote_address: quoteAddress, + limit: limit.toString(), + }); + const url = `${BASE_URL}/defi/trades_pair?${params.toString()}`; + + elizaLogger.info( + `Fetching pair trades for base ${baseAddress} and quote ${quoteAddress} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching pair trades:", error.message); + } + return null; + } +}; + +const formatPairTrade = (trade: PairTrade): string => { + const timestamp = formatTimestamp(trade.timestamp); + const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; + + let response = `${side} - ${timestamp}\n`; + response += `ā€¢ Price: ${formatValue(trade.price)}\n`; + response += `ā€¢ Volume: ${formatValue(trade.volume)}\n`; + response += `ā€¢ Source: ${trade.source}\n`; + if (trade.buyer && trade.seller) { + response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; + response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; + } + response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; + + return response; +}; + +const formatPairTradesResponse = ( + data: PairTradesResponse, + chain: Chain +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + + let response = `Recent Trades for ${data.pair.baseToken}/${data.pair.quoteToken} pair on ${chainName}\n\n`; + + if (data.trades.length === 0) { + return response + "No trades found."; + } + + // Calculate summary statistics + const totalVolume = data.trades.reduce((sum, t) => sum + t.volume, 0); + const averageVolume = totalVolume / data.trades.length; + const buyCount = data.trades.filter((t) => t.side === "buy").length; + const buyRatio = (buyCount / data.trades.length) * 100; + const averagePrice = + data.trades.reduce((sum, t) => sum + t.price, 0) / data.trades.length; + const priceChange = + ((data.trades[data.trades.length - 1].price - data.trades[0].price) / + data.trades[0].price) * + 100; + const highestPrice = Math.max(...data.trades.map((t) => t.price)); + const lowestPrice = Math.min(...data.trades.map((t) => t.price)); + const priceRange = ((highestPrice - lowestPrice) / lowestPrice) * 100; + + response += `šŸ“Š Summary\n`; + response += `ā€¢ Total Trades: ${data.trades.length}\n`; + response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; + response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; + response += `ā€¢ Buy/Sell Ratio: ${buyRatio.toFixed(1)}% buys\n`; + response += `ā€¢ Average Price: ${formatValue(averagePrice)}\n`; + response += `ā€¢ Price Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; + response += `ā€¢ Price Range: ${priceRange.toFixed(2)}%\n\n`; + + // Add market analysis + const volatility = priceRange / Math.sqrt(data.trades.length); + const volumePerTrade = totalVolume / data.trades.length; + let marketAnalysis = ""; + + if (data.trades.length < 5) { + marketAnalysis = "Insufficient data for detailed analysis"; + } else { + // Analyze trading activity + const activityLevel = + data.trades.length > 20 + ? "high" + : data.trades.length > 10 + ? "moderate" + : "low"; + const volumeLevel = + volumePerTrade > averageVolume * 2 + ? "high" + : volumePerTrade > averageVolume + ? "moderate" + : "low"; + const volatilityLevel = + volatility > 5 ? "high" : volatility > 2 ? "moderate" : "low"; + const trend = + Math.abs(priceChange) < 1 + ? "sideways" + : priceChange > 0 + ? "upward" + : "downward"; + + marketAnalysis = `Market shows ${activityLevel} trading activity with ${volumeLevel} volume per trade. `; + marketAnalysis += `${volatilityLevel.charAt(0).toUpperCase() + volatilityLevel.slice(1)} volatility with a ${trend} price trend.`; + } + + response += `šŸ“ˆ Market Analysis\n`; + response += `ā€¢ ${marketAnalysis}\n\n`; + + response += `šŸ”„ Recent Trades\n`; + data.trades.forEach((trade, index) => { + response += `${index + 1}. ${formatPairTrade(trade)}\n\n`; + }); + + if (data.totalCount > data.trades.length) { + response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; + } + + return response; +}; + +export const pairTradesProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsPairTradesKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length !== 2) { + return null; + } + + const chain = extractChain(messageText); + const limit = extractLimit(messageText); + + elizaLogger.info( + `PAIR TRADES provider activated for base ${addresses[0]} and quote ${addresses[1]} on ${chain}` + ); + + const tradesData = await getPairTrades( + apiKey, + addresses[0], + addresses[1], + chain, + limit + ); + + if (!tradesData) { + return null; + } + + return formatPairTradesResponse(tradesData, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/pair-trades-seek-provider.ts b/packages/plugin-birdeye/src/providers/defi/pair-trades-seek-provider.ts new file mode 100644 index 0000000000..7d293d1d68 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/pair-trades-seek-provider.ts @@ -0,0 +1,266 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + extractLimit, + extractTimeRange, + formatTimestamp, + formatValue, + makeApiRequest, + shortenAddress, +} from "../utils"; + +// Types +interface PairTrade { + timestamp: number; + price: number; + volume: number; + side: "buy" | "sell"; + source: string; + txHash: string; + buyer?: string; + seller?: string; + baseToken: string; + quoteToken: string; +} + +interface PairTradesResponse { + trades: PairTrade[]; + totalCount: number; + pair: { + baseToken: string; + quoteToken: string; + }; +} + +// Constants +const PAIR_TRADE_KEYWORDS = [ + "pair trades", + "pair trading", + "pair transactions", + "pair swaps", + "pair buys", + "pair sells", + "pair orders", + "pair executions", + "pair trade history", + "pair trading history", + "pair recent trades", + "pair market activity", + "pair trading activity", + "pair market trades", + "pair exchange history", + "trading pair history", + "trading pair activity", + "base/quote trades", + "base/quote activity", +] as const; + +// Helper functions +const containsPairTradeKeyword = (text: string): boolean => { + return PAIR_TRADE_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getPairTradesByTime = async ( + apiKey: string, + baseAddress: string, + quoteAddress: string, + timestamp: number, + chain: Chain, + limit: number +): Promise => { + try { + const params = new URLSearchParams({ + base_address: baseAddress, + quote_address: quoteAddress, + timestamp: timestamp.toString(), + limit: limit.toString(), + }); + const url = `${BASE_URL}/defi/trades_pair_seek_time?${params.toString()}`; + + elizaLogger.info( + `Fetching pair trades for base ${baseAddress} and quote ${quoteAddress} since ${new Date( + timestamp * 1000 + ).toLocaleString()} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error( + "Error fetching pair trades by time:", + error.message + ); + } + return null; + } +}; + +const formatPairTrade = (trade: PairTrade): string => { + const timestamp = formatTimestamp(trade.timestamp); + const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; + + let response = `${side} - ${timestamp}\n`; + response += `ā€¢ Price: ${formatValue(trade.price)}\n`; + response += `ā€¢ Volume: ${formatValue(trade.volume)}\n`; + response += `ā€¢ Source: ${trade.source}\n`; + if (trade.buyer && trade.seller) { + response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; + response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; + } + response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; + + return response; +}; + +const formatPairTradesResponse = ( + data: PairTradesResponse, + timeRange: { start: number; end: number }, + chain: Chain +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + const startDate = formatTimestamp(timeRange.start); + const endDate = formatTimestamp(timeRange.end); + + let response = `Trade History for ${data.pair.baseToken}/${data.pair.quoteToken} pair on ${chainName}\n`; + response += `Period: ${startDate} to ${endDate}\n\n`; + + if (data.trades.length === 0) { + return response + "No trades found in this time period."; + } + + // Calculate summary statistics + const totalVolume = data.trades.reduce((sum, t) => sum + t.volume, 0); + const averageVolume = totalVolume / data.trades.length; + const buyCount = data.trades.filter((t) => t.side === "buy").length; + const buyRatio = (buyCount / data.trades.length) * 100; + const averagePrice = + data.trades.reduce((sum, t) => sum + t.price, 0) / data.trades.length; + const priceChange = + ((data.trades[data.trades.length - 1].price - data.trades[0].price) / + data.trades[0].price) * + 100; + const highestPrice = Math.max(...data.trades.map((t) => t.price)); + const lowestPrice = Math.min(...data.trades.map((t) => t.price)); + const priceRange = ((highestPrice - lowestPrice) / lowestPrice) * 100; + + response += `šŸ“Š Summary\n`; + response += `ā€¢ Total Trades: ${data.trades.length}\n`; + response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; + response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; + response += `ā€¢ Buy/Sell Ratio: ${buyRatio.toFixed(1)}% buys\n`; + response += `ā€¢ Average Price: ${formatValue(averagePrice)}\n`; + response += `ā€¢ Price Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; + response += `ā€¢ Price Range: ${priceRange.toFixed(2)}%\n\n`; + + // Add market analysis + const volatility = priceRange / Math.sqrt(data.trades.length); + const volumePerTrade = totalVolume / data.trades.length; + let marketAnalysis = ""; + + if (data.trades.length < 5) { + marketAnalysis = "Insufficient data for detailed analysis"; + } else { + // Analyze trading activity + const activityLevel = + data.trades.length > 20 + ? "high" + : data.trades.length > 10 + ? "moderate" + : "low"; + const volumeLevel = + volumePerTrade > averageVolume * 2 + ? "high" + : volumePerTrade > averageVolume + ? "moderate" + : "low"; + const volatilityLevel = + volatility > 5 ? "high" : volatility > 2 ? "moderate" : "low"; + const trend = + Math.abs(priceChange) < 1 + ? "sideways" + : priceChange > 0 + ? "upward" + : "downward"; + + marketAnalysis = `Market shows ${activityLevel} trading activity with ${volumeLevel} volume per trade. `; + marketAnalysis += `${volatilityLevel.charAt(0).toUpperCase() + volatilityLevel.slice(1)} volatility with a ${trend} price trend.`; + } + + response += `šŸ“ˆ Market Analysis\n`; + response += `ā€¢ ${marketAnalysis}\n\n`; + + response += `šŸ”„ Recent Trades\n`; + data.trades.forEach((trade, index) => { + response += `${index + 1}. ${formatPairTrade(trade)}\n\n`; + }); + + if (data.totalCount > data.trades.length) { + response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; + } + + return response; +}; + +export const pairTradesSeekProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsPairTradeKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length !== 2) { + return null; + } + + const chain = extractChain(messageText); + const timeRange = extractTimeRange(messageText); + const limit = extractLimit(messageText); + + elizaLogger.info( + `PAIR TRADES SEEK provider activated for base ${addresses[0]} and quote ${addresses[1]} from ${new Date( + timeRange.start * 1000 + ).toLocaleString()} to ${new Date( + timeRange.end * 1000 + ).toLocaleString()} on ${chain}` + ); + + const tradesData = await getPairTradesByTime( + apiKey, + addresses[0], + addresses[1], + timeRange.start, + chain, + limit + ); + + if (!tradesData) { + return null; + } + + return formatPairTradesResponse(tradesData, timeRange, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/price-history-provider.ts b/packages/plugin-birdeye/src/providers/defi/price-history-provider.ts new file mode 100644 index 0000000000..69c2275488 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/price-history-provider.ts @@ -0,0 +1,230 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + extractTimeRange, + formatTimestamp, + formatValue, + makeApiRequest, +} from "../utils"; + +// Types +interface PriceHistoryData { + price: number; + timestamp: number; + volume?: number; +} + +interface PriceHistoryResponse { + data: PriceHistoryData[]; + token: string; +} + +// Constants +const PRICE_HISTORY_KEYWORDS = [ + "price history", + "historical price", + "price chart", + "price trend", + "price movement", + "price changes", + "price over time", + "price timeline", + "price performance", + "price data", + "historical data", + "price analysis", + "price tracking", + "price evolution", +] as const; + +// Helper functions +const containsPriceHistoryKeyword = (text: string): boolean => { + return PRICE_HISTORY_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getPriceHistory = async ( + apiKey: string, + contractAddress: string, + startTime: number, + endTime: number, + chain: Chain +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + time_from: startTime.toString(), + time_to: endTime.toString(), + }); + const url = `${BASE_URL}/defi/price_history_unix?${params.toString()}`; + + elizaLogger.info( + `Fetching price history for token ${contractAddress} from ${new Date( + startTime * 1000 + ).toLocaleString()} to ${new Date( + endTime * 1000 + ).toLocaleString()} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { + apiKey, + chain, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching price history:", error.message); + } + return null; + } +}; + +const formatPriceHistoryResponse = ( + data: PriceHistoryResponse, + timeRange: { start: number; end: number }, + chain: Chain +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + const startDate = formatTimestamp(timeRange.start); + const endDate = formatTimestamp(timeRange.end); + + let response = `Price History for ${data.token} on ${chainName}\n`; + response += `Period: ${startDate} to ${endDate}\n\n`; + + if (data.data.length === 0) { + return response + "No price data found for this period."; + } + + // Calculate summary statistics + const prices = data.data.map((d) => d.price); + const volumes = data.data.map((d) => d.volume || 0); + const startPrice = data.data[0].price; + const endPrice = data.data[data.data.length - 1].price; + const priceChange = ((endPrice - startPrice) / startPrice) * 100; + const highestPrice = Math.max(...prices); + const lowestPrice = Math.min(...prices); + const averagePrice = prices.reduce((a, b) => a + b, 0) / prices.length; + const totalVolume = volumes.reduce((a, b) => a + b, 0); + const volatility = ((highestPrice - lowestPrice) / averagePrice) * 100; + + response += `šŸ“Š Summary\n`; + response += `ā€¢ Start Price: ${formatValue(startPrice)}\n`; + response += `ā€¢ End Price: ${formatValue(endPrice)}\n`; + response += `ā€¢ Price Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; + response += `ā€¢ Highest Price: ${formatValue(highestPrice)}\n`; + response += `ā€¢ Lowest Price: ${formatValue(lowestPrice)}\n`; + response += `ā€¢ Average Price: ${formatValue(averagePrice)}\n`; + if (totalVolume > 0) { + response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; + } + response += `ā€¢ Volatility: ${volatility.toFixed(2)}%\n\n`; + + // Add trend analysis + const trendStrength = Math.abs(priceChange); + let trendAnalysis = ""; + if (trendStrength < 1) { + trendAnalysis = "Price has remained relatively stable"; + } else if (trendStrength < 5) { + trendAnalysis = + priceChange > 0 + ? "Price shows slight upward movement" + : "Price shows slight downward movement"; + } else if (trendStrength < 10) { + trendAnalysis = + priceChange > 0 + ? "Price demonstrates moderate upward trend" + : "Price demonstrates moderate downward trend"; + } else { + trendAnalysis = + priceChange > 0 + ? "Price exhibits strong upward momentum" + : "Price exhibits strong downward momentum"; + } + + response += `šŸ“ˆ Trend Analysis\n`; + response += `ā€¢ ${trendAnalysis}\n`; + response += `ā€¢ Volatility is ${volatility < 10 ? "low" : volatility < 25 ? "moderate" : "high"}\n\n`; + + // Show key price points + response += `šŸ”‘ Key Price Points\n`; + const keyPoints = [ + { label: "Start", ...data.data[0] }, + { + label: "High", + price: highestPrice, + timestamp: data.data[prices.indexOf(highestPrice)].timestamp, + }, + { + label: "Low", + price: lowestPrice, + timestamp: data.data[prices.indexOf(lowestPrice)].timestamp, + }, + { label: "End", ...data.data[data.data.length - 1] }, + ]; + + keyPoints.forEach((point) => { + response += `ā€¢ ${point.label}: ${formatValue(point.price)} (${formatTimestamp(point.timestamp)})\n`; + }); + + return response; +}; + +export const priceHistoryProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsPriceHistoryKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + const timeRange = extractTimeRange(messageText); + + elizaLogger.info( + `PRICE HISTORY provider activated for token ${addresses[0]} from ${new Date( + timeRange.start * 1000 + ).toLocaleString()} to ${new Date( + timeRange.end * 1000 + ).toLocaleString()} on ${chain}` + ); + + const priceData = await getPriceHistory( + apiKey, + addresses[0], + timeRange.start, + timeRange.end, + chain + ); + + if (!priceData) { + return null; + } + + return formatPriceHistoryResponse(priceData, timeRange, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/price-multiple-provider.ts b/packages/plugin-birdeye/src/providers/defi/price-multiple-provider.ts new file mode 100644 index 0000000000..ad4c95bbde --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/price-multiple-provider.ts @@ -0,0 +1,200 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +// Types +interface TokenPrice { + price: number; + timestamp: number; + token: string; + priceChange24h?: number; + priceChange24hPercent?: number; +} + +interface MultiPriceResponse { + [tokenAddress: string]: TokenPrice; +} + +// Constants +const PRICE_KEYWORDS = [ + "price", + "prices", + "cost", + "worth", + "value", + "compare", + "multiple", + "several", + "many", + "list of", + "these tokens", + "their prices", +] as const; + +const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +] as const; + +const BASE_URL = "https://public-api.birdeye.so"; + +// Helper functions +const containsPriceKeyword = (text: string): boolean => { + return PRICE_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractChain = (text: string): string => { + const chain = CHAIN_KEYWORDS.find((chain) => + text.toLowerCase().includes(chain.toLowerCase()) + ); + return chain || "solana"; +}; + +const extractContractAddresses = (text: string): string[] => { + const words = text.split(/\s+/); + const addresses: string[] = []; + + for (const word of words) { + // Ethereum-like addresses (0x...) + if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { + addresses.push(word); + } + // Solana addresses (base58, typically 32-44 chars) + if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { + addresses.push(word); + } + } + return addresses; +}; + +const getMultiplePrices = async ( + apiKey: string, + addresses: string[], + chain: string = "solana" +): Promise => { + try { + const params = new URLSearchParams({ + tokens: addresses.join(","), + }); + const url = `${BASE_URL}/defi/price_multiple?${params.toString()}`; + + elizaLogger.info( + `Fetching prices for ${addresses.length} tokens on ${chain} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching multiple prices:", error); + return null; + } +}; + +const formatNumber = (num: number): string => { + if (!num && num !== 0) return "N/A"; + return num < 0.01 + ? num.toExponential(2) + : num.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +}; + +const formatPriceResponse = ( + prices: MultiPriceResponse, + chain: string +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + let response = `Token Prices on ${chainName}:\n\n`; + + const sortedTokens = Object.entries(prices).sort((a, b) => { + const priceA = a[1].price || 0; + const priceB = b[1].price || 0; + return priceB - priceA; + }); + + sortedTokens.forEach(([address, data]) => { + const timestamp = new Date(data.timestamp * 1000).toLocaleString(); + response += `${data.token} (${address.slice(0, 6)}...${address.slice(-4)}):\n`; + response += `ā€¢ Price: $${formatNumber(data.price)}\n`; + + if (data.priceChange24h !== undefined) { + const changeSymbol = data.priceChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; + response += `ā€¢ 24h Change: ${changeSymbol} $${formatNumber(Math.abs(data.priceChange24h))} `; + if (data.priceChange24hPercent !== undefined) { + response += `(${data.priceChange24hPercent.toFixed(2)}%)`; + } + response += "\n"; + } + + response += `ā€¢ Last Updated: ${timestamp}\n\n`; + }); + + return response; +}; + +export const priceMultipleProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsPriceKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length < 2) { + // If less than 2 addresses found, let the single price provider handle it + return null; + } + + const chain = extractChain(messageText); + + elizaLogger.info( + `MULTIPLE PRICE provider activated for ${addresses.length} addresses on ${chain}` + ); + + const priceData = await getMultiplePrices(apiKey, addresses, chain); + + if (!priceData) { + return null; + } + + return formatPriceResponse(priceData, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/price-provider.ts b/packages/plugin-birdeye/src/providers/defi/price-provider.ts new file mode 100644 index 0000000000..f7c6cd45a8 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/price-provider.ts @@ -0,0 +1,175 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +// Types +interface PriceData { + price: number; + timestamp: number; + token: string; + priceChange24h?: number; + priceChange24hPercent?: number; +} + +// Constants +const PRICE_KEYWORDS = [ + "price", + "cost", + "worth", + "value", + "rate", + "quote", + "how much", +] as const; + +const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +] as const; + +const BASE_URL = "https://public-api.birdeye.so"; + +// Helper functions +const containsPriceKeyword = (text: string): boolean => { + return PRICE_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractChain = (text: string): string => { + const chain = CHAIN_KEYWORDS.find((chain) => + text.toLowerCase().includes(chain.toLowerCase()) + ); + return chain || "solana"; +}; + +const extractContractAddress = (text: string): string | null => { + const words = text.split(/\s+/); + + for (const word of words) { + // Ethereum-like addresses (0x...) + if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { + return word; + } + // Solana addresses (base58, typically 32-44 chars) + if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { + return word; + } + } + return null; +}; + +const getTokenPrice = async ( + apiKey: string, + contractAddress: string, + chain: string = "solana" +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + }); + const url = `${BASE_URL}/defi/price?${params.toString()}`; + + elizaLogger.info( + `Fetching price for address ${contractAddress} on ${chain} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + elizaLogger.warn( + `Token not found: ${contractAddress} on ${chain}` + ); + return null; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching token price:", error); + return null; + } +}; + +const formatPriceResponse = (price: PriceData, chain: string): string => { + const timestamp = new Date(price.timestamp * 1000).toLocaleString(); + const priceFormatted = + price.price < 0.01 + ? price.price.toExponential(2) + : price.price.toFixed(2); + + let response = `Price for ${price.token} on ${chain.charAt(0).toUpperCase() + chain.slice(1)}:\n\n`; + response += `ā€¢ Current Price: $${priceFormatted}\n`; + + if (price.priceChange24h !== undefined) { + response += `ā€¢ 24h Change: $${price.priceChange24h.toFixed(2)}\n`; + } + + if (price.priceChange24hPercent !== undefined) { + response += `ā€¢ 24h Change %: ${price.priceChange24hPercent.toFixed(2)}%\n`; + } + + response += `ā€¢ Last Updated: ${timestamp}`; + + return response; +}; + +export const priceProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsPriceKeyword(messageText)) { + return null; + } + + const contractAddress = extractContractAddress(messageText); + if (!contractAddress) { + return null; + } + + const chain = extractChain(messageText); + + elizaLogger.info( + `PRICE provider activated for address ${contractAddress} on ${chain}` + ); + + const priceData = await getTokenPrice(apiKey, contractAddress, chain); + + if (!priceData) { + return null; + } + + return formatPriceResponse(priceData, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/price-volume-provider.ts b/packages/plugin-birdeye/src/providers/defi/price-volume-provider.ts new file mode 100644 index 0000000000..1448fbb47f --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/price-volume-provider.ts @@ -0,0 +1,234 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + formatPercentChange, + formatTimestamp, + formatValue, + makeApiRequest, +} from "../utils"; + +// Types +interface PriceVolumeData { + price: number; + volume24h: number; + timestamp: number; + token: string; + priceChange24h?: number; + priceChange24hPercent?: number; + volumeChange24h?: number; + volumeChange24hPercent?: number; +} + +interface MultiPriceVolumeResponse { + [tokenAddress: string]: PriceVolumeData; +} + +// Constants +const PRICE_VOLUME_KEYWORDS = [ + "price and volume", + "volume and price", + "trading volume", + "market activity", + "market data", + "trading data", + "market stats", + "trading stats", + "market metrics", + "trading metrics", +] as const; + +// Helper functions +const containsPriceVolumeKeyword = (text: string): boolean => { + return PRICE_VOLUME_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getSinglePriceVolume = async ( + apiKey: string, + contractAddress: string, + chain: Chain +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + }); + const url = `${BASE_URL}/defi/price_volume_single?${params.toString()}`; + + elizaLogger.info( + `Fetching price/volume data for address ${contractAddress} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error( + "Error fetching price/volume data:", + error.message + ); + } + return null; + } +}; + +const getMultiplePriceVolume = async ( + apiKey: string, + addresses: string[], + chain: Chain +): Promise => { + try { + const url = `${BASE_URL}/defi/price_volume_multi`; + + elizaLogger.info( + `Fetching price/volume data for ${addresses.length} tokens on ${chain}` + ); + + return await makeApiRequest(url, { + apiKey, + chain, + method: "POST", + body: { addresses }, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error( + "Error fetching multiple price/volume data:", + error.message + ); + } + return null; + } +}; + +const formatSinglePriceVolumeResponse = ( + data: PriceVolumeData, + chain: Chain +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + const timestamp = formatTimestamp(data.timestamp); + const priceFormatted = formatValue(data.price); + + let response = `Price & Volume Data for ${data.token} on ${chainName}:\n\n`; + + response += `šŸ’° Price Metrics\n`; + response += `ā€¢ Current Price: ${priceFormatted}\n`; + if (data.priceChange24h !== undefined) { + response += `ā€¢ 24h Price Change: ${formatValue(data.priceChange24h)} (${formatPercentChange(data.priceChange24hPercent)})\n`; + } + + response += `\nšŸ“Š Volume Metrics\n`; + response += `ā€¢ 24h Volume: ${formatValue(data.volume24h)}\n`; + if (data.volumeChange24h !== undefined) { + response += `ā€¢ 24h Volume Change: ${formatValue(data.volumeChange24h)} (${formatPercentChange(data.volumeChange24hPercent)})\n`; + } + + response += `\nā° Last Updated: ${timestamp}`; + + return response; +}; + +const formatMultiplePriceVolumeResponse = ( + data: MultiPriceVolumeResponse, + chain: Chain +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + let response = `Price & Volume Data on ${chainName}:\n\n`; + + // Sort tokens by volume + const sortedTokens = Object.entries(data).sort((a, b) => { + const volumeA = a[1].volume24h || 0; + const volumeB = b[1].volume24h || 0; + return volumeB - volumeA; + }); + + sortedTokens.forEach(([address, tokenData]) => { + const timestamp = formatTimestamp(tokenData.timestamp); + const priceFormatted = formatValue(tokenData.price); + + response += `${tokenData.token} (${address.slice(0, 6)}...${address.slice(-4)})\n`; + response += `ā€¢ Price: ${priceFormatted}`; + if (tokenData.priceChange24hPercent !== undefined) { + response += ` (${formatPercentChange(tokenData.priceChange24hPercent)})`; + } + response += `\n`; + response += `ā€¢ Volume: ${formatValue(tokenData.volume24h)}`; + if (tokenData.volumeChange24hPercent !== undefined) { + response += ` (${formatPercentChange(tokenData.volumeChange24hPercent)})`; + } + response += `\n`; + response += `ā€¢ Updated: ${timestamp}\n\n`; + }); + + return response; +}; + +export const priceVolumeProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsPriceVolumeKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + + if (addresses.length === 1) { + elizaLogger.info( + `PRICE/VOLUME provider activated for address ${addresses[0]} on ${chain}` + ); + + const priceVolumeData = await getSinglePriceVolume( + apiKey, + addresses[0], + chain + ); + + if (!priceVolumeData) { + return null; + } + + return formatSinglePriceVolumeResponse(priceVolumeData, chain); + } else { + elizaLogger.info( + `MULTIPLE PRICE/VOLUME provider activated for ${addresses.length} addresses on ${chain}` + ); + + const priceVolumeData = await getMultiplePriceVolume( + apiKey, + addresses, + chain + ); + + if (!priceVolumeData) { + return null; + } + + return formatMultiplePriceVolumeResponse(priceVolumeData, chain); + } + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/token-trades-provider.ts b/packages/plugin-birdeye/src/providers/defi/token-trades-provider.ts new file mode 100644 index 0000000000..d580eb0df9 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/token-trades-provider.ts @@ -0,0 +1,236 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + extractLimit, + formatTimestamp, + formatValue, + makeApiRequest, + shortenAddress, +} from "../utils"; + +// Types +interface Trade { + timestamp: number; + price: number; + volume: number; + side: "buy" | "sell"; + source: string; + txHash: string; + buyer?: string; + seller?: string; +} + +interface TokenTradesResponse { + trades: Trade[]; + totalCount: number; + token: string; +} + +// Constants +const TOKEN_TRADES_KEYWORDS = [ + "token trades", + "token swaps", + "token transactions", + "token activity", + "token orders", + "token executions", + "token trading", + "token market activity", + "token exchange activity", + "token trading history", + "token market history", + "token exchange history", +] as const; + +// Helper functions +const containsTokenTradesKeyword = (text: string): boolean => { + return TOKEN_TRADES_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getTokenTrades = async ( + apiKey: string, + contractAddress: string, + chain: Chain, + limit: number +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + limit: limit.toString(), + }); + const url = `${BASE_URL}/defi/trades_token?${params.toString()}`; + + elizaLogger.info( + `Fetching token trades for ${contractAddress} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { + apiKey, + chain, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching token trades:", error.message); + } + return null; + } +}; + +const formatTrade = (trade: Trade): string => { + const timestamp = formatTimestamp(trade.timestamp); + const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; + + let response = `${side} - ${timestamp}\n`; + response += `ā€¢ Price: ${formatValue(trade.price)}\n`; + response += `ā€¢ Volume: ${formatValue(trade.volume)}\n`; + response += `ā€¢ Source: ${trade.source}\n`; + if (trade.buyer && trade.seller) { + response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; + response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; + } + response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; + + return response; +}; + +const formatTokenTradesResponse = ( + data: TokenTradesResponse, + chain: Chain +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + + let response = `Recent Trades for ${data.token} on ${chainName}\n\n`; + + if (data.trades.length === 0) { + return response + "No trades found."; + } + + // Calculate summary statistics + const totalVolume = data.trades.reduce((sum, t) => sum + t.volume, 0); + const averageVolume = totalVolume / data.trades.length; + const buyCount = data.trades.filter((t) => t.side === "buy").length; + const buyRatio = (buyCount / data.trades.length) * 100; + const averagePrice = + data.trades.reduce((sum, t) => sum + t.price, 0) / data.trades.length; + const priceChange = + ((data.trades[data.trades.length - 1].price - data.trades[0].price) / + data.trades[0].price) * + 100; + const highestPrice = Math.max(...data.trades.map((t) => t.price)); + const lowestPrice = Math.min(...data.trades.map((t) => t.price)); + const priceRange = ((highestPrice - lowestPrice) / lowestPrice) * 100; + + response += `šŸ“Š Summary\n`; + response += `ā€¢ Total Trades: ${data.trades.length}\n`; + response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; + response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; + response += `ā€¢ Buy/Sell Ratio: ${buyRatio.toFixed(1)}% buys\n`; + response += `ā€¢ Average Price: ${formatValue(averagePrice)}\n`; + response += `ā€¢ Price Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; + response += `ā€¢ Price Range: ${priceRange.toFixed(2)}%\n\n`; + + // Add market analysis + const volatility = priceRange / Math.sqrt(data.trades.length); + const volumePerTrade = totalVolume / data.trades.length; + let marketAnalysis = ""; + + if (data.trades.length < 5) { + marketAnalysis = "Insufficient data for detailed analysis"; + } else { + // Analyze trading activity + const activityLevel = + data.trades.length > 20 + ? "high" + : data.trades.length > 10 + ? "moderate" + : "low"; + const volumeLevel = + volumePerTrade > averageVolume * 2 + ? "high" + : volumePerTrade > averageVolume + ? "moderate" + : "low"; + const volatilityLevel = + volatility > 5 ? "high" : volatility > 2 ? "moderate" : "low"; + const trend = + Math.abs(priceChange) < 1 + ? "sideways" + : priceChange > 0 + ? "upward" + : "downward"; + + marketAnalysis = `Market shows ${activityLevel} trading activity with ${volumeLevel} volume per trade. `; + marketAnalysis += `${volatilityLevel.charAt(0).toUpperCase() + volatilityLevel.slice(1)} volatility with a ${trend} price trend.`; + } + + response += `šŸ“ˆ Market Analysis\n`; + response += `ā€¢ ${marketAnalysis}\n\n`; + + response += `šŸ”„ Recent Trades\n`; + data.trades.forEach((trade, index) => { + response += `${index + 1}. ${formatTrade(trade)}\n\n`; + }); + + if (data.totalCount > data.trades.length) { + response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; + } + + return response; +}; + +export const tokenTradesProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsTokenTradesKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + const limit = extractLimit(messageText); + + elizaLogger.info( + `TOKEN TRADES provider activated for token ${addresses[0]} on ${chain}` + ); + + const tradesData = await getTokenTrades( + apiKey, + addresses[0], + chain, + limit + ); + + if (!tradesData) { + return null; + } + + return formatTokenTradesResponse(tradesData, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/trades-seek-provider.ts b/packages/plugin-birdeye/src/providers/defi/trades-seek-provider.ts new file mode 100644 index 0000000000..8ae96a1600 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/defi/trades-seek-provider.ts @@ -0,0 +1,210 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + extractLimit, + extractTimeRange, + formatTimestamp, + formatValue, + makeApiRequest, + shortenAddress, +} from "../utils"; + +// Types +interface Trade { + timestamp: number; + price: number; + volume: number; + side: "buy" | "sell"; + source: string; + txHash: string; + buyer?: string; + seller?: string; +} + +interface TradesResponse { + trades: Trade[]; + totalCount: number; + token: string; +} + +// Constants +const TOKEN_TRADE_KEYWORDS = [ + "token trades", + "token trading", + "token transactions", + "token swaps", + "token buys", + "token sells", + "token orders", + "token executions", + "token trade history", + "token trading history", + "token recent trades", + "token market activity", + "token trading activity", + "token market trades", + "token exchange history", +] as const; + +// Helper functions +const containsTokenTradeKeyword = (text: string): boolean => { + return TOKEN_TRADE_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getTradesByTime = async ( + apiKey: string, + contractAddress: string, + timestamp: number, + chain: Chain, + limit: number +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + timestamp: timestamp.toString(), + limit: limit.toString(), + }); + const url = `${BASE_URL}/defi/trades_token_seek_time?${params.toString()}`; + + elizaLogger.info( + `Fetching trades for token ${contractAddress} since ${new Date( + timestamp * 1000 + ).toLocaleString()} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching trades by time:", error.message); + } + return null; + } +}; + +const formatTrade = (trade: Trade): string => { + const timestamp = formatTimestamp(trade.timestamp); + const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; + + let response = `${side} - ${timestamp}\n`; + response += `ā€¢ Price: ${formatValue(trade.price)}\n`; + response += `ā€¢ Volume: ${formatValue(trade.volume)}\n`; + response += `ā€¢ Source: ${trade.source}\n`; + if (trade.buyer && trade.seller) { + response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; + response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; + } + response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; + + return response; +}; + +const formatTradesResponse = ( + data: TradesResponse, + timeRange: { start: number; end: number }, + chain: Chain +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + const startDate = formatTimestamp(timeRange.start); + const endDate = formatTimestamp(timeRange.end); + + let response = `Trade History for ${data.token} on ${chainName}\n`; + response += `Period: ${startDate} to ${endDate}\n\n`; + + if (data.trades.length === 0) { + return response + "No trades found in this time period."; + } + + // Calculate summary statistics + const totalVolume = data.trades.reduce((sum, t) => sum + t.volume, 0); + const averageVolume = totalVolume / data.trades.length; + const buyCount = data.trades.filter((t) => t.side === "buy").length; + const buyRatio = (buyCount / data.trades.length) * 100; + const averagePrice = + data.trades.reduce((sum, t) => sum + t.price, 0) / data.trades.length; + const priceChange = + ((data.trades[data.trades.length - 1].price - data.trades[0].price) / + data.trades[0].price) * + 100; + + response += `šŸ“Š Summary\n`; + response += `ā€¢ Total Trades: ${data.trades.length}\n`; + response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; + response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; + response += `ā€¢ Buy/Sell Ratio: ${buyRatio.toFixed(1)}% buys\n`; + response += `ā€¢ Average Price: ${formatValue(averagePrice)}\n`; + response += `ā€¢ Price Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n\n`; + + response += `šŸ“ˆ Recent Trades\n`; + data.trades.forEach((trade, index) => { + response += `${index + 1}. ${formatTrade(trade)}\n\n`; + }); + + if (data.totalCount > data.trades.length) { + response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; + } + + return response; +}; + +export const tokenTradesSeekProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsTokenTradeKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + const timeRange = extractTimeRange(messageText); + const limit = extractLimit(messageText); + + elizaLogger.info( + `TOKEN TRADES SEEK provider activated for token ${addresses[0]} from ${new Date( + timeRange.start * 1000 + ).toLocaleString()} to ${new Date( + timeRange.end * 1000 + ).toLocaleString()} on ${chain}` + ); + + const tradesData = await getTradesByTime( + apiKey, + addresses[0], + timeRange.start, + chain, + limit + ); + + if (!tradesData) { + return null; + } + + return formatTradesResponse(tradesData, timeRange, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/index.ts b/packages/plugin-birdeye/src/providers/index.ts new file mode 100644 index 0000000000..de86d2629c --- /dev/null +++ b/packages/plugin-birdeye/src/providers/index.ts @@ -0,0 +1,148 @@ +import { Provider } from "@elizaos/core"; + +// Import all providers +import { + baseQuoteOHLCVProvider, + networksProvider, + ohlcvProvider, + pairOHLCVProvider, + pairTradesProvider, + pairTradesSeekProvider, + priceHistoryProvider, + priceMultipleProvider, + priceProvider, + priceVolumeProvider, + tokenTradesProvider, + tokenTradesSeekProvider, +} from "./defi"; +import { pairOverviewProvider } from "./pair"; +import { tokenMarketDataProvider } from "./search"; +import { + allMarketListProvider, + newListingProvider, + tokenCreationProvider, + tokenHolderProvider, + tokenListProvider, + tokenMarketProvider, + tokenMetadataProvider, + tokenMintBurnProvider, + tokenOverviewProvider, + tokenSecurityProvider, + tokenTradeProvider, + topTradersProvider, + trendingTokensProvider, +} from "./token"; +import { gainersLosersProvider, tradesSeekProvider } from "./trader"; +import { + portfolioMultichainProvider, + supportedNetworksProvider, + tokenBalanceProvider, + transactionHistoryMultichainProvider, + transactionHistoryProvider, + walletPortfolioProvider, +} from "./wallet"; + +// Export individual providers +export * from "./defi"; +export * from "./pair"; +export * from "./search"; +export * from "./token"; +export * from "./trader"; +export * from "./wallet"; + +// Export providers array +export const providers: Provider[] = [ + // DeFi providers + baseQuoteOHLCVProvider, + networksProvider, + ohlcvProvider, + pairOHLCVProvider, + pairTradesProvider, + pairTradesSeekProvider, + priceHistoryProvider, + priceMultipleProvider, + priceProvider, + priceVolumeProvider, + tokenTradesProvider, + tokenTradesSeekProvider, + + // Pair providers + pairOverviewProvider, + + // Search providers + tokenMarketDataProvider, + + // Token providers + allMarketListProvider, + newListingProvider, + tokenCreationProvider, + tokenHolderProvider, + tokenListProvider, + tokenMarketProvider, + tokenMetadataProvider, + tokenMintBurnProvider, + tokenOverviewProvider, + tokenSecurityProvider, + tokenTradeProvider, + topTradersProvider, + trendingTokensProvider, + + // Trader providers + gainersLosersProvider, + tradesSeekProvider, + + // Wallet providers + portfolioMultichainProvider, + supportedNetworksProvider, + tokenBalanceProvider, + transactionHistoryMultichainProvider, + transactionHistoryProvider, + walletPortfolioProvider, +]; + +// DeFi Providers +export * from "./defi/networks-provider"; +export * from "./defi/ohlcv-base-quote-provider"; +export * from "./defi/ohlcv-pair-provider"; +export * from "./defi/ohlcv-provider"; +export * from "./defi/pair-trades-provider"; +export * from "./defi/pair-trades-seek-provider"; +export * from "./defi/price-history-provider"; +export * from "./defi/price-multiple-provider"; +export * from "./defi/price-provider"; +export * from "./defi/price-volume-provider"; +export * from "./defi/token-trades-provider"; +export * from "./defi/trades-seek-provider"; + +// Token Providers +export * from "./token/all-market-list-provider"; +export * from "./token/new-listing-provider"; +export * from "./token/token-creation-provider"; +export * from "./token/token-holder-provider"; +export * from "./token/token-list-provider"; +export * from "./token/token-market-provider"; +export * from "./token/token-metadata-provider"; +export * from "./token/token-mint-burn-provider"; +export * from "./token/token-overview-provider"; +export * from "./token/token-security-provider"; +export * from "./token/token-trade-provider"; +export * from "./token/top-traders-provider"; +export * from "./token/trending-tokens-provider"; + +// Wallet Providers +export * from "./wallet/portfolio-multichain-provider"; +export * from "./wallet/supported-networks-provider"; +export * from "./wallet/token-balance-provider"; +export * from "./wallet/transaction-history-multichain-provider"; +export * from "./wallet/transaction-history-provider"; +export * from "./wallet/wallet-portfolio-provider"; + +// Trader Providers +export * from "./trader/gainers-losers-provider"; +export * from "./trader/trades-seek-provider"; + +// Pair Providers +export * from "./pair/pair-overview-provider"; + +// Search Providers +export * from "./search/token-market-data-provider"; diff --git a/packages/plugin-birdeye/src/providers/pair/index.ts b/packages/plugin-birdeye/src/providers/pair/index.ts new file mode 100644 index 0000000000..92e68edb47 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/pair/index.ts @@ -0,0 +1 @@ +export * from "./pair-overview-provider"; diff --git a/packages/plugin-birdeye/src/providers/pair/pair-overview-provider.ts b/packages/plugin-birdeye/src/providers/pair/pair-overview-provider.ts new file mode 100644 index 0000000000..f169d59d1c --- /dev/null +++ b/packages/plugin-birdeye/src/providers/pair/pair-overview-provider.ts @@ -0,0 +1,286 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +// Types +interface PairToken { + address: string; + symbol: string; + name: string; + decimals: number; + logoURI?: string; +} + +interface PairOverview { + address: string; + baseToken: PairToken; + quoteToken: PairToken; + price: number; + priceChange24h: number; + priceChange24hPercent: number; + volume24h: number; + liquidity: number; + txCount24h: number; + lastTradeUnixTime: number; + dex: string; +} + +interface MultiPairOverview { + [pairAddress: string]: PairOverview; +} + +// Constants +const PAIR_KEYWORDS = [ + "pair", + "pairs", + "trading pair", + "market", + "markets", + "pool", + "pools", + "liquidity pool", + "dex", + "exchange", +] as const; + +const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +] as const; + +const BASE_URL = "https://public-api.birdeye.so"; + +// Helper functions +const containsPairKeyword = (text: string): boolean => { + return PAIR_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractChain = (text: string): string => { + const chain = CHAIN_KEYWORDS.find((chain) => + text.toLowerCase().includes(chain.toLowerCase()) + ); + return chain || "solana"; +}; + +const extractPairAddresses = (text: string): string[] => { + const words = text.split(/\s+/); + const addresses: string[] = []; + + for (const word of words) { + // Ethereum-like addresses (0x...) + if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { + addresses.push(word); + } + // Solana addresses (base58, typically 32-44 chars) + if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { + addresses.push(word); + } + } + return addresses; +}; + +const getPairOverview = async ( + apiKey: string, + pairAddress: string, + chain: string = "solana" +): Promise => { + try { + const params = new URLSearchParams({ + address: pairAddress, + }); + const url = `${BASE_URL}/pair/overview_single?${params.toString()}`; + + elizaLogger.info( + `Fetching pair overview for address ${pairAddress} on ${chain} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + elizaLogger.warn(`Pair not found: ${pairAddress} on ${chain}`); + return null; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching pair overview:", error); + return null; + } +}; + +const getMultiplePairOverviews = async ( + apiKey: string, + pairAddresses: string[], + chain: string = "solana" +): Promise => { + try { + const params = new URLSearchParams({ + addresses: pairAddresses.join(","), + }); + const url = `${BASE_URL}/pair/overview_multiple?${params.toString()}`; + + elizaLogger.info( + `Fetching multiple pair overviews for ${pairAddresses.length} pairs on ${chain} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching multiple pair overviews:", error); + return null; + } +}; + +const formatValue = (value: number): string => { + if (value >= 1_000_000_000) { + return `$${(value / 1_000_000_000).toFixed(2)}B`; + } + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(2)}M`; + } + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(2)}K`; + } + return `$${value.toFixed(2)}`; +}; + +const formatPairOverview = (pair: PairOverview): string => { + const lastTradeTime = new Date( + pair.lastTradeUnixTime * 1000 + ).toLocaleString(); + const priceFormatted = + pair.price < 0.01 ? pair.price.toExponential(2) : pair.price.toFixed(2); + + let response = `${pair.baseToken.symbol}/${pair.quoteToken.symbol} on ${pair.dex}\n`; + response += `ā€¢ Address: ${pair.address}\n`; + response += `ā€¢ Price: $${priceFormatted}\n`; + + const changeSymbol = pair.priceChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; + response += `ā€¢ 24h Change: ${changeSymbol} ${pair.priceChange24hPercent.toFixed(2)}% (${formatValue(pair.priceChange24h)})\n`; + response += `ā€¢ 24h Volume: ${formatValue(pair.volume24h)}\n`; + response += `ā€¢ Liquidity: ${formatValue(pair.liquidity)}\n`; + response += `ā€¢ 24h Transactions: ${pair.txCount24h.toLocaleString()}\n`; + response += `ā€¢ Last Trade: ${lastTradeTime}\n`; + + return response; +}; + +const formatMultiplePairOverviews = ( + pairs: MultiPairOverview, + chain: string +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + let response = `Trading Pairs on ${chainName}:\n\n`; + + if (Object.keys(pairs).length === 0) { + return response + "No pairs found."; + } + + // Sort pairs by liquidity + const sortedPairs = Object.values(pairs).sort( + (a, b) => b.liquidity - a.liquidity + ); + + sortedPairs.forEach((pair, index) => { + response += `${index + 1}. ${formatPairOverview(pair)}\n`; + }); + + return response; +}; + +export const pairOverviewProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsPairKeyword(messageText)) { + return null; + } + + const pairAddresses = extractPairAddresses(messageText); + if (pairAddresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + + if (pairAddresses.length === 1) { + elizaLogger.info( + `PAIR OVERVIEW provider activated for address ${pairAddresses[0]} on ${chain}` + ); + + const pairData = await getPairOverview( + apiKey, + pairAddresses[0], + chain + ); + + if (!pairData) { + return null; + } + + return formatPairOverview(pairData); + } else { + elizaLogger.info( + `MULTIPLE PAIR OVERVIEW provider activated for ${pairAddresses.length} pairs on ${chain}` + ); + + const pairData = await getMultiplePairOverviews( + apiKey, + pairAddresses, + chain + ); + + if (!pairData) { + return null; + } + + return formatMultiplePairOverviews(pairData, chain); + } + }, +}; diff --git a/packages/plugin-birdeye/src/providers/search/index.ts b/packages/plugin-birdeye/src/providers/search/index.ts new file mode 100644 index 0000000000..a27e735c05 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/search/index.ts @@ -0,0 +1 @@ +export * from "./token-market-data-provider"; diff --git a/packages/plugin-birdeye/src/providers/search/token-market-data-provider.ts b/packages/plugin-birdeye/src/providers/search/token-market-data-provider.ts new file mode 100644 index 0000000000..29690ad1ea --- /dev/null +++ b/packages/plugin-birdeye/src/providers/search/token-market-data-provider.ts @@ -0,0 +1,214 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +interface SearchToken { + address: string; + name: string; + symbol: string; + decimals: number; + volume24hUSD: number; + liquidity: number; + logoURI: string; + price: number; +} + +const SEARCH_KEYWORDS = ["search", "find", "look for", "lookup", "locate"]; + +const TOKEN_KEYWORDS = [ + "token", + "tokens", + "coin", + "coins", + "crypto", + "cryptocurrency", + "asset", + "assets", + "sol", + "solana", +]; + +const SUPPORTED_CHAINS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +]; + +const BASE_URL = "https://public-api.birdeye.so"; + +interface SearchTokensOptions { + query: string; + chain?: string; + limit?: number; + offset?: number; +} + +const searchTokens = async ( + apiKey: string, + options: SearchTokensOptions +): Promise => { + try { + const { query, chain = "solana", limit = 10, offset = 0 } = options; + + const params = new URLSearchParams({ + query, + limit: limit.toString(), + offset: offset.toString(), + }); + + const url = `${BASE_URL}/defi/v3/search?${params.toString()}`; + elizaLogger.info("Searching tokens from:", url); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data.tokens || []; + } catch (error) { + elizaLogger.error("Error searching tokens:", error); + throw error; + } +}; + +const formatSearchResultsToString = ( + tokens: SearchToken[], + query: string, + chain: string +): string => { + if (!tokens.length) { + return `No tokens found matching "${query}" on ${chain}.`; + } + + const formattedTokens = tokens + .map((token, index) => { + const priceFormatted = + token.price != null + ? token.price < 0.01 + ? token.price.toExponential(2) + : token.price.toFixed(2) + : "N/A"; + + const volume = + token.volume24hUSD != null + ? `$${(token.volume24hUSD / 1_000_000).toFixed(2)}M` + : "N/A"; + + const liquidity = + token.liquidity != null + ? `$${(token.liquidity / 1_000_000).toFixed(2)}M` + : "N/A"; + + return ( + `${index + 1}. ${token.name} (${token.symbol}):\n` + + ` Address: ${token.address}\n` + + ` Price: $${priceFormatted}\n` + + ` Volume 24h: ${volume}\n` + + ` Liquidity: ${liquidity}` + ); + }) + .join("\n\n"); + + return `Search results for "${query}" on ${chain.charAt(0).toUpperCase() + chain.slice(1)}:\n\n${formattedTokens}`; +}; + +export const tokenMarketDataProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + return null; + } + + const messageText = message.content.text.toLowerCase(); + + // Check if message contains search-related keywords + const hasSearchKeyword = SEARCH_KEYWORDS.some((keyword) => + messageText.includes(keyword) + ); + + // Check if message contains token-related keywords + const hasTokenKeyword = TOKEN_KEYWORDS.some((keyword) => + messageText.includes(keyword) + ); + + // Extract potential search query + // Look for quotes first + let searchQuery = + messageText.match(/"([^"]+)"/)?.[1] || + messageText.match(/'([^']+)'/)?.[1]; + + // If no quotes, try to extract query after search keywords + if (!searchQuery) { + for (const keyword of SEARCH_KEYWORDS) { + if (messageText.includes(keyword)) { + const parts = messageText.split(keyword); + if (parts[1]) { + searchQuery = parts[1] + .trim() + .split(/[\s,.]/) + .filter((word) => word.length > 1) + .join(" ") + .trim(); + break; + } + } + } + } + + // Determine which chain is being asked about + const requestedChain = + SUPPORTED_CHAINS.find((chain) => + messageText.includes(chain.toLowerCase()) + ) || "solana"; + + // Get the current offset from state or default to 0 + const currentOffset = (_state?.searchTokensOffset as number) || 0; + + // Combine signals to make decision + const shouldProvideData = + searchQuery && hasSearchKeyword && hasTokenKeyword; + + if (!shouldProvideData || !searchQuery) { + return null; + } + + elizaLogger.info( + `Search tokens provider activated for query "${searchQuery}" on ${requestedChain}` + ); + + const searchResults = await searchTokens(apiKey, { + query: searchQuery, + chain: requestedChain, + offset: currentOffset, + limit: 10, + }); + + return formatSearchResultsToString( + searchResults, + searchQuery, + requestedChain + ); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/__tests__/token-overview-provider.test.ts b/packages/plugin-birdeye/src/providers/token/__tests__/token-overview-provider.test.ts new file mode 100644 index 0000000000..85271a9fce --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/__tests__/token-overview-provider.test.ts @@ -0,0 +1,189 @@ +import { IAgentRuntime, Memory, State } from "@elizaos/core"; +import { tokenOverviewProvider } from "../token-overview-provider"; + +// Mock data +const mockTokenOverview = { + address: "0x1234567890123456789012345678901234567890", + symbol: "TEST", + name: "Test Token", + decimals: 18, + logoURI: "https://example.com/logo.png", + price: 1.23, + priceChange24hPercent: 5.67, + liquidity: 1000000, + marketCap: 10000000, + realMc: 9000000, + supply: 1000000, + circulatingSupply: 900000, + holder: 1000, + v24h: 100000, + v24hUSD: 123000, + lastTradeUnixTime: 1704067200, + numberMarkets: 5, + extensions: { + website: "https://example.com", + twitter: "https://twitter.com/test", + telegram: "https://t.me/test", + discord: "https://discord.gg/test", + description: "A test token", + coingeckoId: "test-token", + }, +}; + +// Mock fetch globally +global.fetch = jest.fn(); + +describe("Token Overview Provider", () => { + let mockRuntime: IAgentRuntime; + let mockMessage: Memory; + let mockState: State; + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Mock runtime + mockRuntime = { + getSetting: jest.fn().mockReturnValue("mock-api-key"), + } as unknown as IAgentRuntime; + + // Mock message + mockMessage = { + content: { + text: "Show me overview of 0x1234567890123456789012345678901234567890 on ethereum", + }, + } as Memory; + + // Mock state + mockState = {} as State; + + // Mock successful fetch response + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ data: mockTokenOverview }), + }); + }); + + test("returns null when API key is missing", async () => { + (mockRuntime.getSetting as jest.Mock).mockReturnValue(null); + const result = await tokenOverviewProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).toBeNull(); + }); + + test("returns null when message does not contain overview keywords", async () => { + mockMessage.content.text = "random message without overview keywords"; + const result = await tokenOverviewProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).toBeNull(); + }); + + test("returns null when no contract address is found", async () => { + mockMessage.content.text = "show overview of invalid-address"; + const result = await tokenOverviewProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).toBeNull(); + }); + + test("handles API error gracefully", async () => { + (global.fetch as jest.Mock).mockRejectedValue(new Error("API Error")); + const result = await tokenOverviewProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).toBeNull(); + }); + + test("handles 404 response gracefully", async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 404, + }); + const result = await tokenOverviewProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).toBeNull(); + }); + + test("formats token overview correctly", async () => { + const result = await tokenOverviewProvider.get( + mockRuntime, + mockMessage, + mockState + ); + + // Verify the result contains all expected sections + expect(result).toContain("Token Overview for Test Token (TEST)"); + expect(result).toContain("šŸ“Š Market Data"); + expect(result).toContain("šŸ“ˆ Trading Info"); + expect(result).toContain("šŸ’° Supply Information"); + expect(result).toContain("šŸ”— Token Details"); + expect(result).toContain("šŸŒ Social Links"); + + // Verify specific data points + expect(result).toContain(`Current Price: $${mockTokenOverview.price}`); + expect(result).toContain(`Market Cap: $${mockTokenOverview.marketCap}`); + expect(result).toContain(mockTokenOverview.address); + expect(result).toContain(mockTokenOverview.extensions.website); + }); + + test("handles missing social links gracefully", async () => { + const tokenWithoutSocials = { + ...mockTokenOverview, + extensions: undefined, + }; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ data: tokenWithoutSocials }), + }); + + const result = await tokenOverviewProvider.get( + mockRuntime, + mockMessage, + mockState + ); + expect(result).not.toContain("šŸŒ Social Links"); + }); + + test("extracts chain correctly", async () => { + mockMessage.content.text = + "show overview of 0x1234567890123456789012345678901234567890 on ethereum"; + await tokenOverviewProvider.get(mockRuntime, mockMessage, mockState); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + "x-chain": "ethereum", + }), + }) + ); + }); + + test("defaults to solana chain when not specified", async () => { + mockMessage.content.text = + "show overview of 0x1234567890123456789012345678901234567890"; + await tokenOverviewProvider.get(mockRuntime, mockMessage, mockState); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + "x-chain": "solana", + }), + }) + ); + }); +}); diff --git a/packages/plugin-birdeye/src/providers/token/all-market-list-provider.ts b/packages/plugin-birdeye/src/providers/token/all-market-list-provider.ts new file mode 100644 index 0000000000..dd7d9d8e93 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/all-market-list-provider.ts @@ -0,0 +1,114 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { BASE_URL, Chain, makeApiRequest } from "../utils"; + +// Types +interface Market { + address: string; + name: string; + symbol: string; + baseToken: string; + quoteToken: string; + volume24h: number; + tvl: number; + lastTradeTime: number; +} + +interface AllMarketsResponse { + markets: Market[]; +} + +// Constants +const MARKET_LIST_KEYWORDS = [ + "all markets", + "market list", + "trading pairs", + "available markets", + "list markets", + "show markets", +] as const; + +// Helper functions +const containsMarketListKeyword = (text: string): boolean => { + return MARKET_LIST_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getAllMarkets = async ( + apiKey: string, + chain: Chain = "solana" +): Promise => { + try { + const url = `${BASE_URL}/token/all_market_list`; + + elizaLogger.info("Fetching all markets from:", url); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching markets:", error.message); + } + return null; + } +}; + +const formatAllMarketsResponse = (data: AllMarketsResponse): string => { + let response = "šŸ“Š Available Markets\n\n"; + + // Sort markets by volume + const sortedMarkets = [...data.markets].sort( + (a, b) => b.volume24h - a.volume24h + ); + + sortedMarkets.forEach((market) => { + const lastTradeDate = new Date( + market.lastTradeTime * 1000 + ).toLocaleString(); + + response += `${market.name} (${market.symbol})\n`; + response += `ā€¢ Address: ${market.address}\n`; + response += `ā€¢ Base Token: ${market.baseToken}\n`; + response += `ā€¢ Quote Token: ${market.quoteToken}\n`; + response += `ā€¢ 24h Volume: $${market.volume24h.toLocaleString()}\n`; + response += `ā€¢ TVL: $${market.tvl.toLocaleString()}\n`; + response += `ā€¢ Last Trade: ${lastTradeDate}\n\n`; + }); + + return response.trim(); +}; + +export const allMarketListProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsMarketListKeyword(messageText)) { + return null; + } + + elizaLogger.info("ALL_MARKET_LIST provider activated"); + + const marketsData = await getAllMarkets(apiKey); + + if (!marketsData) { + return null; + } + + return formatAllMarketsResponse(marketsData); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/index.ts b/packages/plugin-birdeye/src/providers/token/index.ts new file mode 100644 index 0000000000..1fdabaf640 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/index.ts @@ -0,0 +1,13 @@ +export * from "./all-market-list-provider"; +export * from "./new-listing-provider"; +export * from "./token-creation-provider"; +export * from "./token-holder-provider"; +export * from "./token-list-provider"; +export * from "./token-market-provider"; +export * from "./token-metadata-provider"; +export * from "./token-mint-burn-provider"; +export * from "./token-overview-provider"; +export * from "./token-security-provider"; +export * from "./token-trade-provider"; +export * from "./top-traders-provider"; +export * from "./trending-tokens-provider"; diff --git a/packages/plugin-birdeye/src/providers/token/new-listing-provider.ts b/packages/plugin-birdeye/src/providers/token/new-listing-provider.ts new file mode 100644 index 0000000000..5ee8952577 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/new-listing-provider.ts @@ -0,0 +1,113 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { BASE_URL, Chain, makeApiRequest } from "../utils"; + +// Types +interface TokenListing { + address: string; + name: string; + symbol: string; + listingTime: number; + initialPrice: number; + currentPrice: number; + priceChange: number; + volume24h: number; +} + +interface NewListingsResponse { + listings: TokenListing[]; +} + +// Constants +const NEW_LISTING_KEYWORDS = [ + "new listings", + "newly listed", + "recent listings", + "latest tokens", + "new tokens", +] as const; + +// Helper functions +const containsNewListingKeyword = (text: string): boolean => { + return NEW_LISTING_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getNewListings = async ( + apiKey: string, + chain: Chain = "solana" +): Promise => { + try { + const url = `${BASE_URL}/token/new_listing`; + + elizaLogger.info("Fetching new token listings from:", url); + + return await makeApiRequest(url, { + apiKey, + chain, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching new listings:", error.message); + } + return null; + } +}; + +const formatNewListingsResponse = (data: NewListingsResponse): string => { + let response = "šŸ†• New Token Listings\n\n"; + + data.listings.forEach((listing) => { + const listingDate = new Date( + listing.listingTime * 1000 + ).toLocaleString(); + const priceChangePercent = (listing.priceChange * 100).toFixed(2); + const priceChangeEmoji = listing.priceChange >= 0 ? "šŸ“ˆ" : "šŸ“‰"; + + response += `${listing.name} (${listing.symbol}) ${priceChangeEmoji}\n`; + response += `ā€¢ Address: ${listing.address}\n`; + response += `ā€¢ Listed: ${listingDate}\n`; + response += `ā€¢ Initial Price: $${listing.initialPrice.toFixed(6)}\n`; + response += `ā€¢ Current Price: $${listing.currentPrice.toFixed(6)}\n`; + response += `ā€¢ Price Change: ${priceChangePercent}%\n`; + response += `ā€¢ 24h Volume: $${listing.volume24h.toLocaleString()}\n\n`; + }); + + return response.trim(); +}; + +export const newListingProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsNewListingKeyword(messageText)) { + return null; + } + + elizaLogger.info("NEW_LISTING provider activated"); + + const listingsData = await getNewListings(apiKey); + + if (!listingsData) { + return null; + } + + return formatNewListingsResponse(listingsData); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/token-creation-provider.ts b/packages/plugin-birdeye/src/providers/token/token-creation-provider.ts new file mode 100644 index 0000000000..fc78c81f39 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/token-creation-provider.ts @@ -0,0 +1,199 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + formatTimestamp, + formatValue, + makeApiRequest, + shortenAddress, +} from "../utils"; + +// Types +interface CreationData { + creator: string; + creatorBalance: number; + creatorBalanceUSD: number; + creatorShare: number; + creationTime: number; + initialSupply: number; + initialSupplyUSD: number; + creationTx: string; +} + +interface CreationResponse { + data: CreationData; + token: string; +} + +// Constants +const CREATION_KEYWORDS = [ + "creation", + "creator", + "created", + "launch", + "launched", + "deployment", + "deployed", + "initial supply", + "token creation", + "token launch", + "token deployment", + "token origin", + "token history", + "token birth", + "genesis", +] as const; + +// Helper functions +const containsCreationKeyword = (text: string): boolean => { + return CREATION_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getTokenCreation = async ( + apiKey: string, + contractAddress: string, + chain: Chain +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + }); + const url = `${BASE_URL}/token/creation?${params.toString()}`; + + elizaLogger.info( + `Fetching creation data for ${contractAddress} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching creation data:", error.message); + } + return null; + } +}; + +const analyzeCreationMetrics = (data: CreationData): string => { + let analysis = ""; + + // Analyze creator's share + if (data.creatorShare > 50) { + analysis += + "āš ļø Creator holds majority of supply, high concentration risk. "; + } else if (data.creatorShare > 20) { + analysis += "āš” Creator maintains significant holdings. "; + } else if (data.creatorShare > 5) { + analysis += "āœ… Creator retains moderate holdings. "; + } else { + analysis += "šŸ”„ Creator holds minimal share of supply. "; + } + + // Analyze initial supply value + if (data.initialSupplyUSD > 1000000) { + analysis += + "šŸ’° Large initial supply value indicates significant launch. "; + } else if (data.initialSupplyUSD > 100000) { + analysis += + "šŸ’« Moderate initial supply value suggests standard launch. "; + } else { + analysis += + "šŸŒ± Small initial supply value indicates grassroots launch. "; + } + + // Analyze creator's current position + const valueChange = data.creatorBalanceUSD / data.initialSupplyUSD; + if (valueChange > 1.5) { + analysis += "šŸ“ˆ Creator's position has significantly appreciated. "; + } else if (valueChange < 0.5) { + analysis += "šŸ“‰ Creator's position has notably decreased. "; + } + + return analysis; +}; + +const formatCreationResponse = ( + data: CreationResponse, + chain: Chain +): string => { + const { data: creationData } = data; + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + + let response = `Token Creation Data for ${data.token} on ${chainName}\n\n`; + + // Creation Analysis + response += "šŸ“Š Creation Analysis\n"; + response += analyzeCreationMetrics(creationData) + "\n\n"; + + // Creation Details + response += "šŸŽ‚ Creation Details\n"; + response += `Creation Time: ${formatTimestamp(creationData.creationTime)}\n`; + response += `Creator: ${shortenAddress(creationData.creator)}\n`; + response += `Creation Tx: ${shortenAddress(creationData.creationTx)}\n\n`; + + // Supply Information + response += "šŸ’° Supply Information\n"; + response += `Initial Supply: ${formatValue(creationData.initialSupply)}\n`; + response += `Initial Value: ${formatValue(creationData.initialSupplyUSD)}\n\n`; + + // Creator Holdings + response += "šŸ‘¤ Creator Holdings\n"; + response += `Current Balance: ${formatValue(creationData.creatorBalance)}\n`; + response += `Current Value: ${formatValue(creationData.creatorBalanceUSD)}\n`; + response += `Share of Supply: ${creationData.creatorShare.toFixed(2)}%`; + + return response; +}; + +export const tokenCreationProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsCreationKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + + elizaLogger.info( + `TOKEN CREATION provider activated for ${addresses[0]} on ${chain}` + ); + + const creationData = await getTokenCreation( + apiKey, + addresses[0], + chain + ); + + if (!creationData) { + return null; + } + + return formatCreationResponse(creationData, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/token-holder-provider.ts b/packages/plugin-birdeye/src/providers/token/token-holder-provider.ts new file mode 100644 index 0000000000..5f9c40b369 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/token-holder-provider.ts @@ -0,0 +1,220 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + extractLimit, + formatValue, + makeApiRequest, + shortenAddress, +} from "../utils"; + +// Types +interface HolderData { + address: string; + balance: number; + balanceUSD: number; + share: number; + rank: number; +} + +interface TokenHolderResponse { + holders: HolderData[]; + totalCount: number; + token: string; +} + +// Constants +const HOLDER_KEYWORDS = [ + "holders", + "holding", + "token holders", + "token holding", + "who holds", + "who owns", + "ownership", + "distribution", + "token distribution", + "token ownership", + "top holders", + "largest holders", + "biggest holders", + "whale holders", + "whale watching", +] as const; + +// Helper functions +const containsHolderKeyword = (text: string): boolean => { + return HOLDER_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getTokenHolders = async ( + apiKey: string, + contractAddress: string, + chain: Chain, + limit: number +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + limit: limit.toString(), + }); + const url = `${BASE_URL}/token/holder?${params.toString()}`; + + elizaLogger.info( + `Fetching token holders for ${contractAddress} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { + apiKey, + chain, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching token holders:", error.message); + } + return null; + } +}; + +const formatHolderData = (holder: HolderData): string => { + let response = `${holder.rank}. ${shortenAddress(holder.address)}\n`; + response += ` ā€¢ Balance: ${holder.balance ? formatValue(holder.balance) : "N/A"}\n`; + response += ` ā€¢ Value: ${holder.balanceUSD ? formatValue(holder.balanceUSD) : "N/A"}\n`; + response += ` ā€¢ Share: ${holder.share ? holder.share.toFixed(2) : "0.00"}%`; + return response; +}; + +const analyzeDistribution = (holders: HolderData[]): string => { + // Calculate concentration metrics + const top10Share = holders + .slice(0, 10) + .reduce((sum, h) => sum + h.share, 0); + const top20Share = holders + .slice(0, 20) + .reduce((sum, h) => sum + h.share, 0); + const top50Share = holders + .slice(0, 50) + .reduce((sum, h) => sum + h.share, 0); + + let analysis = ""; + + // Analyze top holder concentration + const topHolder = holders[0]; + if (topHolder.share > 50) { + analysis += + "šŸšØ Extremely high concentration: Top holder owns majority of supply. "; + } else if (topHolder.share > 20) { + analysis += + "āš ļø High concentration: Top holder owns significant portion. "; + } else if (topHolder.share > 10) { + analysis += + "ā„¹ļø Moderate concentration: Top holder owns notable portion. "; + } else { + analysis += + "āœ… Good distribution: No single holder owns dominant share. "; + } + + // Analyze overall distribution + if (top10Share > 80) { + analysis += + "Top 10 holders control vast majority of supply, indicating high centralization. "; + } else if (top10Share > 50) { + analysis += + "Top 10 holders control majority of supply, showing moderate centralization. "; + } else { + analysis += + "Top 10 holders control less than half of supply, suggesting good distribution. "; + } + + // Provide distribution metrics + analysis += `\n\nDistribution Metrics:\n`; + analysis += `ā€¢ Top 10 Holders: ${top10Share.toFixed(2)}%\n`; + analysis += `ā€¢ Top 20 Holders: ${top20Share.toFixed(2)}%\n`; + analysis += `ā€¢ Top 50 Holders: ${top50Share.toFixed(2)}%`; + + return analysis; +}; + +const formatHolderResponse = ( + data: TokenHolderResponse, + chain: Chain +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + + let response = `Token Holders for ${data.token} on ${chainName}\n\n`; + + if (data.holders.length === 0) { + return response + "No holder data found."; + } + + response += `šŸ“Š Distribution Analysis\n`; + response += analyzeDistribution(data.holders); + response += "\n\n"; + + response += `šŸ‘„ Top Holders\n`; + data.holders.forEach((holder) => { + response += formatHolderData(holder) + "\n\n"; + }); + + if (data.totalCount > data.holders.length) { + response += `Showing ${data.holders.length} of ${data.totalCount} total holders.`; + } + + return response; +}; + +export const tokenHolderProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsHolderKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + const limit = extractLimit(messageText); + + elizaLogger.info( + `TOKEN HOLDER provider activated for ${addresses[0]} on ${chain}` + ); + + const holderData = await getTokenHolders( + apiKey, + addresses[0], + chain, + limit + ); + + if (!holderData) { + return null; + } + + return formatHolderResponse(holderData, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/token-list-provider.ts b/packages/plugin-birdeye/src/providers/token/token-list-provider.ts new file mode 100644 index 0000000000..dfe95705b2 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/token-list-provider.ts @@ -0,0 +1,198 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + formatPercentChange, + formatValue, + makeApiRequest, + shortenAddress, +} from "../utils"; + +// Types +interface TokenListData { + address: string; + name: string; + symbol: string; + decimals: number; + price: number; + priceChange24h: number; + volume24h: number; + marketCap: number; + liquidity: number; + rank: number; +} + +interface TokenListResponse { + data: TokenListData[]; + totalCount: number; +} + +// Constants +const LIST_KEYWORDS = [ + "list", + "top tokens", + "popular tokens", + "trending tokens", + "token list", + "token ranking", + "token rankings", + "token leaderboard", + "best tokens", + "highest volume", + "highest market cap", + "highest liquidity", +] as const; + +// Helper functions +const containsListKeyword = (text: string): boolean => { + return LIST_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getTokenList = async ( + apiKey: string, + chain: Chain, + limit: number = 10 +): Promise => { + try { + const params = new URLSearchParams({ + limit: limit.toString(), + }); + const url = `${BASE_URL}/token/list?${params.toString()}`; + + elizaLogger.info(`Fetching token list on ${chain} from:`, url); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching token list:", error.message); + } + return null; + } +}; + +const formatTokenData = (token: TokenListData, rank: number): string => { + let response = `${rank}. ${token.name} (${token.symbol})\n`; + response += ` ā€¢ Address: ${shortenAddress(token.address)}\n`; + response += ` ā€¢ Price: ${formatValue(token.price)} (${formatPercentChange(token.priceChange24h)})\n`; + response += ` ā€¢ Volume 24h: ${formatValue(token.volume24h)}\n`; + response += ` ā€¢ Market Cap: ${formatValue(token.marketCap)}\n`; + response += ` ā€¢ Liquidity: ${formatValue(token.liquidity)}`; + return response; +}; + +const analyzeTokenList = (tokens: TokenListData[]): string => { + let analysis = ""; + + // Volume analysis + const validVolumes = tokens.filter((t) => t.volume24h != null); + const totalVolume = validVolumes.reduce((sum, t) => sum + t.volume24h, 0); + const avgVolume = + validVolumes.length > 0 ? totalVolume / validVolumes.length : 0; + const highVolumeTokens = validVolumes.filter( + (t) => t.volume24h > avgVolume * 2 + ); + + if (highVolumeTokens.length > 0) { + analysis += `šŸ”„ ${highVolumeTokens.length} tokens showing exceptional trading activity.\n`; + } + + // Price movement analysis + const validPriceChanges = tokens.filter((t) => t.priceChange24h != null); + const positiveMovers = validPriceChanges.filter( + (t) => t.priceChange24h > 0 + ); + const strongMovers = validPriceChanges.filter( + (t) => Math.abs(t.priceChange24h) > 10 + ); + + if (validPriceChanges.length > 0) { + if (positiveMovers.length > validPriceChanges.length / 2) { + analysis += + "šŸ“ˆ Market showing bullish trend with majority positive price movement.\n"; + } else { + analysis += + "šŸ“‰ Market showing bearish trend with majority negative price movement.\n"; + } + + if (strongMovers.length > 0) { + analysis += `āš” ${strongMovers.length} tokens with significant price movement (>10%).\n`; + } + } + + // Liquidity analysis + const totalLiquidity = tokens.reduce((sum, t) => sum + t.liquidity, 0); + const avgLiquidity = totalLiquidity / tokens.length; + const highLiquidityTokens = tokens.filter( + (t) => t.liquidity > avgLiquidity * 2 + ); + + if (highLiquidityTokens.length > 0) { + analysis += `šŸ’§ ${highLiquidityTokens.length} tokens with notably high liquidity.\n`; + } + + return analysis; +}; + +const formatListResponse = (data: TokenListResponse, chain: Chain): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + + let response = `Top Tokens on ${chainName}\n\n`; + + // Market Analysis + response += "šŸ“Š Market Analysis\n"; + response += analyzeTokenList(data.data) + "\n\n"; + + // Token List + response += "šŸ† Token Rankings\n"; + data.data.forEach((token, index) => { + response += formatTokenData(token, index + 1) + "\n\n"; + }); + + if (data.totalCount > data.data.length) { + response += `Showing ${data.data.length} of ${data.totalCount} total tokens.`; + } + + return response; +}; + +export const tokenListProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsListKeyword(messageText)) { + return null; + } + + const chain = extractChain(messageText); + const limit = messageText.toLowerCase().includes("all") ? 100 : 10; + + elizaLogger.info(`TOKEN LIST provider activated for ${chain}`); + + const listData = await getTokenList(apiKey, chain, limit); + + if (!listData) { + return null; + } + + return formatListResponse(listData, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/token-market-provider.ts b/packages/plugin-birdeye/src/providers/token/token-market-provider.ts new file mode 100644 index 0000000000..a5f2ccb85f --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/token-market-provider.ts @@ -0,0 +1,217 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + formatPercentChange, + formatValue, + makeApiRequest, +} from "../utils"; + +// Types +interface MarketData { + price: number; + priceChange24h: number; + priceChange7d: number; + priceChange14d: number; + priceChange30d: number; + volume24h: number; + volume7d: number; + marketCap: number; + fullyDilutedValuation: number; + rank: number; + liquidity: number; + liquidityChange24h: number; + liquidityChange7d: number; +} + +interface MarketResponse { + data: MarketData; + token: string; +} + +// Constants +const MARKET_KEYWORDS = [ + "market", + "price", + "volume", + "liquidity", + "market cap", + "mcap", + "fdv", + "valuation", + "market data", + "market info", + "market stats", + "market metrics", + "market overview", + "market analysis", + "price change", + "price movement", + "price action", + "price performance", +] as const; + +// Helper functions +const containsMarketKeyword = (text: string): boolean => { + return MARKET_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getTokenMarket = async ( + apiKey: string, + contractAddress: string, + chain: Chain +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + }); + const url = `${BASE_URL}/token/market?${params.toString()}`; + + elizaLogger.info( + `Fetching market data for ${contractAddress} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching market data:", error.message); + } + return null; + } +}; + +const formatPriceChanges = (data: MarketData): string => { + let changes = ""; + changes += `24h: ${formatPercentChange(data.priceChange24h)}\n`; + changes += `7d: ${formatPercentChange(data.priceChange7d)}\n`; + changes += `14d: ${formatPercentChange(data.priceChange14d)}\n`; + changes += `30d: ${formatPercentChange(data.priceChange30d)}`; + return changes; +}; + +const formatLiquidityChanges = (data: MarketData): string => { + let changes = ""; + changes += `24h: ${formatPercentChange(data.liquidityChange24h)}\n`; + changes += `7d: ${formatPercentChange(data.liquidityChange7d)}`; + return changes; +}; + +const analyzeMarketMetrics = (data: MarketData): string => { + let analysis = ""; + + // Price trend analysis + if (data.priceChange24h > 5) { + analysis += "šŸ“ˆ Strong bullish momentum in the last 24 hours. "; + } else if (data.priceChange24h < -5) { + analysis += "šŸ“‰ Significant price decline in the last 24 hours. "; + } + + // Volume analysis + const volumeToMcap = (data.volume24h / data.marketCap) * 100; + if (volumeToMcap > 20) { + analysis += "šŸ”„ High trading activity relative to market cap. "; + } else if (volumeToMcap < 1) { + analysis += "āš ļø Low trading volume relative to market cap. "; + } + + // Liquidity analysis + const liquidityToMcap = (data.liquidity / data.marketCap) * 100; + if (liquidityToMcap > 30) { + analysis += "šŸ’§ Strong liquidity relative to market cap. "; + } else if (liquidityToMcap < 5) { + analysis += "āš ļø Limited liquidity relative to market cap. "; + } + + // Market cap vs FDV analysis + if (data.fullyDilutedValuation > data.marketCap * 3) { + analysis += "āš ļø High potential for dilution based on FDV. "; + } + + return analysis || "Market metrics are within normal ranges."; +}; + +const formatMarketResponse = (data: MarketResponse, chain: Chain): string => { + const { data: marketData } = data; + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + + let response = `Market Data for ${data.token} on ${chainName}\n\n`; + + // Market Analysis + response += "šŸ“Š Market Analysis\n"; + response += analyzeMarketMetrics(marketData) + "\n\n"; + + // Price Information + response += "šŸ’° Price Information\n"; + response += `Current Price: ${formatValue(marketData.price)}\n\n`; + response += "Price Changes:\n"; + response += formatPriceChanges(marketData) + "\n\n"; + + // Volume Information + response += "šŸ“ˆ Volume Information\n"; + response += `24h Volume: ${marketData.volume24h ? formatValue(marketData.volume24h) : "N/A"}\n`; + response += `7d Volume: ${marketData.volume7d ? formatValue(marketData.volume7d) : "N/A"}\n\n`; + + // Market Metrics + response += "šŸ“Š Market Metrics\n"; + response += `Market Cap: ${marketData.marketCap ? formatValue(marketData.marketCap) : "N/A"}\n`; + response += `Fully Diluted Valuation: ${marketData.fullyDilutedValuation ? formatValue(marketData.fullyDilutedValuation) : "N/A"}\n`; + response += `Market Rank: ${marketData.rank ? `#${marketData.rank}` : "N/A"}\n\n`; + + // Liquidity Information + response += "šŸ’§ Liquidity Information\n"; + response += `Current Liquidity: ${marketData.liquidity ? formatValue(marketData.liquidity) : "N/A"}\n\n`; + response += "Liquidity Changes:\n"; + response += formatLiquidityChanges(marketData); + + return response; +}; + +export const tokenMarketProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsMarketKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + + elizaLogger.info( + `TOKEN MARKET provider activated for ${addresses[0]} on ${chain}` + ); + + const marketData = await getTokenMarket(apiKey, addresses[0], chain); + + if (!marketData) { + return null; + } + + return formatMarketResponse(marketData, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/token-metadata-provider.ts b/packages/plugin-birdeye/src/providers/token/token-metadata-provider.ts new file mode 100644 index 0000000000..590b70327c --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/token-metadata-provider.ts @@ -0,0 +1,197 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + formatValue, + makeApiRequest, +} from "../utils"; + +// Types +interface TokenMetadata { + address: string; + name: string; + symbol: string; + decimals: number; + totalSupply: number; + totalSupplyUSD: number; + website: string; + twitter: string; + telegram: string; + discord: string; + coingeckoId: string; + description: string; + logo: string; + tags: string[]; +} + +interface MetadataResponse { + metadata: TokenMetadata; +} + +// Constants +const METADATA_KEYWORDS = [ + "metadata", + "token info", + "token information", + "token details", + "token data", + "token description", + "token profile", + "token overview", + "token stats", + "token statistics", + "token social", + "token links", + "token website", + "token socials", +] as const; + +// Helper functions +const containsMetadataKeyword = (text: string): boolean => { + return METADATA_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getTokenMetadata = async ( + apiKey: string, + contractAddress: string, + chain: Chain +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + }); + const url = `${BASE_URL}/token/metadata?${params.toString()}`; + + elizaLogger.info( + `Fetching token metadata for ${contractAddress} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching token metadata:", error.message); + } + return null; + } +}; + +const formatSocialLinks = (metadata: TokenMetadata): string => { + const links = []; + + if (metadata.website) { + links.push(`šŸŒ [Website](${metadata.website})`); + } + if (metadata.twitter) { + links.push(`šŸ¦ [Twitter](${metadata.twitter})`); + } + if (metadata.telegram) { + links.push(`šŸ“± [Telegram](${metadata.telegram})`); + } + if (metadata.discord) { + links.push(`šŸ’¬ [Discord](${metadata.discord})`); + } + if (metadata.coingeckoId) { + links.push( + `šŸ¦Ž [CoinGecko](https://www.coingecko.com/en/coins/${metadata.coingeckoId})` + ); + } + + return links.length > 0 ? links.join("\n") : "No social links available"; +}; + +const formatMetadataResponse = ( + data: MetadataResponse, + chain: Chain +): string => { + const { metadata } = data; + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + + let response = `Token Metadata for ${metadata.name} (${metadata.symbol}) on ${chainName}\n\n`; + + // Basic Information + response += "šŸ“ Basic Information\n"; + response += `ā€¢ Name: ${metadata.name}\n`; + response += `ā€¢ Symbol: ${metadata.symbol}\n`; + response += `ā€¢ Address: ${metadata.address}\n`; + response += `ā€¢ Decimals: ${metadata.decimals}\n`; + response += `ā€¢ Total Supply: ${formatValue(metadata.totalSupply)}\n`; + response += `ā€¢ Total Supply USD: ${formatValue(metadata.totalSupplyUSD)}\n`; + + // Description + if (metadata.description) { + response += "\nšŸ“‹ Description\n"; + response += metadata.description + "\n"; + } + + // Tags + if (metadata.tags && metadata.tags.length > 0) { + response += "\nšŸ·ļø Tags\n"; + response += metadata.tags.map((tag) => `#${tag}`).join(" ") + "\n"; + } + + // Social Links + response += "\nšŸ”— Social Links\n"; + response += formatSocialLinks(metadata) + "\n"; + + // Logo + if (metadata.logo) { + response += "\nšŸ–¼ļø Logo\n"; + response += metadata.logo; + } + + return response; +}; + +export const tokenMetadataProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsMetadataKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + + elizaLogger.info( + `TOKEN METADATA provider activated for ${addresses[0]} on ${chain}` + ); + + const metadataData = await getTokenMetadata( + apiKey, + addresses[0], + chain + ); + + if (!metadataData) { + return null; + } + + return formatMetadataResponse(metadataData, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/token-mint-burn-provider.ts b/packages/plugin-birdeye/src/providers/token/token-mint-burn-provider.ts new file mode 100644 index 0000000000..e60e8d7f9b --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/token-mint-burn-provider.ts @@ -0,0 +1,203 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + formatValue, + makeApiRequest, + shortenAddress, +} from "../utils"; + +// Types +interface MintBurnEvent { + type: "mint" | "burn"; + amount: number; + amountUSD: number; + timestamp: number; + txHash: string; + address: string; +} + +interface MintBurnResponse { + events: MintBurnEvent[]; + token: string; +} + +// Constants +const MINT_BURN_KEYWORDS = [ + "mint", + "burn", + "minting", + "burning", + "token supply", + "supply changes", + "token burns", + "token mints", + "supply history", + "mint history", + "burn history", + "supply events", +] as const; + +// Helper functions +const containsMintBurnKeyword = (text: string): boolean => { + return MINT_BURN_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getTokenMintBurnHistory = async ( + apiKey: string, + contractAddress: string, + chain: Chain +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + }); + const url = `${BASE_URL}/token/mint_burn?${params.toString()}`; + + elizaLogger.info( + `Fetching mint/burn history for ${contractAddress} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error( + "Error fetching mint/burn history:", + error.message + ); + } + return null; + } +}; + +const formatEventData = (event: MintBurnEvent): string => { + const date = new Date(event.timestamp * 1000).toLocaleString(); + const eventType = event.type === "mint" ? "šŸŸ¢ Mint" : "šŸ”“ Burn"; + + let response = `${eventType} Event - ${date}\n`; + response += ` ā€¢ Amount: ${formatValue(event.amount)}\n`; + response += ` ā€¢ Value: ${formatValue(event.amountUSD)}\n`; + response += ` ā€¢ By: ${shortenAddress(event.address)}\n`; + response += ` ā€¢ Tx: ${shortenAddress(event.txHash)}`; + return response; +}; + +const analyzeMintBurnTrends = (events: MintBurnEvent[]): string => { + const mints = events.filter((e) => e.type === "mint"); + const burns = events.filter((e) => e.type === "burn"); + + const totalMinted = mints.reduce((sum, e) => sum + e.amount, 0); + const totalBurned = burns.reduce((sum, e) => sum + e.amount, 0); + const netChange = totalMinted - totalBurned; + + let analysis = "šŸ“Š Supply Change Analysis\n\n"; + + // Supply change metrics + analysis += `Total Minted: ${formatValue(totalMinted)}\n`; + analysis += `Total Burned: ${formatValue(totalBurned)}\n`; + analysis += `Net Change: ${formatValue(Math.abs(netChange))} ${netChange >= 0 ? "increase" : "decrease"}\n\n`; + + // Activity analysis + analysis += "Recent Activity:\n"; + if (mints.length === 0 && burns.length === 0) { + analysis += "ā€¢ No mint/burn activity in the period\n"; + } else { + if (mints.length > 0) { + analysis += `ā€¢ ${mints.length} mint events\n`; + } + if (burns.length > 0) { + analysis += `ā€¢ ${burns.length} burn events\n`; + } + } + + // Supply trend + if (netChange > 0) { + analysis += "\nšŸ“ˆ Supply is expanding"; + } else if (netChange < 0) { + analysis += "\nšŸ“‰ Supply is contracting"; + } else { + analysis += "\nāž”ļø Supply is stable"; + } + + return analysis; +}; + +const formatMintBurnResponse = ( + data: MintBurnResponse, + chain: Chain +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + + let response = `Mint/Burn History for ${data.token} on ${chainName}\n\n`; + + if (data.events.length === 0) { + return response + "No mint/burn events found."; + } + + response += analyzeMintBurnTrends(data.events); + response += "\n\nšŸ“œ Recent Events\n"; + + // Sort events by timestamp in descending order + const sortedEvents = [...data.events].sort( + (a, b) => b.timestamp - a.timestamp + ); + sortedEvents.forEach((event) => { + response += "\n" + formatEventData(event) + "\n"; + }); + + return response; +}; + +export const tokenMintBurnProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsMintBurnKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + + elizaLogger.info( + `TOKEN MINT/BURN provider activated for ${addresses[0]} on ${chain}` + ); + + const mintBurnData = await getTokenMintBurnHistory( + apiKey, + addresses[0], + chain + ); + + if (!mintBurnData) { + return null; + } + + return formatMintBurnResponse(mintBurnData, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/token-overview-provider.ts b/packages/plugin-birdeye/src/providers/token/token-overview-provider.ts new file mode 100644 index 0000000000..594a72fe1e --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/token-overview-provider.ts @@ -0,0 +1,266 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +// Types +interface TokenExtensions { + website?: string; + twitter?: string; + telegram?: string; + discord?: string; + description?: string; + coingeckoId?: string; +} + +interface TokenOverview { + // Basic token info + address: string; + symbol: string; + name: string; + decimals: number; + logoURI: string; + + // Price and market data + price: number; + priceChange24hPercent: number; + liquidity: number; + marketCap: number; + realMc: number; + + // Supply info + supply: number; + circulatingSupply: number; + holder: number; + + // Volume data + v24h: number; + v24hUSD: number; + + // Social/metadata + extensions?: TokenExtensions; + + // Trading info + lastTradeUnixTime: number; + numberMarkets: number; +} + +// Constants +const OVERVIEW_KEYWORDS = [ + "overview", + "details", + "info", + "information", + "about", + "tell me about", + "what is", + "show me", +] as const; + +const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +] as const; + +const BASE_URL = "https://public-api.birdeye.so"; + +// Helper functions +const containsOverviewKeyword = (text: string): boolean => { + return OVERVIEW_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractChain = (text: string): string => { + const chain = CHAIN_KEYWORDS.find((chain) => + text.toLowerCase().includes(chain.toLowerCase()) + ); + return chain || "solana"; +}; + +const extractContractAddress = (text: string): string | null => { + const words = text.split(/\s+/); + + for (const word of words) { + // Ethereum-like addresses (0x...) + if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { + return word; + } + // Solana addresses (base58, typically 32-44 chars) + if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { + return word; + } + } + return null; +}; + +const formatNumber = (num: number): string => { + if (!num && num !== 0) return "N/A"; + return num.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 6, + }); +}; + +const formatSocialLinks = (extensions?: TokenExtensions): string => { + if (!extensions) return ""; + + return Object.entries(extensions) + .filter(([key, value]) => { + try { + return Boolean( + value && + typeof value === "string" && + ["website", "twitter", "telegram", "discord"].includes( + key + ) + ); + } catch (err) { + elizaLogger.warn( + `Error processing social link for key ${key}:`, + err + ); + return false; + } + }) + .map(([key, value]) => { + try { + return `ā€¢ ${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`; + } catch (err) { + elizaLogger.error( + `Error formatting social link for ${key}:`, + err + ); + return ""; + } + }) + .filter(Boolean) + .join("\n"); +}; + +const formatTokenOverview = (token: TokenOverview, chain: string): string => { + const lastTradeTime = new Date( + token.lastTradeUnixTime * 1000 + ).toLocaleString(); + const socialLinks = formatSocialLinks(token.extensions); + + return `Token Overview for ${token.name} (${token.symbol}) on ${chain.charAt(0).toUpperCase() + chain.slice(1)} + +šŸ“Š Market Data +ā€¢ Current Price: $${formatNumber(token.price)} +ā€¢ 24h Change: ${formatNumber(token.priceChange24hPercent)}% +ā€¢ Market Cap: $${formatNumber(token.marketCap)} +ā€¢ Real Market Cap: $${formatNumber(token.realMc)} +ā€¢ Liquidity: $${formatNumber(token.liquidity)} + +šŸ“ˆ Trading Info +ā€¢ 24h Volume: $${formatNumber(token.v24hUSD)} +ā€¢ Number of Markets: ${token.numberMarkets} +ā€¢ Last Trade: ${lastTradeTime} + +šŸ’° Supply Information +ā€¢ Total Supply: ${formatNumber(token.supply)} +ā€¢ Circulating Supply: ${formatNumber(token.circulatingSupply)} +ā€¢ Number of Holders: ${token.holder ? formatNumber(token.holder) : "N/A"} + +šŸ”— Token Details +ā€¢ Contract: ${token.address} +ā€¢ Decimals: ${token.decimals} +${token.extensions?.description ? `ā€¢ Description: ${token.extensions.description}\n` : ""} +${socialLinks ? `\nšŸŒ Social Links\n${socialLinks}` : ""}`; +}; + +const getTokenOverview = async ( + apiKey: string, + contractAddress: string, + chain: string = "solana" +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + }); + const url = `${BASE_URL}/defi/token_overview?${params.toString()}`; + + elizaLogger.info( + `Fetching token overview for address ${contractAddress} on ${chain} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + elizaLogger.warn( + `Token not found: ${contractAddress} on ${chain}` + ); + return null; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching token overview:", error); + return null; + } +}; + +export const tokenOverviewProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsOverviewKeyword(messageText)) { + return null; + } + + const contractAddress = extractContractAddress(messageText); + if (!contractAddress) { + return null; + } + + const chain = extractChain(messageText); + + elizaLogger.info( + `TOKEN OVERVIEW provider activated for address ${contractAddress} on ${chain}` + ); + + const tokenOverview = await getTokenOverview( + apiKey, + contractAddress, + chain + ); + + if (!tokenOverview) { + return null; + } + + return formatTokenOverview(tokenOverview, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/token-security-provider.ts b/packages/plugin-birdeye/src/providers/token/token-security-provider.ts new file mode 100644 index 0000000000..1affa34ad4 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/token-security-provider.ts @@ -0,0 +1,238 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + makeApiRequest, +} from "../utils"; + +// Types +interface SecurityData { + isHoneypot: boolean; + isProxy: boolean; + isVerified: boolean; + isAudited: boolean; + isRenounced: boolean; + isMintable: boolean; + isPausable: boolean; + hasBlacklist: boolean; + hasFeeOnTransfer: boolean; + transferFeePercentage: number; + riskLevel: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; + riskFactors: string[]; +} + +interface SecurityResponse { + data: SecurityData; + token: string; +} + +// Constants +const SECURITY_KEYWORDS = [ + "security", + "risk", + "audit", + "safety", + "honeypot", + "scam", + "safe", + "verified", + "contract security", + "token security", + "token safety", + "token risk", + "token audit", + "security check", + "risk check", + "safety check", +] as const; + +// Helper functions +const containsSecurityKeyword = (text: string): boolean => { + return SECURITY_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getTokenSecurity = async ( + apiKey: string, + contractAddress: string, + chain: Chain +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + }); + const url = `${BASE_URL}/token/security?${params.toString()}`; + + elizaLogger.info( + `Fetching security data for ${contractAddress} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching security data:", error.message); + } + return null; + } +}; + +const getRiskEmoji = (riskLevel: SecurityData["riskLevel"]): string => { + switch (riskLevel) { + case "LOW": + return "āœ…"; + case "MEDIUM": + return "āš ļø"; + case "HIGH": + return "šŸšØ"; + case "CRITICAL": + return "šŸ’€"; + default: + return "ā“"; + } +}; + +const formatSecurityFeatures = (data: SecurityData): string => { + const features = [ + { name: "Contract Verified", value: data.isVerified }, + { name: "Contract Audited", value: data.isAudited }, + { name: "Ownership Renounced", value: data.isRenounced }, + { name: "Mintable Token", value: data.isMintable }, + { name: "Pausable Token", value: data.isPausable }, + { name: "Has Blacklist", value: data.hasBlacklist }, + { name: "Has Transfer Fee", value: data.hasFeeOnTransfer }, + ]; + + return features + .map(({ name, value }) => `ā€¢ ${name}: ${value ? "āœ…" : "āŒ"}`) + .join("\n"); +}; + +const analyzeSecurityRisks = (data: SecurityData): string => { + let analysis = ""; + + // Critical checks + if (data.isHoneypot) { + analysis += + "šŸš« CRITICAL: Token is identified as a honeypot! DO NOT TRADE.\n"; + } + + if (data.isProxy) { + analysis += + "āš ļø Contract is upgradeable (proxy). Owner can modify functionality.\n"; + } + + // Fee analysis + if (data.hasFeeOnTransfer) { + const feeLevel = data.transferFeePercentage > 5 ? "High" : "Standard"; + analysis += `šŸ’ø ${feeLevel} transfer fee (${data.transferFeePercentage}%).\n`; + } + + // Contract security + if (!data.isVerified) { + analysis += "āš ļø Contract is not verified. Cannot audit code.\n"; + } + if (!data.isAudited) { + analysis += "āš ļø No professional audit found.\n"; + } + if (!data.isRenounced) { + analysis += + "šŸ‘¤ Contract ownership retained. Owner can modify contract.\n"; + } + + // Token features + if (data.isMintable) { + analysis += "šŸ“ˆ Token supply can be increased by owner.\n"; + } + if (data.isPausable) { + analysis += "āøļø Trading can be paused by owner.\n"; + } + if (data.hasBlacklist) { + analysis += "šŸš« Addresses can be blacklisted from trading.\n"; + } + + // Risk factors + if (data.riskFactors.length > 0) { + analysis += "\nIdentified Risk Factors:\n"; + data.riskFactors.forEach((factor) => { + analysis += `ā€¢ ${factor}\n`; + }); + } + + return analysis; +}; + +const formatSecurityResponse = ( + data: SecurityResponse, + chain: Chain +): string => { + const { data: securityData } = data; + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + + let response = `Security Analysis for ${data.token} on ${chainName}\n\n`; + + // Overall Risk Level + response += `šŸŽÆ Risk Level: ${getRiskEmoji(securityData.riskLevel)} ${securityData.riskLevel}\n\n`; + + // Security Analysis + response += "šŸ” Security Analysis\n"; + response += analyzeSecurityRisks(securityData) + "\n\n"; + + // Contract Features + response += "šŸ“‹ Contract Features\n"; + response += formatSecurityFeatures(securityData); + + return response; +}; + +export const tokenSecurityProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsSecurityKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + + elizaLogger.info( + `TOKEN SECURITY provider activated for ${addresses[0]} on ${chain}` + ); + + const securityData = await getTokenSecurity( + apiKey, + addresses[0], + chain + ); + + if (!securityData) { + return null; + } + + return formatSecurityResponse(securityData, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/token-trade-provider.ts b/packages/plugin-birdeye/src/providers/token/token-trade-provider.ts new file mode 100644 index 0000000000..8c19da3f12 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/token-trade-provider.ts @@ -0,0 +1,327 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +// Types +interface Trade { + timestamp: number; + price: number; + volume: number; + side: "buy" | "sell"; + source: string; + txHash: string; + buyer?: string; + seller?: string; +} + +interface TokenTradeData { + trades: Trade[]; + totalCount: number; + token: string; +} + +interface MultiTokenTradeData { + [tokenAddress: string]: TokenTradeData; +} + +// Constants +const TRADE_KEYWORDS = [ + "trades", + "trading", + "transactions", + "swaps", + "buys", + "sells", + "orders", + "executions", + "trade history", + "trading history", + "recent trades", +] as const; + +const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +] as const; + +const BASE_URL = "https://public-api.birdeye.so"; + +// Helper functions +const containsTradeKeyword = (text: string): boolean => { + return TRADE_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractChain = (text: string): string => { + const chain = CHAIN_KEYWORDS.find((chain) => + text.toLowerCase().includes(chain.toLowerCase()) + ); + return chain || "solana"; +}; + +const extractContractAddresses = (text: string): string[] => { + const words = text.split(/\s+/); + const addresses: string[] = []; + + for (const word of words) { + // Ethereum-like addresses (0x...) + if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { + addresses.push(word); + } + // Solana addresses (base58, typically 32-44 chars) + if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { + addresses.push(word); + } + } + return addresses; +}; + +const getSingleTokenTrades = async ( + apiKey: string, + contractAddress: string, + chain: string = "solana", + limit: number = 10 +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + limit: limit.toString(), + }); + const url = `${BASE_URL}/token/trade_data_single?${params.toString()}`; + + elizaLogger.info( + `Fetching trade data for token ${contractAddress} on ${chain} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + elizaLogger.warn( + `Token not found: ${contractAddress} on ${chain}` + ); + return null; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching token trade data:", error); + return null; + } +}; + +const getMultipleTokenTrades = async ( + apiKey: string, + addresses: string[], + chain: string = "solana", + limit: number = 5 +): Promise => { + try { + const params = new URLSearchParams({ + addresses: addresses.join(","), + limit: limit.toString(), + }); + const url = `${BASE_URL}/token/trade_data_multiple?${params.toString()}`; + + elizaLogger.info( + `Fetching trade data for ${addresses.length} tokens on ${chain} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching multiple token trade data:", error); + return null; + } +}; + +const formatValue = (value: number): string => { + if (value >= 1_000_000_000) { + return `$${(value / 1_000_000_000).toFixed(2)}B`; + } + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(2)}M`; + } + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(2)}K`; + } + return `$${value.toFixed(2)}`; +}; + +const shortenAddress = (address: string): string => { + if (!address || address.length <= 12) return address || "Unknown"; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; + +const formatTrade = (trade: Trade): string => { + const timestamp = new Date(trade.timestamp * 1000).toLocaleString(); + const priceFormatted = + trade.price != null + ? trade.price < 0.01 + ? trade.price.toExponential(2) + : trade.price.toFixed(2) + : "N/A"; + const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; + + let response = `${side} - ${timestamp}\n`; + response += `ā€¢ Price: $${priceFormatted}\n`; + response += `ā€¢ Volume: ${trade.volume ? formatValue(trade.volume) : "N/A"}\n`; + response += `ā€¢ Source: ${trade.source || "Unknown"}\n`; + if (trade.buyer && trade.seller) { + response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; + response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; + } + response += `ā€¢ Tx: ${trade.txHash ? shortenAddress(trade.txHash) : "N/A"}`; + + return response; +}; + +const formatSingleTokenTradeResponse = ( + data: TokenTradeData, + chain: string +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + let response = `Recent Trades for ${data.token} on ${chainName}:\n\n`; + + if (data.trades.length === 0) { + return response + "No recent trades found."; + } + + data.trades.forEach((trade, index) => { + response += `${index + 1}. ${formatTrade(trade)}\n\n`; + }); + + if (data.totalCount > data.trades.length) { + response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; + } + + return response; +}; + +const formatMultipleTokenTradeResponse = ( + data: MultiTokenTradeData, + chain: string +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + let response = `Recent Trades on ${chainName}:\n\n`; + + const tokens = Object.entries(data); + if (tokens.length === 0) { + return response + "No trades found for any token."; + } + + tokens.forEach(([address, tokenData]) => { + response += `${tokenData.token} (${shortenAddress(address)}):\n`; + + if (tokenData.trades.length === 0) { + response += "No recent trades\n\n"; + return; + } + + tokenData.trades.forEach((trade, index) => { + response += `${index + 1}. ${formatTrade(trade)}\n`; + }); + + if (tokenData.totalCount > tokenData.trades.length) { + response += `Showing ${tokenData.trades.length} of ${tokenData.totalCount} trades\n`; + } + response += "\n"; + }); + + return response; +}; + +export const tokenTradeProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsTradeKeyword(messageText)) { + return null; + } + + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + return null; + } + + const chain = extractChain(messageText); + + if (addresses.length === 1) { + elizaLogger.info( + `TOKEN TRADE provider activated for address ${addresses[0]} on ${chain}` + ); + + const tradeData = await getSingleTokenTrades( + apiKey, + addresses[0], + chain + ); + + if (!tradeData) { + return null; + } + + return formatSingleTokenTradeResponse(tradeData, chain); + } else { + elizaLogger.info( + `MULTIPLE TOKEN TRADE provider activated for ${addresses.length} addresses on ${chain}` + ); + + const tradeData = await getMultipleTokenTrades( + apiKey, + addresses, + chain + ); + + if (!tradeData) { + return null; + } + + return formatMultipleTokenTradeResponse(tradeData, chain); + } + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/top-traders-provider.ts b/packages/plugin-birdeye/src/providers/token/top-traders-provider.ts new file mode 100644 index 0000000000..ec8c9824e8 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/top-traders-provider.ts @@ -0,0 +1,104 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { BASE_URL, Chain, makeApiRequest } from "../utils"; + +// Types +interface Trader { + address: string; + tradeCount: number; + volume: number; + profit: number; + lastTradeTime: number; +} + +interface TopTradersResponse { + traders: Trader[]; +} + +// Constants +const TOP_TRADERS_KEYWORDS = [ + "top traders", + "best traders", + "leading traders", + "most successful traders", + "highest volume traders", +] as const; + +// Helper functions +const containsTopTradersKeyword = (text: string): boolean => { + return TOP_TRADERS_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getTopTraders = async ( + apiKey: string, + chain: Chain = "solana" +): Promise => { + try { + const url = `${BASE_URL}/token/top_traders`; + + elizaLogger.info("Fetching top traders from:", url); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching top traders:", error.message); + } + return null; + } +}; + +const formatTopTradersResponse = (data: TopTradersResponse): string => { + let response = "šŸ† Top Traders\n\n"; + + data.traders.forEach((trader, index) => { + const lastTradeDate = new Date( + trader.lastTradeTime * 1000 + ).toLocaleString(); + const profitPrefix = trader.profit >= 0 ? "+" : ""; + + response += `${index + 1}. Trader ${trader.address.slice(0, 8)}...${trader.address.slice(-6)}\n`; + response += `ā€¢ Trade Count: ${trader.tradeCount.toLocaleString()}\n`; + response += `ā€¢ Volume: $${trader.volume.toLocaleString()}\n`; + response += `ā€¢ Profit: ${profitPrefix}$${trader.profit.toLocaleString()}\n`; + response += `ā€¢ Last Trade: ${lastTradeDate}\n\n`; + }); + + return response.trim(); +}; + +export const topTradersProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsTopTradersKeyword(messageText)) { + return null; + } + + elizaLogger.info("TOP_TRADERS provider activated"); + + const tradersData = await getTopTraders(apiKey); + + if (!tradersData) { + return null; + } + + return formatTopTradersResponse(tradersData); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/token/trending-tokens-provider.ts b/packages/plugin-birdeye/src/providers/token/trending-tokens-provider.ts new file mode 100644 index 0000000000..afe3134653 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/token/trending-tokens-provider.ts @@ -0,0 +1,270 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +interface TrendingToken { + address: string; + name: string; + symbol: string; + decimals: number; + volume24hUSD: number; + liquidity: number; + logoURI: string; + price: number; +} + +const TRENDING_KEYWORDS = [ + "trending", + "popular", + "hot", + "top", + "performing", + "movers", + "gainers", + "volume", + "liquidity", + "market cap", + "price action", +]; + +const TOKEN_KEYWORDS = [ + "token", + "tokens", + "coin", + "coins", + "crypto", + "cryptocurrency", + "asset", + "assets", + "sol", + "solana", +]; + +const ASCENDING_KEYWORDS = [ + "lowest", + "worst", + "bottom", + "least", + "smallest", + "weakest", +]; + +const PAGINATION_KEYWORDS = [ + "more", + "additional", + "next", + "other", + "show more", + "continue", +]; + +const SUPPORTED_CHAINS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +]; + +const BASE_URL = "https://public-api.birdeye.so"; + +interface GetTrendingTokensOptions { + sort_by?: "volume24hUSD" | "rank" | "liquidity"; + sort_type?: "desc" | "asc"; + offset?: number; + limit?: number; + min_liquidity?: number; + chain?: string; +} + +const getTrendingTokens = async ( + apiKey: string, + options: GetTrendingTokensOptions = {} +): Promise => { + try { + const { + sort_by = "volume24hUSD", + sort_type = "desc", + offset = 0, + limit = 10, + min_liquidity = 1000, + chain = "solana", + } = options; + + const params = new URLSearchParams({ + sort_by, + sort_type, + offset: offset.toString(), + limit: limit.toString(), + min_liquidity: min_liquidity.toString(), + }); + + const url = `${BASE_URL}/defi/token_trending?${params.toString()}`; + elizaLogger.info("Fetching trending tokens from:", url); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return (await response.json()).data.tokens; + } catch (error) { + elizaLogger.error("Error fetching trending tokens:", error); + throw error; + } +}; + +const formatTrendingTokensToString = ( + tokens: TrendingToken[], + chain: string +): string => { + if (!tokens.length) { + return "No trending tokens found."; + } + + const formattedTokens = tokens + .map((token, index) => { + const priceFormatted = + token.price != null + ? token.price < 0.01 + ? token.price.toExponential(2) + : token.price.toFixed(2) + : "N/A"; + + const volume = + token.volume24hUSD != null + ? `$${(token.volume24hUSD / 1_000_000).toFixed(2)}M` + : "N/A"; + + const liquidity = + token.liquidity != null + ? `$${(token.liquidity / 1_000_000).toFixed(2)}M` + : "N/A"; + + return ( + `${index + 1}. ${token.name || "Unknown"} (${token.symbol || "N/A"}):\n` + + ` Price: ${priceFormatted}\n` + + ` Volume 24h: ${volume}\n` + + ` Liquidity: ${liquidity}` + ); + }) + .join("\n\n"); + + return `Here are the trending tokens on ${chain.charAt(0).toUpperCase() + chain.slice(1)}:\n\n${formattedTokens}`; +}; + +export const trendingTokensProvider: Provider = { + get: async (runtime: IAgentRuntime, message: Memory, state?: State) => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + return null; + } + + const messageText = message.content.text.toLowerCase(); + + // Check if message contains trending-related keywords + const hasTrendingKeyword = TRENDING_KEYWORDS.some((keyword) => + messageText.includes(keyword) + ); + + // Check if message contains token-related keywords + const hasTokenKeyword = TOKEN_KEYWORDS.some((keyword) => + messageText.includes(keyword) + ); + + // Check if the message is a direct question about trends + const isQuestionAboutTrends = + messageText.includes("?") && + (messageText.includes("what") || + messageText.includes("which") || + messageText.includes("show")) && + hasTrendingKeyword; + + // Check recent conversation context from state + const recentMessages = (state?.recentMessagesData || []) as Memory[]; + const isInTrendingConversation = recentMessages.some( + (msg) => + msg.content?.text?.toLowerCase().includes("trending") || + msg.content?.text?.toLowerCase().includes("token") + ); + + // Determine sorting direction based on keywords + const isAscending = ASCENDING_KEYWORDS.some((keyword) => + messageText.includes(keyword) + ); + const sortType = isAscending ? "asc" : "desc"; + + // Determine if this is a pagination request + const isPaginationRequest = PAGINATION_KEYWORDS.some((keyword) => + messageText.includes(keyword) + ); + + // Get the current offset from state or default to 0 + const currentOffset = (state?.trendingTokensOffset as number) || 0; + const offset = isPaginationRequest ? currentOffset + 10 : 0; + + // Determine sort criteria based on message content + let sortBy: "volume24hUSD" | "rank" | "liquidity" = "volume24hUSD"; + if (messageText.includes("liquidity")) { + sortBy = "liquidity"; + } else if (messageText.includes("rank")) { + sortBy = "rank"; + } + + // Determine which chain is being asked about + const requestedChain = + SUPPORTED_CHAINS.find((chain) => + messageText.includes(chain.toLowerCase()) + ) || "solana"; + + // Combine signals to make decision + const shouldProvideData = + // Direct questions about trends + isQuestionAboutTrends || + // Explicit mentions of trending tokens + (hasTrendingKeyword && hasTokenKeyword) || + // Follow-up in a trending conversation + (isInTrendingConversation && hasTokenKeyword) || + // Pagination request in conversation context + (isPaginationRequest && isInTrendingConversation); + + if (!shouldProvideData) { + return null; + } + + elizaLogger.info( + `TRENDING TOKENS provider activated for ${requestedChain} trending tokens query` + ); + + const trendingTokens = await getTrendingTokens(apiKey, { + sort_by: sortBy, + sort_type: sortType, + offset, + limit: 10, + min_liquidity: 1000, + chain: requestedChain, + }); + + const formattedTrending = formatTrendingTokensToString( + trendingTokens, + requestedChain + ); + + return formattedTrending; + }, +}; diff --git a/packages/plugin-birdeye/src/providers/trader/gainers-losers-provider.ts b/packages/plugin-birdeye/src/providers/trader/gainers-losers-provider.ts new file mode 100644 index 0000000000..c9998243b1 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/trader/gainers-losers-provider.ts @@ -0,0 +1,228 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +// Types +interface TokenMarketData { + address: string; + symbol: string; + name: string; + price: number; + priceChange24h: number; + priceChange24hPercent: number; + volume24h: number; + marketCap: number; + liquidity: number; + logoURI?: string; +} + +interface GainersLosersData { + gainers: TokenMarketData[]; + losers: TokenMarketData[]; + timestamp: number; +} + +// Constants +const GAINERS_KEYWORDS = [ + "gainers", + "top gainers", + "best performing", + "biggest gains", + "movers up", + "green", + "pumping", + "rising", +] as const; + +const LOSERS_KEYWORDS = [ + "losers", + "top losers", + "worst performing", + "biggest losses", + "movers down", + "red", + "dumping", + "falling", +] as const; + +const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +] as const; + +const BASE_URL = "https://public-api.birdeye.so"; + +// Helper functions +const containsGainersKeyword = (text: string): boolean => { + return GAINERS_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const containsLosersKeyword = (text: string): boolean => { + return LOSERS_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractChain = (text: string): string => { + const chain = CHAIN_KEYWORDS.find((chain) => + text.toLowerCase().includes(chain.toLowerCase()) + ); + return chain || "solana"; +}; + +const getGainersLosers = async ( + apiKey: string, + chain: string = "solana" +): Promise => { + try { + const params = new URLSearchParams({ + limit: "10", // Get top 10 gainers and losers + }); + const url = `${BASE_URL}/trader/gainers-losers?${params.toString()}`; + + elizaLogger.info(`Fetching gainers/losers on ${chain} from:`, url); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching gainers/losers:", error); + return null; + } +}; + +const formatValue = (value: number): string => { + if (value >= 1_000_000_000) { + return `$${(value / 1_000_000_000).toFixed(2)}B`; + } + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(2)}M`; + } + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(2)}K`; + } + return `$${value.toFixed(2)}`; +}; + +const formatTokenData = (token: TokenMarketData): string => { + const priceFormatted = + token.price < 0.01 + ? token.price.toExponential(2) + : token.price.toFixed(2); + + return ( + `ā€¢ ${token.symbol} (${token.name})\n` + + ` Price: $${priceFormatted}\n` + + ` 24h Change: ${token.priceChange24hPercent.toFixed(2)}% (${formatValue(token.priceChange24h)})\n` + + ` Volume: ${formatValue(token.volume24h)}\n` + + ` Market Cap: ${formatValue(token.marketCap)}\n` + + ` Liquidity: ${formatValue(token.liquidity)}` + ); +}; + +const formatGainersLosersResponse = ( + data: GainersLosersData, + chain: string, + showGainers: boolean, + showLosers: boolean +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + let response = `Market Movers on ${chainName}\n`; + response += `Last Updated: ${new Date(data.timestamp * 1000).toLocaleString()}\n\n`; + + if (showGainers && Array.isArray(data.gainers) && data.gainers.length > 0) { + response += `šŸ“ˆ Top Gainers:\n`; + data.gainers.forEach((token, index) => { + response += `\n${index + 1}. ${formatTokenData(token)}\n`; + }); + } + + if (showLosers && Array.isArray(data.losers) && data.losers.length > 0) { + if ( + showGainers && + Array.isArray(data.gainers) && + data.gainers.length > 0 + ) + response += "\n"; + response += `šŸ“‰ Top Losers:\n`; + data.losers.forEach((token, index) => { + response += `\n${index + 1}. ${formatTokenData(token)}\n`; + }); + } + + if ( + (!data.gainers?.length && !data.losers?.length) || + (showGainers && !data.gainers?.length && !showLosers) || + (showLosers && !data.losers?.length && !showGainers) + ) { + response += "No market data available at this time."; + } + + return response; +}; + +export const gainersLosersProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + const showGainers = containsGainersKeyword(messageText); + const showLosers = containsLosersKeyword(messageText); + + // If neither gainers nor losers are specifically mentioned, show both + const showBoth = !showGainers && !showLosers; + + if (!showGainers && !showLosers && !showBoth) { + return null; + } + + const chain = extractChain(messageText); + + elizaLogger.info(`GAINERS/LOSERS provider activated for ${chain}`); + + const marketData = await getGainersLosers(apiKey, chain); + + if (!marketData) { + return null; + } + + return formatGainersLosersResponse( + marketData, + chain, + showGainers || showBoth, + showLosers || showBoth + ); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/trader/index.ts b/packages/plugin-birdeye/src/providers/trader/index.ts new file mode 100644 index 0000000000..b5889ef376 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/trader/index.ts @@ -0,0 +1,2 @@ +export * from "./gainers-losers-provider"; +export * from "./trades-seek-provider"; diff --git a/packages/plugin-birdeye/src/providers/trader/trades-seek-provider.ts b/packages/plugin-birdeye/src/providers/trader/trades-seek-provider.ts new file mode 100644 index 0000000000..20db87c394 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/trader/trades-seek-provider.ts @@ -0,0 +1,247 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +// Types +interface Trade { + timestamp: number; + token: string; + tokenAddress: string; + price: number; + volume: number; + side: "buy" | "sell"; + source: string; + txHash: string; + buyer?: string; + seller?: string; +} + +interface TradesResponse { + trades: Trade[]; + totalCount: number; +} + +// Constants +const TIME_SEEK_KEYWORDS = [ + "trades since", + "trades after", + "trades before", + "trades from", + "trades at", + "trading since", + "trading after", + "trading before", + "trading from", + "trading at", + "transactions since", + "transactions after", + "transactions before", + "transactions from", + "transactions at", +] as const; + +const TIME_UNITS = { + second: 1, + minute: 60, + hour: 3600, + day: 86400, + week: 604800, + month: 2592000, +} as const; + +const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +] as const; + +const BASE_URL = "https://public-api.birdeye.so"; + +// Helper functions +const containsTimeSeekKeyword = (text: string): boolean => { + return TIME_SEEK_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractChain = (text: string): string => { + const chain = CHAIN_KEYWORDS.find((chain) => + text.toLowerCase().includes(chain.toLowerCase()) + ); + return chain || "solana"; +}; + +const extractTimeFromText = (text: string): number | null => { + // Try to find time expressions like "1 hour ago", "2 days ago", etc. + const timeRegex = /(\d+)\s*(second|minute|hour|day|week|month)s?\s*ago/i; + const match = text.match(timeRegex); + + if (match) { + const amount = parseInt(match[1]); + const unit = match[2].toLowerCase() as keyof typeof TIME_UNITS; + const now = Math.floor(Date.now() / 1000); + return now - amount * TIME_UNITS[unit]; + } + + // Try to find specific date/time + const dateMatch = text.match(/(\d{4}-\d{2}-\d{2}|\d{2}\/\d{2}\/\d{4})/); + if (dateMatch) { + const date = new Date(dateMatch[1]); + if (!isNaN(date.getTime())) { + return Math.floor(date.getTime() / 1000); + } + } + + return null; +}; + +const getTradesByTime = async ( + apiKey: string, + timestamp: number, + chain: string = "solana", + limit: number = 10 +): Promise => { + try { + const params = new URLSearchParams({ + timestamp: timestamp.toString(), + limit: limit.toString(), + }); + const url = `${BASE_URL}/trader/trades_seek_time?${params.toString()}`; + + elizaLogger.info( + `Fetching trades since ${new Date(timestamp * 1000).toLocaleString()} on ${chain} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching trades by time:", error); + return null; + } +}; + +const formatValue = (value: number): string => { + if (value >= 1_000_000_000) { + return `$${(value / 1_000_000_000).toFixed(2)}B`; + } + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(2)}M`; + } + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(2)}K`; + } + return `$${value.toFixed(2)}`; +}; + +const shortenAddress = (address: string): string => { + if (!address || address.length <= 12) return address || "Unknown"; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; + +const formatTrade = (trade: Trade): string => { + const timestamp = new Date(trade.timestamp * 1000).toLocaleString(); + const priceFormatted = + trade.price < 0.01 + ? trade.price.toExponential(2) + : trade.price.toFixed(2); + const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; + + let response = `${side} ${trade.token} - ${timestamp}\n`; + response += `ā€¢ Token: ${shortenAddress(trade.tokenAddress)}\n`; + response += `ā€¢ Price: $${priceFormatted}\n`; + response += `ā€¢ Volume: ${formatValue(trade.volume)}\n`; + response += `ā€¢ Source: ${trade.source}\n`; + if (trade.buyer && trade.seller) { + response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; + response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; + } + response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; + + return response; +}; + +const formatTradesResponse = ( + data: TradesResponse, + timestamp: number, + chain: string +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + const fromTime = new Date(timestamp * 1000).toLocaleString(); + let response = `Trades on ${chainName} since ${fromTime}:\n\n`; + + if (data.trades.length === 0) { + return response + "No trades found in this time period."; + } + + data.trades.forEach((trade, index) => { + response += `${index + 1}. ${formatTrade(trade)}\n\n`; + }); + + if (data.totalCount > data.trades.length) { + response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; + } + + return response; +}; + +export const tradesSeekProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsTimeSeekKeyword(messageText)) { + return null; + } + + const timestamp = extractTimeFromText(messageText); + if (!timestamp) { + return null; + } + + const chain = extractChain(messageText); + + elizaLogger.info( + `TRADES SEEK provider activated for time ${new Date(timestamp * 1000).toLocaleString()} on ${chain}` + ); + + const tradesData = await getTradesByTime(apiKey, timestamp, chain); + + if (!tradesData) { + return null; + } + + return formatTradesResponse(tradesData, timestamp, chain); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/utils.ts b/packages/plugin-birdeye/src/providers/utils.ts new file mode 100644 index 0000000000..3e7a5d1252 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/utils.ts @@ -0,0 +1,298 @@ +import { elizaLogger } from "@elizaos/core"; + +// Constants +export const BASE_URL = "https://public-api.birdeye.so"; + +export const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +] as const; + +// Types +export type Chain = (typeof CHAIN_KEYWORDS)[number]; + +export class BirdeyeApiError extends Error { + constructor( + public status: number, + message: string + ) { + super(message); + this.name = "BirdeyeApiError"; + } +} + +export interface ApiResponse { + success: boolean; + data: T; + error?: string; +} + +// Time-related types and constants +export const TIME_UNITS = { + second: 1, + minute: 60, + hour: 3600, + day: 86400, + week: 604800, + month: 2592000, +} as const; + +export const TIMEFRAME_KEYWORDS = { + "1m": 60, + "3m": 180, + "5m": 300, + "15m": 900, + "30m": 1800, + "1h": 3600, + "2h": 7200, + "4h": 14400, + "6h": 21600, + "12h": 43200, + "1d": 86400, + "1w": 604800, +} as const; + +export type TimeUnit = keyof typeof TIME_UNITS; +export type Timeframe = keyof typeof TIMEFRAME_KEYWORDS; + +// Helper functions +export const extractChain = (text: string): Chain => { + const chain = CHAIN_KEYWORDS.find((chain) => + text.toLowerCase().includes(chain.toLowerCase()) + ); + return (chain || "solana") as Chain; +}; + +export const extractContractAddresses = (text: string): string[] => { + const words = text.split(/\s+/); + const addresses: string[] = []; + + for (const word of words) { + // Ethereum-like addresses (0x...) + if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { + addresses.push(word); + } + // Solana addresses (base58, typically 32-44 chars) + if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { + addresses.push(word); + } + } + return addresses; +}; + +// Time extraction and analysis +export const extractTimeframe = (text: string): Timeframe => { + // First, check for explicit timeframe mentions + const timeframe = Object.keys(TIMEFRAME_KEYWORDS).find((tf) => + text.toLowerCase().includes(tf.toLowerCase()) + ); + if (timeframe) return timeframe as Timeframe; + + // Check for semantic timeframe hints + const semanticMap = { + "short term": "15m", + "medium term": "1h", + "long term": "1d", + intraday: "1h", + daily: "1d", + weekly: "1w", + detailed: "5m", + quick: "15m", + overview: "1d", + } as const; + + for (const [hint, tf] of Object.entries(semanticMap)) { + if (text.toLowerCase().includes(hint)) { + return tf as Timeframe; + } + } + + // Analyze for time-related words + if (text.match(/minute|min|minutes/i)) return "15m"; + if (text.match(/hour|hourly|hours/i)) return "1h"; + if (text.match(/day|daily|24h/i)) return "1d"; + if (text.match(/week|weekly/i)) return "1w"; + + // Default based on context + if (text.match(/trade|trades|trading|recent/i)) return "15m"; + if (text.match(/trend|analysis|analyze/i)) return "1h"; + if (text.match(/history|historical|long|performance/i)) return "1d"; + + return "1h"; // Default timeframe +}; + +export const extractTimeRange = ( + text: string +): { start: number; end: number } => { + const now = Math.floor(Date.now() / 1000); + + // Check for specific date ranges + const dateRangeMatch = text.match( + /from\s+(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})/i + ); + if (dateRangeMatch) { + const start = new Date(dateRangeMatch[1]).getTime() / 1000; + const end = new Date(dateRangeMatch[2]).getTime() / 1000; + return { start, end }; + } + + // Check for relative time expressions + const timeRegex = /(\d+)\s*(second|minute|hour|day|week|month)s?\s*ago/i; + const match = text.match(timeRegex); + if (match) { + const amount = parseInt(match[1]); + const unit = match[2].toLowerCase() as TimeUnit; + const start = now - amount * TIME_UNITS[unit]; + return { start, end: now }; + } + + // Check for semantic time ranges + const semanticRanges: Record = { + today: TIME_UNITS.day, + "this week": TIME_UNITS.week, + "this month": TIME_UNITS.month, + recent: TIME_UNITS.hour * 4, + latest: TIME_UNITS.hour, + "last hour": TIME_UNITS.hour, + "last day": TIME_UNITS.day, + "last week": TIME_UNITS.week, + "last month": TIME_UNITS.month, + }; + + for (const [range, duration] of Object.entries(semanticRanges)) { + if (text.toLowerCase().includes(range)) { + return { start: now - duration, end: now }; + } + } + + // Analyze context for appropriate default range + if (text.match(/trend|analysis|performance/i)) { + return { start: now - TIME_UNITS.week, end: now }; // 1 week for analysis + } + if (text.match(/trade|trades|trading|recent/i)) { + return { start: now - TIME_UNITS.day, end: now }; // 1 day for trading + } + if (text.match(/history|historical|long term/i)) { + return { start: now - TIME_UNITS.month, end: now }; // 1 month for history + } + + // Default to last 24 hours + return { start: now - TIME_UNITS.day, end: now }; +}; + +export const extractLimit = (text: string): number => { + // Check for explicit limit mentions + const limitMatch = text.match( + /\b(show|display|get|fetch|limit)\s+(\d+)\b/i + ); + if (limitMatch) { + const limit = parseInt(limitMatch[2]); + return Math.min(Math.max(limit, 1), 100); // Clamp between 1 and 100 + } + + // Check for semantic limit hints + if (text.match(/\b(all|everything|full|complete)\b/i)) return 100; + if (text.match(/\b(brief|quick|summary|overview)\b/i)) return 5; + if (text.match(/\b(detailed|comprehensive)\b/i)) return 50; + + // Default based on context + if (text.match(/\b(trade|trades|trading)\b/i)) return 10; + if (text.match(/\b(analysis|analyze|trend)\b/i)) return 24; + if (text.match(/\b(history|historical)\b/i)) return 50; + + return 10; // Default limit +}; + +// Formatting helpers +export const formatValue = (value: number): string => { + if (value >= 1_000_000_000) { + return `$${(value / 1_000_000_000).toFixed(2)}B`; + } + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(2)}M`; + } + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(2)}K`; + } + return `$${value.toFixed(2)}`; +}; + +export const formatPercentChange = (change?: number): string => { + if (change === undefined) return "N/A"; + const symbol = change >= 0 ? "šŸ“ˆ" : "šŸ“‰"; + return `${symbol} ${Math.abs(change).toFixed(2)}%`; +}; + +export const shortenAddress = (address: string): string => { + if (!address || address.length <= 12) return address || "Unknown"; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; + +export const formatTimestamp = (timestamp: number): string => { + return new Date(timestamp * 1000).toLocaleString(); +}; + +export const formatPrice = (price: number): string => { + return price < 0.01 ? price.toExponential(2) : price.toFixed(2); +}; + +// API helpers +export async function makeApiRequest( + url: string, + options: { + apiKey: string; + chain: Chain; + method?: "GET" | "POST"; + body?: any; + } +): Promise { + const { apiKey, chain, method = "GET", body } = options; + + try { + const response = await fetch(url, { + method, + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + ...(body && { "Content-Type": "application/json" }), + }, + ...(body && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + if (response.status === 404) { + throw new BirdeyeApiError(404, "Resource not found"); + } + if (response.status === 429) { + throw new BirdeyeApiError(429, "Rate limit exceeded"); + } + throw new BirdeyeApiError( + response.status, + `HTTP error! status: ${response.status}` + ); + } + + const data: ApiResponse = await response.json(); + + if (!data.success) { + throw new Error(data.error || "Unknown API error"); + } + + return data.data; + } catch (error) { + if (error instanceof BirdeyeApiError) { + elizaLogger.error(`API Error (${error.status}):`, error.message); + } else { + elizaLogger.error("Error making API request:", error); + } + throw error; + } +} diff --git a/packages/plugin-birdeye/src/providers/wallet/index.ts b/packages/plugin-birdeye/src/providers/wallet/index.ts new file mode 100644 index 0000000000..7cc0f1aa25 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/wallet/index.ts @@ -0,0 +1,6 @@ +export * from "./portfolio-multichain-provider"; +export * from "./supported-networks-provider"; +export * from "./token-balance-provider"; +export * from "./transaction-history-multichain-provider"; +export * from "./transaction-history-provider"; +export * from "./wallet-portfolio-provider"; diff --git a/packages/plugin-birdeye/src/providers/wallet/portfolio-multichain-provider.ts b/packages/plugin-birdeye/src/providers/wallet/portfolio-multichain-provider.ts new file mode 100644 index 0000000000..b9058ac7e3 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/wallet/portfolio-multichain-provider.ts @@ -0,0 +1,159 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { BASE_URL, Chain, makeApiRequest } from "../utils"; + +// Types +interface TokenHolding { + chain: Chain; + tokenAddress: string; + symbol: string; + name: string; + balance: number; + price: number; + value: number; + priceChange24h: number; +} + +interface MultichainPortfolioResponse { + holdings: TokenHolding[]; + totalValue: number; + valueChange24h: number; +} + +// Constants +const MULTICHAIN_PORTFOLIO_KEYWORDS = [ + "multichain portfolio", + "cross chain portfolio", + "all chain portfolio", + "portfolio across chains", + "portfolio on all chains", +] as const; + +// Helper functions +const containsMultichainPortfolioKeyword = (text: string): boolean => { + return MULTICHAIN_PORTFOLIO_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractWalletAddress = (text: string): string | null => { + // Look for wallet address patterns + const addressMatch = text.match(/\b[1-9A-HJ-NP-Za-km-z]{32,44}\b/); + return addressMatch ? addressMatch[0] : null; +}; + +const getMultichainPortfolio = async ( + apiKey: string, + walletAddress: string +): Promise => { + try { + const url = `${BASE_URL}/wallet/portfolio_multichain`; + + elizaLogger.info("Fetching multichain portfolio from:", url); + + return await makeApiRequest(url, { + apiKey, + chain: "solana", + body: { wallet: walletAddress }, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error( + "Error fetching multichain portfolio:", + error.message + ); + } + return null; + } +}; + +const formatMultichainPortfolioResponse = ( + data: MultichainPortfolioResponse +): string => { + let response = "šŸŒ Multichain Portfolio Overview\n\n"; + + // Add total portfolio value and 24h change + const valueChangePercent = (data.valueChange24h * 100).toFixed(2); + const valueChangeEmoji = data.valueChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; + + response += `Total Portfolio Value: $${data.totalValue.toLocaleString()}\n`; + response += `24h Change: ${valueChangePercent}% ${valueChangeEmoji}\n\n`; + + // Group holdings by chain + const holdingsByChain = data.holdings.reduce( + (acc, holding) => { + if (!acc[holding.chain]) { + acc[holding.chain] = []; + } + acc[holding.chain].push(holding); + return acc; + }, + {} as Record + ); + + // Format holdings by chain + Object.entries(holdingsByChain).forEach(([chain, holdings]) => { + response += `${chain.toUpperCase()} Holdings\n`; + + // Sort holdings by value + holdings.sort((a, b) => b.value - a.value); + + holdings.forEach((holding) => { + const priceChangePercent = (holding.priceChange24h * 100).toFixed( + 2 + ); + const priceChangeEmoji = holding.priceChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; + + response += `ā€¢ ${holding.name} (${holding.symbol})\n`; + response += ` - Balance: ${holding.balance.toLocaleString()}\n`; + response += ` - Price: $${holding.price.toFixed(6)}\n`; + response += ` - Value: $${holding.value.toLocaleString()}\n`; + response += ` - 24h Change: ${priceChangePercent}% ${priceChangeEmoji}\n\n`; + }); + }); + + return response.trim(); +}; + +export const portfolioMultichainProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsMultichainPortfolioKeyword(messageText)) { + return null; + } + + const walletAddress = extractWalletAddress(messageText); + if (!walletAddress) { + return "Please provide a valid wallet address to check the multichain portfolio."; + } + + elizaLogger.info("PORTFOLIO_MULTICHAIN provider activated"); + + const portfolioData = await getMultichainPortfolio( + apiKey, + walletAddress + ); + + if (!portfolioData) { + return null; + } + + return formatMultichainPortfolioResponse(portfolioData); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/wallet/supported-networks-provider.ts b/packages/plugin-birdeye/src/providers/wallet/supported-networks-provider.ts new file mode 100644 index 0000000000..26b6ca6bbb --- /dev/null +++ b/packages/plugin-birdeye/src/providers/wallet/supported-networks-provider.ts @@ -0,0 +1,131 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { BASE_URL, Chain, makeApiRequest } from "../utils"; + +// Types +interface NetworkSupport { + chain: Chain; + status: "active" | "maintenance" | "deprecated"; + features: string[]; +} + +interface SupportedNetworksResponse { + networks: NetworkSupport[]; +} + +// Constants +const SUPPORTED_NETWORKS_KEYWORDS = [ + "supported wallet networks", + "wallet networks", + "wallet chains", + "supported wallet chains", + "wallet network support", +] as const; + +// Helper functions +const containsSupportedNetworksKeyword = (text: string): boolean => { + return SUPPORTED_NETWORKS_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getSupportedNetworks = async ( + apiKey: string +): Promise => { + try { + const url = `${BASE_URL}/wallet/supported_networks`; + + elizaLogger.info("Fetching supported wallet networks from:", url); + + return await makeApiRequest(url, { + apiKey, + chain: "solana", + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error( + "Error fetching supported networks:", + error.message + ); + } + return null; + } +}; + +const formatSupportedNetworksResponse = ( + data: SupportedNetworksResponse +): string => { + let response = "šŸŒ Supported Wallet Networks\n\n"; + + // Group networks by status + const activeNetworks = data.networks.filter((n) => n.status === "active"); + const maintenanceNetworks = data.networks.filter( + (n) => n.status === "maintenance" + ); + const deprecatedNetworks = data.networks.filter( + (n) => n.status === "deprecated" + ); + + // Format active networks + if (activeNetworks.length > 0) { + response += "šŸŸ¢ Active Networks\n"; + activeNetworks.forEach((network) => { + response += `ā€¢ ${network.chain}\n`; + response += ` - Features: ${network.features.join(", ")}\n\n`; + }); + } + + // Format maintenance networks + if (maintenanceNetworks.length > 0) { + response += "šŸŸ” Networks Under Maintenance\n"; + maintenanceNetworks.forEach((network) => { + response += `ā€¢ ${network.chain}\n`; + response += ` - Features: ${network.features.join(", ")}\n\n`; + }); + } + + // Format deprecated networks + if (deprecatedNetworks.length > 0) { + response += "šŸ”“ Deprecated Networks\n"; + deprecatedNetworks.forEach((network) => { + response += `ā€¢ ${network.chain}\n\n`; + }); + } + + return response.trim(); +}; + +export const supportedNetworksProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsSupportedNetworksKeyword(messageText)) { + return null; + } + + elizaLogger.info("SUPPORTED_NETWORKS provider activated"); + + const networksData = await getSupportedNetworks(apiKey); + + if (!networksData) { + return null; + } + + return formatSupportedNetworksResponse(networksData); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/wallet/token-balance-provider.ts b/packages/plugin-birdeye/src/providers/wallet/token-balance-provider.ts new file mode 100644 index 0000000000..6e82646c45 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/wallet/token-balance-provider.ts @@ -0,0 +1,135 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { BASE_URL, Chain, makeApiRequest } from "../utils"; + +// Types +interface TokenBalance { + tokenAddress: string; + symbol: string; + name: string; + balance: number; + decimals: number; + price: number; + value: number; + priceChange24h: number; +} + +interface TokenBalanceResponse { + balances: TokenBalance[]; + totalValue: number; + valueChange24h: number; +} + +// Constants +const TOKEN_BALANCE_KEYWORDS = [ + "token balance", + "token holdings", + "wallet balance", + "wallet holdings", + "check balance", + "check holdings", +] as const; + +// Helper functions +const containsTokenBalanceKeyword = (text: string): boolean => { + return TOKEN_BALANCE_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractWalletAddress = (text: string): string | null => { + // Look for wallet address patterns + const addressMatch = text.match(/\b[1-9A-HJ-NP-Za-km-z]{32,44}\b/); + return addressMatch ? addressMatch[0] : null; +}; + +const getTokenBalance = async ( + apiKey: string, + walletAddress: string, + chain: Chain = "solana" +): Promise => { + try { + const url = `${BASE_URL}/wallet/token_balance`; + + elizaLogger.info("Fetching token balance from:", url); + + return await makeApiRequest(url, { + apiKey, + chain, + body: { wallet: walletAddress }, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching token balance:", error.message); + } + return null; + } +}; + +const formatTokenBalanceResponse = (data: TokenBalanceResponse): string => { + let response = "šŸ’° Token Balance Overview\n\n"; + + // Add total value and 24h change + const valueChangePercent = (data.valueChange24h * 100).toFixed(2); + const valueChangeEmoji = data.valueChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; + + response += `Total Value: $${data.totalValue.toLocaleString()}\n`; + response += `24h Change: ${valueChangePercent}% ${valueChangeEmoji}\n\n`; + + // Sort balances by value + const sortedBalances = [...data.balances].sort((a, b) => b.value - a.value); + + // Format individual token balances + sortedBalances.forEach((balance) => { + const priceChangePercent = (balance.priceChange24h * 100).toFixed(2); + const priceChangeEmoji = balance.priceChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; + + response += `${balance.name} (${balance.symbol})\n`; + response += `ā€¢ Balance: ${balance.balance.toLocaleString()}\n`; + response += `ā€¢ Price: $${balance.price.toFixed(6)}\n`; + response += `ā€¢ Value: $${balance.value.toLocaleString()}\n`; + response += `ā€¢ 24h Change: ${priceChangePercent}% ${priceChangeEmoji}\n\n`; + }); + + return response.trim(); +}; + +export const tokenBalanceProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsTokenBalanceKeyword(messageText)) { + return null; + } + + const walletAddress = extractWalletAddress(messageText); + if (!walletAddress) { + return "Please provide a valid wallet address to check the token balance."; + } + + elizaLogger.info("TOKEN_BALANCE provider activated"); + + const balanceData = await getTokenBalance(apiKey, walletAddress); + + if (!balanceData) { + return null; + } + + return formatTokenBalanceResponse(balanceData); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/wallet/transaction-history-multichain-provider.ts b/packages/plugin-birdeye/src/providers/wallet/transaction-history-multichain-provider.ts new file mode 100644 index 0000000000..66a6c8cb3f --- /dev/null +++ b/packages/plugin-birdeye/src/providers/wallet/transaction-history-multichain-provider.ts @@ -0,0 +1,174 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; +import { BASE_URL, Chain, makeApiRequest } from "../utils"; + +// Types +interface Transaction { + chain: Chain; + hash: string; + timestamp: number; + type: string; + status: "success" | "failed" | "pending"; + value: number; + fee: number; + from: string; + to: string; + tokenTransfers?: { + token: string; + amount: number; + value: number; + }[]; +} + +interface TransactionHistoryResponse { + transactions: Transaction[]; +} + +// Constants +const MULTICHAIN_HISTORY_KEYWORDS = [ + "multichain transactions", + "cross chain transactions", + "all chain transactions", + "transactions across chains", + "transaction history all chains", +] as const; + +// Helper functions +const containsMultichainHistoryKeyword = (text: string): boolean => { + return MULTICHAIN_HISTORY_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractWalletAddress = (text: string): string | null => { + // Look for wallet address patterns + const addressMatch = text.match(/\b[1-9A-HJ-NP-Za-km-z]{32,44}\b/); + return addressMatch ? addressMatch[0] : null; +}; + +const getTransactionHistory = async ( + apiKey: string, + walletAddress: string +): Promise => { + try { + const url = `${BASE_URL}/wallet/transaction_history_multichain`; + + elizaLogger.info("Fetching multichain transaction history from:", url); + + return await makeApiRequest(url, { + apiKey, + chain: "solana", + body: { wallet: walletAddress }, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error( + "Error fetching transaction history:", + error.message + ); + } + return null; + } +}; + +const formatTransactionStatus = (status: Transaction["status"]): string => { + switch (status) { + case "success": + return "āœ…"; + case "failed": + return "āŒ"; + case "pending": + return "ā³"; + default: + return "ā“"; + } +}; + +const formatTransactionHistoryResponse = ( + data: TransactionHistoryResponse +): string => { + let response = "šŸ“œ Multichain Transaction History\n\n"; + + // Group transactions by chain + const txsByChain = data.transactions.reduce( + (acc, tx) => { + if (!acc[tx.chain]) { + acc[tx.chain] = []; + } + acc[tx.chain].push(tx); + return acc; + }, + {} as Record + ); + + // Format transactions by chain + Object.entries(txsByChain).forEach(([chain, transactions]) => { + response += `${chain.toUpperCase()} Transactions\n`; + + // Sort transactions by timestamp (newest first) + transactions.sort((a, b) => b.timestamp - a.timestamp); + + transactions.forEach((tx) => { + const date = new Date(tx.timestamp * 1000).toLocaleString(); + const statusEmoji = formatTransactionStatus(tx.status); + + response += `${statusEmoji} ${tx.type} - ${date}\n`; + response += `ā€¢ Hash: ${tx.hash}\n`; + response += `ā€¢ Value: $${tx.value.toLocaleString()}\n`; + response += `ā€¢ Fee: $${tx.fee.toFixed(6)}\n`; + response += `ā€¢ From: ${tx.from}\n`; + response += `ā€¢ To: ${tx.to}\n`; + + if (tx.tokenTransfers && tx.tokenTransfers.length > 0) { + response += "ā€¢ Token Transfers:\n"; + tx.tokenTransfers.forEach((transfer) => { + response += ` - ${transfer.token}: ${transfer.amount} ($${transfer.value.toLocaleString()})\n`; + }); + } + + response += "\n"; + }); + }); + + return response.trim(); +}; + +export const transactionHistoryMultichainProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsMultichainHistoryKeyword(messageText)) { + return null; + } + + const walletAddress = extractWalletAddress(messageText); + if (!walletAddress) { + return "Please provide a valid wallet address to check the transaction history."; + } + + elizaLogger.info("TRANSACTION_HISTORY_MULTICHAIN provider activated"); + + const historyData = await getTransactionHistory(apiKey, walletAddress); + + if (!historyData) { + return null; + } + + return formatTransactionHistoryResponse(historyData); + }, +}; diff --git a/packages/plugin-birdeye/src/providers/wallet/transaction-history-provider.ts b/packages/plugin-birdeye/src/providers/wallet/transaction-history-provider.ts new file mode 100644 index 0000000000..a303e0fa10 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/wallet/transaction-history-provider.ts @@ -0,0 +1,381 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +// Types +interface TokenTransfer { + token: string; + symbol: string; + amount: number; + value: number; + decimals: number; +} + +interface Transaction { + hash: string; + timestamp: number; + type: "send" | "receive" | "swap" | "mint" | "burn" | "other"; + from: string; + to: string; + value: number; + fee: number; + success: boolean; + transfers: TokenTransfer[]; +} + +interface TransactionHistory { + transactions: Transaction[]; + totalCount: number; +} + +interface MultichainTransactionHistory { + [chain: string]: TransactionHistory; +} + +// Constants +const TRANSACTION_KEYWORDS = [ + "transaction", + "transactions", + "history", + "transfers", + "activity", + "trades", + "swaps", + "sent", + "received", + "tx", + "txs", +] as const; + +const MULTICHAIN_KEYWORDS = [ + "all chains", + "multichain", + "multi-chain", + "cross chain", + "cross-chain", + "every chain", + "all networks", +] as const; + +const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +] as const; + +const BASE_URL = "https://public-api.birdeye.so"; + +// Helper functions +const containsTransactionKeyword = (text: string): boolean => { + return TRANSACTION_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const isMultichainRequest = (text: string): boolean => { + return MULTICHAIN_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractChain = (text: string): string => { + const chain = CHAIN_KEYWORDS.find((chain) => + text.toLowerCase().includes(chain.toLowerCase()) + ); + return chain || "solana"; +}; + +const extractWalletAddress = (text: string): string | null => { + const words = text.split(/\s+/); + + for (const word of words) { + // Ethereum-like addresses (0x...) + if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { + return word; + } + // Solana addresses (base58, typically 32-44 chars) + if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { + return word; + } + } + return null; +}; + +const getTransactionHistory = async ( + apiKey: string, + walletAddress: string, + chain: string = "solana", + limit: number = 10 +): Promise => { + try { + const params = new URLSearchParams({ + wallet: walletAddress, + limit: limit.toString(), + }); + const url = `${BASE_URL}/wallet/transaction_history?${params.toString()}`; + + elizaLogger.info( + `Fetching transaction history for wallet ${walletAddress} on ${chain} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + elizaLogger.warn( + `Wallet not found: ${walletAddress} on ${chain}` + ); + return null; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching transaction history:", error); + return null; + } +}; + +const getMultichainTransactionHistory = async ( + apiKey: string, + walletAddress: string, + limit: number = 10 +): Promise => { + try { + const params = new URLSearchParams({ + wallet: walletAddress, + limit: limit.toString(), + }); + const url = `${BASE_URL}/wallet/transaction_history_multichain?${params.toString()}`; + + elizaLogger.info( + `Fetching multichain transaction history for wallet ${walletAddress} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + elizaLogger.warn(`Wallet not found: ${walletAddress}`); + return null; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error( + "Error fetching multichain transaction history:", + error + ); + return null; + } +}; + +const formatValue = (value: number): string => { + if (value >= 1_000_000_000) { + return `$${(value / 1_000_000_000).toFixed(2)}B`; + } + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(2)}M`; + } + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(2)}K`; + } + return `$${value.toFixed(2)}`; +}; + +const formatTokenAmount = (amount: number, decimals: number): string => { + const formattedAmount = amount / Math.pow(10, decimals); + if (formattedAmount >= 1_000_000) { + return `${(formattedAmount / 1_000_000).toFixed(2)}M`; + } + if (formattedAmount >= 1_000) { + return `${(formattedAmount / 1_000).toFixed(2)}K`; + } + return formattedAmount.toFixed(decimals > 6 ? 4 : 2); +}; + +const shortenAddress = (address: string): string => { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; + +const formatTransactionType = (type: string): string => { + switch (type.toLowerCase()) { + case "send": + return "šŸ“¤ Sent"; + case "receive": + return "šŸ“„ Received"; + case "swap": + return "šŸ”„ Swapped"; + case "mint": + return "šŸŒŸ Minted"; + case "burn": + return "šŸ”„ Burned"; + default: + return "šŸ“ Other"; + } +}; + +const formatSingleChainHistory = ( + history: TransactionHistory, + chain: string +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + let response = `Transaction History on ${chainName}:\n\n`; + + if (history.transactions.length === 0) { + return response + "No transactions found."; + } + + history.transactions.forEach((tx, index) => { + const date = new Date(tx.timestamp * 1000).toLocaleString(); + response += `${index + 1}. ${formatTransactionType(tx.type)} - ${date}\n`; + response += `ā€¢ Hash: ${shortenAddress(tx.hash)}\n`; + response += `ā€¢ From: ${shortenAddress(tx.from)}\n`; + response += `ā€¢ To: ${shortenAddress(tx.to)}\n`; + response += `ā€¢ Value: ${formatValue(tx.value)}\n`; + response += `ā€¢ Fee: ${formatValue(tx.fee)}\n`; + response += `ā€¢ Status: ${tx.success ? "āœ… Success" : "āŒ Failed"}\n`; + + if (tx.transfers.length > 0) { + response += "ā€¢ Tokens:\n"; + tx.transfers.forEach((transfer) => { + const amount = formatTokenAmount( + transfer.amount, + transfer.decimals + ); + response += ` - ${amount} ${transfer.symbol} (${formatValue(transfer.value)})\n`; + }); + } + response += "\n"; + }); + + if (history.totalCount > history.transactions.length) { + response += `\nShowing ${history.transactions.length} of ${history.totalCount} total transactions.`; + } + + return response; +}; + +const formatMultichainHistory = ( + history: MultichainTransactionHistory +): string => { + let response = `Multichain Transaction History:\n\n`; + + const chains = Object.keys(history); + if (chains.length === 0) { + return response + "No transactions found on any chain."; + } + + chains.forEach((chain) => { + const chainData = history[chain]; + if (chainData.transactions.length > 0) { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + response += `${chainName} (${chainData.totalCount} total transactions):\n`; + + chainData.transactions + .slice(0, 5) // Show only the 5 most recent transactions per chain + .forEach((tx, index) => { + const date = new Date(tx.timestamp * 1000).toLocaleString(); + response += `${index + 1}. ${formatTransactionType(tx.type)} - ${date}\n`; + response += ` Value: ${formatValue(tx.value)} | Status: ${tx.success ? "āœ…" : "āŒ"}\n`; + }); + + if (chainData.transactions.length > 5) { + response += ` ... and ${chainData.totalCount - 5} more transactions\n`; + } + response += "\n"; + } + }); + + return response; +}; + +export const transactionHistoryProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsTransactionKeyword(messageText)) { + return null; + } + + const walletAddress = extractWalletAddress(messageText); + if (!walletAddress) { + return null; + } + + const isMultichain = isMultichainRequest(messageText); + + if (isMultichain) { + elizaLogger.info( + `MULTICHAIN TRANSACTION HISTORY provider activated for wallet ${walletAddress}` + ); + + const historyData = await getMultichainTransactionHistory( + apiKey, + walletAddress + ); + + if (!historyData) { + return null; + } + + return formatMultichainHistory(historyData); + } else { + const chain = extractChain(messageText); + + elizaLogger.info( + `TRANSACTION HISTORY provider activated for wallet ${walletAddress} on ${chain}` + ); + + const historyData = await getTransactionHistory( + apiKey, + walletAddress, + chain + ); + + if (!historyData) { + return null; + } + + return formatSingleChainHistory(historyData, chain); + } + }, +}; diff --git a/packages/plugin-birdeye/src/providers/wallet/wallet-portfolio-provider.ts b/packages/plugin-birdeye/src/providers/wallet/wallet-portfolio-provider.ts new file mode 100644 index 0000000000..04adbfe98f --- /dev/null +++ b/packages/plugin-birdeye/src/providers/wallet/wallet-portfolio-provider.ts @@ -0,0 +1,335 @@ +import { + IAgentRuntime, + Memory, + Provider, + State, + elizaLogger, +} from "@elizaos/core"; + +// Types +interface TokenBalance { + token: string; + symbol: string; + amount: number; + price: number; + value: number; + decimals: number; + logoURI?: string; +} + +interface PortfolioData { + totalValue: number; + tokens: TokenBalance[]; + lastUpdated: number; +} + +interface MultichainPortfolioData { + chains: Record; + totalValue: number; +} + +// Constants +const PORTFOLIO_KEYWORDS = [ + "portfolio", + "holdings", + "balance", + "assets", + "tokens", + "wallet", + "what do i own", + "what do i have", +] as const; + +const MULTICHAIN_KEYWORDS = [ + "all chains", + "multichain", + "multi-chain", + "cross chain", + "cross-chain", + "every chain", + "all networks", +] as const; + +const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", +] as const; + +const BASE_URL = "https://public-api.birdeye.so"; + +// Helper functions +const containsPortfolioKeyword = (text: string): boolean => { + return PORTFOLIO_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const isMultichainRequest = (text: string): boolean => { + return MULTICHAIN_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractChain = (text: string): string => { + const chain = CHAIN_KEYWORDS.find((chain) => + text.toLowerCase().includes(chain.toLowerCase()) + ); + return chain || "solana"; +}; + +const extractWalletAddress = (text: string): string | null => { + const words = text.split(/\s+/); + + for (const word of words) { + // Ethereum-like addresses (0x...) + if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { + return word; + } + // Solana addresses (base58, typically 32-44 chars) + if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { + return word; + } + } + return null; +}; + +const getWalletPortfolio = async ( + apiKey: string, + walletAddress: string, + chain: string = "solana" +): Promise => { + try { + const params = new URLSearchParams({ + wallet: walletAddress, + }); + const url = `${BASE_URL}/wallet/portfolio?${params.toString()}`; + + elizaLogger.info( + `Fetching portfolio for wallet ${walletAddress} on ${chain} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + elizaLogger.warn( + `Wallet not found: ${walletAddress} on ${chain}` + ); + return null; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data; + } catch (error) { + elizaLogger.error("Error fetching wallet portfolio:", error); + return null; + } +}; + +const getMultichainPortfolio = async ( + apiKey: string, + walletAddress: string +): Promise => { + try { + const params = new URLSearchParams({ + wallet: walletAddress, + }); + const url = `${BASE_URL}/wallet/portfolio_multichain?${params.toString()}`; + + elizaLogger.info( + `Fetching multichain portfolio for wallet ${walletAddress} from:`, + url + ); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + elizaLogger.warn(`Wallet not found: ${walletAddress}`); + return null; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + // Transform the response to match our interface + const { totalValue, ...chains } = data.data; + return { + chains, + totalValue, + }; + } catch (error) { + elizaLogger.error("Error fetching multichain portfolio:", error); + return null; + } +}; + +const formatValue = (value: number): string => { + if (value >= 1_000_000_000) { + return `$${(value / 1_000_000_000).toFixed(2)}B`; + } + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(2)}M`; + } + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(2)}K`; + } + return `$${value.toFixed(2)}`; +}; + +const formatTokenAmount = (amount: number, decimals: number): string => { + const formattedAmount = amount / Math.pow(10, decimals); + if (formattedAmount >= 1_000_000) { + return `${(formattedAmount / 1_000_000).toFixed(2)}M`; + } + if (formattedAmount >= 1_000) { + return `${(formattedAmount / 1_000).toFixed(2)}K`; + } + return formattedAmount.toFixed(decimals > 6 ? 4 : 2); +}; + +const formatSingleChainPortfolio = ( + data: PortfolioData, + chain: string +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + let response = `Portfolio on ${chainName}:\n\n`; + + response += `šŸ’° Total Value: ${formatValue(data.totalValue)}\n\n`; + + if (data.tokens.length === 0) { + response += "No tokens found in this wallet."; + return response; + } + + response += `Token Holdings:\n`; + data.tokens + .sort((a, b) => b.value - a.value) + .forEach((token) => { + const amount = formatTokenAmount(token.amount, token.decimals); + response += `ā€¢ ${token.symbol}: ${amount} (${formatValue(token.value)})\n`; + }); + + response += `\nLast Updated: ${new Date(data.lastUpdated * 1000).toLocaleString()}`; + return response; +}; + +const formatMultichainPortfolio = (data: MultichainPortfolioData): string => { + let response = `Multichain Portfolio Overview:\n\n`; + response += `šŸ’° Total Portfolio Value: ${formatValue(data.totalValue)}\n\n`; + + const chains = Object.keys(data.chains); + if (chains.length === 0) { + response += "No assets found across any chains."; + return response; + } + + chains + .sort((a, b) => data.chains[b].totalValue - data.chains[a].totalValue) + .forEach((chain) => { + const chainData = data.chains[chain]; + if (chainData.totalValue > 0) { + const chainName = + chain.charAt(0).toUpperCase() + chain.slice(1); + response += `${chainName} (${formatValue(chainData.totalValue)}):\n`; + chainData.tokens + .sort((a, b) => b.value - a.value) + .slice(0, 5) // Show top 5 tokens per chain + .forEach((token) => { + const amount = formatTokenAmount( + token.amount, + token.decimals + ); + response += `ā€¢ ${token.symbol}: ${amount} (${formatValue(token.value)})\n`; + }); + if (chainData.tokens.length > 5) { + response += ` ... and ${chainData.tokens.length - 5} more tokens\n`; + } + response += "\n"; + } + }); + + return response; +}; + +export const walletPortfolioProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + return null; + } + + const messageText = message.content.text; + + if (!containsPortfolioKeyword(messageText)) { + return null; + } + + const walletAddress = extractWalletAddress(messageText); + if (!walletAddress) { + return null; + } + + const isMultichain = isMultichainRequest(messageText); + + if (isMultichain) { + elizaLogger.info( + `MULTICHAIN PORTFOLIO provider activated for wallet ${walletAddress}` + ); + + const portfolioData = await getMultichainPortfolio( + apiKey, + walletAddress + ); + + if (!portfolioData) { + return null; + } + + return formatMultichainPortfolio(portfolioData); + } else { + const chain = extractChain(messageText); + + elizaLogger.info( + `PORTFOLIO provider activated for wallet ${walletAddress} on ${chain}` + ); + + const portfolioData = await getWalletPortfolio( + apiKey, + walletAddress, + chain + ); + + if (!portfolioData) { + return null; + } + + return formatSingleChainPortfolio(portfolioData, chain); + } + }, +}; diff --git a/packages/plugin-birdeye/tsconfig.json b/packages/plugin-birdeye/tsconfig.json new file mode 100644 index 0000000000..73993deaaf --- /dev/null +++ b/packages/plugin-birdeye/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../core/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ] +} \ No newline at end of file diff --git a/packages/plugin-birdeye/tsup.config.ts b/packages/plugin-birdeye/tsup.config.ts new file mode 100644 index 0000000000..dd25475bb6 --- /dev/null +++ b/packages/plugin-birdeye/tsup.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + sourcemap: true, + clean: true, + format: ["esm"], // Ensure you're targeting CommonJS + external: [ + "dotenv", // Externalize dotenv to prevent bundling + "fs", // Externalize fs to use Node.js built-in module + "path", // Externalize other built-ins if necessary + "@reflink/reflink", + "@node-llama-cpp", + "https", + "http", + "agentkeepalive", + "safe-buffer", + "base-x", + "bs58", + "borsh", + "@solana/buffer-layout", + "stream", + "buffer", + "querystring", + "amqplib", + // Add other modules you want to externalize + ], +}); From 176a0e76572529a57678fd125ec6b437e059aa1f Mon Sep 17 00:00:00 2001 From: "J. Brandon Johnson" Date: Thu, 26 Dec 2024 15:24:38 -0800 Subject: [PATCH 2/5] feat: got metadata endpoint working --- .env | 1 - agent/package.json | 3 +- agent/src/index.ts | 8 +- client/src/Chat.tsx | 12 +- packages/core/src/defaultCharacter.ts | 2 +- .../src/actions/defi/get-ohlcv.ts | 387 +++++++++++++++ .../src/actions/defi/get-price-history.ts | 434 +++++++++++++++++ .../actions/defi/get-supported-networks.ts | 147 ++++++ .../src/actions/defi/get-token-metadata.ts | 393 +++++++++++++++ .../src/actions/defi/get-token-trades.ts | 446 ++++++++++++++++++ packages/plugin-birdeye/src/index.ts | 81 ++-- .../src/providers/defi/networks-provider.ts | 105 +---- .../plugin-birdeye/src/providers/index.ts | 148 ------ .../plugin-birdeye/src/providers/utils.ts | 25 +- pnpm-lock.yaml | 48 ++ scripts/dev.sh | 2 +- 16 files changed, 1945 insertions(+), 297 deletions(-) delete mode 100644 .env create mode 100644 packages/plugin-birdeye/src/actions/defi/get-ohlcv.ts create mode 100644 packages/plugin-birdeye/src/actions/defi/get-price-history.ts create mode 100644 packages/plugin-birdeye/src/actions/defi/get-supported-networks.ts create mode 100644 packages/plugin-birdeye/src/actions/defi/get-token-metadata.ts create mode 100644 packages/plugin-birdeye/src/actions/defi/get-token-trades.ts delete mode 100644 packages/plugin-birdeye/src/providers/index.ts diff --git a/.env b/.env deleted file mode 100644 index 2be7c65ae9..0000000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -# hello world diff --git a/agent/package.json b/agent/package.json index be8a3e0e29..faed1c3efd 100644 --- a/agent/package.json +++ b/agent/package.json @@ -33,6 +33,7 @@ "@elizaos/plugin-abstract": "workspace:*", "@elizaos/plugin-aptos": "workspace:*", "@elizaos/plugin-bootstrap": "workspace:*", + "@elizaos/plugin-birdeye": "workspace:*", "@elizaos/plugin-intiface": "workspace:*", "@elizaos/plugin-coinbase": "workspace:*", "@elizaos/plugin-conflux": "workspace:*", @@ -60,4 +61,4 @@ "ts-node": "10.9.2", "tsup": "8.3.5" } -} +} \ No newline at end of file diff --git a/agent/src/index.ts b/agent/src/index.ts index 46b5bc622a..bc74b22730 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -32,6 +32,7 @@ import { bootstrapPlugin } from "@elizaos/plugin-bootstrap"; import createGoatPlugin from "@elizaos/plugin-goat"; // import { intifacePlugin } from "@elizaos/plugin-intiface"; import { DirectClient } from "@elizaos/client-direct"; +import { abstractPlugin } from "@elizaos/plugin-abstract"; import { aptosPlugin } from "@elizaos/plugin-aptos"; import { birdeyePlugin } from "@elizaos/plugin-birdeye"; import { @@ -56,13 +57,12 @@ import { suiPlugin } from "@elizaos/plugin-sui"; import { TEEMode, teePlugin } from "@elizaos/plugin-tee"; import { tonPlugin } from "@elizaos/plugin-ton"; import { zksyncEraPlugin } from "@elizaos/plugin-zksync-era"; -import { abstractPlugin } from "@elizaos/plugin-abstract"; import Database from "better-sqlite3"; import fs from "fs"; +import net from "net"; import path from "path"; import { fileURLToPath } from "url"; import yargs from "yargs"; -import net from "net"; const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file const __dirname = path.dirname(__filename); // get the name of the directory @@ -482,9 +482,7 @@ export async function createAgent( ? confluxPlugin : null, nodePlugin, - getSecret(character, "BIRDEYE_API_KEY") - ? birdeyePlugin - : null, + getSecret(character, "BIRDEYE_API_KEY") ? birdeyePlugin : null, getSecret(character, "SOLANA_PUBLIC_KEY") || (getSecret(character, "WALLET_PUBLIC_KEY") && !getSecret(character, "WALLET_PUBLIC_KEY")?.startsWith("0x")) diff --git a/client/src/Chat.tsx b/client/src/Chat.tsx index b32cc0b83e..f077935167 100644 --- a/client/src/Chat.tsx +++ b/client/src/Chat.tsx @@ -1,8 +1,8 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { useParams } from "react-router-dom"; -import { useMutation } from "@tanstack/react-query"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; import "./App.css"; type TextResponse = { @@ -58,7 +58,7 @@ export default function Chat() { messages.map((message, index) => (
- {message.text} +
+                                        {message.text}
+                                    
)) diff --git a/packages/core/src/defaultCharacter.ts b/packages/core/src/defaultCharacter.ts index 91cdeba925..17dd8860de 100644 --- a/packages/core/src/defaultCharacter.ts +++ b/packages/core/src/defaultCharacter.ts @@ -5,7 +5,7 @@ export const defaultCharacter: Character = { username: "eliza", plugins: [], clients: [], - modelProvider: ModelProviderName.LLAMALOCAL, + modelProvider: ModelProviderName.ANTHROPIC, settings: { secrets: {}, voice: { diff --git a/packages/plugin-birdeye/src/actions/defi/get-ohlcv.ts b/packages/plugin-birdeye/src/actions/defi/get-ohlcv.ts new file mode 100644 index 0000000000..ea977ea98d --- /dev/null +++ b/packages/plugin-birdeye/src/actions/defi/get-ohlcv.ts @@ -0,0 +1,387 @@ +import { + Action, + ActionExample, + Content, + elizaLogger, + Handler, + HandlerCallback, + IAgentRuntime, + Memory, + State, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + formatTimestamp, + formatValue, + makeApiRequest, +} from "../../providers/utils"; +import { getTokenMetadata, TokenMetadataResponse } from "./get-token-metadata"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const exampleResponse = { + success: true, + data: { + items: [ + { + o: 128.27328370924414, + h: 128.6281001340782, + l: 127.91200927364626, + c: 127.97284640184616, + v: 58641.16636665621, + unixTime: 1726670700, + address: "So11111111111111111111111111111111111111112", + type: "15m", + }, + ], + }, +}; + +type OHLCVResponse = typeof exampleResponse; + +type TimeInterval = + | "1m" + | "3m" + | "5m" + | "15m" + | "30m" + | "1H" + | "2H" + | "4H" + | "6H" + | "8H" + | "12H" + | "1D" + | "3D" + | "1W" + | "1M"; + +const TIME_INTERVALS: Record = { + "1m": ["1 minute", "1min", "1m"], + "3m": ["3 minutes", "3min", "3m"], + "5m": ["5 minutes", "5min", "5m"], + "15m": ["15 minutes", "15min", "15m"], + "30m": ["30 minutes", "30min", "30m"], + "1H": ["1 hour", "1hr", "1h"], + "2H": ["2 hours", "2hr", "2h"], + "4H": ["4 hours", "4hr", "4h"], + "6H": ["6 hours", "6hr", "6h"], + "8H": ["8 hours", "8hr", "8h"], + "12H": ["12 hours", "12hr", "12h"], + "1D": ["1 day", "daily", "1d"], + "3D": ["3 days", "3day", "3d"], + "1W": ["1 week", "weekly", "1w"], + "1M": ["1 month", "monthly", "1m"], +}; + +const DEFAULT_INTERVAL: TimeInterval = "1D"; + +// Constants for keyword matching +const OHLCV_KEYWORDS = [ + "ohlc", + "ohlcv", + "candlestick", + "candle", + "chart", + "price history", + "open close high low", + "opening price", + "closing price", + "historical", +] as const; + +// Helper function to check if text contains OHLCV-related keywords +const containsOHLCVKeyword = (text: string): boolean => { + return OHLCV_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const extractTimeInterval = (text: string): TimeInterval => { + const lowerText = text.toLowerCase(); + + // First try exact matches + for (const [interval, keywords] of Object.entries(TIME_INTERVALS)) { + if (keywords.some((keyword) => lowerText.includes(keyword))) { + return interval as TimeInterval; + } + } + + // Then try common variations + if (lowerText.includes("hourly")) return "1H"; + if (lowerText.includes("daily")) return "1D"; + if (lowerText.includes("weekly")) return "1W"; + if (lowerText.includes("monthly")) return "1M"; + + return DEFAULT_INTERVAL; +}; + +const formatVolume = (volume: number): string => { + if (volume >= 1_000_000_000) { + return `$${(volume / 1_000_000_000).toFixed(2)}B`; + } + if (volume >= 1_000_000) { + return `$${(volume / 1_000_000).toFixed(2)}M`; + } + if (volume >= 1_000) { + return `$${(volume / 1_000).toFixed(2)}K`; + } + return `$${volume.toFixed(2)}`; +}; + +const getOHLCVData = async ( + apiKey: string, + contractAddress: string, + chain: Chain, + interval: TimeInterval = DEFAULT_INTERVAL +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + interval: interval.toLowerCase(), + limit: "24", // Get last 24 periods + }); + const url = `${BASE_URL}/defi/ohlcv?${params.toString()}`; + + elizaLogger.info( + `Fetching OHLCV data for ${contractAddress} on ${chain} with interval ${interval} from:`, + url + ); + + return await makeApiRequest(url, { apiKey, chain }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching OHLCV data:", error.message); + } + return null; + } +}; + +const formatOHLCVResponse = ( + data: OHLCVResponse, + tokenMetadata: TokenMetadataResponse | null, + chain: Chain, + interval: TimeInterval +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + + let tokenInfo = "Unknown Token"; + let tokenLinks = ""; + + if (tokenMetadata?.success) { + const { name, symbol, extensions } = tokenMetadata.data; + tokenInfo = `${name} (${symbol})`; + + const links = []; + if (extensions.website) links.push(`[Website](${extensions.website})`); + if (extensions.coingecko_id) + links.push( + `[CoinGecko](https://www.coingecko.com/en/coins/${extensions.coingecko_id})` + ); + if (links.length > 0) { + tokenLinks = `\nšŸ“Œ More Information: ${links.join(" ā€¢ ")}`; + } + } + + let response = `OHLCV Data for ${tokenInfo} on ${chainName}${tokenLinks}\n`; + response += `Interval: ${TIME_INTERVALS[interval][0]}\n\n`; + + if (!data.success || !data.data.items || data.data.items.length === 0) { + return response + "No OHLCV data available for the specified period."; + } + + const candles = data.data.items; + const latestCandle = candles[candles.length - 1]; + + // Latest candle information + response += `šŸ“Š Latest Candle (${formatTimestamp(latestCandle.unixTime)})\n`; + response += `ā€¢ Open: ${formatValue(latestCandle.o)}\n`; + response += `ā€¢ High: ${formatValue(latestCandle.h)}\n`; + response += `ā€¢ Low: ${formatValue(latestCandle.l)}\n`; + response += `ā€¢ Close: ${formatValue(latestCandle.c)}\n`; + response += `ā€¢ Volume: ${formatVolume(latestCandle.v)}\n\n`; + + // Price change statistics + const priceChange = latestCandle.c - latestCandle.o; + const priceChangePercent = (priceChange / latestCandle.o) * 100; + const trend = priceChange >= 0 ? "šŸ“ˆ" : "šŸ“‰"; + + response += `${trend} Period Change\n`; + response += `ā€¢ Price Change: ${formatValue(priceChange)} (${priceChangePercent.toFixed(2)}%)\n\n`; + + // Volume analysis + const totalVolume = candles.reduce((sum, candle) => sum + candle.v, 0); + const avgVolume = totalVolume / candles.length; + const highestVolume = Math.max(...candles.map((c) => c.v)); + const lowestVolume = Math.min(...candles.map((c) => c.v)); + + response += `šŸ“Š Volume Analysis\n`; + response += `ā€¢ Total Volume: ${formatVolume(totalVolume)}\n`; + response += `ā€¢ Average Volume: ${formatVolume(avgVolume)}\n`; + response += `ā€¢ Highest Volume: ${formatVolume(highestVolume)}\n`; + response += `ā€¢ Lowest Volume: ${formatVolume(lowestVolume)}\n\n`; + + // Market analysis + const volatility = + ((Math.max(...candles.map((c) => c.h)) - + Math.min(...candles.map((c) => c.l))) / + avgVolume) * + 100; + const volumeLevel = + latestCandle.v > avgVolume * 1.5 + ? "high" + : latestCandle.v > avgVolume + ? "moderate" + : "low"; + const volatilityLevel = + volatility > 5 ? "high" : volatility > 2 ? "moderate" : "low"; + + response += `šŸ“ˆ Market Analysis\n`; + response += `ā€¢ Current volume is ${volumeLevel}\n`; + response += `ā€¢ Market volatility is ${volatilityLevel}\n`; + response += `ā€¢ Overall trend is ${priceChange >= 0 ? "upward" : "downward"} for this period\n`; + + return response; +}; + +export const getOHLCVAction: Action = { + name: "GET_OHLCV", + similes: [ + "SHOW_OHLCV", + "VIEW_CANDLESTICK", + "CHECK_PRICE_CHART", + "DISPLAY_OHLCV", + "GET_CANDLESTICK", + "SHOW_PRICE_CHART", + "VIEW_PRICE_HISTORY", + "CHECK_HISTORICAL_PRICES", + "PRICE_CANDLES", + "MARKET_CANDLES", + ], + description: + "Retrieve and analyze OHLCV (Open, High, Low, Close, Volume) data for a token, including price movements and volume analysis.", + validate: async ( + _runtime: IAgentRuntime, + message: Memory, + _state: State | undefined + ): Promise => { + return containsOHLCVKeyword(message.content.text); + }, + handler: (async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: any, + callback: HandlerCallback + ): Promise => { + const callbackData: Content = { + text: "", + action: "GET_OHLCV_RESPONSE", + source: message.content.source, + }; + + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + callbackData.text = + "I'm unable to fetch the OHLCV data due to missing API credentials."; + await callback(callbackData); + return callbackData; + } + + const messageText = message.content.text; + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + callbackData.text = + "I couldn't find a valid token address in your message."; + await callback(callbackData); + return callbackData; + } + + const chain = extractChain(messageText); + const interval = extractTimeInterval(messageText); + + // First fetch token metadata + const tokenMetadata = await getTokenMetadata( + apiKey, + addresses[0], + chain + ); + + elizaLogger.info( + `OHLCV action activated for ${addresses[0]} on ${chain} with ${interval} interval` + ); + + const ohlcvData = await getOHLCVData( + apiKey, + addresses[0], + chain, + interval + ); + + if (!ohlcvData) { + callbackData.text = + "I apologize, but I couldn't retrieve the OHLCV data at the moment."; + await callback(callbackData); + return callbackData; + } + + callbackData.text = formatOHLCVResponse( + ohlcvData, + tokenMetadata, + chain, + interval + ); + await callback(callbackData); + return callbackData; + }) as Handler, + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "Show me the daily OHLCV data for token 0x1234... on Ethereum", + }, + }, + { + user: "{{user2}}", + content: { + text: "Here's the detailed OHLCV analysis including price movements, volume statistics, and market insights.", + action: "GET_OHLCV", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "What's the hourly candlestick data for ABC123... on Solana?", + }, + }, + { + user: "{{user2}}", + content: { + text: "I'll analyze the hourly OHLCV data and provide you with a comprehensive market overview.", + action: "GET_OHLCV", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "Get me 15-minute candles for token XYZ... on BSC", + }, + }, + { + user: "{{user2}}", + content: { + text: "I'll fetch the 15-minute OHLCV data and provide detailed market analysis.", + action: "GET_OHLCV", + }, + }, + ], + ] as ActionExample[][], +}; diff --git a/packages/plugin-birdeye/src/actions/defi/get-price-history.ts b/packages/plugin-birdeye/src/actions/defi/get-price-history.ts new file mode 100644 index 0000000000..cc44b98f59 --- /dev/null +++ b/packages/plugin-birdeye/src/actions/defi/get-price-history.ts @@ -0,0 +1,434 @@ +import { + Action, + ActionExample, + Content, + elizaLogger, + Handler, + HandlerCallback, + IAgentRuntime, + Memory, + State, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + extractTimeRange, + formatTimestamp, + formatValue, + makeApiRequest, +} from "../../providers/utils"; +import { getTokenMetadata, TokenMetadataResponse } from "./get-token-metadata"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const exampleResponse = { + success: true, + data: { + items: [ + { + unixTime: 1726670700, + value: 127.97284640184616, + }, + { + unixTime: 1726671600, + value: 128.04188346328968, + }, + { + unixTime: 1726672500, + value: 127.40223856228901, + }, + ], + }, +}; + +type PriceHistoryResponse = typeof exampleResponse; + +type TimeInterval = + | "1m" + | "3m" + | "5m" + | "15m" + | "30m" + | "1H" + | "2H" + | "4H" + | "6H" + | "8H" + | "12H" + | "1D" + | "3D" + | "1W" + | "1M"; + +const TIME_INTERVALS: Record = { + "1m": "1 minute", + "3m": "3 minutes", + "5m": "5 minutes", + "15m": "15 minutes", + "30m": "30 minutes", + "1H": "1 hour", + "2H": "2 hours", + "4H": "4 hours", + "6H": "6 hours", + "8H": "8 hours", + "12H": "12 hours", + "1D": "1 day", + "3D": "3 days", + "1W": "1 week", + "1M": "1 month", +}; + +const DEFAULT_INTERVAL: TimeInterval = "1D"; + +const extractTimeInterval = (text: string): TimeInterval => { + // First try to match exact interval codes + const intervalMatch = text.match( + /\b(1m|3m|5m|15m|30m|1H|2H|4H|6H|8H|12H|1D|3D|1W|1M)\b/i + ); + if (intervalMatch) { + return intervalMatch[1].toUpperCase() as TimeInterval; + } + + // Then try to match written intervals + const lowerText = text.toLowerCase(); + for (const [interval, description] of Object.entries(TIME_INTERVALS)) { + if (lowerText.includes(description.toLowerCase())) { + return interval as TimeInterval; + } + } + + // Common variations + if (lowerText.includes("hourly")) return "1H"; + if (lowerText.includes("daily")) return "1D"; + if (lowerText.includes("weekly")) return "1W"; + if (lowerText.includes("monthly")) return "1M"; + + return DEFAULT_INTERVAL; +}; + +// Constants for keyword matching +const PRICE_HISTORY_KEYWORDS = [ + "price history", + "historical price", + "price chart", + "price trend", + "price movement", + "price changes", + "price over time", + "price timeline", + "price performance", + "price data", + "historical data", + "price analysis", + "price tracking", + "price evolution", +] as const; + +// Helper function to check if text contains price history related keywords +const containsPriceHistoryKeyword = (text: string): boolean => { + return PRICE_HISTORY_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getPriceHistory = async ( + apiKey: string, + contractAddress: string, + startTime: number, + endTime: number, + chain: Chain, + interval: TimeInterval = DEFAULT_INTERVAL +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + time_from: startTime.toString(), + time_to: endTime.toString(), + interval: interval.toLowerCase(), + }); + const url = `${BASE_URL}/defi/price_history_unix?${params.toString()}`; + + elizaLogger.info( + `Fetching price history for token ${contractAddress} from ${new Date( + startTime * 1000 + ).toLocaleString()} to ${new Date( + endTime * 1000 + ).toLocaleString()} on ${chain} with ${TIME_INTERVALS[interval]} interval from:`, + url + ); + + return await makeApiRequest(url, { + apiKey, + chain, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching price history:", error.message); + } + return null; + } +}; + +const formatPriceHistoryResponse = ( + data: PriceHistoryResponse, + tokenMetadata: TokenMetadataResponse | null, + timeRange: { start: number; end: number }, + chain: Chain, + interval: TimeInterval +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + const startDate = formatTimestamp(timeRange.start); + const endDate = formatTimestamp(timeRange.end); + + let tokenInfo = "Unknown Token"; + let tokenLinks = ""; + + if (tokenMetadata?.success) { + const { name, symbol, extensions } = tokenMetadata.data; + tokenInfo = `${name} (${symbol})`; + + const links = []; + if (extensions.website) links.push(`[Website](${extensions.website})`); + if (extensions.coingecko_id) + links.push( + `[CoinGecko](https://www.coingecko.com/en/coins/${extensions.coingecko_id})` + ); + if (links.length > 0) { + tokenLinks = `\nšŸ“Œ More Information: ${links.join(" ā€¢ ")}`; + } + } + + let response = `Price History for ${tokenInfo} on ${chainName}${tokenLinks}\n`; + response += `Period: ${startDate} to ${endDate} (${TIME_INTERVALS[interval]} intervals)\n\n`; + + if (!data.success || !data.data.items || data.data.items.length === 0) { + return response + "No price data found for this period."; + } + + // Calculate summary statistics + const prices = data.data.items.map((d) => d.value); + const startPrice = data.data.items[0].value; + const endPrice = data.data.items[data.data.items.length - 1].value; + const priceChange = ((endPrice - startPrice) / startPrice) * 100; + const highestPrice = Math.max(...prices); + const lowestPrice = Math.min(...prices); + const averagePrice = prices.reduce((a, b) => a + b, 0) / prices.length; + const volatility = ((highestPrice - lowestPrice) / averagePrice) * 100; + + response += `šŸ“Š Summary\n`; + response += `ā€¢ Start Price: ${formatValue(startPrice)}\n`; + response += `ā€¢ End Price: ${formatValue(endPrice)}\n`; + response += `ā€¢ Price Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; + response += `ā€¢ Highest Price: ${formatValue(highestPrice)}\n`; + response += `ā€¢ Lowest Price: ${formatValue(lowestPrice)}\n`; + response += `ā€¢ Average Price: ${formatValue(averagePrice)}\n`; + response += `ā€¢ Volatility: ${volatility.toFixed(2)}%\n\n`; + + // Add trend analysis + const trendStrength = Math.abs(priceChange); + let trendAnalysis = ""; + if (trendStrength < 1) { + trendAnalysis = "Price has remained relatively stable"; + } else if (trendStrength < 5) { + trendAnalysis = + priceChange > 0 + ? "Price shows slight upward movement" + : "Price shows slight downward movement"; + } else if (trendStrength < 10) { + trendAnalysis = + priceChange > 0 + ? "Price demonstrates moderate upward trend" + : "Price demonstrates moderate downward trend"; + } else { + trendAnalysis = + priceChange > 0 + ? "Price exhibits strong upward momentum" + : "Price exhibits strong downward momentum"; + } + + response += `šŸ“ˆ Trend Analysis\n`; + response += `ā€¢ ${trendAnalysis}\n`; + response += `ā€¢ Volatility is ${volatility < 10 ? "low" : volatility < 25 ? "moderate" : "high"}\n\n`; + + // Show key price points + response += `šŸ”‘ Key Price Points\n`; + const keyPoints = [ + { + label: "Start", + price: data.data.items[0].value, + timestamp: data.data.items[0].unixTime, + }, + { + label: "High", + price: highestPrice, + timestamp: data.data.items[prices.indexOf(highestPrice)].unixTime, + }, + { + label: "Low", + price: lowestPrice, + timestamp: data.data.items[prices.indexOf(lowestPrice)].unixTime, + }, + { + label: "End", + price: data.data.items[data.data.items.length - 1].value, + timestamp: data.data.items[data.data.items.length - 1].unixTime, + }, + ]; + + keyPoints.forEach((point) => { + response += `ā€¢ ${point.label}: ${formatValue(point.price)} (${formatTimestamp(point.timestamp)})\n`; + }); + + return response; +}; + +export const getPriceHistoryAction: Action = { + name: "GET_PRICE_HISTORY", + similes: [ + "SHOW_PRICE_HISTORY", + "VIEW_PRICE_HISTORY", + "CHECK_PRICE_HISTORY", + "DISPLAY_PRICE_HISTORY", + "ANALYZE_PRICE_HISTORY", + "GET_HISTORICAL_PRICES", + "SHOW_HISTORICAL_PRICES", + "VIEW_PRICE_TREND", + "CHECK_PRICE_TREND", + "ANALYZE_PRICE_TREND", + "PRICE_PERFORMANCE", + "TOKEN_PERFORMANCE", + ], + description: + "Retrieve and analyze historical price data for a token, including price changes, trends, and key statistics over a specified time period.", + validate: async ( + _runtime: IAgentRuntime, + message: Memory, + _state: State | undefined + ): Promise => { + return containsPriceHistoryKeyword(message.content.text); + }, + handler: (async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: any, + callback: HandlerCallback + ): Promise => { + const callbackData: Content = { + text: "", + action: "GET_PRICE_HISTORY_RESPONSE", + source: message.content.source, + }; + + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + callbackData.text = + "I'm unable to fetch the price history due to missing API credentials."; + await callback(callbackData); + return callbackData; + } + + const messageText = message.content.text; + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + callbackData.text = + "I couldn't find a valid token address in your message."; + await callback(callbackData); + return callbackData; + } + + const chain = extractChain(messageText); + const timeRange = extractTimeRange(messageText); + const interval = extractTimeInterval(messageText); + + // First fetch token metadata + const tokenMetadata = await getTokenMetadata( + apiKey, + addresses[0], + chain + ); + + elizaLogger.info( + `PRICE HISTORY action activated for token ${addresses[0]} from ${new Date( + timeRange.start * 1000 + ).toLocaleString()} to ${new Date( + timeRange.end * 1000 + ).toLocaleString()} on ${chain} with ${TIME_INTERVALS[interval]} interval` + ); + + const priceData = await getPriceHistory( + apiKey, + addresses[0], + timeRange.start, + timeRange.end, + chain, + interval + ); + + if (!priceData) { + callbackData.text = + "I apologize, but I couldn't retrieve the price history data at the moment."; + await callback(callbackData); + return callbackData; + } + + callbackData.text = formatPriceHistoryResponse( + priceData, + tokenMetadata, + timeRange, + chain, + interval + ); + await callback(callbackData); + return callbackData; + }) as Handler, + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "Show me the daily price history for token 0x1234... on Ethereum for the last week", + }, + }, + { + user: "{{user2}}", + content: { + text: "Here's the detailed daily price history analysis for the token, including price changes, trends, and key statistics over the specified period.", + action: "GET_PRICE_HISTORY", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "What's the hourly price trend for ABC123... on Solana?", + }, + }, + { + user: "{{user2}}", + content: { + text: "I'll analyze the hourly price history and provide you with a comprehensive overview of the token's performance.", + action: "GET_PRICE_HISTORY", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "Get 5-minute interval price data for token XYZ... on BSC", + }, + }, + { + user: "{{user2}}", + content: { + text: "I'll fetch the price history with 5-minute intervals and analyze the detailed price movements.", + action: "GET_PRICE_HISTORY", + }, + }, + ], + ] as ActionExample[][], +}; diff --git a/packages/plugin-birdeye/src/actions/defi/get-supported-networks.ts b/packages/plugin-birdeye/src/actions/defi/get-supported-networks.ts new file mode 100644 index 0000000000..0f476e9fd8 --- /dev/null +++ b/packages/plugin-birdeye/src/actions/defi/get-supported-networks.ts @@ -0,0 +1,147 @@ +import { + Action, + ActionExample, + Content, + Handler, + HandlerCallback, + IAgentRuntime, + Memory, + State, + elizaLogger, +} from "@elizaos/core"; +import { BASE_URL, makeApiRequest } from "../../providers/utils"; + +// Constants for keyword matching +const NETWORK_KEYWORDS = [ + "supported networks", + "available networks", + "supported chains", + "available chains", + "which networks", + "which chains", + "list networks", + "list chains", + "show networks", + "show chains", + "network support", + "chain support", +] as const; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const exampleResponse = { + success: true, + data: [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", + ], +}; + +// Helper function to check if text contains network-related keywords +const containsNetworkKeyword = (text: string): boolean => { + return NETWORK_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +export const getSupportedNetworksAction: Action = { + name: "GET_SUPPORTED_NETWORKS", + similes: [ + "LIST_NETWORKS", + "SHOW_NETWORKS", + "AVAILABLE_NETWORKS", + "SUPPORTED_CHAINS", + "LIST_CHAINS", + "SHOW_CHAINS", + "NETWORK_SUPPORT", + "CHAIN_SUPPORT", + ], + description: + "Retrieve and display the list of networks supported by the Birdeye API for token information, swaps, prices, and market data.", + validate: async ( + _runtime: IAgentRuntime, + message: Memory, + _state: State | undefined + ): Promise => { + return containsNetworkKeyword(message.content.text); + }, + handler: (async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: any, + callback: HandlerCallback + ): Promise => { + const callbackData: Content = { + text: "", + action: "GET_SUPPORTED_NETWORKS_RESPONSE", + source: message.content.source, + }; + + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + callbackData.text = + "I'm unable to fetch the supported networks due to missing API credentials."; + await callback(callbackData); + return callbackData; + } + + elizaLogger.info("Fetching supported networks"); + const url = `${BASE_URL}/defi/networks`; + + const networksData = await makeApiRequest(url, { + apiKey, + }); + + if (!networksData) { + callbackData.text = + "I apologize, but I couldn't retrieve the list of supported networks at the moment."; + await callback(callbackData); + return callbackData; + } + + callbackData.text = `Currently supported networks for information about tokens, swaps, prices, gainers and losers are: ${networksData.data.join(", ")}`; + await callback(callbackData); + return callbackData; + }) as Handler, + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "What networks are supported?", + }, + }, + { + user: "{{user2}}", + content: { + text: "Here are the currently supported networks: solana, ethereum, arbitrum, avalanche, bsc, optimism, polygon, base, zksync, sui", + action: "GET_SUPPORTED_NETWORKS", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "Show me the available chains", + }, + }, + { + user: "{{user2}}", + content: { + text: "The available chains are: solana, ethereum, arbitrum, avalanche, bsc, optimism, polygon, base, zksync, sui", + action: "GET_SUPPORTED_NETWORKS", + }, + }, + ], + ] as ActionExample[][], +}; diff --git a/packages/plugin-birdeye/src/actions/defi/get-token-metadata.ts b/packages/plugin-birdeye/src/actions/defi/get-token-metadata.ts new file mode 100644 index 0000000000..705c22ee12 --- /dev/null +++ b/packages/plugin-birdeye/src/actions/defi/get-token-metadata.ts @@ -0,0 +1,393 @@ +import { + Action, + ActionExample, + Content, + elizaLogger, + Handler, + HandlerCallback, + IAgentRuntime, + Memory, + State, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + CHAIN_KEYWORDS, + extractChain, + extractContractAddresses, + makeApiRequest, +} from "../../providers/utils"; + +// Define explicit interface instead of using typeof +export interface TokenMetadataResponse { + data: { + address: string; + symbol: string; + name: string; + decimals: number; + extensions: { + coingecko_id?: string; + website?: string; + twitter?: string; + discord?: string; + medium?: string; + }; + logo_uri?: string; + }; + success: boolean; +} + +// Constants for keyword matching +const METADATA_KEYWORDS = [ + "metadata", + "token info", + "token information", + "token details", + "token data", + "token description", + "token profile", + "token overview", + "token stats", + "token statistics", + "token social", + "token links", + "token website", + "token socials", +] as const; + +// Helper function to check if text contains metadata-related keywords +const containsMetadataKeyword = (text: string): boolean => { + return METADATA_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +export const getTokenMetadata = async ( + apiKey: string, + contractAddress: string, + chain: Chain +): Promise => { + try { + // Validate address format based on chain + const isValidAddress = (() => { + switch (chain) { + case "solana": + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test( + contractAddress + ); + case "sui": + return /^0x[a-fA-F0-9]{64}$/i.test(contractAddress); + case "ethereum": + case "arbitrum": + case "avalanche": + case "bsc": + case "optimism": + case "polygon": + case "base": + case "zksync": + return /^0x[a-fA-F0-9]{40}$/i.test(contractAddress); + default: + return false; + } + })(); + + if (!isValidAddress) { + elizaLogger.error( + `Invalid address format for ${chain}: ${contractAddress}` + ); + return null; + } + + const params = new URLSearchParams({ + address: contractAddress, + }); + const url = `${BASE_URL}/defi/v3/token/meta-data/single?${params.toString()}`; + + elizaLogger.info( + `Fetching token metadata for ${contractAddress} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { + apiKey, + chain, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching token metadata:", error.message); + } + return null; + } +}; + +const formatSocialLinks = (data: TokenMetadataResponse["data"]): string => { + const links = []; + const { extensions } = data; + + if (!extensions) { + return "No social links available"; + } + + if (extensions.website) { + links.push(`šŸŒ [Website](${extensions.website})`); + } + if (extensions.twitter) { + links.push(`šŸ¦ [Twitter](${extensions.twitter})`); + } + if (extensions.discord) { + links.push(`šŸ’¬ [Discord](${extensions.discord})`); + } + if (extensions.medium) { + links.push(`šŸ“ [Medium](${extensions.medium})`); + } + if (extensions.coingecko_id) { + links.push( + `šŸ¦Ž [CoinGecko](https://www.coingecko.com/en/coins/${extensions.coingecko_id})` + ); + } + + return links.length > 0 ? links.join("\n") : "No social links available"; +}; + +const formatMetadataResponse = ( + data: TokenMetadataResponse, + chain: Chain +): string => { + const tokenData = data.data; + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + const chainExplorer = (() => { + switch (chain) { + case "solana": + return `https://solscan.io/token/${tokenData.address}`; + case "ethereum": + return `https://etherscan.io/token/${tokenData.address}`; + case "arbitrum": + return `https://arbiscan.io/token/${tokenData.address}`; + case "avalanche": + return `https://snowtrace.io/token/${tokenData.address}`; + case "bsc": + return `https://bscscan.com/token/${tokenData.address}`; + case "optimism": + return `https://optimistic.etherscan.io/token/${tokenData.address}`; + case "polygon": + return `https://polygonscan.com/token/${tokenData.address}`; + case "base": + return `https://basescan.org/token/${tokenData.address}`; + case "zksync": + return `https://explorer.zksync.io/address/${tokenData.address}`; + case "sui": + return `https://suiscan.xyz/mainnet/object/${tokenData.address}`; + default: + return null; + } + })(); + + let response = `Token Metadata for ${tokenData.name} (${tokenData.symbol}) on ${chainName}\n\n`; + + // Basic Information + response += "šŸ“ Basic Information\n"; + response += `ā€¢ Name: ${tokenData.name}\n`; + response += `ā€¢ Symbol: ${tokenData.symbol}\n`; + response += `ā€¢ Address: ${tokenData.address}\n`; + response += `ā€¢ Decimals: ${tokenData.decimals}\n`; + if (chainExplorer) { + response += `ā€¢ Explorer: [View on ${chainName} Explorer](${chainExplorer})\n`; + } + + // Social Links + response += "\nšŸ”— Social Links & Extensions\n"; + response += formatSocialLinks(tokenData) + "\n"; + + // Logo + if (tokenData.logo_uri) { + response += "\nšŸ–¼ļø Logo\n"; + response += tokenData.logo_uri; + } + + return response; +}; + +export const getTokenMetadataAction: Action = { + name: "GET_TOKEN_METADATA", + similes: [ + "SHOW_TOKEN_INFO", + "VIEW_TOKEN_DETAILS", + "CHECK_TOKEN_METADATA", + "DISPLAY_TOKEN_INFO", + "GET_TOKEN_DETAILS", + "TOKEN_INFORMATION", + "TOKEN_PROFILE", + "TOKEN_OVERVIEW", + "TOKEN_SOCIAL_LINKS", + "TOKEN_STATISTICS", + "TOKEN_DESCRIPTION", + ], + description: + "Retrieve and display comprehensive token metadata including basic information, description, social links, and other relevant details.", + validate: async ( + _runtime: IAgentRuntime, + message: Memory, + _state: State | undefined + ): Promise => { + return containsMetadataKeyword(message.content.text); + }, + handler: (async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: any, + callback: HandlerCallback + ): Promise => { + const callbackData: Content = { + text: "", + action: "GET_TOKEN_METADATA_RESPONSE", + source: message.content.source, + }; + + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + callbackData.text = + "I'm unable to fetch the token metadata due to missing API credentials."; + await callback(callbackData); + return callbackData; + } + + const messageText = message.content.text; + const addresses = extractContractAddresses(messageText); + const chain = extractChain(messageText); + + // Check if a specific chain was mentioned + const isChainMentioned = CHAIN_KEYWORDS.some((keyword) => + messageText.toLowerCase().includes(keyword.toLowerCase()) + ); + + if (addresses.length === 0) { + callbackData.text = isChainMentioned + ? `I couldn't find a valid token address for ${chain} chain in your message. ${chain} addresses should match the format: ${getChainAddressFormat(chain)}` + : "I couldn't find a valid token address in your message."; + await callback(callbackData); + return callbackData; + } + + // Validate that the address matches the specified chain format + const isValidForChain = (() => { + switch (chain) { + case "solana": + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addresses[0]); + case "sui": + return ( + /^0x[a-fA-F0-9]{64}$/i.test(addresses[0]) || + /^0x[a-fA-F0-9]{64}::[a-zA-Z0-9_]+::[a-zA-Z0-9_]+$/i.test( + addresses[0] + ) + ); + case "ethereum": + case "arbitrum": + case "avalanche": + case "bsc": + case "optimism": + case "polygon": + case "base": + case "zksync": + return /^0x[a-fA-F0-9]{40}$/i.test(addresses[0]); + default: + return false; + } + })(); + + if (!isValidForChain && isChainMentioned) { + callbackData.text = `The provided address doesn't match the format for ${chain} chain. ${chain} addresses should match the format: ${getChainAddressFormat(chain)}`; + await callback(callbackData); + return callbackData; + } + + elizaLogger.info( + `TOKEN METADATA action activated for ${addresses[0]} on ${chain}` + ); + + const metadataData = await getTokenMetadata( + apiKey, + addresses[0], + chain + ); + + if (!metadataData) { + callbackData.text = + "I apologize, but I couldn't retrieve the token metadata at the moment."; + await callback(callbackData); + return callbackData; + } + + callbackData.text = formatMetadataResponse(metadataData, chain); + await callback(callbackData); + return callbackData; + }) as Handler, + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "Show me the token information for 0x1234... on Ethereum", + }, + }, + { + user: "{{user2}}", + content: { + text: "Here's the detailed token metadata including basic information, social links, and other relevant details.", + action: "GET_TOKEN_METADATA", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "What are the token details for ABC123... on Solana?", + }, + }, + { + user: "{{user2}}", + content: { + text: "I'll fetch and display the comprehensive token profile with all available information.", + action: "GET_TOKEN_METADATA", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "Get me the social links and description for token XYZ... on BSC", + }, + }, + { + user: "{{user2}}", + content: { + text: "I'll retrieve the token's metadata including its social media links and description.", + action: "GET_TOKEN_METADATA", + }, + }, + ], + ] as ActionExample[][], +}; + +const getChainAddressFormat = (chain: Chain): string => { + switch (chain) { + case "solana": + return "Base58 string (32-44 characters)"; + case "sui": + return "0x followed by 64 hexadecimal characters, optionally followed by ::module::type"; + case "ethereum": + case "arbitrum": + case "avalanche": + case "bsc": + case "optimism": + case "polygon": + case "base": + case "zksync": + return "0x followed by 40 hexadecimal characters"; + default: + return "unknown format"; + } +}; diff --git a/packages/plugin-birdeye/src/actions/defi/get-token-trades.ts b/packages/plugin-birdeye/src/actions/defi/get-token-trades.ts new file mode 100644 index 0000000000..d7883cac20 --- /dev/null +++ b/packages/plugin-birdeye/src/actions/defi/get-token-trades.ts @@ -0,0 +1,446 @@ +import { + Action, + ActionExample, + Content, + elizaLogger, + Handler, + HandlerCallback, + IAgentRuntime, + Memory, + State, +} from "@elizaos/core"; +import { + BASE_URL, + Chain, + extractChain, + extractContractAddresses, + extractLimit, + formatTimestamp, + formatValue, + makeApiRequest, + shortenAddress, +} from "../../providers/utils"; +import { getTokenMetadata, TokenMetadataResponse } from "./get-token-metadata"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const exampleResponse = { + success: true, + data: { + items: [ + { + quote: { + symbol: "POTUS", + decimals: 6, + address: "7hyHfdgxwtaj1QpQSJw3s4R2LMxShPpmr2GsCw9npump", + amount: 13922809, + feeInfo: null, + uiAmount: 13.922809, + price: null, + nearestPrice: 0.00008581853084042329, + changeAmount: 13922809, + uiChangeAmount: 13.922809, + }, + base: { + symbol: "SOL", + decimals: 9, + address: "So11111111111111111111111111111111111111112", + amount: 9090, + uiAmount: 0.00000909, + price: null, + nearestPrice: 128.201119598627, + changeAmount: -9090, + uiChangeAmount: -0.00000909, + }, + basePrice: null, + quotePrice: null, + txHash: "5G72183B77KafzKv4GEJNnDrV7rtspv5X5WM9yG9g1P89iG1UCGBuAqcMasgGhRYN24bmWsNPkQqptRbX5uoH44K", + source: "raydium", + blockUnixTime: 1726676178, + txType: "swap", + owner: "AavgaV4YKned3RN6JVMANKmAaVS2Tpfnw88HbYtzgBAn", + side: "sell", + alias: null, + pricePair: 1531662.1562156219, + from: { + symbol: "SOL", + decimals: 9, + address: "So11111111111111111111111111111111111111112", + amount: 9090, + uiAmount: 0.00000909, + price: null, + nearestPrice: 128.201119598627, + changeAmount: -9090, + uiChangeAmount: -0.00000909, + }, + to: { + symbol: "POTUS", + decimals: 6, + address: "7hyHfdgxwtaj1QpQSJw3s4R2LMxShPpmr2GsCw9npump", + amount: 13922809, + feeInfo: null, + uiAmount: 13.922809, + price: null, + nearestPrice: 0.00008581853084042329, + changeAmount: 13922809, + uiChangeAmount: 13.922809, + }, + tokenPrice: null, + poolId: "2L8fo6g6me9ZubZhH2iiz6616GouRbGeEuvNoGv69xWE", + }, + { + quote: { + symbol: "PEAKY", + decimals: 6, + address: "62uBW5K24PdxXk185tNjz9pwzkpHinKt8qZznxPPpump", + amount: 35238136, + feeInfo: null, + uiAmount: 35.238136, + price: null, + nearestPrice: 0.00003742796781669965, + changeAmount: 35238136, + uiChangeAmount: 35.238136, + }, + base: { + symbol: "SOL", + decimals: 9, + address: "So11111111111111111111111111111111111111112", + amount: 10333, + uiAmount: 0.000010333, + price: null, + nearestPrice: 128.201119598627, + changeAmount: -10333, + uiChangeAmount: -0.000010333, + }, + basePrice: null, + quotePrice: null, + txHash: "zXdSLDTX4MVzunVJgFbJmiLY9z2hZ3n28w6bYvKn1aZVL1QZGozkyMMMteFqpyWraUTdRyX1GKFnJYkqPsL5SJK", + source: "raydium", + blockUnixTime: 1726676178, + txType: "swap", + owner: "CDt3xtwPVWDbhENL3QhDX5XYVx9JNCvewfdfCRy1cKFt", + side: "sell", + alias: null, + pricePair: 3410252.2016839255, + from: { + symbol: "SOL", + decimals: 9, + address: "So11111111111111111111111111111111111111112", + amount: 10333, + uiAmount: 0.000010333, + price: null, + nearestPrice: 128.201119598627, + changeAmount: -10333, + uiChangeAmount: -0.000010333, + }, + to: { + symbol: "PEAKY", + decimals: 6, + address: "62uBW5K24PdxXk185tNjz9pwzkpHinKt8qZznxPPpump", + amount: 35238136, + feeInfo: null, + uiAmount: 35.238136, + price: null, + nearestPrice: 0.00003742796781669965, + changeAmount: 35238136, + uiChangeAmount: 35.238136, + }, + tokenPrice: null, + poolId: "5vsk6iYjKXEo6x7maZJwh36UjqwFxkRtoHK5Nphh3ht1", + }, + ], + hasNext: true, + }, +}; + +type TokenTradesResponse = typeof exampleResponse; + +// Constants for keyword matching +const TOKEN_TRADES_KEYWORDS = [ + "token trades", + "token swaps", + "token transactions", + "token activity", + "token orders", + "token executions", + "token trading", + "token market activity", + "token exchange activity", + "token trading history", + "token market history", + "token exchange history", +] as const; + +// Helper function to check if text contains trades-related keywords +const containsTokenTradesKeyword = (text: string): boolean => { + return TOKEN_TRADES_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; + +const getTokenTrades = async ( + apiKey: string, + contractAddress: string, + chain: Chain, + limit: number +): Promise => { + try { + const params = new URLSearchParams({ + address: contractAddress, + limit: limit.toString(), + }); + const url = `${BASE_URL}/defi/trades_token?${params.toString()}`; + + elizaLogger.info( + `Fetching token trades for ${contractAddress} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { + apiKey, + chain, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching token trades:", error.message); + } + return null; + } +}; + +const formatTrade = ( + trade: TokenTradesResponse["data"]["items"][0] +): string => { + const timestamp = formatTimestamp(trade.blockUnixTime); + const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; + const baseAmount = formatValue(trade.base.uiAmount); + const quoteAmount = formatValue(trade.quote.uiAmount); + + let response = `${side} - ${timestamp}\n`; + response += `ā€¢ ${baseAmount} ${trade.base.symbol} ā‡„ ${quoteAmount} ${trade.quote.symbol}\n`; + response += `ā€¢ Source: ${trade.source}\n`; + response += `ā€¢ Owner: ${shortenAddress(trade.owner)}\n`; + response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; + + return response; +}; + +const formatTokenTradesResponse = ( + data: TokenTradesResponse, + tokenMetadata: TokenMetadataResponse | null, + chain: Chain +): string => { + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + + let tokenInfo = "Unknown Token"; + let tokenLinks = ""; + + if (tokenMetadata?.success) { + const { name, symbol, extensions } = tokenMetadata.data; + tokenInfo = `${name} (${symbol})`; + + const links = []; + if (extensions.website) links.push(`[Website](${extensions.website})`); + if (extensions.coingecko_id) + links.push( + `[CoinGecko](https://www.coingecko.com/en/coins/${extensions.coingecko_id})` + ); + if (links.length > 0) { + tokenLinks = `\nšŸ“Œ More Information: ${links.join(" ā€¢ ")}`; + } + } + + let response = `Recent Trades for ${tokenInfo} on ${chainName}${tokenLinks}\n\n`; + + if (!data.success || !data.data.items || data.data.items.length === 0) { + return response + "No trades found."; + } + + const trades = data.data.items; + + // Calculate summary statistics + const buyTrades = trades.filter((t) => t.side === "buy"); + const buyRatio = (buyTrades.length / trades.length) * 100; + + const baseVolume = trades.reduce( + (sum, t) => sum + Math.abs(t.base.uiAmount), + 0 + ); + const quoteVolume = trades.reduce( + (sum, t) => sum + Math.abs(t.quote.uiAmount), + 0 + ); + const averageBaseAmount = baseVolume / trades.length; + const averageQuoteAmount = quoteVolume / trades.length; + + response += `šŸ“Š Summary\n`; + response += `ā€¢ Total Trades: ${trades.length}\n`; + response += `ā€¢ Buy/Sell Ratio: ${buyRatio.toFixed(1)}% buys\n`; + response += `ā€¢ Total Volume: ${formatValue(baseVolume)} ${trades[0].base.symbol}\n`; + response += `ā€¢ Average Trade Size: ${formatValue(averageBaseAmount)} ${trades[0].base.symbol}\n`; + response += `ā€¢ Total Quote Volume: ${formatValue(quoteVolume)} ${trades[0].quote.symbol}\n`; + response += `ā€¢ Average Quote Size: ${formatValue(averageQuoteAmount)} ${trades[0].quote.symbol}\n\n`; + + // Add market analysis + const tradeFrequency = + trades.length > 20 ? "high" : trades.length > 10 ? "moderate" : "low"; + const volumeLevel = + baseVolume > averageBaseAmount * 2 + ? "high" + : baseVolume > averageBaseAmount + ? "moderate" + : "low"; + const marketAnalysis = `Market shows ${tradeFrequency} trading activity with ${volumeLevel} volume per trade.`; + + response += `šŸ“ˆ Market Analysis\n`; + response += `ā€¢ ${marketAnalysis}\n\n`; + + response += `šŸ”„ Recent Trades\n`; + trades.forEach((trade, index) => { + response += `${index + 1}. ${formatTrade(trade)}\n\n`; + }); + + if (data.data.hasNext) { + response += `Note: More trades are available. This is a limited view of the most recent activity.`; + } + + return response; +}; + +export const getTokenTradesAction: Action = { + name: "GET_TOKEN_TRADES", + similes: [ + "SHOW_TOKEN_TRADES", + "VIEW_TOKEN_TRADES", + "CHECK_TOKEN_TRADES", + "DISPLAY_TOKEN_TRADES", + "GET_TRADE_HISTORY", + "SHOW_TRADE_HISTORY", + "VIEW_TRADING_ACTIVITY", + "CHECK_MARKET_ACTIVITY", + "TOKEN_TRADING_HISTORY", + "TOKEN_MARKET_ACTIVITY", + ], + description: + "Retrieve and analyze recent trading activity for a token, including trade details, volume statistics, and market analysis.", + validate: async ( + _runtime: IAgentRuntime, + message: Memory, + _state: State | undefined + ): Promise => { + return containsTokenTradesKeyword(message.content.text); + }, + handler: (async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: any, + callback: HandlerCallback + ): Promise => { + const callbackData: Content = { + text: "", + action: "GET_TOKEN_TRADES_RESPONSE", + source: message.content.source, + }; + + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); + callbackData.text = + "I'm unable to fetch the token trades due to missing API credentials."; + await callback(callbackData); + return callbackData; + } + + const messageText = message.content.text; + const addresses = extractContractAddresses(messageText); + if (addresses.length === 0) { + callbackData.text = + "I couldn't find a valid token address in your message."; + await callback(callbackData); + return callbackData; + } + + const chain = extractChain(messageText); + const limit = extractLimit(messageText); + + // First fetch token metadata + const tokenMetadata = await getTokenMetadata( + apiKey, + addresses[0], + chain + ); + + elizaLogger.info( + `TOKEN TRADES action activated for ${addresses[0]} on ${chain}` + ); + + const tradesData = await getTokenTrades( + apiKey, + addresses[0], + chain, + limit + ); + + if (!tradesData) { + callbackData.text = + "I apologize, but I couldn't retrieve the token trades at the moment."; + await callback(callbackData); + return callbackData; + } + + callbackData.text = formatTokenTradesResponse( + tradesData, + tokenMetadata, + chain + ); + await callback(callbackData); + return callbackData; + }) as Handler, + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "Show me recent trades for token 0x1234... on Ethereum", + }, + }, + { + user: "{{user2}}", + content: { + text: "Here's the detailed trading activity analysis including recent trades, volume statistics, and market insights.", + action: "GET_TOKEN_TRADES", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "What's the trading activity for ABC123... on Solana?", + }, + }, + { + user: "{{user2}}", + content: { + text: "I'll analyze the recent trading activity and provide you with a comprehensive overview of the market.", + action: "GET_TOKEN_TRADES", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "Get me the last 20 trades for token XYZ... on BSC", + }, + }, + { + user: "{{user2}}", + content: { + text: "I'll fetch the recent trades and provide detailed statistics about the trading activity.", + action: "GET_TOKEN_TRADES", + }, + }, + ], + ] as ActionExample[][], +}; diff --git a/packages/plugin-birdeye/src/index.ts b/packages/plugin-birdeye/src/index.ts index c73b2d36dd..f686940ea5 100644 --- a/packages/plugin-birdeye/src/index.ts +++ b/packages/plugin-birdeye/src/index.ts @@ -1,56 +1,45 @@ import { Plugin } from "@elizaos/core"; -import { - gainersLosersProvider, - ohlcvProvider, - pairOverviewProvider, - priceMultipleProvider, - priceProvider, - priceVolumeProvider, - tokenCreationProvider, - tokenListProvider, - tokenMarketDataProvider, - tokenOverviewProvider, - tokenSecurityProvider, - tokenTradeProvider, - tradesSeekProvider, - transactionHistoryProvider, - trendingTokensProvider, - walletPortfolioProvider, -} from "./providers"; +import { getOHLCVAction } from "./actions/defi/get-ohlcv"; +import { getPriceHistoryAction } from "./actions/defi/get-price-history"; +import { getSupportedNetworksAction } from "./actions/defi/get-supported-networks"; +import { getTokenMetadataAction } from "./actions/defi/get-token-metadata"; +import { getTokenTradesAction } from "./actions/defi/get-token-trades"; export const birdeyePlugin: Plugin = { name: "birdeye", description: "Birdeye Plugin for token data and analytics", - actions: [], + actions: [ + getSupportedNetworksAction, + getTokenMetadataAction, + getPriceHistoryAction, + getOHLCVAction, + getTokenTradesAction, + ], evaluators: [], providers: [ - // DeFi providers - priceProvider, - priceMultipleProvider, - ohlcvProvider, - priceVolumeProvider, - - // Pair providers - pairOverviewProvider, - - // Search providers - tokenMarketDataProvider, - - // Token providers - tokenOverviewProvider, - tokenSecurityProvider, - tokenListProvider, - trendingTokensProvider, - tokenCreationProvider, - tokenTradeProvider, - - // Trader providers - gainersLosersProvider, - tradesSeekProvider, - - // Wallet providers - transactionHistoryProvider, - walletPortfolioProvider, + // networksProvider, + // // DeFi providers + // priceProvider, + // priceMultipleProvider, + // ohlcvProvider, + // priceVolumeProvider, + // // Pair providers + // pairOverviewProvider, + // // Search providers + // tokenMarketDataProvider, + // // Token providers + // tokenOverviewProvider, + // tokenSecurityProvider, + // tokenListProvider, + // trendingTokensProvider, + // tokenCreationProvider, + // tokenTradeProvider, + // // Trader providers + // gainersLosersProvider, + // tradesSeekProvider, + // // Wallet providers + // transactionHistoryProvider, + // walletPortfolioProvider, ], }; diff --git a/packages/plugin-birdeye/src/providers/defi/networks-provider.ts b/packages/plugin-birdeye/src/providers/defi/networks-provider.ts index 458cbb881b..a13659bc3c 100644 --- a/packages/plugin-birdeye/src/providers/defi/networks-provider.ts +++ b/packages/plugin-birdeye/src/providers/defi/networks-provider.ts @@ -7,20 +7,6 @@ import { } from "@elizaos/core"; import { BASE_URL, makeApiRequest } from "../utils"; -// Types -interface NetworkInfo { - name: string; - chainId: string; - rpcUrl: string; - explorerUrl: string; - status: "active" | "maintenance" | "deprecated"; - features: string[]; -} - -interface NetworksResponse { - networks: NetworkInfo[]; -} - // Constants const NETWORK_KEYWORDS = [ "supported networks", @@ -44,69 +30,22 @@ const containsNetworkKeyword = (text: string): boolean => { ); }; -const getNetworks = async ( - apiKey: string -): Promise => { - try { - const url = `${BASE_URL}/defi/networks`; - - elizaLogger.info("Fetching supported networks from:", url); - - return await makeApiRequest(url, { - apiKey, - chain: "solana", - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching networks:", error.message); - } - return null; - } -}; - -const formatNetworkResponse = (data: NetworksResponse): string => { - let response = "Supported Networks on Birdeye\n\n"; - - // Group networks by status - const activeNetworks = data.networks.filter((n) => n.status === "active"); - const maintenanceNetworks = data.networks.filter( - (n) => n.status === "maintenance" - ); - const deprecatedNetworks = data.networks.filter( - (n) => n.status === "deprecated" - ); - - // Format active networks - if (activeNetworks.length > 0) { - response += "šŸŸ¢ Active Networks\n"; - activeNetworks.forEach((network) => { - response += `ā€¢ ${network.name}\n`; - response += ` - Chain ID: ${network.chainId}\n`; - response += ` - Features: ${network.features.join(", ")}\n`; - response += ` - Explorer: ${network.explorerUrl}\n\n`; - }); - } - - // Format maintenance networks - if (maintenanceNetworks.length > 0) { - response += "šŸŸ” Networks Under Maintenance\n"; - maintenanceNetworks.forEach((network) => { - response += `ā€¢ ${network.name}\n`; - response += ` - Chain ID: ${network.chainId}\n`; - response += ` - Features: ${network.features.join(", ")}\n\n`; - }); - } - - // Format deprecated networks - if (deprecatedNetworks.length > 0) { - response += "šŸ”“ Deprecated Networks\n"; - deprecatedNetworks.forEach((network) => { - response += `ā€¢ ${network.name}\n`; - response += ` - Chain ID: ${network.chainId}\n\n`; - }); - } - - return response.trim(); +// use sample response to simplify type generation +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const sampleResponse = { + data: [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", + ], + success: true, }; export const networksProvider: Provider = { @@ -129,12 +68,20 @@ export const networksProvider: Provider = { elizaLogger.info("NETWORKS provider activated"); - const networksData = await getNetworks(apiKey); + const url = `${BASE_URL}/defi/networks`; + + elizaLogger.info("Fetching supported networks from:", url); + + const networksData = await makeApiRequest(url, { + apiKey, + }); + + console.log(JSON.stringify(networksData, null, 2)); if (!networksData) { return null; } - return formatNetworkResponse(networksData); + return `Currently supported networks for information about tokens, swaps, prices, gainers and losers are: ${networksData.data.join(", ")}`; }, }; diff --git a/packages/plugin-birdeye/src/providers/index.ts b/packages/plugin-birdeye/src/providers/index.ts deleted file mode 100644 index de86d2629c..0000000000 --- a/packages/plugin-birdeye/src/providers/index.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Provider } from "@elizaos/core"; - -// Import all providers -import { - baseQuoteOHLCVProvider, - networksProvider, - ohlcvProvider, - pairOHLCVProvider, - pairTradesProvider, - pairTradesSeekProvider, - priceHistoryProvider, - priceMultipleProvider, - priceProvider, - priceVolumeProvider, - tokenTradesProvider, - tokenTradesSeekProvider, -} from "./defi"; -import { pairOverviewProvider } from "./pair"; -import { tokenMarketDataProvider } from "./search"; -import { - allMarketListProvider, - newListingProvider, - tokenCreationProvider, - tokenHolderProvider, - tokenListProvider, - tokenMarketProvider, - tokenMetadataProvider, - tokenMintBurnProvider, - tokenOverviewProvider, - tokenSecurityProvider, - tokenTradeProvider, - topTradersProvider, - trendingTokensProvider, -} from "./token"; -import { gainersLosersProvider, tradesSeekProvider } from "./trader"; -import { - portfolioMultichainProvider, - supportedNetworksProvider, - tokenBalanceProvider, - transactionHistoryMultichainProvider, - transactionHistoryProvider, - walletPortfolioProvider, -} from "./wallet"; - -// Export individual providers -export * from "./defi"; -export * from "./pair"; -export * from "./search"; -export * from "./token"; -export * from "./trader"; -export * from "./wallet"; - -// Export providers array -export const providers: Provider[] = [ - // DeFi providers - baseQuoteOHLCVProvider, - networksProvider, - ohlcvProvider, - pairOHLCVProvider, - pairTradesProvider, - pairTradesSeekProvider, - priceHistoryProvider, - priceMultipleProvider, - priceProvider, - priceVolumeProvider, - tokenTradesProvider, - tokenTradesSeekProvider, - - // Pair providers - pairOverviewProvider, - - // Search providers - tokenMarketDataProvider, - - // Token providers - allMarketListProvider, - newListingProvider, - tokenCreationProvider, - tokenHolderProvider, - tokenListProvider, - tokenMarketProvider, - tokenMetadataProvider, - tokenMintBurnProvider, - tokenOverviewProvider, - tokenSecurityProvider, - tokenTradeProvider, - topTradersProvider, - trendingTokensProvider, - - // Trader providers - gainersLosersProvider, - tradesSeekProvider, - - // Wallet providers - portfolioMultichainProvider, - supportedNetworksProvider, - tokenBalanceProvider, - transactionHistoryMultichainProvider, - transactionHistoryProvider, - walletPortfolioProvider, -]; - -// DeFi Providers -export * from "./defi/networks-provider"; -export * from "./defi/ohlcv-base-quote-provider"; -export * from "./defi/ohlcv-pair-provider"; -export * from "./defi/ohlcv-provider"; -export * from "./defi/pair-trades-provider"; -export * from "./defi/pair-trades-seek-provider"; -export * from "./defi/price-history-provider"; -export * from "./defi/price-multiple-provider"; -export * from "./defi/price-provider"; -export * from "./defi/price-volume-provider"; -export * from "./defi/token-trades-provider"; -export * from "./defi/trades-seek-provider"; - -// Token Providers -export * from "./token/all-market-list-provider"; -export * from "./token/new-listing-provider"; -export * from "./token/token-creation-provider"; -export * from "./token/token-holder-provider"; -export * from "./token/token-list-provider"; -export * from "./token/token-market-provider"; -export * from "./token/token-metadata-provider"; -export * from "./token/token-mint-burn-provider"; -export * from "./token/token-overview-provider"; -export * from "./token/token-security-provider"; -export * from "./token/token-trade-provider"; -export * from "./token/top-traders-provider"; -export * from "./token/trending-tokens-provider"; - -// Wallet Providers -export * from "./wallet/portfolio-multichain-provider"; -export * from "./wallet/supported-networks-provider"; -export * from "./wallet/token-balance-provider"; -export * from "./wallet/transaction-history-multichain-provider"; -export * from "./wallet/transaction-history-provider"; -export * from "./wallet/wallet-portfolio-provider"; - -// Trader Providers -export * from "./trader/gainers-losers-provider"; -export * from "./trader/trades-seek-provider"; - -// Pair Providers -export * from "./pair/pair-overview-provider"; - -// Search Providers -export * from "./search/token-market-data-provider"; diff --git a/packages/plugin-birdeye/src/providers/utils.ts b/packages/plugin-birdeye/src/providers/utils.ts index 3e7a5d1252..40db78f7a7 100644 --- a/packages/plugin-birdeye/src/providers/utils.ts +++ b/packages/plugin-birdeye/src/providers/utils.ts @@ -76,12 +76,21 @@ export const extractContractAddresses = (text: string): string[] => { const addresses: string[] = []; for (const word of words) { - // Ethereum-like addresses (0x...) + // Ethereum-like addresses (0x...) - for Ethereum, Arbitrum, Avalanche, BSC, Optimism, Polygon, Base, zkSync if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { addresses.push(word); } // Solana addresses (base58, typically 32-44 chars) - if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { + else if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { + addresses.push(word); + } + // Sui addresses - both formats: + // 1. Simple object ID: 0x followed by 64 hex chars + // 2. Full token format: 0x:::: + else if ( + /^0x[a-fA-F0-9]{64}$/i.test(word) || + /^0x[a-fA-F0-9]{64}::[a-zA-Z0-9_]+::[a-zA-Z0-9_]+$/i.test(word) + ) { addresses.push(word); } } @@ -249,12 +258,12 @@ export async function makeApiRequest( url: string, options: { apiKey: string; - chain: Chain; + chain?: Chain; method?: "GET" | "POST"; body?: any; } ): Promise { - const { apiKey, chain, method = "GET", body } = options; + const { apiKey, chain = "solana", method = "GET", body } = options; try { const response = await fetch(url, { @@ -280,13 +289,9 @@ export async function makeApiRequest( ); } - const data: ApiResponse = await response.json(); - - if (!data.success) { - throw new Error(data.error || "Unknown API error"); - } + const responseJson: T = await response.json(); - return data.data; + return responseJson; } catch (error) { if (error instanceof BirdeyeApiError) { elizaLogger.error(`API Error (${error.status}):`, error.message); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e979dca71..6562f8c455 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: '@elizaos/plugin-aptos': specifier: workspace:* version: link:../packages/plugin-aptos + '@elizaos/plugin-birdeye': + specifier: workspace:* + version: link:../packages/plugin-birdeye '@elizaos/plugin-bootstrap': specifier: workspace:* version: link:../packages/plugin-bootstrap @@ -992,6 +995,51 @@ importers: specifier: 7.1.0 version: 7.1.0 + packages/plugin-birdeye: + dependencies: + '@coral-xyz/anchor': + specifier: 0.30.1 + version: 0.30.1(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10) + '@elizaos/core': + specifier: workspace:* + version: link:../core + '@solana/spl-token': + specifier: 0.4.9 + version: 0.4.9(@solana/web3.js@1.95.8(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10))(bufferutil@4.0.8)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.6.3)(utf-8-validate@5.0.10) + '@solana/web3.js': + specifier: 1.95.8 + version: 1.95.8(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10) + bignumber: + specifier: 1.1.0 + version: 1.1.0 + bignumber.js: + specifier: 9.1.2 + version: 9.1.2 + bs58: + specifier: 6.0.0 + version: 6.0.0 + fomo-sdk-solana: + specifier: 1.3.2 + version: 1.3.2(bufferutil@4.0.8)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.6.3)(utf-8-validate@5.0.10) + form-data: + specifier: 4.0.1 + version: 4.0.1 + node-cache: + specifier: 5.1.2 + version: 5.1.2 + pumpdotfun-sdk: + specifier: 1.3.2 + version: 1.3.2(bufferutil@4.0.8)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(rollup@4.28.1)(typescript@5.6.3)(utf-8-validate@5.0.10) + tsup: + specifier: 8.3.5 + version: 8.3.5(@swc/core@1.10.1(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.49)(typescript@5.6.3)(yaml@2.6.1) + vitest: + specifier: 2.1.4 + version: 2.1.4(@types/node@22.10.2)(jsdom@25.0.1(bufferutil@4.0.8)(canvas@2.11.2(encoding@0.1.13))(utf-8-validate@5.0.10))(terser@5.37.0) + whatwg-url: + specifier: 7.1.0 + version: 7.1.0 + packages/plugin-bootstrap: dependencies: '@elizaos/core': diff --git a/scripts/dev.sh b/scripts/dev.sh index 22b5466f9d..9ed45c99eb 100644 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -74,7 +74,7 @@ if [ ! -d "$PACKAGES_DIR" ]; then fi # List of working folders to watch (relative to $PACKAGES_DIR) -WORKING_FOLDERS=("client-direct") # Core is handled separately +WORKING_FOLDERS=("client-direct" "plugin-birdeye") # Core is handled separately # Initialize an array to hold package-specific commands COMMANDS=() From abee6e158857cdcb7cf20d46fcb7cd6f396262a2 Mon Sep 17 00:00:00 2001 From: "J. Brandon Johnson" Date: Sat, 28 Dec 2024 09:06:00 -0800 Subject: [PATCH 3/5] chore: cleanup token search provider --- .../src/actions/defi/get-ohlcv.ts | 23 +- .../src/actions/defi/get-price-history.ts | 24 +- .../src/actions/defi/get-token-metadata.ts | 240 +------- .../src/actions/defi/get-token-trades.ts | 446 -------------- ...{get-supported-networks.ts => networks.ts} | 49 +- .../src/actions/defi/networks.utils.ts | 27 + packages/plugin-birdeye/src/index.ts | 41 +- .../src/providers/__tests__/utils.test.ts | 145 ----- .../src/providers/address-search-provider.ts | 111 ++++ .../defi/__tests__/price-provider.test.ts | 218 ------- .../src/providers/defi/index.ts | 12 - .../src/providers/defi/networks-provider.ts | 87 --- .../defi/ohlcv-base-quote-provider.ts | 244 -------- .../src/providers/defi/ohlcv-pair-provider.ts | 210 ------- .../src/providers/defi/ohlcv-provider.ts | 256 -------- .../providers/defi/pair-trades-provider.ts | 245 -------- .../defi/pair-trades-seek-provider.ts | 266 --------- .../providers/defi/price-history-provider.ts | 230 -------- .../providers/defi/price-multiple-provider.ts | 200 ------- .../src/providers/defi/price-provider.ts | 175 ------ .../providers/defi/price-volume-provider.ts | 234 -------- .../providers/defi/token-trades-provider.ts | 236 -------- .../providers/defi/trades-seek-provider.ts | 210 ------- .../src/providers/pair/index.ts | 1 - .../providers/pair/pair-overview-provider.ts | 286 --------- .../src/providers/search/index.ts | 1 - .../search/token-market-data-provider.ts | 214 ------- .../__tests__/token-overview-provider.test.ts | 189 ------ .../token/all-market-list-provider.ts | 114 ---- .../src/providers/token/index.ts | 13 - .../providers/token/new-listing-provider.ts | 113 ---- .../token/token-creation-provider.ts | 199 ------- .../providers/token/token-holder-provider.ts | 220 ------- .../providers/token/token-list-provider.ts | 198 ------- .../providers/token/token-market-provider.ts | 217 ------- .../token/token-metadata-provider.ts | 197 ------- .../token/token-mint-burn-provider.ts | 203 ------- .../token/token-overview-provider.ts | 266 --------- .../token/token-security-provider.ts | 238 -------- .../providers/token/token-trade-provider.ts | 327 ----------- .../providers/token/top-traders-provider.ts | 104 ---- .../token/trending-tokens-provider.ts | 270 --------- .../trader/gainers-losers-provider.ts | 228 -------- .../src/providers/trader/index.ts | 2 - .../providers/trader/trades-seek-provider.ts | 247 -------- .../plugin-birdeye/src/providers/utils.ts | 303 ---------- .../src/providers/wallet/index.ts | 6 - .../wallet/portfolio-multichain-provider.ts | 159 ----- .../wallet/supported-networks-provider.ts | 131 ----- .../wallet/token-balance-provider.ts | 135 ----- ...transaction-history-multichain-provider.ts | 174 ------ .../wallet/transaction-history-provider.ts | 381 ------------ .../wallet/wallet-portfolio-provider.ts | 335 ----------- packages/plugin-birdeye/src/services.ts | 170 ++++++ .../plugin-birdeye/src/types/search-token.ts | 43 ++ packages/plugin-birdeye/src/types/shared.ts | 23 + .../src/types/token-metadata.ts | 18 + packages/plugin-birdeye/src/types/wallet.ts | 24 + packages/plugin-birdeye/src/utils.ts | 546 ++++++++++++++++++ .../plugin-solana/src/evaluators/trust.ts | 25 +- packages/plugin-solana/src/index.ts | 17 +- .../src/providers/trustScoreProvider.ts | 39 +- src/providers/address-search.provider.ts | 9 + 63 files changed, 1070 insertions(+), 9244 deletions(-) delete mode 100644 packages/plugin-birdeye/src/actions/defi/get-token-trades.ts rename packages/plugin-birdeye/src/actions/defi/{get-supported-networks.ts => networks.ts} (72%) create mode 100644 packages/plugin-birdeye/src/actions/defi/networks.utils.ts delete mode 100644 packages/plugin-birdeye/src/providers/__tests__/utils.test.ts create mode 100644 packages/plugin-birdeye/src/providers/address-search-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/__tests__/price-provider.test.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/index.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/networks-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/ohlcv-base-quote-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/ohlcv-pair-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/ohlcv-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/pair-trades-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/pair-trades-seek-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/price-history-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/price-multiple-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/price-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/price-volume-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/token-trades-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/defi/trades-seek-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/pair/index.ts delete mode 100644 packages/plugin-birdeye/src/providers/pair/pair-overview-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/search/index.ts delete mode 100644 packages/plugin-birdeye/src/providers/search/token-market-data-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/__tests__/token-overview-provider.test.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/all-market-list-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/index.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/new-listing-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/token-creation-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/token-holder-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/token-list-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/token-market-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/token-metadata-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/token-mint-burn-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/token-overview-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/token-security-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/token-trade-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/top-traders-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/token/trending-tokens-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/trader/gainers-losers-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/trader/index.ts delete mode 100644 packages/plugin-birdeye/src/providers/trader/trades-seek-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/utils.ts delete mode 100644 packages/plugin-birdeye/src/providers/wallet/index.ts delete mode 100644 packages/plugin-birdeye/src/providers/wallet/portfolio-multichain-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/wallet/supported-networks-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/wallet/token-balance-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/wallet/transaction-history-multichain-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/wallet/transaction-history-provider.ts delete mode 100644 packages/plugin-birdeye/src/providers/wallet/wallet-portfolio-provider.ts create mode 100644 packages/plugin-birdeye/src/services.ts create mode 100644 packages/plugin-birdeye/src/types/search-token.ts create mode 100644 packages/plugin-birdeye/src/types/shared.ts create mode 100644 packages/plugin-birdeye/src/types/token-metadata.ts create mode 100644 packages/plugin-birdeye/src/types/wallet.ts create mode 100644 packages/plugin-birdeye/src/utils.ts create mode 100644 src/providers/address-search.provider.ts diff --git a/packages/plugin-birdeye/src/actions/defi/get-ohlcv.ts b/packages/plugin-birdeye/src/actions/defi/get-ohlcv.ts index ea977ea98d..7300cc224f 100644 --- a/packages/plugin-birdeye/src/actions/defi/get-ohlcv.ts +++ b/packages/plugin-birdeye/src/actions/defi/get-ohlcv.ts @@ -9,16 +9,17 @@ import { Memory, State, } from "@elizaos/core"; +import { getTokenMetadata } from "../../services"; +import { BirdeyeChain } from "../../types/shared"; +import { TokenMetadataResponse } from "../../types/token-metadata"; import { BASE_URL, - Chain, extractChain, extractContractAddresses, formatTimestamp, formatValue, makeApiRequest, -} from "../../providers/utils"; -import { getTokenMetadata, TokenMetadataResponse } from "./get-token-metadata"; +} from "../../utils"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const exampleResponse = { @@ -134,7 +135,7 @@ const formatVolume = (volume: number): string => { const getOHLCVData = async ( apiKey: string, contractAddress: string, - chain: Chain, + chain: BirdeyeChain, interval: TimeInterval = DEFAULT_INTERVAL ): Promise => { try { @@ -162,7 +163,7 @@ const getOHLCVData = async ( const formatOHLCVResponse = ( data: OHLCVResponse, tokenMetadata: TokenMetadataResponse | null, - chain: Chain, + chain: BirdeyeChain, interval: TimeInterval ): string => { const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); @@ -174,7 +175,7 @@ const formatOHLCVResponse = ( const { name, symbol, extensions } = tokenMetadata.data; tokenInfo = `${name} (${symbol})`; - const links = []; + const links: string[] = []; if (extensions.website) links.push(`[Website](${extensions.website})`); if (extensions.coingecko_id) links.push( @@ -306,8 +307,8 @@ export const getOHLCVAction: Action = { // First fetch token metadata const tokenMetadata = await getTokenMetadata( apiKey, - addresses[0], - chain + addresses[0].toString(), + chain as BirdeyeChain ); elizaLogger.info( @@ -316,8 +317,8 @@ export const getOHLCVAction: Action = { const ohlcvData = await getOHLCVData( apiKey, - addresses[0], - chain, + addresses[0].toString(), + chain as BirdeyeChain, interval ); @@ -331,7 +332,7 @@ export const getOHLCVAction: Action = { callbackData.text = formatOHLCVResponse( ohlcvData, tokenMetadata, - chain, + chain as BirdeyeChain, interval ); await callback(callbackData); diff --git a/packages/plugin-birdeye/src/actions/defi/get-price-history.ts b/packages/plugin-birdeye/src/actions/defi/get-price-history.ts index cc44b98f59..60ae76f0a0 100644 --- a/packages/plugin-birdeye/src/actions/defi/get-price-history.ts +++ b/packages/plugin-birdeye/src/actions/defi/get-price-history.ts @@ -9,18 +9,18 @@ import { Memory, State, } from "@elizaos/core"; +import { getTokenMetadata } from "../../services"; +import { BirdeyeChain } from "../../types/shared"; +import { TokenMetadataResponse } from "../../types/token-metadata"; import { BASE_URL, - Chain, extractChain, extractContractAddresses, extractTimeRange, formatTimestamp, formatValue, makeApiRequest, -} from "../../providers/utils"; -import { getTokenMetadata, TokenMetadataResponse } from "./get-token-metadata"; - +} from "../../utils"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const exampleResponse = { success: true, @@ -137,7 +137,7 @@ const getPriceHistory = async ( contractAddress: string, startTime: number, endTime: number, - chain: Chain, + chain: BirdeyeChain, interval: TimeInterval = DEFAULT_INTERVAL ): Promise => { try { @@ -174,7 +174,7 @@ const formatPriceHistoryResponse = ( data: PriceHistoryResponse, tokenMetadata: TokenMetadataResponse | null, timeRange: { start: number; end: number }, - chain: Chain, + chain: BirdeyeChain, interval: TimeInterval ): string => { const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); @@ -188,7 +188,7 @@ const formatPriceHistoryResponse = ( const { name, symbol, extensions } = tokenMetadata.data; tokenInfo = `${name} (${symbol})`; - const links = []; + const links: string[] = []; if (extensions.website) links.push(`[Website](${extensions.website})`); if (extensions.coingecko_id) links.push( @@ -346,8 +346,8 @@ export const getPriceHistoryAction: Action = { // First fetch token metadata const tokenMetadata = await getTokenMetadata( apiKey, - addresses[0], - chain + addresses[0].toString(), + chain as BirdeyeChain ); elizaLogger.info( @@ -360,10 +360,10 @@ export const getPriceHistoryAction: Action = { const priceData = await getPriceHistory( apiKey, - addresses[0], + addresses[0].toString(), timeRange.start, timeRange.end, - chain, + chain as BirdeyeChain, interval ); @@ -378,7 +378,7 @@ export const getPriceHistoryAction: Action = { priceData, tokenMetadata, timeRange, - chain, + chain as BirdeyeChain, interval ); await callback(callbackData); diff --git a/packages/plugin-birdeye/src/actions/defi/get-token-metadata.ts b/packages/plugin-birdeye/src/actions/defi/get-token-metadata.ts index 705c22ee12..cd8cca6815 100644 --- a/packages/plugin-birdeye/src/actions/defi/get-token-metadata.ts +++ b/packages/plugin-birdeye/src/actions/defi/get-token-metadata.ts @@ -9,33 +9,13 @@ import { Memory, State, } from "@elizaos/core"; +import { BirdeyeChain } from "../../types/shared"; import { - BASE_URL, - Chain, + CHAIN_ALIASES, CHAIN_KEYWORDS, extractChain, extractContractAddresses, - makeApiRequest, -} from "../../providers/utils"; - -// Define explicit interface instead of using typeof -export interface TokenMetadataResponse { - data: { - address: string; - symbol: string; - name: string; - decimals: number; - extensions: { - coingecko_id?: string; - website?: string; - twitter?: string; - discord?: string; - medium?: string; - }; - logo_uri?: string; - }; - success: boolean; -} +} from "../../utils"; // Constants for keyword matching const METADATA_KEYWORDS = [ @@ -62,151 +42,6 @@ const containsMetadataKeyword = (text: string): boolean => { ); }; -export const getTokenMetadata = async ( - apiKey: string, - contractAddress: string, - chain: Chain -): Promise => { - try { - // Validate address format based on chain - const isValidAddress = (() => { - switch (chain) { - case "solana": - return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test( - contractAddress - ); - case "sui": - return /^0x[a-fA-F0-9]{64}$/i.test(contractAddress); - case "ethereum": - case "arbitrum": - case "avalanche": - case "bsc": - case "optimism": - case "polygon": - case "base": - case "zksync": - return /^0x[a-fA-F0-9]{40}$/i.test(contractAddress); - default: - return false; - } - })(); - - if (!isValidAddress) { - elizaLogger.error( - `Invalid address format for ${chain}: ${contractAddress}` - ); - return null; - } - - const params = new URLSearchParams({ - address: contractAddress, - }); - const url = `${BASE_URL}/defi/v3/token/meta-data/single?${params.toString()}`; - - elizaLogger.info( - `Fetching token metadata for ${contractAddress} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { - apiKey, - chain, - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching token metadata:", error.message); - } - return null; - } -}; - -const formatSocialLinks = (data: TokenMetadataResponse["data"]): string => { - const links = []; - const { extensions } = data; - - if (!extensions) { - return "No social links available"; - } - - if (extensions.website) { - links.push(`šŸŒ [Website](${extensions.website})`); - } - if (extensions.twitter) { - links.push(`šŸ¦ [Twitter](${extensions.twitter})`); - } - if (extensions.discord) { - links.push(`šŸ’¬ [Discord](${extensions.discord})`); - } - if (extensions.medium) { - links.push(`šŸ“ [Medium](${extensions.medium})`); - } - if (extensions.coingecko_id) { - links.push( - `šŸ¦Ž [CoinGecko](https://www.coingecko.com/en/coins/${extensions.coingecko_id})` - ); - } - - return links.length > 0 ? links.join("\n") : "No social links available"; -}; - -const formatMetadataResponse = ( - data: TokenMetadataResponse, - chain: Chain -): string => { - const tokenData = data.data; - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - const chainExplorer = (() => { - switch (chain) { - case "solana": - return `https://solscan.io/token/${tokenData.address}`; - case "ethereum": - return `https://etherscan.io/token/${tokenData.address}`; - case "arbitrum": - return `https://arbiscan.io/token/${tokenData.address}`; - case "avalanche": - return `https://snowtrace.io/token/${tokenData.address}`; - case "bsc": - return `https://bscscan.com/token/${tokenData.address}`; - case "optimism": - return `https://optimistic.etherscan.io/token/${tokenData.address}`; - case "polygon": - return `https://polygonscan.com/token/${tokenData.address}`; - case "base": - return `https://basescan.org/token/${tokenData.address}`; - case "zksync": - return `https://explorer.zksync.io/address/${tokenData.address}`; - case "sui": - return `https://suiscan.xyz/mainnet/object/${tokenData.address}`; - default: - return null; - } - })(); - - let response = `Token Metadata for ${tokenData.name} (${tokenData.symbol}) on ${chainName}\n\n`; - - // Basic Information - response += "šŸ“ Basic Information\n"; - response += `ā€¢ Name: ${tokenData.name}\n`; - response += `ā€¢ Symbol: ${tokenData.symbol}\n`; - response += `ā€¢ Address: ${tokenData.address}\n`; - response += `ā€¢ Decimals: ${tokenData.decimals}\n`; - if (chainExplorer) { - response += `ā€¢ Explorer: [View on ${chainName} Explorer](${chainExplorer})\n`; - } - - // Social Links - response += "\nšŸ”— Social Links & Extensions\n"; - response += formatSocialLinks(tokenData) + "\n"; - - // Logo - if (tokenData.logo_uri) { - response += "\nšŸ–¼ļø Logo\n"; - response += tokenData.logo_uri; - } - - return response; -}; - export const getTokenMetadataAction: Action = { name: "GET_TOKEN_METADATA", similes: [ @@ -257,69 +92,26 @@ export const getTokenMetadataAction: Action = { const addresses = extractContractAddresses(messageText); const chain = extractChain(messageText); - // Check if a specific chain was mentioned - const isChainMentioned = CHAIN_KEYWORDS.some((keyword) => - messageText.toLowerCase().includes(keyword.toLowerCase()) - ); + // Check if a specific chain was mentioned (including aliases) + const normalizedText = messageText.toLowerCase(); + const isChainMentioned = + CHAIN_KEYWORDS.some((keyword) => + normalizedText.includes(keyword.toLowerCase()) + ) || + Object.keys(CHAIN_ALIASES).some((alias) => + normalizedText.includes(alias.toLowerCase()) + ); if (addresses.length === 0) { callbackData.text = isChainMentioned - ? `I couldn't find a valid token address for ${chain} chain in your message. ${chain} addresses should match the format: ${getChainAddressFormat(chain)}` + ? `I couldn't find a valid token address for ${chain} chain in your message. ${chain} addresses should match the format: ${getChainAddressFormat( + chain as BirdeyeChain + )}` : "I couldn't find a valid token address in your message."; await callback(callbackData); return callbackData; } - // Validate that the address matches the specified chain format - const isValidForChain = (() => { - switch (chain) { - case "solana": - return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addresses[0]); - case "sui": - return ( - /^0x[a-fA-F0-9]{64}$/i.test(addresses[0]) || - /^0x[a-fA-F0-9]{64}::[a-zA-Z0-9_]+::[a-zA-Z0-9_]+$/i.test( - addresses[0] - ) - ); - case "ethereum": - case "arbitrum": - case "avalanche": - case "bsc": - case "optimism": - case "polygon": - case "base": - case "zksync": - return /^0x[a-fA-F0-9]{40}$/i.test(addresses[0]); - default: - return false; - } - })(); - - if (!isValidForChain && isChainMentioned) { - callbackData.text = `The provided address doesn't match the format for ${chain} chain. ${chain} addresses should match the format: ${getChainAddressFormat(chain)}`; - await callback(callbackData); - return callbackData; - } - - elizaLogger.info( - `TOKEN METADATA action activated for ${addresses[0]} on ${chain}` - ); - - const metadataData = await getTokenMetadata( - apiKey, - addresses[0], - chain - ); - - if (!metadataData) { - callbackData.text = - "I apologize, but I couldn't retrieve the token metadata at the moment."; - await callback(callbackData); - return callbackData; - } - - callbackData.text = formatMetadataResponse(metadataData, chain); await callback(callbackData); return callbackData; }) as Handler, @@ -372,7 +164,7 @@ export const getTokenMetadataAction: Action = { ] as ActionExample[][], }; -const getChainAddressFormat = (chain: Chain): string => { +const getChainAddressFormat = (chain: BirdeyeChain): string => { switch (chain) { case "solana": return "Base58 string (32-44 characters)"; diff --git a/packages/plugin-birdeye/src/actions/defi/get-token-trades.ts b/packages/plugin-birdeye/src/actions/defi/get-token-trades.ts deleted file mode 100644 index d7883cac20..0000000000 --- a/packages/plugin-birdeye/src/actions/defi/get-token-trades.ts +++ /dev/null @@ -1,446 +0,0 @@ -import { - Action, - ActionExample, - Content, - elizaLogger, - Handler, - HandlerCallback, - IAgentRuntime, - Memory, - State, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - extractLimit, - formatTimestamp, - formatValue, - makeApiRequest, - shortenAddress, -} from "../../providers/utils"; -import { getTokenMetadata, TokenMetadataResponse } from "./get-token-metadata"; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const exampleResponse = { - success: true, - data: { - items: [ - { - quote: { - symbol: "POTUS", - decimals: 6, - address: "7hyHfdgxwtaj1QpQSJw3s4R2LMxShPpmr2GsCw9npump", - amount: 13922809, - feeInfo: null, - uiAmount: 13.922809, - price: null, - nearestPrice: 0.00008581853084042329, - changeAmount: 13922809, - uiChangeAmount: 13.922809, - }, - base: { - symbol: "SOL", - decimals: 9, - address: "So11111111111111111111111111111111111111112", - amount: 9090, - uiAmount: 0.00000909, - price: null, - nearestPrice: 128.201119598627, - changeAmount: -9090, - uiChangeAmount: -0.00000909, - }, - basePrice: null, - quotePrice: null, - txHash: "5G72183B77KafzKv4GEJNnDrV7rtspv5X5WM9yG9g1P89iG1UCGBuAqcMasgGhRYN24bmWsNPkQqptRbX5uoH44K", - source: "raydium", - blockUnixTime: 1726676178, - txType: "swap", - owner: "AavgaV4YKned3RN6JVMANKmAaVS2Tpfnw88HbYtzgBAn", - side: "sell", - alias: null, - pricePair: 1531662.1562156219, - from: { - symbol: "SOL", - decimals: 9, - address: "So11111111111111111111111111111111111111112", - amount: 9090, - uiAmount: 0.00000909, - price: null, - nearestPrice: 128.201119598627, - changeAmount: -9090, - uiChangeAmount: -0.00000909, - }, - to: { - symbol: "POTUS", - decimals: 6, - address: "7hyHfdgxwtaj1QpQSJw3s4R2LMxShPpmr2GsCw9npump", - amount: 13922809, - feeInfo: null, - uiAmount: 13.922809, - price: null, - nearestPrice: 0.00008581853084042329, - changeAmount: 13922809, - uiChangeAmount: 13.922809, - }, - tokenPrice: null, - poolId: "2L8fo6g6me9ZubZhH2iiz6616GouRbGeEuvNoGv69xWE", - }, - { - quote: { - symbol: "PEAKY", - decimals: 6, - address: "62uBW5K24PdxXk185tNjz9pwzkpHinKt8qZznxPPpump", - amount: 35238136, - feeInfo: null, - uiAmount: 35.238136, - price: null, - nearestPrice: 0.00003742796781669965, - changeAmount: 35238136, - uiChangeAmount: 35.238136, - }, - base: { - symbol: "SOL", - decimals: 9, - address: "So11111111111111111111111111111111111111112", - amount: 10333, - uiAmount: 0.000010333, - price: null, - nearestPrice: 128.201119598627, - changeAmount: -10333, - uiChangeAmount: -0.000010333, - }, - basePrice: null, - quotePrice: null, - txHash: "zXdSLDTX4MVzunVJgFbJmiLY9z2hZ3n28w6bYvKn1aZVL1QZGozkyMMMteFqpyWraUTdRyX1GKFnJYkqPsL5SJK", - source: "raydium", - blockUnixTime: 1726676178, - txType: "swap", - owner: "CDt3xtwPVWDbhENL3QhDX5XYVx9JNCvewfdfCRy1cKFt", - side: "sell", - alias: null, - pricePair: 3410252.2016839255, - from: { - symbol: "SOL", - decimals: 9, - address: "So11111111111111111111111111111111111111112", - amount: 10333, - uiAmount: 0.000010333, - price: null, - nearestPrice: 128.201119598627, - changeAmount: -10333, - uiChangeAmount: -0.000010333, - }, - to: { - symbol: "PEAKY", - decimals: 6, - address: "62uBW5K24PdxXk185tNjz9pwzkpHinKt8qZznxPPpump", - amount: 35238136, - feeInfo: null, - uiAmount: 35.238136, - price: null, - nearestPrice: 0.00003742796781669965, - changeAmount: 35238136, - uiChangeAmount: 35.238136, - }, - tokenPrice: null, - poolId: "5vsk6iYjKXEo6x7maZJwh36UjqwFxkRtoHK5Nphh3ht1", - }, - ], - hasNext: true, - }, -}; - -type TokenTradesResponse = typeof exampleResponse; - -// Constants for keyword matching -const TOKEN_TRADES_KEYWORDS = [ - "token trades", - "token swaps", - "token transactions", - "token activity", - "token orders", - "token executions", - "token trading", - "token market activity", - "token exchange activity", - "token trading history", - "token market history", - "token exchange history", -] as const; - -// Helper function to check if text contains trades-related keywords -const containsTokenTradesKeyword = (text: string): boolean => { - return TOKEN_TRADES_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getTokenTrades = async ( - apiKey: string, - contractAddress: string, - chain: Chain, - limit: number -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - limit: limit.toString(), - }); - const url = `${BASE_URL}/defi/trades_token?${params.toString()}`; - - elizaLogger.info( - `Fetching token trades for ${contractAddress} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { - apiKey, - chain, - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching token trades:", error.message); - } - return null; - } -}; - -const formatTrade = ( - trade: TokenTradesResponse["data"]["items"][0] -): string => { - const timestamp = formatTimestamp(trade.blockUnixTime); - const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; - const baseAmount = formatValue(trade.base.uiAmount); - const quoteAmount = formatValue(trade.quote.uiAmount); - - let response = `${side} - ${timestamp}\n`; - response += `ā€¢ ${baseAmount} ${trade.base.symbol} ā‡„ ${quoteAmount} ${trade.quote.symbol}\n`; - response += `ā€¢ Source: ${trade.source}\n`; - response += `ā€¢ Owner: ${shortenAddress(trade.owner)}\n`; - response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; - - return response; -}; - -const formatTokenTradesResponse = ( - data: TokenTradesResponse, - tokenMetadata: TokenMetadataResponse | null, - chain: Chain -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - - let tokenInfo = "Unknown Token"; - let tokenLinks = ""; - - if (tokenMetadata?.success) { - const { name, symbol, extensions } = tokenMetadata.data; - tokenInfo = `${name} (${symbol})`; - - const links = []; - if (extensions.website) links.push(`[Website](${extensions.website})`); - if (extensions.coingecko_id) - links.push( - `[CoinGecko](https://www.coingecko.com/en/coins/${extensions.coingecko_id})` - ); - if (links.length > 0) { - tokenLinks = `\nšŸ“Œ More Information: ${links.join(" ā€¢ ")}`; - } - } - - let response = `Recent Trades for ${tokenInfo} on ${chainName}${tokenLinks}\n\n`; - - if (!data.success || !data.data.items || data.data.items.length === 0) { - return response + "No trades found."; - } - - const trades = data.data.items; - - // Calculate summary statistics - const buyTrades = trades.filter((t) => t.side === "buy"); - const buyRatio = (buyTrades.length / trades.length) * 100; - - const baseVolume = trades.reduce( - (sum, t) => sum + Math.abs(t.base.uiAmount), - 0 - ); - const quoteVolume = trades.reduce( - (sum, t) => sum + Math.abs(t.quote.uiAmount), - 0 - ); - const averageBaseAmount = baseVolume / trades.length; - const averageQuoteAmount = quoteVolume / trades.length; - - response += `šŸ“Š Summary\n`; - response += `ā€¢ Total Trades: ${trades.length}\n`; - response += `ā€¢ Buy/Sell Ratio: ${buyRatio.toFixed(1)}% buys\n`; - response += `ā€¢ Total Volume: ${formatValue(baseVolume)} ${trades[0].base.symbol}\n`; - response += `ā€¢ Average Trade Size: ${formatValue(averageBaseAmount)} ${trades[0].base.symbol}\n`; - response += `ā€¢ Total Quote Volume: ${formatValue(quoteVolume)} ${trades[0].quote.symbol}\n`; - response += `ā€¢ Average Quote Size: ${formatValue(averageQuoteAmount)} ${trades[0].quote.symbol}\n\n`; - - // Add market analysis - const tradeFrequency = - trades.length > 20 ? "high" : trades.length > 10 ? "moderate" : "low"; - const volumeLevel = - baseVolume > averageBaseAmount * 2 - ? "high" - : baseVolume > averageBaseAmount - ? "moderate" - : "low"; - const marketAnalysis = `Market shows ${tradeFrequency} trading activity with ${volumeLevel} volume per trade.`; - - response += `šŸ“ˆ Market Analysis\n`; - response += `ā€¢ ${marketAnalysis}\n\n`; - - response += `šŸ”„ Recent Trades\n`; - trades.forEach((trade, index) => { - response += `${index + 1}. ${formatTrade(trade)}\n\n`; - }); - - if (data.data.hasNext) { - response += `Note: More trades are available. This is a limited view of the most recent activity.`; - } - - return response; -}; - -export const getTokenTradesAction: Action = { - name: "GET_TOKEN_TRADES", - similes: [ - "SHOW_TOKEN_TRADES", - "VIEW_TOKEN_TRADES", - "CHECK_TOKEN_TRADES", - "DISPLAY_TOKEN_TRADES", - "GET_TRADE_HISTORY", - "SHOW_TRADE_HISTORY", - "VIEW_TRADING_ACTIVITY", - "CHECK_MARKET_ACTIVITY", - "TOKEN_TRADING_HISTORY", - "TOKEN_MARKET_ACTIVITY", - ], - description: - "Retrieve and analyze recent trading activity for a token, including trade details, volume statistics, and market analysis.", - validate: async ( - _runtime: IAgentRuntime, - message: Memory, - _state: State | undefined - ): Promise => { - return containsTokenTradesKeyword(message.content.text); - }, - handler: (async ( - runtime: IAgentRuntime, - message: Memory, - _state: State | undefined, - _options: any, - callback: HandlerCallback - ): Promise => { - const callbackData: Content = { - text: "", - action: "GET_TOKEN_TRADES_RESPONSE", - source: message.content.source, - }; - - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - callbackData.text = - "I'm unable to fetch the token trades due to missing API credentials."; - await callback(callbackData); - return callbackData; - } - - const messageText = message.content.text; - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - callbackData.text = - "I couldn't find a valid token address in your message."; - await callback(callbackData); - return callbackData; - } - - const chain = extractChain(messageText); - const limit = extractLimit(messageText); - - // First fetch token metadata - const tokenMetadata = await getTokenMetadata( - apiKey, - addresses[0], - chain - ); - - elizaLogger.info( - `TOKEN TRADES action activated for ${addresses[0]} on ${chain}` - ); - - const tradesData = await getTokenTrades( - apiKey, - addresses[0], - chain, - limit - ); - - if (!tradesData) { - callbackData.text = - "I apologize, but I couldn't retrieve the token trades at the moment."; - await callback(callbackData); - return callbackData; - } - - callbackData.text = formatTokenTradesResponse( - tradesData, - tokenMetadata, - chain - ); - await callback(callbackData); - return callbackData; - }) as Handler, - examples: [ - [ - { - user: "{{user1}}", - content: { - text: "Show me recent trades for token 0x1234... on Ethereum", - }, - }, - { - user: "{{user2}}", - content: { - text: "Here's the detailed trading activity analysis including recent trades, volume statistics, and market insights.", - action: "GET_TOKEN_TRADES", - }, - }, - ], - [ - { - user: "{{user1}}", - content: { - text: "What's the trading activity for ABC123... on Solana?", - }, - }, - { - user: "{{user2}}", - content: { - text: "I'll analyze the recent trading activity and provide you with a comprehensive overview of the market.", - action: "GET_TOKEN_TRADES", - }, - }, - ], - [ - { - user: "{{user1}}", - content: { - text: "Get me the last 20 trades for token XYZ... on BSC", - }, - }, - { - user: "{{user2}}", - content: { - text: "I'll fetch the recent trades and provide detailed statistics about the trading activity.", - action: "GET_TOKEN_TRADES", - }, - }, - ], - ] as ActionExample[][], -}; diff --git a/packages/plugin-birdeye/src/actions/defi/get-supported-networks.ts b/packages/plugin-birdeye/src/actions/defi/networks.ts similarity index 72% rename from packages/plugin-birdeye/src/actions/defi/get-supported-networks.ts rename to packages/plugin-birdeye/src/actions/defi/networks.ts index 0f476e9fd8..415d9f2080 100644 --- a/packages/plugin-birdeye/src/actions/defi/get-supported-networks.ts +++ b/packages/plugin-birdeye/src/actions/defi/networks.ts @@ -2,54 +2,15 @@ import { Action, ActionExample, Content, + elizaLogger, Handler, HandlerCallback, IAgentRuntime, Memory, State, - elizaLogger, } from "@elizaos/core"; -import { BASE_URL, makeApiRequest } from "../../providers/utils"; - -// Constants for keyword matching -const NETWORK_KEYWORDS = [ - "supported networks", - "available networks", - "supported chains", - "available chains", - "which networks", - "which chains", - "list networks", - "list chains", - "show networks", - "show chains", - "network support", - "chain support", -] as const; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const exampleResponse = { - success: true, - data: [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", - ], -}; - -// Helper function to check if text contains network-related keywords -const containsNetworkKeyword = (text: string): boolean => { - return NETWORK_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; +import { BASE_URL, makeApiRequest } from "../../utils.ts"; +import { containsNetworkKeyword, NetworksResponse } from "./networks.utils.ts"; export const getSupportedNetworksAction: Action = { name: "GET_SUPPORTED_NETWORKS", @@ -97,7 +58,7 @@ export const getSupportedNetworksAction: Action = { elizaLogger.info("Fetching supported networks"); const url = `${BASE_URL}/defi/networks`; - const networksData = await makeApiRequest(url, { + const networksData = await makeApiRequest(url, { apiKey, }); @@ -108,7 +69,7 @@ export const getSupportedNetworksAction: Action = { return callbackData; } - callbackData.text = `Currently supported networks for information about tokens, swaps, prices, gainers and losers are: ${networksData.data.join(", ")}`; + callbackData.text = `Currently supported networks are: ${networksData.data.join(", ")}`; await callback(callbackData); return callbackData; }) as Handler, diff --git a/packages/plugin-birdeye/src/actions/defi/networks.utils.ts b/packages/plugin-birdeye/src/actions/defi/networks.utils.ts new file mode 100644 index 0000000000..eefe89597c --- /dev/null +++ b/packages/plugin-birdeye/src/actions/defi/networks.utils.ts @@ -0,0 +1,27 @@ +export interface NetworksResponse { + success: boolean; + data: string[]; +} + +// Constants for keyword matching +export const NETWORK_KEYWORDS = [ + "supported networks", + "available networks", + "supported chains", + "available chains", + "which networks", + "which chains", + "list networks", + "list chains", + "show networks", + "show chains", + "network support", + "chain support", +] as const; + +// Helper function to check if text contains network-related keywords +export const containsNetworkKeyword = (text: string): boolean => { + return NETWORK_KEYWORDS.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()) + ); +}; diff --git a/packages/plugin-birdeye/src/index.ts b/packages/plugin-birdeye/src/index.ts index f686940ea5..e997bfafb3 100644 --- a/packages/plugin-birdeye/src/index.ts +++ b/packages/plugin-birdeye/src/index.ts @@ -1,46 +1,19 @@ import { Plugin } from "@elizaos/core"; -import { getOHLCVAction } from "./actions/defi/get-ohlcv"; -import { getPriceHistoryAction } from "./actions/defi/get-price-history"; -import { getSupportedNetworksAction } from "./actions/defi/get-supported-networks"; -import { getTokenMetadataAction } from "./actions/defi/get-token-metadata"; -import { getTokenTradesAction } from "./actions/defi/get-token-trades"; +import { getSupportedNetworksAction } from "./actions/defi/networks"; +import { addressSearchProvider } from "./providers/address-search-provider"; export const birdeyePlugin: Plugin = { name: "birdeye", description: "Birdeye Plugin for token data and analytics", actions: [ getSupportedNetworksAction, - getTokenMetadataAction, - getPriceHistoryAction, - getOHLCVAction, - getTokenTradesAction, + // getTokenMetadataAction, + // getPriceHistoryAction, + // getOHLCVAction, + // getTokenTradesAction, ], evaluators: [], - providers: [ - // networksProvider, - // // DeFi providers - // priceProvider, - // priceMultipleProvider, - // ohlcvProvider, - // priceVolumeProvider, - // // Pair providers - // pairOverviewProvider, - // // Search providers - // tokenMarketDataProvider, - // // Token providers - // tokenOverviewProvider, - // tokenSecurityProvider, - // tokenListProvider, - // trendingTokensProvider, - // tokenCreationProvider, - // tokenTradeProvider, - // // Trader providers - // gainersLosersProvider, - // tradesSeekProvider, - // // Wallet providers - // transactionHistoryProvider, - // walletPortfolioProvider, - ], + providers: [addressSearchProvider], }; export default birdeyePlugin; diff --git a/packages/plugin-birdeye/src/providers/__tests__/utils.test.ts b/packages/plugin-birdeye/src/providers/__tests__/utils.test.ts deleted file mode 100644 index cdc8922733..0000000000 --- a/packages/plugin-birdeye/src/providers/__tests__/utils.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { - extractChain, - extractContractAddresses, - extractLimit, - extractTimeframe, - extractTimeRange, - formatPercentChange, - formatPrice, - formatTimestamp, - formatValue, - shortenAddress, - TIME_UNITS, -} from "../utils"; - -describe("Chain Extraction", () => { - test("extracts chain from text correctly", () => { - expect(extractChain("Check price on Solana")).toBe("solana"); - expect(extractChain("Look up Ethereum token")).toBe("ethereum"); - expect(extractChain("No chain mentioned")).toBe("solana"); // default - }); -}); - -describe("Contract Address Extraction", () => { - test("extracts Ethereum addresses correctly", () => { - const text = - "Token address is 0x1234567890123456789012345678901234567890"; - expect(extractContractAddresses(text)).toEqual([ - "0x1234567890123456789012345678901234567890", - ]); - }); - - test("extracts Solana addresses correctly", () => { - const text = - "Token address is TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; - expect(extractContractAddresses(text)).toEqual([ - "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - ]); - }); - - test("extracts multiple addresses correctly", () => { - const text = - "0x1234567890123456789012345678901234567890 TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; - expect(extractContractAddresses(text)).toHaveLength(2); - }); -}); - -describe("Timeframe Extraction", () => { - test("extracts explicit timeframes correctly", () => { - expect(extractTimeframe("Show 1h chart")).toBe("1h"); - expect(extractTimeframe("Display 15m data")).toBe("15m"); - expect(extractTimeframe("Get 1d overview")).toBe("1d"); - }); - - test("extracts semantic timeframes correctly", () => { - expect(extractTimeframe("Show short term analysis")).toBe("15m"); - expect(extractTimeframe("Get medium term view")).toBe("1h"); - expect(extractTimeframe("Display long term data")).toBe("1d"); - }); - - test("returns default timeframe for unclear input", () => { - expect(extractTimeframe("Show me the data")).toBe("1h"); - }); -}); - -describe("Time Range Extraction", () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date("2024-01-01T00:00:00Z")); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - test("extracts specific date ranges", () => { - const result = extractTimeRange("from 2023-12-01 to 2023-12-31"); - expect(result.start).toBe(new Date("2023-12-01").getTime() / 1000); - expect(result.end).toBe(new Date("2023-12-31").getTime() / 1000); - }); - - test("extracts relative time ranges", () => { - const now = Math.floor(Date.now() / 1000); - const result = extractTimeRange("24 hours ago"); - expect(result.end).toBe(now); - expect(result.start).toBe(now - TIME_UNITS.day); - }); - - test("handles semantic time ranges", () => { - const now = Math.floor(Date.now() / 1000); - const result = extractTimeRange("show me today's data"); - expect(result.end).toBe(now); - expect(result.start).toBe(now - TIME_UNITS.day); - }); -}); - -describe("Limit Extraction", () => { - test("extracts explicit limits", () => { - expect(extractLimit("show 20 results")).toBe(20); - expect(extractLimit("display 5 items")).toBe(5); - expect(extractLimit("fetch 200 records")).toBe(100); // clamped to max - }); - - test("extracts semantic limits", () => { - expect(extractLimit("show me everything")).toBe(100); - expect(extractLimit("give me a brief overview")).toBe(5); - expect(extractLimit("provide detailed analysis")).toBe(50); - }); - - test("returns default limit for unclear input", () => { - expect(extractLimit("show me the data")).toBe(10); - }); -}); - -describe("Formatting Functions", () => { - test("formats values correctly", () => { - expect(formatValue(1500000000)).toBe("$1.50B"); - expect(formatValue(1500000)).toBe("$1.50M"); - expect(formatValue(1500)).toBe("$1.50K"); - expect(formatValue(150)).toBe("$150.00"); - }); - - test("formats percent changes correctly", () => { - expect(formatPercentChange(10.5)).toBe("šŸ“ˆ 10.50%"); - expect(formatPercentChange(-5.25)).toBe("šŸ“‰ 5.25%"); - expect(formatPercentChange(undefined)).toBe("N/A"); - }); - - test("shortens addresses correctly", () => { - expect( - shortenAddress("0x1234567890123456789012345678901234567890") - ).toBe("0x1234...7890"); - expect(shortenAddress("short")).toBe("short"); - expect(shortenAddress("")).toBe("Unknown"); - }); - - test("formats timestamps correctly", () => { - const timestamp = 1704067200; // 2024-01-01 00:00:00 UTC - expect(formatTimestamp(timestamp)).toMatch(/2024/); - }); - - test("formats prices correctly", () => { - expect(formatPrice(123.456)).toBe("123.46"); - expect(formatPrice(0.000123)).toBe("1.23e-4"); - }); -}); diff --git a/packages/plugin-birdeye/src/providers/address-search-provider.ts b/packages/plugin-birdeye/src/providers/address-search-provider.ts new file mode 100644 index 0000000000..40f178a762 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/address-search-provider.ts @@ -0,0 +1,111 @@ +import { + elizaLogger, + IAgentRuntime, + Memory, + Provider, + State, +} from "@elizaos/core"; +import { getTokenMetadata, searchTokens } from "../services"; +import { + extractChain, + extractContractAddresses, + extractSymbols, + formatTokenInfo, +} from "../utils"; + +/** + * Searches message text for contract addresses, symbols, or wallet addresses and enriches them with: + * - Portfolio data if its a wallet address + * - Token metadata if its a contract address or symbol + */ +export const addressSearchProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); + if (!apiKey) { + return null; + } + + const messageText = message.content.text; + + // STEP 1 - Extract addresses and symbols + const addresses = extractContractAddresses(messageText); + const symbols = extractSymbols(messageText); + + if (addresses.length === 0 && symbols.length === 0) return null; + + elizaLogger.info( + `Searching Birdeye provider for ${addresses.length} addresses and ${symbols.length} symbols` + ); + + // STEP 2 - Search Birdeye services for token matches based on addresses and symbols + + // Search in parallel for all terms + const searchAddressesForTokenMatch = addresses.map((address) => + searchTokens(apiKey, { + keyword: address.address, + limit: 1, + }).then((results) => ({ + searchTerm: address.address, + address: address.address, + // find the result that matches the address + result: + results.find((r) => r.address === address.address) || null, + })) + ); + + // Search in parallel for all terms + const searchSymbolsForTokenMatch = symbols.map((symbol) => + searchTokens(apiKey, { + keyword: symbol, + limit: 1, + }).then((results) => ({ + searchTerm: symbol, + symbol: results[0]?.symbol || null, + address: results[0]?.address || null, + // find the result that matches the symbol + result: results.find((r) => r.symbol === symbol) || null, + })) + ); + + const results = await Promise.all([ + ...searchAddressesForTokenMatch, + ...searchSymbolsForTokenMatch, + ]); + const validResults = results.filter((r) => r.result !== null); + + // bail if no valid results + if (validResults.length === 0) return null; + + // for each result, get the chain from the search term + const resultsWithChains = validResults.map( + ({ searchTerm, address }) => ({ + searchTerm, + address, + chain: extractChain(address), + }) + ); + + // STEP 3 - get metadata for all valid results and format them. This includes additional token information like social links, logo, etc. + const resultsWithMetadata = await Promise.all( + resultsWithChains.map(({ address, chain }) => + getTokenMetadata(apiKey, address, chain) + ) + ); + + // STEP 4 - Format all results together + const completeResults = `The following data is available for the symbols and contract addresses requested: ${validResults + .map( + ({ searchTerm, result }, index) => + `Search term "${searchTerm}":\n${formatTokenInfo(result!, resultsWithMetadata[index])}` + ) + .join("\n\n")}`; + + console.log(completeResults); + + return completeResults; + }, +}; diff --git a/packages/plugin-birdeye/src/providers/defi/__tests__/price-provider.test.ts b/packages/plugin-birdeye/src/providers/defi/__tests__/price-provider.test.ts deleted file mode 100644 index 5c364f5848..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/__tests__/price-provider.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { IAgentRuntime, Memory, State } from "@elizaos/core"; -import { priceProvider } from "../price-provider"; - -// Mock data -const mockPriceData = { - price: 1.23, - timestamp: 1704067200, // 2024-01-01 00:00:00 UTC - token: "TEST", - priceChange24h: 0.05, - priceChange24hPercent: 4.23, -}; - -// Mock fetch globally -global.fetch = jest.fn(); - -describe("Price Provider", () => { - let mockRuntime: IAgentRuntime; - let mockMessage: Memory; - let mockState: State; - - beforeEach(() => { - // Reset mocks - jest.clearAllMocks(); - - // Mock runtime - mockRuntime = { - getSetting: jest.fn().mockReturnValue("mock-api-key"), - } as unknown as IAgentRuntime; - - // Mock message - mockMessage = { - content: { - text: "What is the price of 0x1234567890123456789012345678901234567890 on ethereum", - }, - } as Memory; - - // Mock state - mockState = {} as State; - - // Mock successful fetch response - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({ data: mockPriceData }), - }); - }); - - test("returns null when API key is missing", async () => { - (mockRuntime.getSetting as jest.Mock).mockReturnValue(null); - const result = await priceProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).toBeNull(); - }); - - test("returns null when message does not contain price keywords", async () => { - mockMessage.content.text = "random message without price keywords"; - const result = await priceProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).toBeNull(); - }); - - test("returns null when no contract address is found", async () => { - mockMessage.content.text = "what is the price of invalid-address"; - const result = await priceProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).toBeNull(); - }); - - test("handles API error gracefully", async () => { - (global.fetch as jest.Mock).mockRejectedValue(new Error("API Error")); - const result = await priceProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).toBeNull(); - }); - - test("handles 404 response gracefully", async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 404, - }); - const result = await priceProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).toBeNull(); - }); - - test("formats price response correctly with all data", async () => { - const result = await priceProvider.get( - mockRuntime, - mockMessage, - mockState - ); - - expect(result).toContain( - `Price for ${mockPriceData.token} on Ethereum` - ); - expect(result).toContain( - `Current Price: $${mockPriceData.price.toFixed(2)}` - ); - expect(result).toContain( - `24h Change: $${mockPriceData.priceChange24h.toFixed(2)}` - ); - expect(result).toContain( - `24h Change %: ${mockPriceData.priceChange24hPercent.toFixed(2)}%` - ); - expect(result).toContain("Last Updated:"); - }); - - test("formats price response correctly with minimal data", async () => { - const minimalPriceData = { - price: 0.000123, - timestamp: 1704067200, - token: "TEST", - }; - - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({ data: minimalPriceData }), - }); - - const result = await priceProvider.get( - mockRuntime, - mockMessage, - mockState - ); - - expect(result).toContain( - `Price for ${minimalPriceData.token} on Ethereum` - ); - expect(result).toContain("Current Price: $1.23e-4"); // Scientific notation for small numbers - expect(result).not.toContain("24h Change:"); - expect(result).not.toContain("24h Change %:"); - expect(result).toContain("Last Updated:"); - }); - - test("extracts chain correctly", async () => { - mockMessage.content.text = - "what is the price of 0x1234567890123456789012345678901234567890 on ethereum"; - await priceProvider.get(mockRuntime, mockMessage, mockState); - - expect(global.fetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - "x-chain": "ethereum", - }), - }) - ); - }); - - test("defaults to solana chain when not specified", async () => { - mockMessage.content.text = - "what is the price of 0x1234567890123456789012345678901234567890"; - await priceProvider.get(mockRuntime, mockMessage, mockState); - - expect(global.fetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - "x-chain": "solana", - }), - }) - ); - }); - - test("recognizes various price keywords", async () => { - const priceKeywords = [ - "price", - "cost", - "worth", - "value", - "rate", - "quote", - "how much", - ]; - - for (const keyword of priceKeywords) { - mockMessage.content.text = `${keyword} of 0x1234567890123456789012345678901234567890`; - const result = await priceProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).not.toBeNull(); - } - }); - - test("handles different address formats", async () => { - // Test Ethereum address - mockMessage.content.text = - "price of 0x1234567890123456789012345678901234567890"; - let result = await priceProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).not.toBeNull(); - - // Test Solana address - mockMessage.content.text = - "price of TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; - result = await priceProvider.get(mockRuntime, mockMessage, mockState); - expect(result).not.toBeNull(); - }); -}); diff --git a/packages/plugin-birdeye/src/providers/defi/index.ts b/packages/plugin-birdeye/src/providers/defi/index.ts deleted file mode 100644 index af593176d4..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from "./networks-provider"; -export * from "./ohlcv-base-quote-provider"; -export * from "./ohlcv-pair-provider"; -export * from "./ohlcv-provider"; -export * from "./pair-trades-provider"; -export * from "./pair-trades-seek-provider"; -export * from "./price-history-provider"; -export * from "./price-multiple-provider"; -export * from "./price-provider"; -export * from "./price-volume-provider"; -export * from "./token-trades-provider"; -export * from "./trades-seek-provider"; diff --git a/packages/plugin-birdeye/src/providers/defi/networks-provider.ts b/packages/plugin-birdeye/src/providers/defi/networks-provider.ts deleted file mode 100644 index a13659bc3c..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/networks-provider.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { BASE_URL, makeApiRequest } from "../utils"; - -// Constants -const NETWORK_KEYWORDS = [ - "supported networks", - "available networks", - "supported chains", - "available chains", - "which networks", - "which chains", - "list networks", - "list chains", - "show networks", - "show chains", - "network support", - "chain support", -] as const; - -// Helper functions -const containsNetworkKeyword = (text: string): boolean => { - return NETWORK_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -// use sample response to simplify type generation -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const sampleResponse = { - data: [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", - ], - success: true, -}; - -export const networksProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsNetworkKeyword(messageText)) { - return null; - } - - elizaLogger.info("NETWORKS provider activated"); - - const url = `${BASE_URL}/defi/networks`; - - elizaLogger.info("Fetching supported networks from:", url); - - const networksData = await makeApiRequest(url, { - apiKey, - }); - - console.log(JSON.stringify(networksData, null, 2)); - - if (!networksData) { - return null; - } - - return `Currently supported networks for information about tokens, swaps, prices, gainers and losers are: ${networksData.data.join(", ")}`; - }, -}; diff --git a/packages/plugin-birdeye/src/providers/defi/ohlcv-base-quote-provider.ts b/packages/plugin-birdeye/src/providers/defi/ohlcv-base-quote-provider.ts deleted file mode 100644 index 0a07937ade..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/ohlcv-base-quote-provider.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - Timeframe, - extractChain, - extractContractAddresses, - extractLimit, - extractTimeRange, - extractTimeframe, - formatTimestamp, - formatValue, - makeApiRequest, -} from "../utils"; - -// Types -interface OHLCVData { - timestamp: number; - open: number; - high: number; - low: number; - close: number; - volume: number; -} - -interface BaseQuoteOHLCVResponse { - data: OHLCVData[]; - pair: { - baseToken: string; - quoteToken: string; - }; -} - -// Constants -const BASE_QUOTE_OHLCV_KEYWORDS = [ - "base quote ohlcv", - "base quote candlestick", - "base quote candles", - "base quote chart", - "base quote price history", - "base quote historical data", - "base quote market data", - "base quote trading data", - "base/quote chart", - "base/quote price", - "base/quote history", - "base/quote movement", - "token pair chart", - "token pair price", - "token pair history", - "token pair movement", -] as const; - -// Helper functions -const containsBaseQuoteOHLCVKeyword = (text: string): boolean => { - return BASE_QUOTE_OHLCV_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getBaseQuoteOHLCV = async ( - apiKey: string, - baseAddress: string, - quoteAddress: string, - timeframe: Timeframe, - chain: Chain, - limit: number -): Promise => { - try { - const params = new URLSearchParams({ - base_address: baseAddress, - quote_address: quoteAddress, - timeframe, - limit: limit.toString(), - }); - const url = `${BASE_URL}/defi/ohlcv_base_quote?${params.toString()}`; - - elizaLogger.info( - `Fetching base/quote OHLCV data for ${baseAddress}/${quoteAddress} with ${timeframe} timeframe on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { - apiKey, - chain, - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error( - "Error fetching base/quote OHLCV data:", - error.message - ); - } - return null; - } -}; - -const formatOHLCVData = (data: OHLCVData): string => { - const timestamp = formatTimestamp(data.timestamp); - const change = ((data.close - data.open) / data.open) * 100; - const trend = change >= 0 ? "šŸŸ¢" : "šŸ”“"; - - let response = `${trend} ${timestamp}\n`; - response += `ā€¢ Open: ${formatValue(data.open)}\n`; - response += `ā€¢ High: ${formatValue(data.high)}\n`; - response += `ā€¢ Low: ${formatValue(data.low)}\n`; - response += `ā€¢ Close: ${formatValue(data.close)}\n`; - response += `ā€¢ Volume: ${formatValue(data.volume)}\n`; - response += `ā€¢ Change: ${change >= 0 ? "+" : ""}${change.toFixed(2)}%`; - - return response; -}; - -const formatBaseQuoteOHLCVResponse = ( - data: BaseQuoteOHLCVResponse, - timeframe: Timeframe, - chain: Chain, - timeRange: { start: number; end: number } -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - const startDate = formatTimestamp(timeRange.start); - const endDate = formatTimestamp(timeRange.end); - - let response = `Base/Quote OHLCV Data for ${data.pair.baseToken}/${data.pair.quoteToken} on ${chainName}\n`; - response += `Timeframe: ${timeframe} (${startDate} to ${endDate})\n\n`; - - if (data.data.length === 0) { - return response + "No OHLCV data found for this base/quote pair."; - } - - // Calculate summary statistics - const latestPrice = data.data[data.data.length - 1].close; - const earliestPrice = data.data[0].open; - const priceChange = ((latestPrice - earliestPrice) / earliestPrice) * 100; - const totalVolume = data.data.reduce((sum, d) => sum + d.volume, 0); - const highestPrice = Math.max(...data.data.map((d) => d.high)); - const lowestPrice = Math.min(...data.data.map((d) => d.low)); - const averageVolume = totalVolume / data.data.length; - const volatility = ((highestPrice - lowestPrice) / lowestPrice) * 100; - - response += `šŸ“Š Summary\n`; - response += `ā€¢ Current Price: ${formatValue(latestPrice)}\n`; - response += `ā€¢ Period Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; - response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; - response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; - response += `ā€¢ Highest Price: ${formatValue(highestPrice)}\n`; - response += `ā€¢ Lowest Price: ${formatValue(lowestPrice)}\n`; - response += `ā€¢ Volatility: ${volatility.toFixed(2)}%\n\n`; - - // Add trend analysis - const trendStrength = Math.abs(priceChange); - let trendAnalysis = ""; - if (trendStrength < 1) { - trendAnalysis = "Sideways movement with low volatility"; - } else if (trendStrength < 5) { - trendAnalysis = - priceChange > 0 ? "Slight upward trend" : "Slight downward trend"; - } else if (trendStrength < 10) { - trendAnalysis = - priceChange > 0 - ? "Moderate upward trend" - : "Moderate downward trend"; - } else { - trendAnalysis = - priceChange > 0 ? "Strong upward trend" : "Strong downward trend"; - } - - response += `šŸ“ˆ Trend Analysis\n`; - response += `ā€¢ ${trendAnalysis}\n`; - response += `ā€¢ Volatility is ${volatility < 5 ? "low" : volatility < 15 ? "moderate" : "high"}\n\n`; - - response += `šŸ“Š Recent Data\n`; - // Show only the last 5 entries - const recentData = data.data.slice(-5); - recentData.forEach((candle, index) => { - response += `${index + 1}. ${formatOHLCVData(candle)}\n\n`; - }); - - if (data.data.length > 5) { - response += `Showing last 5 of ${data.data.length} candles.`; - } - - return response; -}; - -export const baseQuoteOHLCVProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsBaseQuoteOHLCVKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length !== 2) { - return null; - } - - const chain = extractChain(messageText); - const timeframe = extractTimeframe(messageText); - const timeRange = extractTimeRange(messageText); - const limit = extractLimit(messageText); - - elizaLogger.info( - `BASE/QUOTE OHLCV provider activated for base ${addresses[0]} and quote ${addresses[1]} with ${timeframe} timeframe on ${chain}` - ); - - const ohlcvData = await getBaseQuoteOHLCV( - apiKey, - addresses[0], - addresses[1], - timeframe, - chain, - limit - ); - - if (!ohlcvData) { - return null; - } - - return formatBaseQuoteOHLCVResponse( - ohlcvData, - timeframe, - chain, - timeRange - ); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/defi/ohlcv-pair-provider.ts b/packages/plugin-birdeye/src/providers/defi/ohlcv-pair-provider.ts deleted file mode 100644 index 8cfa6f9fac..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/ohlcv-pair-provider.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - Timeframe, - extractChain, - extractContractAddresses, - extractLimit, - extractTimeRange, - extractTimeframe, - formatTimestamp, - formatValue, - makeApiRequest, -} from "../utils"; - -// Types -interface OHLCVData { - timestamp: number; - open: number; - high: number; - low: number; - close: number; - volume: number; -} - -interface PairOHLCVResponse { - data: OHLCVData[]; - pair: { - baseToken: string; - quoteToken: string; - }; -} - -// Constants -const PAIR_OHLCV_KEYWORDS = [ - "pair ohlcv", - "pair candlestick", - "pair candles", - "pair chart", - "pair price history", - "pair historical data", - "pair market data", - "pair trading data", - "trading chart", - "price chart", - "market chart", - "candlestick chart", - "price action", - "market action", - "price movement", - "market movement", -] as const; - -// Helper functions -const containsPairOHLCVKeyword = (text: string): boolean => { - return PAIR_OHLCV_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getPairOHLCV = async ( - apiKey: string, - baseAddress: string, - quoteAddress: string, - timeframe: Timeframe, - chain: Chain, - limit: number -): Promise => { - try { - const params = new URLSearchParams({ - base_address: baseAddress, - quote_address: quoteAddress, - timeframe, - limit: limit.toString(), - }); - const url = `${BASE_URL}/defi/ohlcv_pair?${params.toString()}`; - - elizaLogger.info( - `Fetching OHLCV data for pair ${baseAddress}/${quoteAddress} with ${timeframe} timeframe on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching pair OHLCV data:", error.message); - } - return null; - } -}; - -const formatOHLCVData = (data: OHLCVData): string => { - const timestamp = formatTimestamp(data.timestamp); - const change = ((data.close - data.open) / data.open) * 100; - const trend = change >= 0 ? "šŸŸ¢" : "šŸ”“"; - - let response = `${trend} ${timestamp}\n`; - response += `ā€¢ Open: ${formatValue(data.open)}\n`; - response += `ā€¢ High: ${formatValue(data.high)}\n`; - response += `ā€¢ Low: ${formatValue(data.low)}\n`; - response += `ā€¢ Close: ${formatValue(data.close)}\n`; - response += `ā€¢ Volume: ${formatValue(data.volume)}\n`; - response += `ā€¢ Change: ${change >= 0 ? "+" : ""}${change.toFixed(2)}%`; - - return response; -}; - -const formatPairOHLCVResponse = ( - data: PairOHLCVResponse, - timeframe: Timeframe, - chain: Chain, - timeRange: { start: number; end: number } -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - const startDate = formatTimestamp(timeRange.start); - const endDate = formatTimestamp(timeRange.end); - - let response = `OHLCV Data for ${data.pair.baseToken}/${data.pair.quoteToken} pair on ${chainName}\n`; - response += `Timeframe: ${timeframe} (${startDate} to ${endDate})\n\n`; - - if (data.data.length === 0) { - return response + "No OHLCV data found for this pair."; - } - - // Calculate summary statistics - const latestPrice = data.data[data.data.length - 1].close; - const earliestPrice = data.data[0].open; - const priceChange = ((latestPrice - earliestPrice) / earliestPrice) * 100; - const totalVolume = data.data.reduce((sum, d) => sum + d.volume, 0); - const highestPrice = Math.max(...data.data.map((d) => d.high)); - const lowestPrice = Math.min(...data.data.map((d) => d.low)); - const averageVolume = totalVolume / data.data.length; - - response += `šŸ“Š Summary\n`; - response += `ā€¢ Current Price: ${formatValue(latestPrice)}\n`; - response += `ā€¢ Period Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; - response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; - response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; - response += `ā€¢ Highest Price: ${formatValue(highestPrice)}\n`; - response += `ā€¢ Lowest Price: ${formatValue(lowestPrice)}\n`; - response += `ā€¢ Price Range: ${(((highestPrice - lowestPrice) / lowestPrice) * 100).toFixed(2)}%\n\n`; - - response += `šŸ“ˆ Recent Data\n`; - // Show only the last 5 entries - const recentData = data.data.slice(-5); - recentData.forEach((candle, index) => { - response += `${index + 1}. ${formatOHLCVData(candle)}\n\n`; - }); - - if (data.data.length > 5) { - response += `Showing last 5 of ${data.data.length} candles.`; - } - - return response; -}; - -export const pairOHLCVProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsPairOHLCVKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length !== 2) { - return null; - } - - const chain = extractChain(messageText); - const timeframe = extractTimeframe(messageText); - const timeRange = extractTimeRange(messageText); - const limit = extractLimit(messageText); - - elizaLogger.info( - `PAIR OHLCV provider activated for base ${addresses[0]} and quote ${addresses[1]} with ${timeframe} timeframe on ${chain}` - ); - - const ohlcvData = await getPairOHLCV( - apiKey, - addresses[0], - addresses[1], - timeframe, - chain, - limit - ); - - if (!ohlcvData) { - return null; - } - - return formatPairOHLCVResponse(ohlcvData, timeframe, chain, timeRange); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/defi/ohlcv-provider.ts b/packages/plugin-birdeye/src/providers/defi/ohlcv-provider.ts deleted file mode 100644 index 259e1b4a0d..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/ohlcv-provider.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -// Types -interface OHLCVData { - timestamp: number; - open: number; - high: number; - low: number; - close: number; - volume: number; -} - -// Constants -const OHLCV_KEYWORDS = [ - "ohlc", - "ohlcv", - "candlestick", - "candle", - "chart", - "price history", - "historical", -] as const; - -const TIME_INTERVAL_KEYWORDS = { - "1m": ["1 minute", "1min", "1m"], - "3m": ["3 minutes", "3min", "3m"], - "5m": ["5 minutes", "5min", "5m"], - "15m": ["15 minutes", "15min", "15m"], - "30m": ["30 minutes", "30min", "30m"], - "1h": ["1 hour", "1hr", "1h"], - "2h": ["2 hours", "2hr", "2h"], - "4h": ["4 hours", "4hr", "4h"], - "6h": ["6 hours", "6hr", "6h"], - "12h": ["12 hours", "12hr", "12h"], - "1d": ["1 day", "daily", "1d"], - "1w": ["1 week", "weekly", "1w"], - "1mo": ["1 month", "monthly", "1mo"], -} as const; - -const CHAIN_KEYWORDS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -] as const; - -const BASE_URL = "https://public-api.birdeye.so"; - -// Helper functions -const containsOHLCVKeyword = (text: string): boolean => { - return OHLCV_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractTimeInterval = (text: string): string => { - const lowerText = text.toLowerCase(); - for (const [interval, keywords] of Object.entries(TIME_INTERVAL_KEYWORDS)) { - if (keywords.some((keyword) => lowerText.includes(keyword))) { - return interval; - } - } - return "1d"; // Default to daily if no interval specified -}; - -const extractChain = (text: string): string => { - const chain = CHAIN_KEYWORDS.find((chain) => - text.toLowerCase().includes(chain.toLowerCase()) - ); - return chain || "solana"; -}; - -const extractContractAddress = (text: string): string | null => { - const words = text.split(/\s+/); - - for (const word of words) { - // Ethereum-like addresses (0x...) - if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { - return word; - } - // Solana addresses (base58, typically 32-44 chars) - if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { - return word; - } - } - return null; -}; - -const getOHLCVData = async ( - apiKey: string, - contractAddress: string, - interval: string = "1d", - chain: string = "solana" -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - interval, - limit: "24", // Get last 24 periods - }); - const url = `${BASE_URL}/defi/ohlcv?${params.toString()}`; - - elizaLogger.info( - `Fetching OHLCV data for address ${contractAddress} on ${chain} with interval ${interval} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - elizaLogger.warn( - `Token not found: ${contractAddress} on ${chain}` - ); - return null; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching OHLCV data:", error); - return null; - } -}; - -const formatNumber = (num: number): string => { - if (!num && num !== 0) return "N/A"; - return num < 0.01 - ? num.toExponential(2) - : num.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 6, - }); -}; - -const formatVolume = (volume: number): string => { - if (volume >= 1_000_000_000) { - return `$${(volume / 1_000_000_000).toFixed(2)}B`; - } - if (volume >= 1_000_000) { - return `$${(volume / 1_000_000).toFixed(2)}M`; - } - if (volume >= 1_000) { - return `$${(volume / 1_000).toFixed(2)}K`; - } - return `$${volume.toFixed(2)}`; -}; - -const formatOHLCVResponse = ( - data: OHLCVData[], - interval: string, - chain: string -): string => { - if (data.length === 0) { - return "No OHLCV data available for the specified period."; - } - - // Sort data by timestamp in ascending order - const sortedData = [...data].sort((a, b) => a.timestamp - b.timestamp); - const latestData = sortedData[sortedData.length - 1]; - - let response = `OHLCV Data (${interval}) on ${chain.charAt(0).toUpperCase() + chain.slice(1)}:\n\n`; - - // Latest price information - response += `šŸ“Š Latest Candle (${new Date(latestData.timestamp * 1000).toLocaleString()})\n`; - response += `ā€¢ Open: $${formatNumber(latestData.open)}\n`; - response += `ā€¢ High: $${formatNumber(latestData.high)}\n`; - response += `ā€¢ Low: $${formatNumber(latestData.low)}\n`; - response += `ā€¢ Close: $${formatNumber(latestData.close)}\n`; - response += `ā€¢ Volume: ${formatVolume(latestData.volume)}\n`; - - // Price change statistics - const priceChange = latestData.close - latestData.open; - const priceChangePercent = (priceChange / latestData.open) * 100; - const trend = priceChange >= 0 ? "šŸ“ˆ" : "šŸ“‰"; - - response += `\n${trend} Period Change\n`; - response += `ā€¢ Price Change: $${formatNumber(priceChange)} (${priceChangePercent.toFixed(2)}%)\n`; - - // Volume analysis - const totalVolume = sortedData.reduce( - (sum, candle) => sum + candle.volume, - 0 - ); - const avgVolume = totalVolume / sortedData.length; - - response += `\nšŸ“Š Volume Analysis\n`; - response += `ā€¢ Total Volume: ${formatVolume(totalVolume)}\n`; - response += `ā€¢ Average Volume: ${formatVolume(avgVolume)}\n`; - - return response; -}; - -export const ohlcvProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsOHLCVKeyword(messageText)) { - return null; - } - - const contractAddress = extractContractAddress(messageText); - if (!contractAddress) { - return null; - } - - const chain = extractChain(messageText); - const interval = extractTimeInterval(messageText); - - elizaLogger.info( - `OHLCV provider activated for address ${contractAddress} on ${chain} with interval ${interval}` - ); - - const ohlcvData = await getOHLCVData( - apiKey, - contractAddress, - interval, - chain - ); - - if (!ohlcvData) { - return null; - } - - return formatOHLCVResponse(ohlcvData, interval, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/defi/pair-trades-provider.ts b/packages/plugin-birdeye/src/providers/defi/pair-trades-provider.ts deleted file mode 100644 index d0eefb6da9..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/pair-trades-provider.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - extractLimit, - formatTimestamp, - formatValue, - makeApiRequest, - shortenAddress, -} from "../utils"; - -// Types -interface PairTrade { - timestamp: number; - price: number; - volume: number; - side: "buy" | "sell"; - source: string; - txHash: string; - buyer?: string; - seller?: string; - baseToken: string; - quoteToken: string; -} - -interface PairTradesResponse { - trades: PairTrade[]; - totalCount: number; - pair: { - baseToken: string; - quoteToken: string; - }; -} - -// Constants -const PAIR_TRADES_KEYWORDS = [ - "pair trades", - "pair swaps", - "pair transactions", - "pair activity", - "pair orders", - "pair executions", - "pair trading", - "pair market activity", - "pair exchange activity", - "pair trading history", - "pair market history", - "pair exchange history", - "trading pair activity", - "trading pair history", - "base/quote trades", - "base/quote activity", -] as const; - -// Helper functions -const containsPairTradesKeyword = (text: string): boolean => { - return PAIR_TRADES_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getPairTrades = async ( - apiKey: string, - baseAddress: string, - quoteAddress: string, - chain: Chain, - limit: number -): Promise => { - try { - const params = new URLSearchParams({ - base_address: baseAddress, - quote_address: quoteAddress, - limit: limit.toString(), - }); - const url = `${BASE_URL}/defi/trades_pair?${params.toString()}`; - - elizaLogger.info( - `Fetching pair trades for base ${baseAddress} and quote ${quoteAddress} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching pair trades:", error.message); - } - return null; - } -}; - -const formatPairTrade = (trade: PairTrade): string => { - const timestamp = formatTimestamp(trade.timestamp); - const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; - - let response = `${side} - ${timestamp}\n`; - response += `ā€¢ Price: ${formatValue(trade.price)}\n`; - response += `ā€¢ Volume: ${formatValue(trade.volume)}\n`; - response += `ā€¢ Source: ${trade.source}\n`; - if (trade.buyer && trade.seller) { - response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; - response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; - } - response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; - - return response; -}; - -const formatPairTradesResponse = ( - data: PairTradesResponse, - chain: Chain -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - - let response = `Recent Trades for ${data.pair.baseToken}/${data.pair.quoteToken} pair on ${chainName}\n\n`; - - if (data.trades.length === 0) { - return response + "No trades found."; - } - - // Calculate summary statistics - const totalVolume = data.trades.reduce((sum, t) => sum + t.volume, 0); - const averageVolume = totalVolume / data.trades.length; - const buyCount = data.trades.filter((t) => t.side === "buy").length; - const buyRatio = (buyCount / data.trades.length) * 100; - const averagePrice = - data.trades.reduce((sum, t) => sum + t.price, 0) / data.trades.length; - const priceChange = - ((data.trades[data.trades.length - 1].price - data.trades[0].price) / - data.trades[0].price) * - 100; - const highestPrice = Math.max(...data.trades.map((t) => t.price)); - const lowestPrice = Math.min(...data.trades.map((t) => t.price)); - const priceRange = ((highestPrice - lowestPrice) / lowestPrice) * 100; - - response += `šŸ“Š Summary\n`; - response += `ā€¢ Total Trades: ${data.trades.length}\n`; - response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; - response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; - response += `ā€¢ Buy/Sell Ratio: ${buyRatio.toFixed(1)}% buys\n`; - response += `ā€¢ Average Price: ${formatValue(averagePrice)}\n`; - response += `ā€¢ Price Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; - response += `ā€¢ Price Range: ${priceRange.toFixed(2)}%\n\n`; - - // Add market analysis - const volatility = priceRange / Math.sqrt(data.trades.length); - const volumePerTrade = totalVolume / data.trades.length; - let marketAnalysis = ""; - - if (data.trades.length < 5) { - marketAnalysis = "Insufficient data for detailed analysis"; - } else { - // Analyze trading activity - const activityLevel = - data.trades.length > 20 - ? "high" - : data.trades.length > 10 - ? "moderate" - : "low"; - const volumeLevel = - volumePerTrade > averageVolume * 2 - ? "high" - : volumePerTrade > averageVolume - ? "moderate" - : "low"; - const volatilityLevel = - volatility > 5 ? "high" : volatility > 2 ? "moderate" : "low"; - const trend = - Math.abs(priceChange) < 1 - ? "sideways" - : priceChange > 0 - ? "upward" - : "downward"; - - marketAnalysis = `Market shows ${activityLevel} trading activity with ${volumeLevel} volume per trade. `; - marketAnalysis += `${volatilityLevel.charAt(0).toUpperCase() + volatilityLevel.slice(1)} volatility with a ${trend} price trend.`; - } - - response += `šŸ“ˆ Market Analysis\n`; - response += `ā€¢ ${marketAnalysis}\n\n`; - - response += `šŸ”„ Recent Trades\n`; - data.trades.forEach((trade, index) => { - response += `${index + 1}. ${formatPairTrade(trade)}\n\n`; - }); - - if (data.totalCount > data.trades.length) { - response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; - } - - return response; -}; - -export const pairTradesProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsPairTradesKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length !== 2) { - return null; - } - - const chain = extractChain(messageText); - const limit = extractLimit(messageText); - - elizaLogger.info( - `PAIR TRADES provider activated for base ${addresses[0]} and quote ${addresses[1]} on ${chain}` - ); - - const tradesData = await getPairTrades( - apiKey, - addresses[0], - addresses[1], - chain, - limit - ); - - if (!tradesData) { - return null; - } - - return formatPairTradesResponse(tradesData, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/defi/pair-trades-seek-provider.ts b/packages/plugin-birdeye/src/providers/defi/pair-trades-seek-provider.ts deleted file mode 100644 index 7d293d1d68..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/pair-trades-seek-provider.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - extractLimit, - extractTimeRange, - formatTimestamp, - formatValue, - makeApiRequest, - shortenAddress, -} from "../utils"; - -// Types -interface PairTrade { - timestamp: number; - price: number; - volume: number; - side: "buy" | "sell"; - source: string; - txHash: string; - buyer?: string; - seller?: string; - baseToken: string; - quoteToken: string; -} - -interface PairTradesResponse { - trades: PairTrade[]; - totalCount: number; - pair: { - baseToken: string; - quoteToken: string; - }; -} - -// Constants -const PAIR_TRADE_KEYWORDS = [ - "pair trades", - "pair trading", - "pair transactions", - "pair swaps", - "pair buys", - "pair sells", - "pair orders", - "pair executions", - "pair trade history", - "pair trading history", - "pair recent trades", - "pair market activity", - "pair trading activity", - "pair market trades", - "pair exchange history", - "trading pair history", - "trading pair activity", - "base/quote trades", - "base/quote activity", -] as const; - -// Helper functions -const containsPairTradeKeyword = (text: string): boolean => { - return PAIR_TRADE_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getPairTradesByTime = async ( - apiKey: string, - baseAddress: string, - quoteAddress: string, - timestamp: number, - chain: Chain, - limit: number -): Promise => { - try { - const params = new URLSearchParams({ - base_address: baseAddress, - quote_address: quoteAddress, - timestamp: timestamp.toString(), - limit: limit.toString(), - }); - const url = `${BASE_URL}/defi/trades_pair_seek_time?${params.toString()}`; - - elizaLogger.info( - `Fetching pair trades for base ${baseAddress} and quote ${quoteAddress} since ${new Date( - timestamp * 1000 - ).toLocaleString()} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error( - "Error fetching pair trades by time:", - error.message - ); - } - return null; - } -}; - -const formatPairTrade = (trade: PairTrade): string => { - const timestamp = formatTimestamp(trade.timestamp); - const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; - - let response = `${side} - ${timestamp}\n`; - response += `ā€¢ Price: ${formatValue(trade.price)}\n`; - response += `ā€¢ Volume: ${formatValue(trade.volume)}\n`; - response += `ā€¢ Source: ${trade.source}\n`; - if (trade.buyer && trade.seller) { - response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; - response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; - } - response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; - - return response; -}; - -const formatPairTradesResponse = ( - data: PairTradesResponse, - timeRange: { start: number; end: number }, - chain: Chain -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - const startDate = formatTimestamp(timeRange.start); - const endDate = formatTimestamp(timeRange.end); - - let response = `Trade History for ${data.pair.baseToken}/${data.pair.quoteToken} pair on ${chainName}\n`; - response += `Period: ${startDate} to ${endDate}\n\n`; - - if (data.trades.length === 0) { - return response + "No trades found in this time period."; - } - - // Calculate summary statistics - const totalVolume = data.trades.reduce((sum, t) => sum + t.volume, 0); - const averageVolume = totalVolume / data.trades.length; - const buyCount = data.trades.filter((t) => t.side === "buy").length; - const buyRatio = (buyCount / data.trades.length) * 100; - const averagePrice = - data.trades.reduce((sum, t) => sum + t.price, 0) / data.trades.length; - const priceChange = - ((data.trades[data.trades.length - 1].price - data.trades[0].price) / - data.trades[0].price) * - 100; - const highestPrice = Math.max(...data.trades.map((t) => t.price)); - const lowestPrice = Math.min(...data.trades.map((t) => t.price)); - const priceRange = ((highestPrice - lowestPrice) / lowestPrice) * 100; - - response += `šŸ“Š Summary\n`; - response += `ā€¢ Total Trades: ${data.trades.length}\n`; - response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; - response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; - response += `ā€¢ Buy/Sell Ratio: ${buyRatio.toFixed(1)}% buys\n`; - response += `ā€¢ Average Price: ${formatValue(averagePrice)}\n`; - response += `ā€¢ Price Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; - response += `ā€¢ Price Range: ${priceRange.toFixed(2)}%\n\n`; - - // Add market analysis - const volatility = priceRange / Math.sqrt(data.trades.length); - const volumePerTrade = totalVolume / data.trades.length; - let marketAnalysis = ""; - - if (data.trades.length < 5) { - marketAnalysis = "Insufficient data for detailed analysis"; - } else { - // Analyze trading activity - const activityLevel = - data.trades.length > 20 - ? "high" - : data.trades.length > 10 - ? "moderate" - : "low"; - const volumeLevel = - volumePerTrade > averageVolume * 2 - ? "high" - : volumePerTrade > averageVolume - ? "moderate" - : "low"; - const volatilityLevel = - volatility > 5 ? "high" : volatility > 2 ? "moderate" : "low"; - const trend = - Math.abs(priceChange) < 1 - ? "sideways" - : priceChange > 0 - ? "upward" - : "downward"; - - marketAnalysis = `Market shows ${activityLevel} trading activity with ${volumeLevel} volume per trade. `; - marketAnalysis += `${volatilityLevel.charAt(0).toUpperCase() + volatilityLevel.slice(1)} volatility with a ${trend} price trend.`; - } - - response += `šŸ“ˆ Market Analysis\n`; - response += `ā€¢ ${marketAnalysis}\n\n`; - - response += `šŸ”„ Recent Trades\n`; - data.trades.forEach((trade, index) => { - response += `${index + 1}. ${formatPairTrade(trade)}\n\n`; - }); - - if (data.totalCount > data.trades.length) { - response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; - } - - return response; -}; - -export const pairTradesSeekProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsPairTradeKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length !== 2) { - return null; - } - - const chain = extractChain(messageText); - const timeRange = extractTimeRange(messageText); - const limit = extractLimit(messageText); - - elizaLogger.info( - `PAIR TRADES SEEK provider activated for base ${addresses[0]} and quote ${addresses[1]} from ${new Date( - timeRange.start * 1000 - ).toLocaleString()} to ${new Date( - timeRange.end * 1000 - ).toLocaleString()} on ${chain}` - ); - - const tradesData = await getPairTradesByTime( - apiKey, - addresses[0], - addresses[1], - timeRange.start, - chain, - limit - ); - - if (!tradesData) { - return null; - } - - return formatPairTradesResponse(tradesData, timeRange, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/defi/price-history-provider.ts b/packages/plugin-birdeye/src/providers/defi/price-history-provider.ts deleted file mode 100644 index 69c2275488..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/price-history-provider.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - extractTimeRange, - formatTimestamp, - formatValue, - makeApiRequest, -} from "../utils"; - -// Types -interface PriceHistoryData { - price: number; - timestamp: number; - volume?: number; -} - -interface PriceHistoryResponse { - data: PriceHistoryData[]; - token: string; -} - -// Constants -const PRICE_HISTORY_KEYWORDS = [ - "price history", - "historical price", - "price chart", - "price trend", - "price movement", - "price changes", - "price over time", - "price timeline", - "price performance", - "price data", - "historical data", - "price analysis", - "price tracking", - "price evolution", -] as const; - -// Helper functions -const containsPriceHistoryKeyword = (text: string): boolean => { - return PRICE_HISTORY_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getPriceHistory = async ( - apiKey: string, - contractAddress: string, - startTime: number, - endTime: number, - chain: Chain -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - time_from: startTime.toString(), - time_to: endTime.toString(), - }); - const url = `${BASE_URL}/defi/price_history_unix?${params.toString()}`; - - elizaLogger.info( - `Fetching price history for token ${contractAddress} from ${new Date( - startTime * 1000 - ).toLocaleString()} to ${new Date( - endTime * 1000 - ).toLocaleString()} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { - apiKey, - chain, - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching price history:", error.message); - } - return null; - } -}; - -const formatPriceHistoryResponse = ( - data: PriceHistoryResponse, - timeRange: { start: number; end: number }, - chain: Chain -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - const startDate = formatTimestamp(timeRange.start); - const endDate = formatTimestamp(timeRange.end); - - let response = `Price History for ${data.token} on ${chainName}\n`; - response += `Period: ${startDate} to ${endDate}\n\n`; - - if (data.data.length === 0) { - return response + "No price data found for this period."; - } - - // Calculate summary statistics - const prices = data.data.map((d) => d.price); - const volumes = data.data.map((d) => d.volume || 0); - const startPrice = data.data[0].price; - const endPrice = data.data[data.data.length - 1].price; - const priceChange = ((endPrice - startPrice) / startPrice) * 100; - const highestPrice = Math.max(...prices); - const lowestPrice = Math.min(...prices); - const averagePrice = prices.reduce((a, b) => a + b, 0) / prices.length; - const totalVolume = volumes.reduce((a, b) => a + b, 0); - const volatility = ((highestPrice - lowestPrice) / averagePrice) * 100; - - response += `šŸ“Š Summary\n`; - response += `ā€¢ Start Price: ${formatValue(startPrice)}\n`; - response += `ā€¢ End Price: ${formatValue(endPrice)}\n`; - response += `ā€¢ Price Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; - response += `ā€¢ Highest Price: ${formatValue(highestPrice)}\n`; - response += `ā€¢ Lowest Price: ${formatValue(lowestPrice)}\n`; - response += `ā€¢ Average Price: ${formatValue(averagePrice)}\n`; - if (totalVolume > 0) { - response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; - } - response += `ā€¢ Volatility: ${volatility.toFixed(2)}%\n\n`; - - // Add trend analysis - const trendStrength = Math.abs(priceChange); - let trendAnalysis = ""; - if (trendStrength < 1) { - trendAnalysis = "Price has remained relatively stable"; - } else if (trendStrength < 5) { - trendAnalysis = - priceChange > 0 - ? "Price shows slight upward movement" - : "Price shows slight downward movement"; - } else if (trendStrength < 10) { - trendAnalysis = - priceChange > 0 - ? "Price demonstrates moderate upward trend" - : "Price demonstrates moderate downward trend"; - } else { - trendAnalysis = - priceChange > 0 - ? "Price exhibits strong upward momentum" - : "Price exhibits strong downward momentum"; - } - - response += `šŸ“ˆ Trend Analysis\n`; - response += `ā€¢ ${trendAnalysis}\n`; - response += `ā€¢ Volatility is ${volatility < 10 ? "low" : volatility < 25 ? "moderate" : "high"}\n\n`; - - // Show key price points - response += `šŸ”‘ Key Price Points\n`; - const keyPoints = [ - { label: "Start", ...data.data[0] }, - { - label: "High", - price: highestPrice, - timestamp: data.data[prices.indexOf(highestPrice)].timestamp, - }, - { - label: "Low", - price: lowestPrice, - timestamp: data.data[prices.indexOf(lowestPrice)].timestamp, - }, - { label: "End", ...data.data[data.data.length - 1] }, - ]; - - keyPoints.forEach((point) => { - response += `ā€¢ ${point.label}: ${formatValue(point.price)} (${formatTimestamp(point.timestamp)})\n`; - }); - - return response; -}; - -export const priceHistoryProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsPriceHistoryKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - const timeRange = extractTimeRange(messageText); - - elizaLogger.info( - `PRICE HISTORY provider activated for token ${addresses[0]} from ${new Date( - timeRange.start * 1000 - ).toLocaleString()} to ${new Date( - timeRange.end * 1000 - ).toLocaleString()} on ${chain}` - ); - - const priceData = await getPriceHistory( - apiKey, - addresses[0], - timeRange.start, - timeRange.end, - chain - ); - - if (!priceData) { - return null; - } - - return formatPriceHistoryResponse(priceData, timeRange, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/defi/price-multiple-provider.ts b/packages/plugin-birdeye/src/providers/defi/price-multiple-provider.ts deleted file mode 100644 index ad4c95bbde..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/price-multiple-provider.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -// Types -interface TokenPrice { - price: number; - timestamp: number; - token: string; - priceChange24h?: number; - priceChange24hPercent?: number; -} - -interface MultiPriceResponse { - [tokenAddress: string]: TokenPrice; -} - -// Constants -const PRICE_KEYWORDS = [ - "price", - "prices", - "cost", - "worth", - "value", - "compare", - "multiple", - "several", - "many", - "list of", - "these tokens", - "their prices", -] as const; - -const CHAIN_KEYWORDS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -] as const; - -const BASE_URL = "https://public-api.birdeye.so"; - -// Helper functions -const containsPriceKeyword = (text: string): boolean => { - return PRICE_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractChain = (text: string): string => { - const chain = CHAIN_KEYWORDS.find((chain) => - text.toLowerCase().includes(chain.toLowerCase()) - ); - return chain || "solana"; -}; - -const extractContractAddresses = (text: string): string[] => { - const words = text.split(/\s+/); - const addresses: string[] = []; - - for (const word of words) { - // Ethereum-like addresses (0x...) - if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { - addresses.push(word); - } - // Solana addresses (base58, typically 32-44 chars) - if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { - addresses.push(word); - } - } - return addresses; -}; - -const getMultiplePrices = async ( - apiKey: string, - addresses: string[], - chain: string = "solana" -): Promise => { - try { - const params = new URLSearchParams({ - tokens: addresses.join(","), - }); - const url = `${BASE_URL}/defi/price_multiple?${params.toString()}`; - - elizaLogger.info( - `Fetching prices for ${addresses.length} tokens on ${chain} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching multiple prices:", error); - return null; - } -}; - -const formatNumber = (num: number): string => { - if (!num && num !== 0) return "N/A"; - return num < 0.01 - ? num.toExponential(2) - : num.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); -}; - -const formatPriceResponse = ( - prices: MultiPriceResponse, - chain: string -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - let response = `Token Prices on ${chainName}:\n\n`; - - const sortedTokens = Object.entries(prices).sort((a, b) => { - const priceA = a[1].price || 0; - const priceB = b[1].price || 0; - return priceB - priceA; - }); - - sortedTokens.forEach(([address, data]) => { - const timestamp = new Date(data.timestamp * 1000).toLocaleString(); - response += `${data.token} (${address.slice(0, 6)}...${address.slice(-4)}):\n`; - response += `ā€¢ Price: $${formatNumber(data.price)}\n`; - - if (data.priceChange24h !== undefined) { - const changeSymbol = data.priceChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; - response += `ā€¢ 24h Change: ${changeSymbol} $${formatNumber(Math.abs(data.priceChange24h))} `; - if (data.priceChange24hPercent !== undefined) { - response += `(${data.priceChange24hPercent.toFixed(2)}%)`; - } - response += "\n"; - } - - response += `ā€¢ Last Updated: ${timestamp}\n\n`; - }); - - return response; -}; - -export const priceMultipleProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsPriceKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length < 2) { - // If less than 2 addresses found, let the single price provider handle it - return null; - } - - const chain = extractChain(messageText); - - elizaLogger.info( - `MULTIPLE PRICE provider activated for ${addresses.length} addresses on ${chain}` - ); - - const priceData = await getMultiplePrices(apiKey, addresses, chain); - - if (!priceData) { - return null; - } - - return formatPriceResponse(priceData, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/defi/price-provider.ts b/packages/plugin-birdeye/src/providers/defi/price-provider.ts deleted file mode 100644 index f7c6cd45a8..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/price-provider.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -// Types -interface PriceData { - price: number; - timestamp: number; - token: string; - priceChange24h?: number; - priceChange24hPercent?: number; -} - -// Constants -const PRICE_KEYWORDS = [ - "price", - "cost", - "worth", - "value", - "rate", - "quote", - "how much", -] as const; - -const CHAIN_KEYWORDS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -] as const; - -const BASE_URL = "https://public-api.birdeye.so"; - -// Helper functions -const containsPriceKeyword = (text: string): boolean => { - return PRICE_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractChain = (text: string): string => { - const chain = CHAIN_KEYWORDS.find((chain) => - text.toLowerCase().includes(chain.toLowerCase()) - ); - return chain || "solana"; -}; - -const extractContractAddress = (text: string): string | null => { - const words = text.split(/\s+/); - - for (const word of words) { - // Ethereum-like addresses (0x...) - if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { - return word; - } - // Solana addresses (base58, typically 32-44 chars) - if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { - return word; - } - } - return null; -}; - -const getTokenPrice = async ( - apiKey: string, - contractAddress: string, - chain: string = "solana" -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - }); - const url = `${BASE_URL}/defi/price?${params.toString()}`; - - elizaLogger.info( - `Fetching price for address ${contractAddress} on ${chain} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - elizaLogger.warn( - `Token not found: ${contractAddress} on ${chain}` - ); - return null; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching token price:", error); - return null; - } -}; - -const formatPriceResponse = (price: PriceData, chain: string): string => { - const timestamp = new Date(price.timestamp * 1000).toLocaleString(); - const priceFormatted = - price.price < 0.01 - ? price.price.toExponential(2) - : price.price.toFixed(2); - - let response = `Price for ${price.token} on ${chain.charAt(0).toUpperCase() + chain.slice(1)}:\n\n`; - response += `ā€¢ Current Price: $${priceFormatted}\n`; - - if (price.priceChange24h !== undefined) { - response += `ā€¢ 24h Change: $${price.priceChange24h.toFixed(2)}\n`; - } - - if (price.priceChange24hPercent !== undefined) { - response += `ā€¢ 24h Change %: ${price.priceChange24hPercent.toFixed(2)}%\n`; - } - - response += `ā€¢ Last Updated: ${timestamp}`; - - return response; -}; - -export const priceProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsPriceKeyword(messageText)) { - return null; - } - - const contractAddress = extractContractAddress(messageText); - if (!contractAddress) { - return null; - } - - const chain = extractChain(messageText); - - elizaLogger.info( - `PRICE provider activated for address ${contractAddress} on ${chain}` - ); - - const priceData = await getTokenPrice(apiKey, contractAddress, chain); - - if (!priceData) { - return null; - } - - return formatPriceResponse(priceData, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/defi/price-volume-provider.ts b/packages/plugin-birdeye/src/providers/defi/price-volume-provider.ts deleted file mode 100644 index 1448fbb47f..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/price-volume-provider.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - formatPercentChange, - formatTimestamp, - formatValue, - makeApiRequest, -} from "../utils"; - -// Types -interface PriceVolumeData { - price: number; - volume24h: number; - timestamp: number; - token: string; - priceChange24h?: number; - priceChange24hPercent?: number; - volumeChange24h?: number; - volumeChange24hPercent?: number; -} - -interface MultiPriceVolumeResponse { - [tokenAddress: string]: PriceVolumeData; -} - -// Constants -const PRICE_VOLUME_KEYWORDS = [ - "price and volume", - "volume and price", - "trading volume", - "market activity", - "market data", - "trading data", - "market stats", - "trading stats", - "market metrics", - "trading metrics", -] as const; - -// Helper functions -const containsPriceVolumeKeyword = (text: string): boolean => { - return PRICE_VOLUME_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getSinglePriceVolume = async ( - apiKey: string, - contractAddress: string, - chain: Chain -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - }); - const url = `${BASE_URL}/defi/price_volume_single?${params.toString()}`; - - elizaLogger.info( - `Fetching price/volume data for address ${contractAddress} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error( - "Error fetching price/volume data:", - error.message - ); - } - return null; - } -}; - -const getMultiplePriceVolume = async ( - apiKey: string, - addresses: string[], - chain: Chain -): Promise => { - try { - const url = `${BASE_URL}/defi/price_volume_multi`; - - elizaLogger.info( - `Fetching price/volume data for ${addresses.length} tokens on ${chain}` - ); - - return await makeApiRequest(url, { - apiKey, - chain, - method: "POST", - body: { addresses }, - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error( - "Error fetching multiple price/volume data:", - error.message - ); - } - return null; - } -}; - -const formatSinglePriceVolumeResponse = ( - data: PriceVolumeData, - chain: Chain -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - const timestamp = formatTimestamp(data.timestamp); - const priceFormatted = formatValue(data.price); - - let response = `Price & Volume Data for ${data.token} on ${chainName}:\n\n`; - - response += `šŸ’° Price Metrics\n`; - response += `ā€¢ Current Price: ${priceFormatted}\n`; - if (data.priceChange24h !== undefined) { - response += `ā€¢ 24h Price Change: ${formatValue(data.priceChange24h)} (${formatPercentChange(data.priceChange24hPercent)})\n`; - } - - response += `\nšŸ“Š Volume Metrics\n`; - response += `ā€¢ 24h Volume: ${formatValue(data.volume24h)}\n`; - if (data.volumeChange24h !== undefined) { - response += `ā€¢ 24h Volume Change: ${formatValue(data.volumeChange24h)} (${formatPercentChange(data.volumeChange24hPercent)})\n`; - } - - response += `\nā° Last Updated: ${timestamp}`; - - return response; -}; - -const formatMultiplePriceVolumeResponse = ( - data: MultiPriceVolumeResponse, - chain: Chain -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - let response = `Price & Volume Data on ${chainName}:\n\n`; - - // Sort tokens by volume - const sortedTokens = Object.entries(data).sort((a, b) => { - const volumeA = a[1].volume24h || 0; - const volumeB = b[1].volume24h || 0; - return volumeB - volumeA; - }); - - sortedTokens.forEach(([address, tokenData]) => { - const timestamp = formatTimestamp(tokenData.timestamp); - const priceFormatted = formatValue(tokenData.price); - - response += `${tokenData.token} (${address.slice(0, 6)}...${address.slice(-4)})\n`; - response += `ā€¢ Price: ${priceFormatted}`; - if (tokenData.priceChange24hPercent !== undefined) { - response += ` (${formatPercentChange(tokenData.priceChange24hPercent)})`; - } - response += `\n`; - response += `ā€¢ Volume: ${formatValue(tokenData.volume24h)}`; - if (tokenData.volumeChange24hPercent !== undefined) { - response += ` (${formatPercentChange(tokenData.volumeChange24hPercent)})`; - } - response += `\n`; - response += `ā€¢ Updated: ${timestamp}\n\n`; - }); - - return response; -}; - -export const priceVolumeProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsPriceVolumeKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - - if (addresses.length === 1) { - elizaLogger.info( - `PRICE/VOLUME provider activated for address ${addresses[0]} on ${chain}` - ); - - const priceVolumeData = await getSinglePriceVolume( - apiKey, - addresses[0], - chain - ); - - if (!priceVolumeData) { - return null; - } - - return formatSinglePriceVolumeResponse(priceVolumeData, chain); - } else { - elizaLogger.info( - `MULTIPLE PRICE/VOLUME provider activated for ${addresses.length} addresses on ${chain}` - ); - - const priceVolumeData = await getMultiplePriceVolume( - apiKey, - addresses, - chain - ); - - if (!priceVolumeData) { - return null; - } - - return formatMultiplePriceVolumeResponse(priceVolumeData, chain); - } - }, -}; diff --git a/packages/plugin-birdeye/src/providers/defi/token-trades-provider.ts b/packages/plugin-birdeye/src/providers/defi/token-trades-provider.ts deleted file mode 100644 index d580eb0df9..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/token-trades-provider.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - extractLimit, - formatTimestamp, - formatValue, - makeApiRequest, - shortenAddress, -} from "../utils"; - -// Types -interface Trade { - timestamp: number; - price: number; - volume: number; - side: "buy" | "sell"; - source: string; - txHash: string; - buyer?: string; - seller?: string; -} - -interface TokenTradesResponse { - trades: Trade[]; - totalCount: number; - token: string; -} - -// Constants -const TOKEN_TRADES_KEYWORDS = [ - "token trades", - "token swaps", - "token transactions", - "token activity", - "token orders", - "token executions", - "token trading", - "token market activity", - "token exchange activity", - "token trading history", - "token market history", - "token exchange history", -] as const; - -// Helper functions -const containsTokenTradesKeyword = (text: string): boolean => { - return TOKEN_TRADES_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getTokenTrades = async ( - apiKey: string, - contractAddress: string, - chain: Chain, - limit: number -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - limit: limit.toString(), - }); - const url = `${BASE_URL}/defi/trades_token?${params.toString()}`; - - elizaLogger.info( - `Fetching token trades for ${contractAddress} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { - apiKey, - chain, - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching token trades:", error.message); - } - return null; - } -}; - -const formatTrade = (trade: Trade): string => { - const timestamp = formatTimestamp(trade.timestamp); - const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; - - let response = `${side} - ${timestamp}\n`; - response += `ā€¢ Price: ${formatValue(trade.price)}\n`; - response += `ā€¢ Volume: ${formatValue(trade.volume)}\n`; - response += `ā€¢ Source: ${trade.source}\n`; - if (trade.buyer && trade.seller) { - response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; - response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; - } - response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; - - return response; -}; - -const formatTokenTradesResponse = ( - data: TokenTradesResponse, - chain: Chain -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - - let response = `Recent Trades for ${data.token} on ${chainName}\n\n`; - - if (data.trades.length === 0) { - return response + "No trades found."; - } - - // Calculate summary statistics - const totalVolume = data.trades.reduce((sum, t) => sum + t.volume, 0); - const averageVolume = totalVolume / data.trades.length; - const buyCount = data.trades.filter((t) => t.side === "buy").length; - const buyRatio = (buyCount / data.trades.length) * 100; - const averagePrice = - data.trades.reduce((sum, t) => sum + t.price, 0) / data.trades.length; - const priceChange = - ((data.trades[data.trades.length - 1].price - data.trades[0].price) / - data.trades[0].price) * - 100; - const highestPrice = Math.max(...data.trades.map((t) => t.price)); - const lowestPrice = Math.min(...data.trades.map((t) => t.price)); - const priceRange = ((highestPrice - lowestPrice) / lowestPrice) * 100; - - response += `šŸ“Š Summary\n`; - response += `ā€¢ Total Trades: ${data.trades.length}\n`; - response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; - response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; - response += `ā€¢ Buy/Sell Ratio: ${buyRatio.toFixed(1)}% buys\n`; - response += `ā€¢ Average Price: ${formatValue(averagePrice)}\n`; - response += `ā€¢ Price Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n`; - response += `ā€¢ Price Range: ${priceRange.toFixed(2)}%\n\n`; - - // Add market analysis - const volatility = priceRange / Math.sqrt(data.trades.length); - const volumePerTrade = totalVolume / data.trades.length; - let marketAnalysis = ""; - - if (data.trades.length < 5) { - marketAnalysis = "Insufficient data for detailed analysis"; - } else { - // Analyze trading activity - const activityLevel = - data.trades.length > 20 - ? "high" - : data.trades.length > 10 - ? "moderate" - : "low"; - const volumeLevel = - volumePerTrade > averageVolume * 2 - ? "high" - : volumePerTrade > averageVolume - ? "moderate" - : "low"; - const volatilityLevel = - volatility > 5 ? "high" : volatility > 2 ? "moderate" : "low"; - const trend = - Math.abs(priceChange) < 1 - ? "sideways" - : priceChange > 0 - ? "upward" - : "downward"; - - marketAnalysis = `Market shows ${activityLevel} trading activity with ${volumeLevel} volume per trade. `; - marketAnalysis += `${volatilityLevel.charAt(0).toUpperCase() + volatilityLevel.slice(1)} volatility with a ${trend} price trend.`; - } - - response += `šŸ“ˆ Market Analysis\n`; - response += `ā€¢ ${marketAnalysis}\n\n`; - - response += `šŸ”„ Recent Trades\n`; - data.trades.forEach((trade, index) => { - response += `${index + 1}. ${formatTrade(trade)}\n\n`; - }); - - if (data.totalCount > data.trades.length) { - response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; - } - - return response; -}; - -export const tokenTradesProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsTokenTradesKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - const limit = extractLimit(messageText); - - elizaLogger.info( - `TOKEN TRADES provider activated for token ${addresses[0]} on ${chain}` - ); - - const tradesData = await getTokenTrades( - apiKey, - addresses[0], - chain, - limit - ); - - if (!tradesData) { - return null; - } - - return formatTokenTradesResponse(tradesData, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/defi/trades-seek-provider.ts b/packages/plugin-birdeye/src/providers/defi/trades-seek-provider.ts deleted file mode 100644 index 8ae96a1600..0000000000 --- a/packages/plugin-birdeye/src/providers/defi/trades-seek-provider.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - extractLimit, - extractTimeRange, - formatTimestamp, - formatValue, - makeApiRequest, - shortenAddress, -} from "../utils"; - -// Types -interface Trade { - timestamp: number; - price: number; - volume: number; - side: "buy" | "sell"; - source: string; - txHash: string; - buyer?: string; - seller?: string; -} - -interface TradesResponse { - trades: Trade[]; - totalCount: number; - token: string; -} - -// Constants -const TOKEN_TRADE_KEYWORDS = [ - "token trades", - "token trading", - "token transactions", - "token swaps", - "token buys", - "token sells", - "token orders", - "token executions", - "token trade history", - "token trading history", - "token recent trades", - "token market activity", - "token trading activity", - "token market trades", - "token exchange history", -] as const; - -// Helper functions -const containsTokenTradeKeyword = (text: string): boolean => { - return TOKEN_TRADE_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getTradesByTime = async ( - apiKey: string, - contractAddress: string, - timestamp: number, - chain: Chain, - limit: number -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - timestamp: timestamp.toString(), - limit: limit.toString(), - }); - const url = `${BASE_URL}/defi/trades_token_seek_time?${params.toString()}`; - - elizaLogger.info( - `Fetching trades for token ${contractAddress} since ${new Date( - timestamp * 1000 - ).toLocaleString()} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching trades by time:", error.message); - } - return null; - } -}; - -const formatTrade = (trade: Trade): string => { - const timestamp = formatTimestamp(trade.timestamp); - const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; - - let response = `${side} - ${timestamp}\n`; - response += `ā€¢ Price: ${formatValue(trade.price)}\n`; - response += `ā€¢ Volume: ${formatValue(trade.volume)}\n`; - response += `ā€¢ Source: ${trade.source}\n`; - if (trade.buyer && trade.seller) { - response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; - response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; - } - response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; - - return response; -}; - -const formatTradesResponse = ( - data: TradesResponse, - timeRange: { start: number; end: number }, - chain: Chain -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - const startDate = formatTimestamp(timeRange.start); - const endDate = formatTimestamp(timeRange.end); - - let response = `Trade History for ${data.token} on ${chainName}\n`; - response += `Period: ${startDate} to ${endDate}\n\n`; - - if (data.trades.length === 0) { - return response + "No trades found in this time period."; - } - - // Calculate summary statistics - const totalVolume = data.trades.reduce((sum, t) => sum + t.volume, 0); - const averageVolume = totalVolume / data.trades.length; - const buyCount = data.trades.filter((t) => t.side === "buy").length; - const buyRatio = (buyCount / data.trades.length) * 100; - const averagePrice = - data.trades.reduce((sum, t) => sum + t.price, 0) / data.trades.length; - const priceChange = - ((data.trades[data.trades.length - 1].price - data.trades[0].price) / - data.trades[0].price) * - 100; - - response += `šŸ“Š Summary\n`; - response += `ā€¢ Total Trades: ${data.trades.length}\n`; - response += `ā€¢ Total Volume: ${formatValue(totalVolume)}\n`; - response += `ā€¢ Average Volume: ${formatValue(averageVolume)}\n`; - response += `ā€¢ Buy/Sell Ratio: ${buyRatio.toFixed(1)}% buys\n`; - response += `ā€¢ Average Price: ${formatValue(averagePrice)}\n`; - response += `ā€¢ Price Change: ${priceChange >= 0 ? "+" : ""}${priceChange.toFixed(2)}%\n\n`; - - response += `šŸ“ˆ Recent Trades\n`; - data.trades.forEach((trade, index) => { - response += `${index + 1}. ${formatTrade(trade)}\n\n`; - }); - - if (data.totalCount > data.trades.length) { - response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; - } - - return response; -}; - -export const tokenTradesSeekProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsTokenTradeKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - const timeRange = extractTimeRange(messageText); - const limit = extractLimit(messageText); - - elizaLogger.info( - `TOKEN TRADES SEEK provider activated for token ${addresses[0]} from ${new Date( - timeRange.start * 1000 - ).toLocaleString()} to ${new Date( - timeRange.end * 1000 - ).toLocaleString()} on ${chain}` - ); - - const tradesData = await getTradesByTime( - apiKey, - addresses[0], - timeRange.start, - chain, - limit - ); - - if (!tradesData) { - return null; - } - - return formatTradesResponse(tradesData, timeRange, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/pair/index.ts b/packages/plugin-birdeye/src/providers/pair/index.ts deleted file mode 100644 index 92e68edb47..0000000000 --- a/packages/plugin-birdeye/src/providers/pair/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./pair-overview-provider"; diff --git a/packages/plugin-birdeye/src/providers/pair/pair-overview-provider.ts b/packages/plugin-birdeye/src/providers/pair/pair-overview-provider.ts deleted file mode 100644 index f169d59d1c..0000000000 --- a/packages/plugin-birdeye/src/providers/pair/pair-overview-provider.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -// Types -interface PairToken { - address: string; - symbol: string; - name: string; - decimals: number; - logoURI?: string; -} - -interface PairOverview { - address: string; - baseToken: PairToken; - quoteToken: PairToken; - price: number; - priceChange24h: number; - priceChange24hPercent: number; - volume24h: number; - liquidity: number; - txCount24h: number; - lastTradeUnixTime: number; - dex: string; -} - -interface MultiPairOverview { - [pairAddress: string]: PairOverview; -} - -// Constants -const PAIR_KEYWORDS = [ - "pair", - "pairs", - "trading pair", - "market", - "markets", - "pool", - "pools", - "liquidity pool", - "dex", - "exchange", -] as const; - -const CHAIN_KEYWORDS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -] as const; - -const BASE_URL = "https://public-api.birdeye.so"; - -// Helper functions -const containsPairKeyword = (text: string): boolean => { - return PAIR_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractChain = (text: string): string => { - const chain = CHAIN_KEYWORDS.find((chain) => - text.toLowerCase().includes(chain.toLowerCase()) - ); - return chain || "solana"; -}; - -const extractPairAddresses = (text: string): string[] => { - const words = text.split(/\s+/); - const addresses: string[] = []; - - for (const word of words) { - // Ethereum-like addresses (0x...) - if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { - addresses.push(word); - } - // Solana addresses (base58, typically 32-44 chars) - if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { - addresses.push(word); - } - } - return addresses; -}; - -const getPairOverview = async ( - apiKey: string, - pairAddress: string, - chain: string = "solana" -): Promise => { - try { - const params = new URLSearchParams({ - address: pairAddress, - }); - const url = `${BASE_URL}/pair/overview_single?${params.toString()}`; - - elizaLogger.info( - `Fetching pair overview for address ${pairAddress} on ${chain} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - elizaLogger.warn(`Pair not found: ${pairAddress} on ${chain}`); - return null; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching pair overview:", error); - return null; - } -}; - -const getMultiplePairOverviews = async ( - apiKey: string, - pairAddresses: string[], - chain: string = "solana" -): Promise => { - try { - const params = new URLSearchParams({ - addresses: pairAddresses.join(","), - }); - const url = `${BASE_URL}/pair/overview_multiple?${params.toString()}`; - - elizaLogger.info( - `Fetching multiple pair overviews for ${pairAddresses.length} pairs on ${chain} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching multiple pair overviews:", error); - return null; - } -}; - -const formatValue = (value: number): string => { - if (value >= 1_000_000_000) { - return `$${(value / 1_000_000_000).toFixed(2)}B`; - } - if (value >= 1_000_000) { - return `$${(value / 1_000_000).toFixed(2)}M`; - } - if (value >= 1_000) { - return `$${(value / 1_000).toFixed(2)}K`; - } - return `$${value.toFixed(2)}`; -}; - -const formatPairOverview = (pair: PairOverview): string => { - const lastTradeTime = new Date( - pair.lastTradeUnixTime * 1000 - ).toLocaleString(); - const priceFormatted = - pair.price < 0.01 ? pair.price.toExponential(2) : pair.price.toFixed(2); - - let response = `${pair.baseToken.symbol}/${pair.quoteToken.symbol} on ${pair.dex}\n`; - response += `ā€¢ Address: ${pair.address}\n`; - response += `ā€¢ Price: $${priceFormatted}\n`; - - const changeSymbol = pair.priceChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; - response += `ā€¢ 24h Change: ${changeSymbol} ${pair.priceChange24hPercent.toFixed(2)}% (${formatValue(pair.priceChange24h)})\n`; - response += `ā€¢ 24h Volume: ${formatValue(pair.volume24h)}\n`; - response += `ā€¢ Liquidity: ${formatValue(pair.liquidity)}\n`; - response += `ā€¢ 24h Transactions: ${pair.txCount24h.toLocaleString()}\n`; - response += `ā€¢ Last Trade: ${lastTradeTime}\n`; - - return response; -}; - -const formatMultiplePairOverviews = ( - pairs: MultiPairOverview, - chain: string -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - let response = `Trading Pairs on ${chainName}:\n\n`; - - if (Object.keys(pairs).length === 0) { - return response + "No pairs found."; - } - - // Sort pairs by liquidity - const sortedPairs = Object.values(pairs).sort( - (a, b) => b.liquidity - a.liquidity - ); - - sortedPairs.forEach((pair, index) => { - response += `${index + 1}. ${formatPairOverview(pair)}\n`; - }); - - return response; -}; - -export const pairOverviewProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsPairKeyword(messageText)) { - return null; - } - - const pairAddresses = extractPairAddresses(messageText); - if (pairAddresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - - if (pairAddresses.length === 1) { - elizaLogger.info( - `PAIR OVERVIEW provider activated for address ${pairAddresses[0]} on ${chain}` - ); - - const pairData = await getPairOverview( - apiKey, - pairAddresses[0], - chain - ); - - if (!pairData) { - return null; - } - - return formatPairOverview(pairData); - } else { - elizaLogger.info( - `MULTIPLE PAIR OVERVIEW provider activated for ${pairAddresses.length} pairs on ${chain}` - ); - - const pairData = await getMultiplePairOverviews( - apiKey, - pairAddresses, - chain - ); - - if (!pairData) { - return null; - } - - return formatMultiplePairOverviews(pairData, chain); - } - }, -}; diff --git a/packages/plugin-birdeye/src/providers/search/index.ts b/packages/plugin-birdeye/src/providers/search/index.ts deleted file mode 100644 index a27e735c05..0000000000 --- a/packages/plugin-birdeye/src/providers/search/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./token-market-data-provider"; diff --git a/packages/plugin-birdeye/src/providers/search/token-market-data-provider.ts b/packages/plugin-birdeye/src/providers/search/token-market-data-provider.ts deleted file mode 100644 index 29690ad1ea..0000000000 --- a/packages/plugin-birdeye/src/providers/search/token-market-data-provider.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -interface SearchToken { - address: string; - name: string; - symbol: string; - decimals: number; - volume24hUSD: number; - liquidity: number; - logoURI: string; - price: number; -} - -const SEARCH_KEYWORDS = ["search", "find", "look for", "lookup", "locate"]; - -const TOKEN_KEYWORDS = [ - "token", - "tokens", - "coin", - "coins", - "crypto", - "cryptocurrency", - "asset", - "assets", - "sol", - "solana", -]; - -const SUPPORTED_CHAINS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -]; - -const BASE_URL = "https://public-api.birdeye.so"; - -interface SearchTokensOptions { - query: string; - chain?: string; - limit?: number; - offset?: number; -} - -const searchTokens = async ( - apiKey: string, - options: SearchTokensOptions -): Promise => { - try { - const { query, chain = "solana", limit = 10, offset = 0 } = options; - - const params = new URLSearchParams({ - query, - limit: limit.toString(), - offset: offset.toString(), - }); - - const url = `${BASE_URL}/defi/v3/search?${params.toString()}`; - elizaLogger.info("Searching tokens from:", url); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data.tokens || []; - } catch (error) { - elizaLogger.error("Error searching tokens:", error); - throw error; - } -}; - -const formatSearchResultsToString = ( - tokens: SearchToken[], - query: string, - chain: string -): string => { - if (!tokens.length) { - return `No tokens found matching "${query}" on ${chain}.`; - } - - const formattedTokens = tokens - .map((token, index) => { - const priceFormatted = - token.price != null - ? token.price < 0.01 - ? token.price.toExponential(2) - : token.price.toFixed(2) - : "N/A"; - - const volume = - token.volume24hUSD != null - ? `$${(token.volume24hUSD / 1_000_000).toFixed(2)}M` - : "N/A"; - - const liquidity = - token.liquidity != null - ? `$${(token.liquidity / 1_000_000).toFixed(2)}M` - : "N/A"; - - return ( - `${index + 1}. ${token.name} (${token.symbol}):\n` + - ` Address: ${token.address}\n` + - ` Price: $${priceFormatted}\n` + - ` Volume 24h: ${volume}\n` + - ` Liquidity: ${liquidity}` - ); - }) - .join("\n\n"); - - return `Search results for "${query}" on ${chain.charAt(0).toUpperCase() + chain.slice(1)}:\n\n${formattedTokens}`; -}; - -export const tokenMarketDataProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - return null; - } - - const messageText = message.content.text.toLowerCase(); - - // Check if message contains search-related keywords - const hasSearchKeyword = SEARCH_KEYWORDS.some((keyword) => - messageText.includes(keyword) - ); - - // Check if message contains token-related keywords - const hasTokenKeyword = TOKEN_KEYWORDS.some((keyword) => - messageText.includes(keyword) - ); - - // Extract potential search query - // Look for quotes first - let searchQuery = - messageText.match(/"([^"]+)"/)?.[1] || - messageText.match(/'([^']+)'/)?.[1]; - - // If no quotes, try to extract query after search keywords - if (!searchQuery) { - for (const keyword of SEARCH_KEYWORDS) { - if (messageText.includes(keyword)) { - const parts = messageText.split(keyword); - if (parts[1]) { - searchQuery = parts[1] - .trim() - .split(/[\s,.]/) - .filter((word) => word.length > 1) - .join(" ") - .trim(); - break; - } - } - } - } - - // Determine which chain is being asked about - const requestedChain = - SUPPORTED_CHAINS.find((chain) => - messageText.includes(chain.toLowerCase()) - ) || "solana"; - - // Get the current offset from state or default to 0 - const currentOffset = (_state?.searchTokensOffset as number) || 0; - - // Combine signals to make decision - const shouldProvideData = - searchQuery && hasSearchKeyword && hasTokenKeyword; - - if (!shouldProvideData || !searchQuery) { - return null; - } - - elizaLogger.info( - `Search tokens provider activated for query "${searchQuery}" on ${requestedChain}` - ); - - const searchResults = await searchTokens(apiKey, { - query: searchQuery, - chain: requestedChain, - offset: currentOffset, - limit: 10, - }); - - return formatSearchResultsToString( - searchResults, - searchQuery, - requestedChain - ); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/__tests__/token-overview-provider.test.ts b/packages/plugin-birdeye/src/providers/token/__tests__/token-overview-provider.test.ts deleted file mode 100644 index 85271a9fce..0000000000 --- a/packages/plugin-birdeye/src/providers/token/__tests__/token-overview-provider.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { IAgentRuntime, Memory, State } from "@elizaos/core"; -import { tokenOverviewProvider } from "../token-overview-provider"; - -// Mock data -const mockTokenOverview = { - address: "0x1234567890123456789012345678901234567890", - symbol: "TEST", - name: "Test Token", - decimals: 18, - logoURI: "https://example.com/logo.png", - price: 1.23, - priceChange24hPercent: 5.67, - liquidity: 1000000, - marketCap: 10000000, - realMc: 9000000, - supply: 1000000, - circulatingSupply: 900000, - holder: 1000, - v24h: 100000, - v24hUSD: 123000, - lastTradeUnixTime: 1704067200, - numberMarkets: 5, - extensions: { - website: "https://example.com", - twitter: "https://twitter.com/test", - telegram: "https://t.me/test", - discord: "https://discord.gg/test", - description: "A test token", - coingeckoId: "test-token", - }, -}; - -// Mock fetch globally -global.fetch = jest.fn(); - -describe("Token Overview Provider", () => { - let mockRuntime: IAgentRuntime; - let mockMessage: Memory; - let mockState: State; - - beforeEach(() => { - // Reset mocks - jest.clearAllMocks(); - - // Mock runtime - mockRuntime = { - getSetting: jest.fn().mockReturnValue("mock-api-key"), - } as unknown as IAgentRuntime; - - // Mock message - mockMessage = { - content: { - text: "Show me overview of 0x1234567890123456789012345678901234567890 on ethereum", - }, - } as Memory; - - // Mock state - mockState = {} as State; - - // Mock successful fetch response - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({ data: mockTokenOverview }), - }); - }); - - test("returns null when API key is missing", async () => { - (mockRuntime.getSetting as jest.Mock).mockReturnValue(null); - const result = await tokenOverviewProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).toBeNull(); - }); - - test("returns null when message does not contain overview keywords", async () => { - mockMessage.content.text = "random message without overview keywords"; - const result = await tokenOverviewProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).toBeNull(); - }); - - test("returns null when no contract address is found", async () => { - mockMessage.content.text = "show overview of invalid-address"; - const result = await tokenOverviewProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).toBeNull(); - }); - - test("handles API error gracefully", async () => { - (global.fetch as jest.Mock).mockRejectedValue(new Error("API Error")); - const result = await tokenOverviewProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).toBeNull(); - }); - - test("handles 404 response gracefully", async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 404, - }); - const result = await tokenOverviewProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).toBeNull(); - }); - - test("formats token overview correctly", async () => { - const result = await tokenOverviewProvider.get( - mockRuntime, - mockMessage, - mockState - ); - - // Verify the result contains all expected sections - expect(result).toContain("Token Overview for Test Token (TEST)"); - expect(result).toContain("šŸ“Š Market Data"); - expect(result).toContain("šŸ“ˆ Trading Info"); - expect(result).toContain("šŸ’° Supply Information"); - expect(result).toContain("šŸ”— Token Details"); - expect(result).toContain("šŸŒ Social Links"); - - // Verify specific data points - expect(result).toContain(`Current Price: $${mockTokenOverview.price}`); - expect(result).toContain(`Market Cap: $${mockTokenOverview.marketCap}`); - expect(result).toContain(mockTokenOverview.address); - expect(result).toContain(mockTokenOverview.extensions.website); - }); - - test("handles missing social links gracefully", async () => { - const tokenWithoutSocials = { - ...mockTokenOverview, - extensions: undefined, - }; - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({ data: tokenWithoutSocials }), - }); - - const result = await tokenOverviewProvider.get( - mockRuntime, - mockMessage, - mockState - ); - expect(result).not.toContain("šŸŒ Social Links"); - }); - - test("extracts chain correctly", async () => { - mockMessage.content.text = - "show overview of 0x1234567890123456789012345678901234567890 on ethereum"; - await tokenOverviewProvider.get(mockRuntime, mockMessage, mockState); - - expect(global.fetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - "x-chain": "ethereum", - }), - }) - ); - }); - - test("defaults to solana chain when not specified", async () => { - mockMessage.content.text = - "show overview of 0x1234567890123456789012345678901234567890"; - await tokenOverviewProvider.get(mockRuntime, mockMessage, mockState); - - expect(global.fetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - "x-chain": "solana", - }), - }) - ); - }); -}); diff --git a/packages/plugin-birdeye/src/providers/token/all-market-list-provider.ts b/packages/plugin-birdeye/src/providers/token/all-market-list-provider.ts deleted file mode 100644 index dd7d9d8e93..0000000000 --- a/packages/plugin-birdeye/src/providers/token/all-market-list-provider.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { BASE_URL, Chain, makeApiRequest } from "../utils"; - -// Types -interface Market { - address: string; - name: string; - symbol: string; - baseToken: string; - quoteToken: string; - volume24h: number; - tvl: number; - lastTradeTime: number; -} - -interface AllMarketsResponse { - markets: Market[]; -} - -// Constants -const MARKET_LIST_KEYWORDS = [ - "all markets", - "market list", - "trading pairs", - "available markets", - "list markets", - "show markets", -] as const; - -// Helper functions -const containsMarketListKeyword = (text: string): boolean => { - return MARKET_LIST_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getAllMarkets = async ( - apiKey: string, - chain: Chain = "solana" -): Promise => { - try { - const url = `${BASE_URL}/token/all_market_list`; - - elizaLogger.info("Fetching all markets from:", url); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching markets:", error.message); - } - return null; - } -}; - -const formatAllMarketsResponse = (data: AllMarketsResponse): string => { - let response = "šŸ“Š Available Markets\n\n"; - - // Sort markets by volume - const sortedMarkets = [...data.markets].sort( - (a, b) => b.volume24h - a.volume24h - ); - - sortedMarkets.forEach((market) => { - const lastTradeDate = new Date( - market.lastTradeTime * 1000 - ).toLocaleString(); - - response += `${market.name} (${market.symbol})\n`; - response += `ā€¢ Address: ${market.address}\n`; - response += `ā€¢ Base Token: ${market.baseToken}\n`; - response += `ā€¢ Quote Token: ${market.quoteToken}\n`; - response += `ā€¢ 24h Volume: $${market.volume24h.toLocaleString()}\n`; - response += `ā€¢ TVL: $${market.tvl.toLocaleString()}\n`; - response += `ā€¢ Last Trade: ${lastTradeDate}\n\n`; - }); - - return response.trim(); -}; - -export const allMarketListProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsMarketListKeyword(messageText)) { - return null; - } - - elizaLogger.info("ALL_MARKET_LIST provider activated"); - - const marketsData = await getAllMarkets(apiKey); - - if (!marketsData) { - return null; - } - - return formatAllMarketsResponse(marketsData); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/index.ts b/packages/plugin-birdeye/src/providers/token/index.ts deleted file mode 100644 index 1fdabaf640..0000000000 --- a/packages/plugin-birdeye/src/providers/token/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * from "./all-market-list-provider"; -export * from "./new-listing-provider"; -export * from "./token-creation-provider"; -export * from "./token-holder-provider"; -export * from "./token-list-provider"; -export * from "./token-market-provider"; -export * from "./token-metadata-provider"; -export * from "./token-mint-burn-provider"; -export * from "./token-overview-provider"; -export * from "./token-security-provider"; -export * from "./token-trade-provider"; -export * from "./top-traders-provider"; -export * from "./trending-tokens-provider"; diff --git a/packages/plugin-birdeye/src/providers/token/new-listing-provider.ts b/packages/plugin-birdeye/src/providers/token/new-listing-provider.ts deleted file mode 100644 index 5ee8952577..0000000000 --- a/packages/plugin-birdeye/src/providers/token/new-listing-provider.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { BASE_URL, Chain, makeApiRequest } from "../utils"; - -// Types -interface TokenListing { - address: string; - name: string; - symbol: string; - listingTime: number; - initialPrice: number; - currentPrice: number; - priceChange: number; - volume24h: number; -} - -interface NewListingsResponse { - listings: TokenListing[]; -} - -// Constants -const NEW_LISTING_KEYWORDS = [ - "new listings", - "newly listed", - "recent listings", - "latest tokens", - "new tokens", -] as const; - -// Helper functions -const containsNewListingKeyword = (text: string): boolean => { - return NEW_LISTING_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getNewListings = async ( - apiKey: string, - chain: Chain = "solana" -): Promise => { - try { - const url = `${BASE_URL}/token/new_listing`; - - elizaLogger.info("Fetching new token listings from:", url); - - return await makeApiRequest(url, { - apiKey, - chain, - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching new listings:", error.message); - } - return null; - } -}; - -const formatNewListingsResponse = (data: NewListingsResponse): string => { - let response = "šŸ†• New Token Listings\n\n"; - - data.listings.forEach((listing) => { - const listingDate = new Date( - listing.listingTime * 1000 - ).toLocaleString(); - const priceChangePercent = (listing.priceChange * 100).toFixed(2); - const priceChangeEmoji = listing.priceChange >= 0 ? "šŸ“ˆ" : "šŸ“‰"; - - response += `${listing.name} (${listing.symbol}) ${priceChangeEmoji}\n`; - response += `ā€¢ Address: ${listing.address}\n`; - response += `ā€¢ Listed: ${listingDate}\n`; - response += `ā€¢ Initial Price: $${listing.initialPrice.toFixed(6)}\n`; - response += `ā€¢ Current Price: $${listing.currentPrice.toFixed(6)}\n`; - response += `ā€¢ Price Change: ${priceChangePercent}%\n`; - response += `ā€¢ 24h Volume: $${listing.volume24h.toLocaleString()}\n\n`; - }); - - return response.trim(); -}; - -export const newListingProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsNewListingKeyword(messageText)) { - return null; - } - - elizaLogger.info("NEW_LISTING provider activated"); - - const listingsData = await getNewListings(apiKey); - - if (!listingsData) { - return null; - } - - return formatNewListingsResponse(listingsData); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/token-creation-provider.ts b/packages/plugin-birdeye/src/providers/token/token-creation-provider.ts deleted file mode 100644 index fc78c81f39..0000000000 --- a/packages/plugin-birdeye/src/providers/token/token-creation-provider.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - formatTimestamp, - formatValue, - makeApiRequest, - shortenAddress, -} from "../utils"; - -// Types -interface CreationData { - creator: string; - creatorBalance: number; - creatorBalanceUSD: number; - creatorShare: number; - creationTime: number; - initialSupply: number; - initialSupplyUSD: number; - creationTx: string; -} - -interface CreationResponse { - data: CreationData; - token: string; -} - -// Constants -const CREATION_KEYWORDS = [ - "creation", - "creator", - "created", - "launch", - "launched", - "deployment", - "deployed", - "initial supply", - "token creation", - "token launch", - "token deployment", - "token origin", - "token history", - "token birth", - "genesis", -] as const; - -// Helper functions -const containsCreationKeyword = (text: string): boolean => { - return CREATION_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getTokenCreation = async ( - apiKey: string, - contractAddress: string, - chain: Chain -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - }); - const url = `${BASE_URL}/token/creation?${params.toString()}`; - - elizaLogger.info( - `Fetching creation data for ${contractAddress} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching creation data:", error.message); - } - return null; - } -}; - -const analyzeCreationMetrics = (data: CreationData): string => { - let analysis = ""; - - // Analyze creator's share - if (data.creatorShare > 50) { - analysis += - "āš ļø Creator holds majority of supply, high concentration risk. "; - } else if (data.creatorShare > 20) { - analysis += "āš” Creator maintains significant holdings. "; - } else if (data.creatorShare > 5) { - analysis += "āœ… Creator retains moderate holdings. "; - } else { - analysis += "šŸ”„ Creator holds minimal share of supply. "; - } - - // Analyze initial supply value - if (data.initialSupplyUSD > 1000000) { - analysis += - "šŸ’° Large initial supply value indicates significant launch. "; - } else if (data.initialSupplyUSD > 100000) { - analysis += - "šŸ’« Moderate initial supply value suggests standard launch. "; - } else { - analysis += - "šŸŒ± Small initial supply value indicates grassroots launch. "; - } - - // Analyze creator's current position - const valueChange = data.creatorBalanceUSD / data.initialSupplyUSD; - if (valueChange > 1.5) { - analysis += "šŸ“ˆ Creator's position has significantly appreciated. "; - } else if (valueChange < 0.5) { - analysis += "šŸ“‰ Creator's position has notably decreased. "; - } - - return analysis; -}; - -const formatCreationResponse = ( - data: CreationResponse, - chain: Chain -): string => { - const { data: creationData } = data; - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - - let response = `Token Creation Data for ${data.token} on ${chainName}\n\n`; - - // Creation Analysis - response += "šŸ“Š Creation Analysis\n"; - response += analyzeCreationMetrics(creationData) + "\n\n"; - - // Creation Details - response += "šŸŽ‚ Creation Details\n"; - response += `Creation Time: ${formatTimestamp(creationData.creationTime)}\n`; - response += `Creator: ${shortenAddress(creationData.creator)}\n`; - response += `Creation Tx: ${shortenAddress(creationData.creationTx)}\n\n`; - - // Supply Information - response += "šŸ’° Supply Information\n"; - response += `Initial Supply: ${formatValue(creationData.initialSupply)}\n`; - response += `Initial Value: ${formatValue(creationData.initialSupplyUSD)}\n\n`; - - // Creator Holdings - response += "šŸ‘¤ Creator Holdings\n"; - response += `Current Balance: ${formatValue(creationData.creatorBalance)}\n`; - response += `Current Value: ${formatValue(creationData.creatorBalanceUSD)}\n`; - response += `Share of Supply: ${creationData.creatorShare.toFixed(2)}%`; - - return response; -}; - -export const tokenCreationProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsCreationKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - - elizaLogger.info( - `TOKEN CREATION provider activated for ${addresses[0]} on ${chain}` - ); - - const creationData = await getTokenCreation( - apiKey, - addresses[0], - chain - ); - - if (!creationData) { - return null; - } - - return formatCreationResponse(creationData, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/token-holder-provider.ts b/packages/plugin-birdeye/src/providers/token/token-holder-provider.ts deleted file mode 100644 index 5f9c40b369..0000000000 --- a/packages/plugin-birdeye/src/providers/token/token-holder-provider.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - extractLimit, - formatValue, - makeApiRequest, - shortenAddress, -} from "../utils"; - -// Types -interface HolderData { - address: string; - balance: number; - balanceUSD: number; - share: number; - rank: number; -} - -interface TokenHolderResponse { - holders: HolderData[]; - totalCount: number; - token: string; -} - -// Constants -const HOLDER_KEYWORDS = [ - "holders", - "holding", - "token holders", - "token holding", - "who holds", - "who owns", - "ownership", - "distribution", - "token distribution", - "token ownership", - "top holders", - "largest holders", - "biggest holders", - "whale holders", - "whale watching", -] as const; - -// Helper functions -const containsHolderKeyword = (text: string): boolean => { - return HOLDER_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getTokenHolders = async ( - apiKey: string, - contractAddress: string, - chain: Chain, - limit: number -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - limit: limit.toString(), - }); - const url = `${BASE_URL}/token/holder?${params.toString()}`; - - elizaLogger.info( - `Fetching token holders for ${contractAddress} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { - apiKey, - chain, - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching token holders:", error.message); - } - return null; - } -}; - -const formatHolderData = (holder: HolderData): string => { - let response = `${holder.rank}. ${shortenAddress(holder.address)}\n`; - response += ` ā€¢ Balance: ${holder.balance ? formatValue(holder.balance) : "N/A"}\n`; - response += ` ā€¢ Value: ${holder.balanceUSD ? formatValue(holder.balanceUSD) : "N/A"}\n`; - response += ` ā€¢ Share: ${holder.share ? holder.share.toFixed(2) : "0.00"}%`; - return response; -}; - -const analyzeDistribution = (holders: HolderData[]): string => { - // Calculate concentration metrics - const top10Share = holders - .slice(0, 10) - .reduce((sum, h) => sum + h.share, 0); - const top20Share = holders - .slice(0, 20) - .reduce((sum, h) => sum + h.share, 0); - const top50Share = holders - .slice(0, 50) - .reduce((sum, h) => sum + h.share, 0); - - let analysis = ""; - - // Analyze top holder concentration - const topHolder = holders[0]; - if (topHolder.share > 50) { - analysis += - "šŸšØ Extremely high concentration: Top holder owns majority of supply. "; - } else if (topHolder.share > 20) { - analysis += - "āš ļø High concentration: Top holder owns significant portion. "; - } else if (topHolder.share > 10) { - analysis += - "ā„¹ļø Moderate concentration: Top holder owns notable portion. "; - } else { - analysis += - "āœ… Good distribution: No single holder owns dominant share. "; - } - - // Analyze overall distribution - if (top10Share > 80) { - analysis += - "Top 10 holders control vast majority of supply, indicating high centralization. "; - } else if (top10Share > 50) { - analysis += - "Top 10 holders control majority of supply, showing moderate centralization. "; - } else { - analysis += - "Top 10 holders control less than half of supply, suggesting good distribution. "; - } - - // Provide distribution metrics - analysis += `\n\nDistribution Metrics:\n`; - analysis += `ā€¢ Top 10 Holders: ${top10Share.toFixed(2)}%\n`; - analysis += `ā€¢ Top 20 Holders: ${top20Share.toFixed(2)}%\n`; - analysis += `ā€¢ Top 50 Holders: ${top50Share.toFixed(2)}%`; - - return analysis; -}; - -const formatHolderResponse = ( - data: TokenHolderResponse, - chain: Chain -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - - let response = `Token Holders for ${data.token} on ${chainName}\n\n`; - - if (data.holders.length === 0) { - return response + "No holder data found."; - } - - response += `šŸ“Š Distribution Analysis\n`; - response += analyzeDistribution(data.holders); - response += "\n\n"; - - response += `šŸ‘„ Top Holders\n`; - data.holders.forEach((holder) => { - response += formatHolderData(holder) + "\n\n"; - }); - - if (data.totalCount > data.holders.length) { - response += `Showing ${data.holders.length} of ${data.totalCount} total holders.`; - } - - return response; -}; - -export const tokenHolderProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsHolderKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - const limit = extractLimit(messageText); - - elizaLogger.info( - `TOKEN HOLDER provider activated for ${addresses[0]} on ${chain}` - ); - - const holderData = await getTokenHolders( - apiKey, - addresses[0], - chain, - limit - ); - - if (!holderData) { - return null; - } - - return formatHolderResponse(holderData, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/token-list-provider.ts b/packages/plugin-birdeye/src/providers/token/token-list-provider.ts deleted file mode 100644 index dfe95705b2..0000000000 --- a/packages/plugin-birdeye/src/providers/token/token-list-provider.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - formatPercentChange, - formatValue, - makeApiRequest, - shortenAddress, -} from "../utils"; - -// Types -interface TokenListData { - address: string; - name: string; - symbol: string; - decimals: number; - price: number; - priceChange24h: number; - volume24h: number; - marketCap: number; - liquidity: number; - rank: number; -} - -interface TokenListResponse { - data: TokenListData[]; - totalCount: number; -} - -// Constants -const LIST_KEYWORDS = [ - "list", - "top tokens", - "popular tokens", - "trending tokens", - "token list", - "token ranking", - "token rankings", - "token leaderboard", - "best tokens", - "highest volume", - "highest market cap", - "highest liquidity", -] as const; - -// Helper functions -const containsListKeyword = (text: string): boolean => { - return LIST_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getTokenList = async ( - apiKey: string, - chain: Chain, - limit: number = 10 -): Promise => { - try { - const params = new URLSearchParams({ - limit: limit.toString(), - }); - const url = `${BASE_URL}/token/list?${params.toString()}`; - - elizaLogger.info(`Fetching token list on ${chain} from:`, url); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching token list:", error.message); - } - return null; - } -}; - -const formatTokenData = (token: TokenListData, rank: number): string => { - let response = `${rank}. ${token.name} (${token.symbol})\n`; - response += ` ā€¢ Address: ${shortenAddress(token.address)}\n`; - response += ` ā€¢ Price: ${formatValue(token.price)} (${formatPercentChange(token.priceChange24h)})\n`; - response += ` ā€¢ Volume 24h: ${formatValue(token.volume24h)}\n`; - response += ` ā€¢ Market Cap: ${formatValue(token.marketCap)}\n`; - response += ` ā€¢ Liquidity: ${formatValue(token.liquidity)}`; - return response; -}; - -const analyzeTokenList = (tokens: TokenListData[]): string => { - let analysis = ""; - - // Volume analysis - const validVolumes = tokens.filter((t) => t.volume24h != null); - const totalVolume = validVolumes.reduce((sum, t) => sum + t.volume24h, 0); - const avgVolume = - validVolumes.length > 0 ? totalVolume / validVolumes.length : 0; - const highVolumeTokens = validVolumes.filter( - (t) => t.volume24h > avgVolume * 2 - ); - - if (highVolumeTokens.length > 0) { - analysis += `šŸ”„ ${highVolumeTokens.length} tokens showing exceptional trading activity.\n`; - } - - // Price movement analysis - const validPriceChanges = tokens.filter((t) => t.priceChange24h != null); - const positiveMovers = validPriceChanges.filter( - (t) => t.priceChange24h > 0 - ); - const strongMovers = validPriceChanges.filter( - (t) => Math.abs(t.priceChange24h) > 10 - ); - - if (validPriceChanges.length > 0) { - if (positiveMovers.length > validPriceChanges.length / 2) { - analysis += - "šŸ“ˆ Market showing bullish trend with majority positive price movement.\n"; - } else { - analysis += - "šŸ“‰ Market showing bearish trend with majority negative price movement.\n"; - } - - if (strongMovers.length > 0) { - analysis += `āš” ${strongMovers.length} tokens with significant price movement (>10%).\n`; - } - } - - // Liquidity analysis - const totalLiquidity = tokens.reduce((sum, t) => sum + t.liquidity, 0); - const avgLiquidity = totalLiquidity / tokens.length; - const highLiquidityTokens = tokens.filter( - (t) => t.liquidity > avgLiquidity * 2 - ); - - if (highLiquidityTokens.length > 0) { - analysis += `šŸ’§ ${highLiquidityTokens.length} tokens with notably high liquidity.\n`; - } - - return analysis; -}; - -const formatListResponse = (data: TokenListResponse, chain: Chain): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - - let response = `Top Tokens on ${chainName}\n\n`; - - // Market Analysis - response += "šŸ“Š Market Analysis\n"; - response += analyzeTokenList(data.data) + "\n\n"; - - // Token List - response += "šŸ† Token Rankings\n"; - data.data.forEach((token, index) => { - response += formatTokenData(token, index + 1) + "\n\n"; - }); - - if (data.totalCount > data.data.length) { - response += `Showing ${data.data.length} of ${data.totalCount} total tokens.`; - } - - return response; -}; - -export const tokenListProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsListKeyword(messageText)) { - return null; - } - - const chain = extractChain(messageText); - const limit = messageText.toLowerCase().includes("all") ? 100 : 10; - - elizaLogger.info(`TOKEN LIST provider activated for ${chain}`); - - const listData = await getTokenList(apiKey, chain, limit); - - if (!listData) { - return null; - } - - return formatListResponse(listData, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/token-market-provider.ts b/packages/plugin-birdeye/src/providers/token/token-market-provider.ts deleted file mode 100644 index a5f2ccb85f..0000000000 --- a/packages/plugin-birdeye/src/providers/token/token-market-provider.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - formatPercentChange, - formatValue, - makeApiRequest, -} from "../utils"; - -// Types -interface MarketData { - price: number; - priceChange24h: number; - priceChange7d: number; - priceChange14d: number; - priceChange30d: number; - volume24h: number; - volume7d: number; - marketCap: number; - fullyDilutedValuation: number; - rank: number; - liquidity: number; - liquidityChange24h: number; - liquidityChange7d: number; -} - -interface MarketResponse { - data: MarketData; - token: string; -} - -// Constants -const MARKET_KEYWORDS = [ - "market", - "price", - "volume", - "liquidity", - "market cap", - "mcap", - "fdv", - "valuation", - "market data", - "market info", - "market stats", - "market metrics", - "market overview", - "market analysis", - "price change", - "price movement", - "price action", - "price performance", -] as const; - -// Helper functions -const containsMarketKeyword = (text: string): boolean => { - return MARKET_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getTokenMarket = async ( - apiKey: string, - contractAddress: string, - chain: Chain -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - }); - const url = `${BASE_URL}/token/market?${params.toString()}`; - - elizaLogger.info( - `Fetching market data for ${contractAddress} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching market data:", error.message); - } - return null; - } -}; - -const formatPriceChanges = (data: MarketData): string => { - let changes = ""; - changes += `24h: ${formatPercentChange(data.priceChange24h)}\n`; - changes += `7d: ${formatPercentChange(data.priceChange7d)}\n`; - changes += `14d: ${formatPercentChange(data.priceChange14d)}\n`; - changes += `30d: ${formatPercentChange(data.priceChange30d)}`; - return changes; -}; - -const formatLiquidityChanges = (data: MarketData): string => { - let changes = ""; - changes += `24h: ${formatPercentChange(data.liquidityChange24h)}\n`; - changes += `7d: ${formatPercentChange(data.liquidityChange7d)}`; - return changes; -}; - -const analyzeMarketMetrics = (data: MarketData): string => { - let analysis = ""; - - // Price trend analysis - if (data.priceChange24h > 5) { - analysis += "šŸ“ˆ Strong bullish momentum in the last 24 hours. "; - } else if (data.priceChange24h < -5) { - analysis += "šŸ“‰ Significant price decline in the last 24 hours. "; - } - - // Volume analysis - const volumeToMcap = (data.volume24h / data.marketCap) * 100; - if (volumeToMcap > 20) { - analysis += "šŸ”„ High trading activity relative to market cap. "; - } else if (volumeToMcap < 1) { - analysis += "āš ļø Low trading volume relative to market cap. "; - } - - // Liquidity analysis - const liquidityToMcap = (data.liquidity / data.marketCap) * 100; - if (liquidityToMcap > 30) { - analysis += "šŸ’§ Strong liquidity relative to market cap. "; - } else if (liquidityToMcap < 5) { - analysis += "āš ļø Limited liquidity relative to market cap. "; - } - - // Market cap vs FDV analysis - if (data.fullyDilutedValuation > data.marketCap * 3) { - analysis += "āš ļø High potential for dilution based on FDV. "; - } - - return analysis || "Market metrics are within normal ranges."; -}; - -const formatMarketResponse = (data: MarketResponse, chain: Chain): string => { - const { data: marketData } = data; - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - - let response = `Market Data for ${data.token} on ${chainName}\n\n`; - - // Market Analysis - response += "šŸ“Š Market Analysis\n"; - response += analyzeMarketMetrics(marketData) + "\n\n"; - - // Price Information - response += "šŸ’° Price Information\n"; - response += `Current Price: ${formatValue(marketData.price)}\n\n`; - response += "Price Changes:\n"; - response += formatPriceChanges(marketData) + "\n\n"; - - // Volume Information - response += "šŸ“ˆ Volume Information\n"; - response += `24h Volume: ${marketData.volume24h ? formatValue(marketData.volume24h) : "N/A"}\n`; - response += `7d Volume: ${marketData.volume7d ? formatValue(marketData.volume7d) : "N/A"}\n\n`; - - // Market Metrics - response += "šŸ“Š Market Metrics\n"; - response += `Market Cap: ${marketData.marketCap ? formatValue(marketData.marketCap) : "N/A"}\n`; - response += `Fully Diluted Valuation: ${marketData.fullyDilutedValuation ? formatValue(marketData.fullyDilutedValuation) : "N/A"}\n`; - response += `Market Rank: ${marketData.rank ? `#${marketData.rank}` : "N/A"}\n\n`; - - // Liquidity Information - response += "šŸ’§ Liquidity Information\n"; - response += `Current Liquidity: ${marketData.liquidity ? formatValue(marketData.liquidity) : "N/A"}\n\n`; - response += "Liquidity Changes:\n"; - response += formatLiquidityChanges(marketData); - - return response; -}; - -export const tokenMarketProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsMarketKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - - elizaLogger.info( - `TOKEN MARKET provider activated for ${addresses[0]} on ${chain}` - ); - - const marketData = await getTokenMarket(apiKey, addresses[0], chain); - - if (!marketData) { - return null; - } - - return formatMarketResponse(marketData, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/token-metadata-provider.ts b/packages/plugin-birdeye/src/providers/token/token-metadata-provider.ts deleted file mode 100644 index 590b70327c..0000000000 --- a/packages/plugin-birdeye/src/providers/token/token-metadata-provider.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - formatValue, - makeApiRequest, -} from "../utils"; - -// Types -interface TokenMetadata { - address: string; - name: string; - symbol: string; - decimals: number; - totalSupply: number; - totalSupplyUSD: number; - website: string; - twitter: string; - telegram: string; - discord: string; - coingeckoId: string; - description: string; - logo: string; - tags: string[]; -} - -interface MetadataResponse { - metadata: TokenMetadata; -} - -// Constants -const METADATA_KEYWORDS = [ - "metadata", - "token info", - "token information", - "token details", - "token data", - "token description", - "token profile", - "token overview", - "token stats", - "token statistics", - "token social", - "token links", - "token website", - "token socials", -] as const; - -// Helper functions -const containsMetadataKeyword = (text: string): boolean => { - return METADATA_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getTokenMetadata = async ( - apiKey: string, - contractAddress: string, - chain: Chain -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - }); - const url = `${BASE_URL}/token/metadata?${params.toString()}`; - - elizaLogger.info( - `Fetching token metadata for ${contractAddress} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching token metadata:", error.message); - } - return null; - } -}; - -const formatSocialLinks = (metadata: TokenMetadata): string => { - const links = []; - - if (metadata.website) { - links.push(`šŸŒ [Website](${metadata.website})`); - } - if (metadata.twitter) { - links.push(`šŸ¦ [Twitter](${metadata.twitter})`); - } - if (metadata.telegram) { - links.push(`šŸ“± [Telegram](${metadata.telegram})`); - } - if (metadata.discord) { - links.push(`šŸ’¬ [Discord](${metadata.discord})`); - } - if (metadata.coingeckoId) { - links.push( - `šŸ¦Ž [CoinGecko](https://www.coingecko.com/en/coins/${metadata.coingeckoId})` - ); - } - - return links.length > 0 ? links.join("\n") : "No social links available"; -}; - -const formatMetadataResponse = ( - data: MetadataResponse, - chain: Chain -): string => { - const { metadata } = data; - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - - let response = `Token Metadata for ${metadata.name} (${metadata.symbol}) on ${chainName}\n\n`; - - // Basic Information - response += "šŸ“ Basic Information\n"; - response += `ā€¢ Name: ${metadata.name}\n`; - response += `ā€¢ Symbol: ${metadata.symbol}\n`; - response += `ā€¢ Address: ${metadata.address}\n`; - response += `ā€¢ Decimals: ${metadata.decimals}\n`; - response += `ā€¢ Total Supply: ${formatValue(metadata.totalSupply)}\n`; - response += `ā€¢ Total Supply USD: ${formatValue(metadata.totalSupplyUSD)}\n`; - - // Description - if (metadata.description) { - response += "\nšŸ“‹ Description\n"; - response += metadata.description + "\n"; - } - - // Tags - if (metadata.tags && metadata.tags.length > 0) { - response += "\nšŸ·ļø Tags\n"; - response += metadata.tags.map((tag) => `#${tag}`).join(" ") + "\n"; - } - - // Social Links - response += "\nšŸ”— Social Links\n"; - response += formatSocialLinks(metadata) + "\n"; - - // Logo - if (metadata.logo) { - response += "\nšŸ–¼ļø Logo\n"; - response += metadata.logo; - } - - return response; -}; - -export const tokenMetadataProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsMetadataKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - - elizaLogger.info( - `TOKEN METADATA provider activated for ${addresses[0]} on ${chain}` - ); - - const metadataData = await getTokenMetadata( - apiKey, - addresses[0], - chain - ); - - if (!metadataData) { - return null; - } - - return formatMetadataResponse(metadataData, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/token-mint-burn-provider.ts b/packages/plugin-birdeye/src/providers/token/token-mint-burn-provider.ts deleted file mode 100644 index e60e8d7f9b..0000000000 --- a/packages/plugin-birdeye/src/providers/token/token-mint-burn-provider.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - formatValue, - makeApiRequest, - shortenAddress, -} from "../utils"; - -// Types -interface MintBurnEvent { - type: "mint" | "burn"; - amount: number; - amountUSD: number; - timestamp: number; - txHash: string; - address: string; -} - -interface MintBurnResponse { - events: MintBurnEvent[]; - token: string; -} - -// Constants -const MINT_BURN_KEYWORDS = [ - "mint", - "burn", - "minting", - "burning", - "token supply", - "supply changes", - "token burns", - "token mints", - "supply history", - "mint history", - "burn history", - "supply events", -] as const; - -// Helper functions -const containsMintBurnKeyword = (text: string): boolean => { - return MINT_BURN_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getTokenMintBurnHistory = async ( - apiKey: string, - contractAddress: string, - chain: Chain -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - }); - const url = `${BASE_URL}/token/mint_burn?${params.toString()}`; - - elizaLogger.info( - `Fetching mint/burn history for ${contractAddress} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error( - "Error fetching mint/burn history:", - error.message - ); - } - return null; - } -}; - -const formatEventData = (event: MintBurnEvent): string => { - const date = new Date(event.timestamp * 1000).toLocaleString(); - const eventType = event.type === "mint" ? "šŸŸ¢ Mint" : "šŸ”“ Burn"; - - let response = `${eventType} Event - ${date}\n`; - response += ` ā€¢ Amount: ${formatValue(event.amount)}\n`; - response += ` ā€¢ Value: ${formatValue(event.amountUSD)}\n`; - response += ` ā€¢ By: ${shortenAddress(event.address)}\n`; - response += ` ā€¢ Tx: ${shortenAddress(event.txHash)}`; - return response; -}; - -const analyzeMintBurnTrends = (events: MintBurnEvent[]): string => { - const mints = events.filter((e) => e.type === "mint"); - const burns = events.filter((e) => e.type === "burn"); - - const totalMinted = mints.reduce((sum, e) => sum + e.amount, 0); - const totalBurned = burns.reduce((sum, e) => sum + e.amount, 0); - const netChange = totalMinted - totalBurned; - - let analysis = "šŸ“Š Supply Change Analysis\n\n"; - - // Supply change metrics - analysis += `Total Minted: ${formatValue(totalMinted)}\n`; - analysis += `Total Burned: ${formatValue(totalBurned)}\n`; - analysis += `Net Change: ${formatValue(Math.abs(netChange))} ${netChange >= 0 ? "increase" : "decrease"}\n\n`; - - // Activity analysis - analysis += "Recent Activity:\n"; - if (mints.length === 0 && burns.length === 0) { - analysis += "ā€¢ No mint/burn activity in the period\n"; - } else { - if (mints.length > 0) { - analysis += `ā€¢ ${mints.length} mint events\n`; - } - if (burns.length > 0) { - analysis += `ā€¢ ${burns.length} burn events\n`; - } - } - - // Supply trend - if (netChange > 0) { - analysis += "\nšŸ“ˆ Supply is expanding"; - } else if (netChange < 0) { - analysis += "\nšŸ“‰ Supply is contracting"; - } else { - analysis += "\nāž”ļø Supply is stable"; - } - - return analysis; -}; - -const formatMintBurnResponse = ( - data: MintBurnResponse, - chain: Chain -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - - let response = `Mint/Burn History for ${data.token} on ${chainName}\n\n`; - - if (data.events.length === 0) { - return response + "No mint/burn events found."; - } - - response += analyzeMintBurnTrends(data.events); - response += "\n\nšŸ“œ Recent Events\n"; - - // Sort events by timestamp in descending order - const sortedEvents = [...data.events].sort( - (a, b) => b.timestamp - a.timestamp - ); - sortedEvents.forEach((event) => { - response += "\n" + formatEventData(event) + "\n"; - }); - - return response; -}; - -export const tokenMintBurnProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsMintBurnKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - - elizaLogger.info( - `TOKEN MINT/BURN provider activated for ${addresses[0]} on ${chain}` - ); - - const mintBurnData = await getTokenMintBurnHistory( - apiKey, - addresses[0], - chain - ); - - if (!mintBurnData) { - return null; - } - - return formatMintBurnResponse(mintBurnData, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/token-overview-provider.ts b/packages/plugin-birdeye/src/providers/token/token-overview-provider.ts deleted file mode 100644 index 594a72fe1e..0000000000 --- a/packages/plugin-birdeye/src/providers/token/token-overview-provider.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -// Types -interface TokenExtensions { - website?: string; - twitter?: string; - telegram?: string; - discord?: string; - description?: string; - coingeckoId?: string; -} - -interface TokenOverview { - // Basic token info - address: string; - symbol: string; - name: string; - decimals: number; - logoURI: string; - - // Price and market data - price: number; - priceChange24hPercent: number; - liquidity: number; - marketCap: number; - realMc: number; - - // Supply info - supply: number; - circulatingSupply: number; - holder: number; - - // Volume data - v24h: number; - v24hUSD: number; - - // Social/metadata - extensions?: TokenExtensions; - - // Trading info - lastTradeUnixTime: number; - numberMarkets: number; -} - -// Constants -const OVERVIEW_KEYWORDS = [ - "overview", - "details", - "info", - "information", - "about", - "tell me about", - "what is", - "show me", -] as const; - -const CHAIN_KEYWORDS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -] as const; - -const BASE_URL = "https://public-api.birdeye.so"; - -// Helper functions -const containsOverviewKeyword = (text: string): boolean => { - return OVERVIEW_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractChain = (text: string): string => { - const chain = CHAIN_KEYWORDS.find((chain) => - text.toLowerCase().includes(chain.toLowerCase()) - ); - return chain || "solana"; -}; - -const extractContractAddress = (text: string): string | null => { - const words = text.split(/\s+/); - - for (const word of words) { - // Ethereum-like addresses (0x...) - if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { - return word; - } - // Solana addresses (base58, typically 32-44 chars) - if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { - return word; - } - } - return null; -}; - -const formatNumber = (num: number): string => { - if (!num && num !== 0) return "N/A"; - return num.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 6, - }); -}; - -const formatSocialLinks = (extensions?: TokenExtensions): string => { - if (!extensions) return ""; - - return Object.entries(extensions) - .filter(([key, value]) => { - try { - return Boolean( - value && - typeof value === "string" && - ["website", "twitter", "telegram", "discord"].includes( - key - ) - ); - } catch (err) { - elizaLogger.warn( - `Error processing social link for key ${key}:`, - err - ); - return false; - } - }) - .map(([key, value]) => { - try { - return `ā€¢ ${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`; - } catch (err) { - elizaLogger.error( - `Error formatting social link for ${key}:`, - err - ); - return ""; - } - }) - .filter(Boolean) - .join("\n"); -}; - -const formatTokenOverview = (token: TokenOverview, chain: string): string => { - const lastTradeTime = new Date( - token.lastTradeUnixTime * 1000 - ).toLocaleString(); - const socialLinks = formatSocialLinks(token.extensions); - - return `Token Overview for ${token.name} (${token.symbol}) on ${chain.charAt(0).toUpperCase() + chain.slice(1)} - -šŸ“Š Market Data -ā€¢ Current Price: $${formatNumber(token.price)} -ā€¢ 24h Change: ${formatNumber(token.priceChange24hPercent)}% -ā€¢ Market Cap: $${formatNumber(token.marketCap)} -ā€¢ Real Market Cap: $${formatNumber(token.realMc)} -ā€¢ Liquidity: $${formatNumber(token.liquidity)} - -šŸ“ˆ Trading Info -ā€¢ 24h Volume: $${formatNumber(token.v24hUSD)} -ā€¢ Number of Markets: ${token.numberMarkets} -ā€¢ Last Trade: ${lastTradeTime} - -šŸ’° Supply Information -ā€¢ Total Supply: ${formatNumber(token.supply)} -ā€¢ Circulating Supply: ${formatNumber(token.circulatingSupply)} -ā€¢ Number of Holders: ${token.holder ? formatNumber(token.holder) : "N/A"} - -šŸ”— Token Details -ā€¢ Contract: ${token.address} -ā€¢ Decimals: ${token.decimals} -${token.extensions?.description ? `ā€¢ Description: ${token.extensions.description}\n` : ""} -${socialLinks ? `\nšŸŒ Social Links\n${socialLinks}` : ""}`; -}; - -const getTokenOverview = async ( - apiKey: string, - contractAddress: string, - chain: string = "solana" -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - }); - const url = `${BASE_URL}/defi/token_overview?${params.toString()}`; - - elizaLogger.info( - `Fetching token overview for address ${contractAddress} on ${chain} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - elizaLogger.warn( - `Token not found: ${contractAddress} on ${chain}` - ); - return null; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching token overview:", error); - return null; - } -}; - -export const tokenOverviewProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsOverviewKeyword(messageText)) { - return null; - } - - const contractAddress = extractContractAddress(messageText); - if (!contractAddress) { - return null; - } - - const chain = extractChain(messageText); - - elizaLogger.info( - `TOKEN OVERVIEW provider activated for address ${contractAddress} on ${chain}` - ); - - const tokenOverview = await getTokenOverview( - apiKey, - contractAddress, - chain - ); - - if (!tokenOverview) { - return null; - } - - return formatTokenOverview(tokenOverview, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/token-security-provider.ts b/packages/plugin-birdeye/src/providers/token/token-security-provider.ts deleted file mode 100644 index 1affa34ad4..0000000000 --- a/packages/plugin-birdeye/src/providers/token/token-security-provider.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { - BASE_URL, - Chain, - extractChain, - extractContractAddresses, - makeApiRequest, -} from "../utils"; - -// Types -interface SecurityData { - isHoneypot: boolean; - isProxy: boolean; - isVerified: boolean; - isAudited: boolean; - isRenounced: boolean; - isMintable: boolean; - isPausable: boolean; - hasBlacklist: boolean; - hasFeeOnTransfer: boolean; - transferFeePercentage: number; - riskLevel: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; - riskFactors: string[]; -} - -interface SecurityResponse { - data: SecurityData; - token: string; -} - -// Constants -const SECURITY_KEYWORDS = [ - "security", - "risk", - "audit", - "safety", - "honeypot", - "scam", - "safe", - "verified", - "contract security", - "token security", - "token safety", - "token risk", - "token audit", - "security check", - "risk check", - "safety check", -] as const; - -// Helper functions -const containsSecurityKeyword = (text: string): boolean => { - return SECURITY_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getTokenSecurity = async ( - apiKey: string, - contractAddress: string, - chain: Chain -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - }); - const url = `${BASE_URL}/token/security?${params.toString()}`; - - elizaLogger.info( - `Fetching security data for ${contractAddress} on ${chain} from:`, - url - ); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching security data:", error.message); - } - return null; - } -}; - -const getRiskEmoji = (riskLevel: SecurityData["riskLevel"]): string => { - switch (riskLevel) { - case "LOW": - return "āœ…"; - case "MEDIUM": - return "āš ļø"; - case "HIGH": - return "šŸšØ"; - case "CRITICAL": - return "šŸ’€"; - default: - return "ā“"; - } -}; - -const formatSecurityFeatures = (data: SecurityData): string => { - const features = [ - { name: "Contract Verified", value: data.isVerified }, - { name: "Contract Audited", value: data.isAudited }, - { name: "Ownership Renounced", value: data.isRenounced }, - { name: "Mintable Token", value: data.isMintable }, - { name: "Pausable Token", value: data.isPausable }, - { name: "Has Blacklist", value: data.hasBlacklist }, - { name: "Has Transfer Fee", value: data.hasFeeOnTransfer }, - ]; - - return features - .map(({ name, value }) => `ā€¢ ${name}: ${value ? "āœ…" : "āŒ"}`) - .join("\n"); -}; - -const analyzeSecurityRisks = (data: SecurityData): string => { - let analysis = ""; - - // Critical checks - if (data.isHoneypot) { - analysis += - "šŸš« CRITICAL: Token is identified as a honeypot! DO NOT TRADE.\n"; - } - - if (data.isProxy) { - analysis += - "āš ļø Contract is upgradeable (proxy). Owner can modify functionality.\n"; - } - - // Fee analysis - if (data.hasFeeOnTransfer) { - const feeLevel = data.transferFeePercentage > 5 ? "High" : "Standard"; - analysis += `šŸ’ø ${feeLevel} transfer fee (${data.transferFeePercentage}%).\n`; - } - - // Contract security - if (!data.isVerified) { - analysis += "āš ļø Contract is not verified. Cannot audit code.\n"; - } - if (!data.isAudited) { - analysis += "āš ļø No professional audit found.\n"; - } - if (!data.isRenounced) { - analysis += - "šŸ‘¤ Contract ownership retained. Owner can modify contract.\n"; - } - - // Token features - if (data.isMintable) { - analysis += "šŸ“ˆ Token supply can be increased by owner.\n"; - } - if (data.isPausable) { - analysis += "āøļø Trading can be paused by owner.\n"; - } - if (data.hasBlacklist) { - analysis += "šŸš« Addresses can be blacklisted from trading.\n"; - } - - // Risk factors - if (data.riskFactors.length > 0) { - analysis += "\nIdentified Risk Factors:\n"; - data.riskFactors.forEach((factor) => { - analysis += `ā€¢ ${factor}\n`; - }); - } - - return analysis; -}; - -const formatSecurityResponse = ( - data: SecurityResponse, - chain: Chain -): string => { - const { data: securityData } = data; - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - - let response = `Security Analysis for ${data.token} on ${chainName}\n\n`; - - // Overall Risk Level - response += `šŸŽÆ Risk Level: ${getRiskEmoji(securityData.riskLevel)} ${securityData.riskLevel}\n\n`; - - // Security Analysis - response += "šŸ” Security Analysis\n"; - response += analyzeSecurityRisks(securityData) + "\n\n"; - - // Contract Features - response += "šŸ“‹ Contract Features\n"; - response += formatSecurityFeatures(securityData); - - return response; -}; - -export const tokenSecurityProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsSecurityKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - - elizaLogger.info( - `TOKEN SECURITY provider activated for ${addresses[0]} on ${chain}` - ); - - const securityData = await getTokenSecurity( - apiKey, - addresses[0], - chain - ); - - if (!securityData) { - return null; - } - - return formatSecurityResponse(securityData, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/token-trade-provider.ts b/packages/plugin-birdeye/src/providers/token/token-trade-provider.ts deleted file mode 100644 index 8c19da3f12..0000000000 --- a/packages/plugin-birdeye/src/providers/token/token-trade-provider.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -// Types -interface Trade { - timestamp: number; - price: number; - volume: number; - side: "buy" | "sell"; - source: string; - txHash: string; - buyer?: string; - seller?: string; -} - -interface TokenTradeData { - trades: Trade[]; - totalCount: number; - token: string; -} - -interface MultiTokenTradeData { - [tokenAddress: string]: TokenTradeData; -} - -// Constants -const TRADE_KEYWORDS = [ - "trades", - "trading", - "transactions", - "swaps", - "buys", - "sells", - "orders", - "executions", - "trade history", - "trading history", - "recent trades", -] as const; - -const CHAIN_KEYWORDS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -] as const; - -const BASE_URL = "https://public-api.birdeye.so"; - -// Helper functions -const containsTradeKeyword = (text: string): boolean => { - return TRADE_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractChain = (text: string): string => { - const chain = CHAIN_KEYWORDS.find((chain) => - text.toLowerCase().includes(chain.toLowerCase()) - ); - return chain || "solana"; -}; - -const extractContractAddresses = (text: string): string[] => { - const words = text.split(/\s+/); - const addresses: string[] = []; - - for (const word of words) { - // Ethereum-like addresses (0x...) - if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { - addresses.push(word); - } - // Solana addresses (base58, typically 32-44 chars) - if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { - addresses.push(word); - } - } - return addresses; -}; - -const getSingleTokenTrades = async ( - apiKey: string, - contractAddress: string, - chain: string = "solana", - limit: number = 10 -): Promise => { - try { - const params = new URLSearchParams({ - address: contractAddress, - limit: limit.toString(), - }); - const url = `${BASE_URL}/token/trade_data_single?${params.toString()}`; - - elizaLogger.info( - `Fetching trade data for token ${contractAddress} on ${chain} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - elizaLogger.warn( - `Token not found: ${contractAddress} on ${chain}` - ); - return null; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching token trade data:", error); - return null; - } -}; - -const getMultipleTokenTrades = async ( - apiKey: string, - addresses: string[], - chain: string = "solana", - limit: number = 5 -): Promise => { - try { - const params = new URLSearchParams({ - addresses: addresses.join(","), - limit: limit.toString(), - }); - const url = `${BASE_URL}/token/trade_data_multiple?${params.toString()}`; - - elizaLogger.info( - `Fetching trade data for ${addresses.length} tokens on ${chain} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching multiple token trade data:", error); - return null; - } -}; - -const formatValue = (value: number): string => { - if (value >= 1_000_000_000) { - return `$${(value / 1_000_000_000).toFixed(2)}B`; - } - if (value >= 1_000_000) { - return `$${(value / 1_000_000).toFixed(2)}M`; - } - if (value >= 1_000) { - return `$${(value / 1_000).toFixed(2)}K`; - } - return `$${value.toFixed(2)}`; -}; - -const shortenAddress = (address: string): string => { - if (!address || address.length <= 12) return address || "Unknown"; - return `${address.slice(0, 6)}...${address.slice(-4)}`; -}; - -const formatTrade = (trade: Trade): string => { - const timestamp = new Date(trade.timestamp * 1000).toLocaleString(); - const priceFormatted = - trade.price != null - ? trade.price < 0.01 - ? trade.price.toExponential(2) - : trade.price.toFixed(2) - : "N/A"; - const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; - - let response = `${side} - ${timestamp}\n`; - response += `ā€¢ Price: $${priceFormatted}\n`; - response += `ā€¢ Volume: ${trade.volume ? formatValue(trade.volume) : "N/A"}\n`; - response += `ā€¢ Source: ${trade.source || "Unknown"}\n`; - if (trade.buyer && trade.seller) { - response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; - response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; - } - response += `ā€¢ Tx: ${trade.txHash ? shortenAddress(trade.txHash) : "N/A"}`; - - return response; -}; - -const formatSingleTokenTradeResponse = ( - data: TokenTradeData, - chain: string -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - let response = `Recent Trades for ${data.token} on ${chainName}:\n\n`; - - if (data.trades.length === 0) { - return response + "No recent trades found."; - } - - data.trades.forEach((trade, index) => { - response += `${index + 1}. ${formatTrade(trade)}\n\n`; - }); - - if (data.totalCount > data.trades.length) { - response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; - } - - return response; -}; - -const formatMultipleTokenTradeResponse = ( - data: MultiTokenTradeData, - chain: string -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - let response = `Recent Trades on ${chainName}:\n\n`; - - const tokens = Object.entries(data); - if (tokens.length === 0) { - return response + "No trades found for any token."; - } - - tokens.forEach(([address, tokenData]) => { - response += `${tokenData.token} (${shortenAddress(address)}):\n`; - - if (tokenData.trades.length === 0) { - response += "No recent trades\n\n"; - return; - } - - tokenData.trades.forEach((trade, index) => { - response += `${index + 1}. ${formatTrade(trade)}\n`; - }); - - if (tokenData.totalCount > tokenData.trades.length) { - response += `Showing ${tokenData.trades.length} of ${tokenData.totalCount} trades\n`; - } - response += "\n"; - }); - - return response; -}; - -export const tokenTradeProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsTradeKeyword(messageText)) { - return null; - } - - const addresses = extractContractAddresses(messageText); - if (addresses.length === 0) { - return null; - } - - const chain = extractChain(messageText); - - if (addresses.length === 1) { - elizaLogger.info( - `TOKEN TRADE provider activated for address ${addresses[0]} on ${chain}` - ); - - const tradeData = await getSingleTokenTrades( - apiKey, - addresses[0], - chain - ); - - if (!tradeData) { - return null; - } - - return formatSingleTokenTradeResponse(tradeData, chain); - } else { - elizaLogger.info( - `MULTIPLE TOKEN TRADE provider activated for ${addresses.length} addresses on ${chain}` - ); - - const tradeData = await getMultipleTokenTrades( - apiKey, - addresses, - chain - ); - - if (!tradeData) { - return null; - } - - return formatMultipleTokenTradeResponse(tradeData, chain); - } - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/top-traders-provider.ts b/packages/plugin-birdeye/src/providers/token/top-traders-provider.ts deleted file mode 100644 index ec8c9824e8..0000000000 --- a/packages/plugin-birdeye/src/providers/token/top-traders-provider.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { BASE_URL, Chain, makeApiRequest } from "../utils"; - -// Types -interface Trader { - address: string; - tradeCount: number; - volume: number; - profit: number; - lastTradeTime: number; -} - -interface TopTradersResponse { - traders: Trader[]; -} - -// Constants -const TOP_TRADERS_KEYWORDS = [ - "top traders", - "best traders", - "leading traders", - "most successful traders", - "highest volume traders", -] as const; - -// Helper functions -const containsTopTradersKeyword = (text: string): boolean => { - return TOP_TRADERS_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getTopTraders = async ( - apiKey: string, - chain: Chain = "solana" -): Promise => { - try { - const url = `${BASE_URL}/token/top_traders`; - - elizaLogger.info("Fetching top traders from:", url); - - return await makeApiRequest(url, { apiKey, chain }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching top traders:", error.message); - } - return null; - } -}; - -const formatTopTradersResponse = (data: TopTradersResponse): string => { - let response = "šŸ† Top Traders\n\n"; - - data.traders.forEach((trader, index) => { - const lastTradeDate = new Date( - trader.lastTradeTime * 1000 - ).toLocaleString(); - const profitPrefix = trader.profit >= 0 ? "+" : ""; - - response += `${index + 1}. Trader ${trader.address.slice(0, 8)}...${trader.address.slice(-6)}\n`; - response += `ā€¢ Trade Count: ${trader.tradeCount.toLocaleString()}\n`; - response += `ā€¢ Volume: $${trader.volume.toLocaleString()}\n`; - response += `ā€¢ Profit: ${profitPrefix}$${trader.profit.toLocaleString()}\n`; - response += `ā€¢ Last Trade: ${lastTradeDate}\n\n`; - }); - - return response.trim(); -}; - -export const topTradersProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsTopTradersKeyword(messageText)) { - return null; - } - - elizaLogger.info("TOP_TRADERS provider activated"); - - const tradersData = await getTopTraders(apiKey); - - if (!tradersData) { - return null; - } - - return formatTopTradersResponse(tradersData); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/token/trending-tokens-provider.ts b/packages/plugin-birdeye/src/providers/token/trending-tokens-provider.ts deleted file mode 100644 index afe3134653..0000000000 --- a/packages/plugin-birdeye/src/providers/token/trending-tokens-provider.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -interface TrendingToken { - address: string; - name: string; - symbol: string; - decimals: number; - volume24hUSD: number; - liquidity: number; - logoURI: string; - price: number; -} - -const TRENDING_KEYWORDS = [ - "trending", - "popular", - "hot", - "top", - "performing", - "movers", - "gainers", - "volume", - "liquidity", - "market cap", - "price action", -]; - -const TOKEN_KEYWORDS = [ - "token", - "tokens", - "coin", - "coins", - "crypto", - "cryptocurrency", - "asset", - "assets", - "sol", - "solana", -]; - -const ASCENDING_KEYWORDS = [ - "lowest", - "worst", - "bottom", - "least", - "smallest", - "weakest", -]; - -const PAGINATION_KEYWORDS = [ - "more", - "additional", - "next", - "other", - "show more", - "continue", -]; - -const SUPPORTED_CHAINS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -]; - -const BASE_URL = "https://public-api.birdeye.so"; - -interface GetTrendingTokensOptions { - sort_by?: "volume24hUSD" | "rank" | "liquidity"; - sort_type?: "desc" | "asc"; - offset?: number; - limit?: number; - min_liquidity?: number; - chain?: string; -} - -const getTrendingTokens = async ( - apiKey: string, - options: GetTrendingTokensOptions = {} -): Promise => { - try { - const { - sort_by = "volume24hUSD", - sort_type = "desc", - offset = 0, - limit = 10, - min_liquidity = 1000, - chain = "solana", - } = options; - - const params = new URLSearchParams({ - sort_by, - sort_type, - offset: offset.toString(), - limit: limit.toString(), - min_liquidity: min_liquidity.toString(), - }); - - const url = `${BASE_URL}/defi/token_trending?${params.toString()}`; - elizaLogger.info("Fetching trending tokens from:", url); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return (await response.json()).data.tokens; - } catch (error) { - elizaLogger.error("Error fetching trending tokens:", error); - throw error; - } -}; - -const formatTrendingTokensToString = ( - tokens: TrendingToken[], - chain: string -): string => { - if (!tokens.length) { - return "No trending tokens found."; - } - - const formattedTokens = tokens - .map((token, index) => { - const priceFormatted = - token.price != null - ? token.price < 0.01 - ? token.price.toExponential(2) - : token.price.toFixed(2) - : "N/A"; - - const volume = - token.volume24hUSD != null - ? `$${(token.volume24hUSD / 1_000_000).toFixed(2)}M` - : "N/A"; - - const liquidity = - token.liquidity != null - ? `$${(token.liquidity / 1_000_000).toFixed(2)}M` - : "N/A"; - - return ( - `${index + 1}. ${token.name || "Unknown"} (${token.symbol || "N/A"}):\n` + - ` Price: ${priceFormatted}\n` + - ` Volume 24h: ${volume}\n` + - ` Liquidity: ${liquidity}` - ); - }) - .join("\n\n"); - - return `Here are the trending tokens on ${chain.charAt(0).toUpperCase() + chain.slice(1)}:\n\n${formattedTokens}`; -}; - -export const trendingTokensProvider: Provider = { - get: async (runtime: IAgentRuntime, message: Memory, state?: State) => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - return null; - } - - const messageText = message.content.text.toLowerCase(); - - // Check if message contains trending-related keywords - const hasTrendingKeyword = TRENDING_KEYWORDS.some((keyword) => - messageText.includes(keyword) - ); - - // Check if message contains token-related keywords - const hasTokenKeyword = TOKEN_KEYWORDS.some((keyword) => - messageText.includes(keyword) - ); - - // Check if the message is a direct question about trends - const isQuestionAboutTrends = - messageText.includes("?") && - (messageText.includes("what") || - messageText.includes("which") || - messageText.includes("show")) && - hasTrendingKeyword; - - // Check recent conversation context from state - const recentMessages = (state?.recentMessagesData || []) as Memory[]; - const isInTrendingConversation = recentMessages.some( - (msg) => - msg.content?.text?.toLowerCase().includes("trending") || - msg.content?.text?.toLowerCase().includes("token") - ); - - // Determine sorting direction based on keywords - const isAscending = ASCENDING_KEYWORDS.some((keyword) => - messageText.includes(keyword) - ); - const sortType = isAscending ? "asc" : "desc"; - - // Determine if this is a pagination request - const isPaginationRequest = PAGINATION_KEYWORDS.some((keyword) => - messageText.includes(keyword) - ); - - // Get the current offset from state or default to 0 - const currentOffset = (state?.trendingTokensOffset as number) || 0; - const offset = isPaginationRequest ? currentOffset + 10 : 0; - - // Determine sort criteria based on message content - let sortBy: "volume24hUSD" | "rank" | "liquidity" = "volume24hUSD"; - if (messageText.includes("liquidity")) { - sortBy = "liquidity"; - } else if (messageText.includes("rank")) { - sortBy = "rank"; - } - - // Determine which chain is being asked about - const requestedChain = - SUPPORTED_CHAINS.find((chain) => - messageText.includes(chain.toLowerCase()) - ) || "solana"; - - // Combine signals to make decision - const shouldProvideData = - // Direct questions about trends - isQuestionAboutTrends || - // Explicit mentions of trending tokens - (hasTrendingKeyword && hasTokenKeyword) || - // Follow-up in a trending conversation - (isInTrendingConversation && hasTokenKeyword) || - // Pagination request in conversation context - (isPaginationRequest && isInTrendingConversation); - - if (!shouldProvideData) { - return null; - } - - elizaLogger.info( - `TRENDING TOKENS provider activated for ${requestedChain} trending tokens query` - ); - - const trendingTokens = await getTrendingTokens(apiKey, { - sort_by: sortBy, - sort_type: sortType, - offset, - limit: 10, - min_liquidity: 1000, - chain: requestedChain, - }); - - const formattedTrending = formatTrendingTokensToString( - trendingTokens, - requestedChain - ); - - return formattedTrending; - }, -}; diff --git a/packages/plugin-birdeye/src/providers/trader/gainers-losers-provider.ts b/packages/plugin-birdeye/src/providers/trader/gainers-losers-provider.ts deleted file mode 100644 index c9998243b1..0000000000 --- a/packages/plugin-birdeye/src/providers/trader/gainers-losers-provider.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -// Types -interface TokenMarketData { - address: string; - symbol: string; - name: string; - price: number; - priceChange24h: number; - priceChange24hPercent: number; - volume24h: number; - marketCap: number; - liquidity: number; - logoURI?: string; -} - -interface GainersLosersData { - gainers: TokenMarketData[]; - losers: TokenMarketData[]; - timestamp: number; -} - -// Constants -const GAINERS_KEYWORDS = [ - "gainers", - "top gainers", - "best performing", - "biggest gains", - "movers up", - "green", - "pumping", - "rising", -] as const; - -const LOSERS_KEYWORDS = [ - "losers", - "top losers", - "worst performing", - "biggest losses", - "movers down", - "red", - "dumping", - "falling", -] as const; - -const CHAIN_KEYWORDS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -] as const; - -const BASE_URL = "https://public-api.birdeye.so"; - -// Helper functions -const containsGainersKeyword = (text: string): boolean => { - return GAINERS_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const containsLosersKeyword = (text: string): boolean => { - return LOSERS_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractChain = (text: string): string => { - const chain = CHAIN_KEYWORDS.find((chain) => - text.toLowerCase().includes(chain.toLowerCase()) - ); - return chain || "solana"; -}; - -const getGainersLosers = async ( - apiKey: string, - chain: string = "solana" -): Promise => { - try { - const params = new URLSearchParams({ - limit: "10", // Get top 10 gainers and losers - }); - const url = `${BASE_URL}/trader/gainers-losers?${params.toString()}`; - - elizaLogger.info(`Fetching gainers/losers on ${chain} from:`, url); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching gainers/losers:", error); - return null; - } -}; - -const formatValue = (value: number): string => { - if (value >= 1_000_000_000) { - return `$${(value / 1_000_000_000).toFixed(2)}B`; - } - if (value >= 1_000_000) { - return `$${(value / 1_000_000).toFixed(2)}M`; - } - if (value >= 1_000) { - return `$${(value / 1_000).toFixed(2)}K`; - } - return `$${value.toFixed(2)}`; -}; - -const formatTokenData = (token: TokenMarketData): string => { - const priceFormatted = - token.price < 0.01 - ? token.price.toExponential(2) - : token.price.toFixed(2); - - return ( - `ā€¢ ${token.symbol} (${token.name})\n` + - ` Price: $${priceFormatted}\n` + - ` 24h Change: ${token.priceChange24hPercent.toFixed(2)}% (${formatValue(token.priceChange24h)})\n` + - ` Volume: ${formatValue(token.volume24h)}\n` + - ` Market Cap: ${formatValue(token.marketCap)}\n` + - ` Liquidity: ${formatValue(token.liquidity)}` - ); -}; - -const formatGainersLosersResponse = ( - data: GainersLosersData, - chain: string, - showGainers: boolean, - showLosers: boolean -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - let response = `Market Movers on ${chainName}\n`; - response += `Last Updated: ${new Date(data.timestamp * 1000).toLocaleString()}\n\n`; - - if (showGainers && Array.isArray(data.gainers) && data.gainers.length > 0) { - response += `šŸ“ˆ Top Gainers:\n`; - data.gainers.forEach((token, index) => { - response += `\n${index + 1}. ${formatTokenData(token)}\n`; - }); - } - - if (showLosers && Array.isArray(data.losers) && data.losers.length > 0) { - if ( - showGainers && - Array.isArray(data.gainers) && - data.gainers.length > 0 - ) - response += "\n"; - response += `šŸ“‰ Top Losers:\n`; - data.losers.forEach((token, index) => { - response += `\n${index + 1}. ${formatTokenData(token)}\n`; - }); - } - - if ( - (!data.gainers?.length && !data.losers?.length) || - (showGainers && !data.gainers?.length && !showLosers) || - (showLosers && !data.losers?.length && !showGainers) - ) { - response += "No market data available at this time."; - } - - return response; -}; - -export const gainersLosersProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - const showGainers = containsGainersKeyword(messageText); - const showLosers = containsLosersKeyword(messageText); - - // If neither gainers nor losers are specifically mentioned, show both - const showBoth = !showGainers && !showLosers; - - if (!showGainers && !showLosers && !showBoth) { - return null; - } - - const chain = extractChain(messageText); - - elizaLogger.info(`GAINERS/LOSERS provider activated for ${chain}`); - - const marketData = await getGainersLosers(apiKey, chain); - - if (!marketData) { - return null; - } - - return formatGainersLosersResponse( - marketData, - chain, - showGainers || showBoth, - showLosers || showBoth - ); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/trader/index.ts b/packages/plugin-birdeye/src/providers/trader/index.ts deleted file mode 100644 index b5889ef376..0000000000 --- a/packages/plugin-birdeye/src/providers/trader/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./gainers-losers-provider"; -export * from "./trades-seek-provider"; diff --git a/packages/plugin-birdeye/src/providers/trader/trades-seek-provider.ts b/packages/plugin-birdeye/src/providers/trader/trades-seek-provider.ts deleted file mode 100644 index 20db87c394..0000000000 --- a/packages/plugin-birdeye/src/providers/trader/trades-seek-provider.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -// Types -interface Trade { - timestamp: number; - token: string; - tokenAddress: string; - price: number; - volume: number; - side: "buy" | "sell"; - source: string; - txHash: string; - buyer?: string; - seller?: string; -} - -interface TradesResponse { - trades: Trade[]; - totalCount: number; -} - -// Constants -const TIME_SEEK_KEYWORDS = [ - "trades since", - "trades after", - "trades before", - "trades from", - "trades at", - "trading since", - "trading after", - "trading before", - "trading from", - "trading at", - "transactions since", - "transactions after", - "transactions before", - "transactions from", - "transactions at", -] as const; - -const TIME_UNITS = { - second: 1, - minute: 60, - hour: 3600, - day: 86400, - week: 604800, - month: 2592000, -} as const; - -const CHAIN_KEYWORDS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -] as const; - -const BASE_URL = "https://public-api.birdeye.so"; - -// Helper functions -const containsTimeSeekKeyword = (text: string): boolean => { - return TIME_SEEK_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractChain = (text: string): string => { - const chain = CHAIN_KEYWORDS.find((chain) => - text.toLowerCase().includes(chain.toLowerCase()) - ); - return chain || "solana"; -}; - -const extractTimeFromText = (text: string): number | null => { - // Try to find time expressions like "1 hour ago", "2 days ago", etc. - const timeRegex = /(\d+)\s*(second|minute|hour|day|week|month)s?\s*ago/i; - const match = text.match(timeRegex); - - if (match) { - const amount = parseInt(match[1]); - const unit = match[2].toLowerCase() as keyof typeof TIME_UNITS; - const now = Math.floor(Date.now() / 1000); - return now - amount * TIME_UNITS[unit]; - } - - // Try to find specific date/time - const dateMatch = text.match(/(\d{4}-\d{2}-\d{2}|\d{2}\/\d{2}\/\d{4})/); - if (dateMatch) { - const date = new Date(dateMatch[1]); - if (!isNaN(date.getTime())) { - return Math.floor(date.getTime() / 1000); - } - } - - return null; -}; - -const getTradesByTime = async ( - apiKey: string, - timestamp: number, - chain: string = "solana", - limit: number = 10 -): Promise => { - try { - const params = new URLSearchParams({ - timestamp: timestamp.toString(), - limit: limit.toString(), - }); - const url = `${BASE_URL}/trader/trades_seek_time?${params.toString()}`; - - elizaLogger.info( - `Fetching trades since ${new Date(timestamp * 1000).toLocaleString()} on ${chain} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching trades by time:", error); - return null; - } -}; - -const formatValue = (value: number): string => { - if (value >= 1_000_000_000) { - return `$${(value / 1_000_000_000).toFixed(2)}B`; - } - if (value >= 1_000_000) { - return `$${(value / 1_000_000).toFixed(2)}M`; - } - if (value >= 1_000) { - return `$${(value / 1_000).toFixed(2)}K`; - } - return `$${value.toFixed(2)}`; -}; - -const shortenAddress = (address: string): string => { - if (!address || address.length <= 12) return address || "Unknown"; - return `${address.slice(0, 6)}...${address.slice(-4)}`; -}; - -const formatTrade = (trade: Trade): string => { - const timestamp = new Date(trade.timestamp * 1000).toLocaleString(); - const priceFormatted = - trade.price < 0.01 - ? trade.price.toExponential(2) - : trade.price.toFixed(2); - const side = trade.side === "buy" ? "šŸŸ¢ Buy" : "šŸ”“ Sell"; - - let response = `${side} ${trade.token} - ${timestamp}\n`; - response += `ā€¢ Token: ${shortenAddress(trade.tokenAddress)}\n`; - response += `ā€¢ Price: $${priceFormatted}\n`; - response += `ā€¢ Volume: ${formatValue(trade.volume)}\n`; - response += `ā€¢ Source: ${trade.source}\n`; - if (trade.buyer && trade.seller) { - response += `ā€¢ Buyer: ${shortenAddress(trade.buyer)}\n`; - response += `ā€¢ Seller: ${shortenAddress(trade.seller)}\n`; - } - response += `ā€¢ Tx: ${shortenAddress(trade.txHash)}`; - - return response; -}; - -const formatTradesResponse = ( - data: TradesResponse, - timestamp: number, - chain: string -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - const fromTime = new Date(timestamp * 1000).toLocaleString(); - let response = `Trades on ${chainName} since ${fromTime}:\n\n`; - - if (data.trades.length === 0) { - return response + "No trades found in this time period."; - } - - data.trades.forEach((trade, index) => { - response += `${index + 1}. ${formatTrade(trade)}\n\n`; - }); - - if (data.totalCount > data.trades.length) { - response += `Showing ${data.trades.length} of ${data.totalCount} total trades.`; - } - - return response; -}; - -export const tradesSeekProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsTimeSeekKeyword(messageText)) { - return null; - } - - const timestamp = extractTimeFromText(messageText); - if (!timestamp) { - return null; - } - - const chain = extractChain(messageText); - - elizaLogger.info( - `TRADES SEEK provider activated for time ${new Date(timestamp * 1000).toLocaleString()} on ${chain}` - ); - - const tradesData = await getTradesByTime(apiKey, timestamp, chain); - - if (!tradesData) { - return null; - } - - return formatTradesResponse(tradesData, timestamp, chain); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/utils.ts b/packages/plugin-birdeye/src/providers/utils.ts deleted file mode 100644 index 40db78f7a7..0000000000 --- a/packages/plugin-birdeye/src/providers/utils.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { elizaLogger } from "@elizaos/core"; - -// Constants -export const BASE_URL = "https://public-api.birdeye.so"; - -export const CHAIN_KEYWORDS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -] as const; - -// Types -export type Chain = (typeof CHAIN_KEYWORDS)[number]; - -export class BirdeyeApiError extends Error { - constructor( - public status: number, - message: string - ) { - super(message); - this.name = "BirdeyeApiError"; - } -} - -export interface ApiResponse { - success: boolean; - data: T; - error?: string; -} - -// Time-related types and constants -export const TIME_UNITS = { - second: 1, - minute: 60, - hour: 3600, - day: 86400, - week: 604800, - month: 2592000, -} as const; - -export const TIMEFRAME_KEYWORDS = { - "1m": 60, - "3m": 180, - "5m": 300, - "15m": 900, - "30m": 1800, - "1h": 3600, - "2h": 7200, - "4h": 14400, - "6h": 21600, - "12h": 43200, - "1d": 86400, - "1w": 604800, -} as const; - -export type TimeUnit = keyof typeof TIME_UNITS; -export type Timeframe = keyof typeof TIMEFRAME_KEYWORDS; - -// Helper functions -export const extractChain = (text: string): Chain => { - const chain = CHAIN_KEYWORDS.find((chain) => - text.toLowerCase().includes(chain.toLowerCase()) - ); - return (chain || "solana") as Chain; -}; - -export const extractContractAddresses = (text: string): string[] => { - const words = text.split(/\s+/); - const addresses: string[] = []; - - for (const word of words) { - // Ethereum-like addresses (0x...) - for Ethereum, Arbitrum, Avalanche, BSC, Optimism, Polygon, Base, zkSync - if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { - addresses.push(word); - } - // Solana addresses (base58, typically 32-44 chars) - else if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { - addresses.push(word); - } - // Sui addresses - both formats: - // 1. Simple object ID: 0x followed by 64 hex chars - // 2. Full token format: 0x:::: - else if ( - /^0x[a-fA-F0-9]{64}$/i.test(word) || - /^0x[a-fA-F0-9]{64}::[a-zA-Z0-9_]+::[a-zA-Z0-9_]+$/i.test(word) - ) { - addresses.push(word); - } - } - return addresses; -}; - -// Time extraction and analysis -export const extractTimeframe = (text: string): Timeframe => { - // First, check for explicit timeframe mentions - const timeframe = Object.keys(TIMEFRAME_KEYWORDS).find((tf) => - text.toLowerCase().includes(tf.toLowerCase()) - ); - if (timeframe) return timeframe as Timeframe; - - // Check for semantic timeframe hints - const semanticMap = { - "short term": "15m", - "medium term": "1h", - "long term": "1d", - intraday: "1h", - daily: "1d", - weekly: "1w", - detailed: "5m", - quick: "15m", - overview: "1d", - } as const; - - for (const [hint, tf] of Object.entries(semanticMap)) { - if (text.toLowerCase().includes(hint)) { - return tf as Timeframe; - } - } - - // Analyze for time-related words - if (text.match(/minute|min|minutes/i)) return "15m"; - if (text.match(/hour|hourly|hours/i)) return "1h"; - if (text.match(/day|daily|24h/i)) return "1d"; - if (text.match(/week|weekly/i)) return "1w"; - - // Default based on context - if (text.match(/trade|trades|trading|recent/i)) return "15m"; - if (text.match(/trend|analysis|analyze/i)) return "1h"; - if (text.match(/history|historical|long|performance/i)) return "1d"; - - return "1h"; // Default timeframe -}; - -export const extractTimeRange = ( - text: string -): { start: number; end: number } => { - const now = Math.floor(Date.now() / 1000); - - // Check for specific date ranges - const dateRangeMatch = text.match( - /from\s+(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})/i - ); - if (dateRangeMatch) { - const start = new Date(dateRangeMatch[1]).getTime() / 1000; - const end = new Date(dateRangeMatch[2]).getTime() / 1000; - return { start, end }; - } - - // Check for relative time expressions - const timeRegex = /(\d+)\s*(second|minute|hour|day|week|month)s?\s*ago/i; - const match = text.match(timeRegex); - if (match) { - const amount = parseInt(match[1]); - const unit = match[2].toLowerCase() as TimeUnit; - const start = now - amount * TIME_UNITS[unit]; - return { start, end: now }; - } - - // Check for semantic time ranges - const semanticRanges: Record = { - today: TIME_UNITS.day, - "this week": TIME_UNITS.week, - "this month": TIME_UNITS.month, - recent: TIME_UNITS.hour * 4, - latest: TIME_UNITS.hour, - "last hour": TIME_UNITS.hour, - "last day": TIME_UNITS.day, - "last week": TIME_UNITS.week, - "last month": TIME_UNITS.month, - }; - - for (const [range, duration] of Object.entries(semanticRanges)) { - if (text.toLowerCase().includes(range)) { - return { start: now - duration, end: now }; - } - } - - // Analyze context for appropriate default range - if (text.match(/trend|analysis|performance/i)) { - return { start: now - TIME_UNITS.week, end: now }; // 1 week for analysis - } - if (text.match(/trade|trades|trading|recent/i)) { - return { start: now - TIME_UNITS.day, end: now }; // 1 day for trading - } - if (text.match(/history|historical|long term/i)) { - return { start: now - TIME_UNITS.month, end: now }; // 1 month for history - } - - // Default to last 24 hours - return { start: now - TIME_UNITS.day, end: now }; -}; - -export const extractLimit = (text: string): number => { - // Check for explicit limit mentions - const limitMatch = text.match( - /\b(show|display|get|fetch|limit)\s+(\d+)\b/i - ); - if (limitMatch) { - const limit = parseInt(limitMatch[2]); - return Math.min(Math.max(limit, 1), 100); // Clamp between 1 and 100 - } - - // Check for semantic limit hints - if (text.match(/\b(all|everything|full|complete)\b/i)) return 100; - if (text.match(/\b(brief|quick|summary|overview)\b/i)) return 5; - if (text.match(/\b(detailed|comprehensive)\b/i)) return 50; - - // Default based on context - if (text.match(/\b(trade|trades|trading)\b/i)) return 10; - if (text.match(/\b(analysis|analyze|trend)\b/i)) return 24; - if (text.match(/\b(history|historical)\b/i)) return 50; - - return 10; // Default limit -}; - -// Formatting helpers -export const formatValue = (value: number): string => { - if (value >= 1_000_000_000) { - return `$${(value / 1_000_000_000).toFixed(2)}B`; - } - if (value >= 1_000_000) { - return `$${(value / 1_000_000).toFixed(2)}M`; - } - if (value >= 1_000) { - return `$${(value / 1_000).toFixed(2)}K`; - } - return `$${value.toFixed(2)}`; -}; - -export const formatPercentChange = (change?: number): string => { - if (change === undefined) return "N/A"; - const symbol = change >= 0 ? "šŸ“ˆ" : "šŸ“‰"; - return `${symbol} ${Math.abs(change).toFixed(2)}%`; -}; - -export const shortenAddress = (address: string): string => { - if (!address || address.length <= 12) return address || "Unknown"; - return `${address.slice(0, 6)}...${address.slice(-4)}`; -}; - -export const formatTimestamp = (timestamp: number): string => { - return new Date(timestamp * 1000).toLocaleString(); -}; - -export const formatPrice = (price: number): string => { - return price < 0.01 ? price.toExponential(2) : price.toFixed(2); -}; - -// API helpers -export async function makeApiRequest( - url: string, - options: { - apiKey: string; - chain?: Chain; - method?: "GET" | "POST"; - body?: any; - } -): Promise { - const { apiKey, chain = "solana", method = "GET", body } = options; - - try { - const response = await fetch(url, { - method, - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - ...(body && { "Content-Type": "application/json" }), - }, - ...(body && { body: JSON.stringify(body) }), - }); - - if (!response.ok) { - if (response.status === 404) { - throw new BirdeyeApiError(404, "Resource not found"); - } - if (response.status === 429) { - throw new BirdeyeApiError(429, "Rate limit exceeded"); - } - throw new BirdeyeApiError( - response.status, - `HTTP error! status: ${response.status}` - ); - } - - const responseJson: T = await response.json(); - - return responseJson; - } catch (error) { - if (error instanceof BirdeyeApiError) { - elizaLogger.error(`API Error (${error.status}):`, error.message); - } else { - elizaLogger.error("Error making API request:", error); - } - throw error; - } -} diff --git a/packages/plugin-birdeye/src/providers/wallet/index.ts b/packages/plugin-birdeye/src/providers/wallet/index.ts deleted file mode 100644 index 7cc0f1aa25..0000000000 --- a/packages/plugin-birdeye/src/providers/wallet/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./portfolio-multichain-provider"; -export * from "./supported-networks-provider"; -export * from "./token-balance-provider"; -export * from "./transaction-history-multichain-provider"; -export * from "./transaction-history-provider"; -export * from "./wallet-portfolio-provider"; diff --git a/packages/plugin-birdeye/src/providers/wallet/portfolio-multichain-provider.ts b/packages/plugin-birdeye/src/providers/wallet/portfolio-multichain-provider.ts deleted file mode 100644 index b9058ac7e3..0000000000 --- a/packages/plugin-birdeye/src/providers/wallet/portfolio-multichain-provider.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { BASE_URL, Chain, makeApiRequest } from "../utils"; - -// Types -interface TokenHolding { - chain: Chain; - tokenAddress: string; - symbol: string; - name: string; - balance: number; - price: number; - value: number; - priceChange24h: number; -} - -interface MultichainPortfolioResponse { - holdings: TokenHolding[]; - totalValue: number; - valueChange24h: number; -} - -// Constants -const MULTICHAIN_PORTFOLIO_KEYWORDS = [ - "multichain portfolio", - "cross chain portfolio", - "all chain portfolio", - "portfolio across chains", - "portfolio on all chains", -] as const; - -// Helper functions -const containsMultichainPortfolioKeyword = (text: string): boolean => { - return MULTICHAIN_PORTFOLIO_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractWalletAddress = (text: string): string | null => { - // Look for wallet address patterns - const addressMatch = text.match(/\b[1-9A-HJ-NP-Za-km-z]{32,44}\b/); - return addressMatch ? addressMatch[0] : null; -}; - -const getMultichainPortfolio = async ( - apiKey: string, - walletAddress: string -): Promise => { - try { - const url = `${BASE_URL}/wallet/portfolio_multichain`; - - elizaLogger.info("Fetching multichain portfolio from:", url); - - return await makeApiRequest(url, { - apiKey, - chain: "solana", - body: { wallet: walletAddress }, - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error( - "Error fetching multichain portfolio:", - error.message - ); - } - return null; - } -}; - -const formatMultichainPortfolioResponse = ( - data: MultichainPortfolioResponse -): string => { - let response = "šŸŒ Multichain Portfolio Overview\n\n"; - - // Add total portfolio value and 24h change - const valueChangePercent = (data.valueChange24h * 100).toFixed(2); - const valueChangeEmoji = data.valueChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; - - response += `Total Portfolio Value: $${data.totalValue.toLocaleString()}\n`; - response += `24h Change: ${valueChangePercent}% ${valueChangeEmoji}\n\n`; - - // Group holdings by chain - const holdingsByChain = data.holdings.reduce( - (acc, holding) => { - if (!acc[holding.chain]) { - acc[holding.chain] = []; - } - acc[holding.chain].push(holding); - return acc; - }, - {} as Record - ); - - // Format holdings by chain - Object.entries(holdingsByChain).forEach(([chain, holdings]) => { - response += `${chain.toUpperCase()} Holdings\n`; - - // Sort holdings by value - holdings.sort((a, b) => b.value - a.value); - - holdings.forEach((holding) => { - const priceChangePercent = (holding.priceChange24h * 100).toFixed( - 2 - ); - const priceChangeEmoji = holding.priceChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; - - response += `ā€¢ ${holding.name} (${holding.symbol})\n`; - response += ` - Balance: ${holding.balance.toLocaleString()}\n`; - response += ` - Price: $${holding.price.toFixed(6)}\n`; - response += ` - Value: $${holding.value.toLocaleString()}\n`; - response += ` - 24h Change: ${priceChangePercent}% ${priceChangeEmoji}\n\n`; - }); - }); - - return response.trim(); -}; - -export const portfolioMultichainProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsMultichainPortfolioKeyword(messageText)) { - return null; - } - - const walletAddress = extractWalletAddress(messageText); - if (!walletAddress) { - return "Please provide a valid wallet address to check the multichain portfolio."; - } - - elizaLogger.info("PORTFOLIO_MULTICHAIN provider activated"); - - const portfolioData = await getMultichainPortfolio( - apiKey, - walletAddress - ); - - if (!portfolioData) { - return null; - } - - return formatMultichainPortfolioResponse(portfolioData); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/wallet/supported-networks-provider.ts b/packages/plugin-birdeye/src/providers/wallet/supported-networks-provider.ts deleted file mode 100644 index 26b6ca6bbb..0000000000 --- a/packages/plugin-birdeye/src/providers/wallet/supported-networks-provider.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { BASE_URL, Chain, makeApiRequest } from "../utils"; - -// Types -interface NetworkSupport { - chain: Chain; - status: "active" | "maintenance" | "deprecated"; - features: string[]; -} - -interface SupportedNetworksResponse { - networks: NetworkSupport[]; -} - -// Constants -const SUPPORTED_NETWORKS_KEYWORDS = [ - "supported wallet networks", - "wallet networks", - "wallet chains", - "supported wallet chains", - "wallet network support", -] as const; - -// Helper functions -const containsSupportedNetworksKeyword = (text: string): boolean => { - return SUPPORTED_NETWORKS_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const getSupportedNetworks = async ( - apiKey: string -): Promise => { - try { - const url = `${BASE_URL}/wallet/supported_networks`; - - elizaLogger.info("Fetching supported wallet networks from:", url); - - return await makeApiRequest(url, { - apiKey, - chain: "solana", - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error( - "Error fetching supported networks:", - error.message - ); - } - return null; - } -}; - -const formatSupportedNetworksResponse = ( - data: SupportedNetworksResponse -): string => { - let response = "šŸŒ Supported Wallet Networks\n\n"; - - // Group networks by status - const activeNetworks = data.networks.filter((n) => n.status === "active"); - const maintenanceNetworks = data.networks.filter( - (n) => n.status === "maintenance" - ); - const deprecatedNetworks = data.networks.filter( - (n) => n.status === "deprecated" - ); - - // Format active networks - if (activeNetworks.length > 0) { - response += "šŸŸ¢ Active Networks\n"; - activeNetworks.forEach((network) => { - response += `ā€¢ ${network.chain}\n`; - response += ` - Features: ${network.features.join(", ")}\n\n`; - }); - } - - // Format maintenance networks - if (maintenanceNetworks.length > 0) { - response += "šŸŸ” Networks Under Maintenance\n"; - maintenanceNetworks.forEach((network) => { - response += `ā€¢ ${network.chain}\n`; - response += ` - Features: ${network.features.join(", ")}\n\n`; - }); - } - - // Format deprecated networks - if (deprecatedNetworks.length > 0) { - response += "šŸ”“ Deprecated Networks\n"; - deprecatedNetworks.forEach((network) => { - response += `ā€¢ ${network.chain}\n\n`; - }); - } - - return response.trim(); -}; - -export const supportedNetworksProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsSupportedNetworksKeyword(messageText)) { - return null; - } - - elizaLogger.info("SUPPORTED_NETWORKS provider activated"); - - const networksData = await getSupportedNetworks(apiKey); - - if (!networksData) { - return null; - } - - return formatSupportedNetworksResponse(networksData); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/wallet/token-balance-provider.ts b/packages/plugin-birdeye/src/providers/wallet/token-balance-provider.ts deleted file mode 100644 index 6e82646c45..0000000000 --- a/packages/plugin-birdeye/src/providers/wallet/token-balance-provider.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { BASE_URL, Chain, makeApiRequest } from "../utils"; - -// Types -interface TokenBalance { - tokenAddress: string; - symbol: string; - name: string; - balance: number; - decimals: number; - price: number; - value: number; - priceChange24h: number; -} - -interface TokenBalanceResponse { - balances: TokenBalance[]; - totalValue: number; - valueChange24h: number; -} - -// Constants -const TOKEN_BALANCE_KEYWORDS = [ - "token balance", - "token holdings", - "wallet balance", - "wallet holdings", - "check balance", - "check holdings", -] as const; - -// Helper functions -const containsTokenBalanceKeyword = (text: string): boolean => { - return TOKEN_BALANCE_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractWalletAddress = (text: string): string | null => { - // Look for wallet address patterns - const addressMatch = text.match(/\b[1-9A-HJ-NP-Za-km-z]{32,44}\b/); - return addressMatch ? addressMatch[0] : null; -}; - -const getTokenBalance = async ( - apiKey: string, - walletAddress: string, - chain: Chain = "solana" -): Promise => { - try { - const url = `${BASE_URL}/wallet/token_balance`; - - elizaLogger.info("Fetching token balance from:", url); - - return await makeApiRequest(url, { - apiKey, - chain, - body: { wallet: walletAddress }, - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error("Error fetching token balance:", error.message); - } - return null; - } -}; - -const formatTokenBalanceResponse = (data: TokenBalanceResponse): string => { - let response = "šŸ’° Token Balance Overview\n\n"; - - // Add total value and 24h change - const valueChangePercent = (data.valueChange24h * 100).toFixed(2); - const valueChangeEmoji = data.valueChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; - - response += `Total Value: $${data.totalValue.toLocaleString()}\n`; - response += `24h Change: ${valueChangePercent}% ${valueChangeEmoji}\n\n`; - - // Sort balances by value - const sortedBalances = [...data.balances].sort((a, b) => b.value - a.value); - - // Format individual token balances - sortedBalances.forEach((balance) => { - const priceChangePercent = (balance.priceChange24h * 100).toFixed(2); - const priceChangeEmoji = balance.priceChange24h >= 0 ? "šŸ“ˆ" : "šŸ“‰"; - - response += `${balance.name} (${balance.symbol})\n`; - response += `ā€¢ Balance: ${balance.balance.toLocaleString()}\n`; - response += `ā€¢ Price: $${balance.price.toFixed(6)}\n`; - response += `ā€¢ Value: $${balance.value.toLocaleString()}\n`; - response += `ā€¢ 24h Change: ${priceChangePercent}% ${priceChangeEmoji}\n\n`; - }); - - return response.trim(); -}; - -export const tokenBalanceProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsTokenBalanceKeyword(messageText)) { - return null; - } - - const walletAddress = extractWalletAddress(messageText); - if (!walletAddress) { - return "Please provide a valid wallet address to check the token balance."; - } - - elizaLogger.info("TOKEN_BALANCE provider activated"); - - const balanceData = await getTokenBalance(apiKey, walletAddress); - - if (!balanceData) { - return null; - } - - return formatTokenBalanceResponse(balanceData); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/wallet/transaction-history-multichain-provider.ts b/packages/plugin-birdeye/src/providers/wallet/transaction-history-multichain-provider.ts deleted file mode 100644 index 66a6c8cb3f..0000000000 --- a/packages/plugin-birdeye/src/providers/wallet/transaction-history-multichain-provider.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; -import { BASE_URL, Chain, makeApiRequest } from "../utils"; - -// Types -interface Transaction { - chain: Chain; - hash: string; - timestamp: number; - type: string; - status: "success" | "failed" | "pending"; - value: number; - fee: number; - from: string; - to: string; - tokenTransfers?: { - token: string; - amount: number; - value: number; - }[]; -} - -interface TransactionHistoryResponse { - transactions: Transaction[]; -} - -// Constants -const MULTICHAIN_HISTORY_KEYWORDS = [ - "multichain transactions", - "cross chain transactions", - "all chain transactions", - "transactions across chains", - "transaction history all chains", -] as const; - -// Helper functions -const containsMultichainHistoryKeyword = (text: string): boolean => { - return MULTICHAIN_HISTORY_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractWalletAddress = (text: string): string | null => { - // Look for wallet address patterns - const addressMatch = text.match(/\b[1-9A-HJ-NP-Za-km-z]{32,44}\b/); - return addressMatch ? addressMatch[0] : null; -}; - -const getTransactionHistory = async ( - apiKey: string, - walletAddress: string -): Promise => { - try { - const url = `${BASE_URL}/wallet/transaction_history_multichain`; - - elizaLogger.info("Fetching multichain transaction history from:", url); - - return await makeApiRequest(url, { - apiKey, - chain: "solana", - body: { wallet: walletAddress }, - }); - } catch (error) { - if (error instanceof Error) { - elizaLogger.error( - "Error fetching transaction history:", - error.message - ); - } - return null; - } -}; - -const formatTransactionStatus = (status: Transaction["status"]): string => { - switch (status) { - case "success": - return "āœ…"; - case "failed": - return "āŒ"; - case "pending": - return "ā³"; - default: - return "ā“"; - } -}; - -const formatTransactionHistoryResponse = ( - data: TransactionHistoryResponse -): string => { - let response = "šŸ“œ Multichain Transaction History\n\n"; - - // Group transactions by chain - const txsByChain = data.transactions.reduce( - (acc, tx) => { - if (!acc[tx.chain]) { - acc[tx.chain] = []; - } - acc[tx.chain].push(tx); - return acc; - }, - {} as Record - ); - - // Format transactions by chain - Object.entries(txsByChain).forEach(([chain, transactions]) => { - response += `${chain.toUpperCase()} Transactions\n`; - - // Sort transactions by timestamp (newest first) - transactions.sort((a, b) => b.timestamp - a.timestamp); - - transactions.forEach((tx) => { - const date = new Date(tx.timestamp * 1000).toLocaleString(); - const statusEmoji = formatTransactionStatus(tx.status); - - response += `${statusEmoji} ${tx.type} - ${date}\n`; - response += `ā€¢ Hash: ${tx.hash}\n`; - response += `ā€¢ Value: $${tx.value.toLocaleString()}\n`; - response += `ā€¢ Fee: $${tx.fee.toFixed(6)}\n`; - response += `ā€¢ From: ${tx.from}\n`; - response += `ā€¢ To: ${tx.to}\n`; - - if (tx.tokenTransfers && tx.tokenTransfers.length > 0) { - response += "ā€¢ Token Transfers:\n"; - tx.tokenTransfers.forEach((transfer) => { - response += ` - ${transfer.token}: ${transfer.amount} ($${transfer.value.toLocaleString()})\n`; - }); - } - - response += "\n"; - }); - }); - - return response.trim(); -}; - -export const transactionHistoryMultichainProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsMultichainHistoryKeyword(messageText)) { - return null; - } - - const walletAddress = extractWalletAddress(messageText); - if (!walletAddress) { - return "Please provide a valid wallet address to check the transaction history."; - } - - elizaLogger.info("TRANSACTION_HISTORY_MULTICHAIN provider activated"); - - const historyData = await getTransactionHistory(apiKey, walletAddress); - - if (!historyData) { - return null; - } - - return formatTransactionHistoryResponse(historyData); - }, -}; diff --git a/packages/plugin-birdeye/src/providers/wallet/transaction-history-provider.ts b/packages/plugin-birdeye/src/providers/wallet/transaction-history-provider.ts deleted file mode 100644 index a303e0fa10..0000000000 --- a/packages/plugin-birdeye/src/providers/wallet/transaction-history-provider.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -// Types -interface TokenTransfer { - token: string; - symbol: string; - amount: number; - value: number; - decimals: number; -} - -interface Transaction { - hash: string; - timestamp: number; - type: "send" | "receive" | "swap" | "mint" | "burn" | "other"; - from: string; - to: string; - value: number; - fee: number; - success: boolean; - transfers: TokenTransfer[]; -} - -interface TransactionHistory { - transactions: Transaction[]; - totalCount: number; -} - -interface MultichainTransactionHistory { - [chain: string]: TransactionHistory; -} - -// Constants -const TRANSACTION_KEYWORDS = [ - "transaction", - "transactions", - "history", - "transfers", - "activity", - "trades", - "swaps", - "sent", - "received", - "tx", - "txs", -] as const; - -const MULTICHAIN_KEYWORDS = [ - "all chains", - "multichain", - "multi-chain", - "cross chain", - "cross-chain", - "every chain", - "all networks", -] as const; - -const CHAIN_KEYWORDS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -] as const; - -const BASE_URL = "https://public-api.birdeye.so"; - -// Helper functions -const containsTransactionKeyword = (text: string): boolean => { - return TRANSACTION_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const isMultichainRequest = (text: string): boolean => { - return MULTICHAIN_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractChain = (text: string): string => { - const chain = CHAIN_KEYWORDS.find((chain) => - text.toLowerCase().includes(chain.toLowerCase()) - ); - return chain || "solana"; -}; - -const extractWalletAddress = (text: string): string | null => { - const words = text.split(/\s+/); - - for (const word of words) { - // Ethereum-like addresses (0x...) - if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { - return word; - } - // Solana addresses (base58, typically 32-44 chars) - if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { - return word; - } - } - return null; -}; - -const getTransactionHistory = async ( - apiKey: string, - walletAddress: string, - chain: string = "solana", - limit: number = 10 -): Promise => { - try { - const params = new URLSearchParams({ - wallet: walletAddress, - limit: limit.toString(), - }); - const url = `${BASE_URL}/wallet/transaction_history?${params.toString()}`; - - elizaLogger.info( - `Fetching transaction history for wallet ${walletAddress} on ${chain} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - elizaLogger.warn( - `Wallet not found: ${walletAddress} on ${chain}` - ); - return null; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching transaction history:", error); - return null; - } -}; - -const getMultichainTransactionHistory = async ( - apiKey: string, - walletAddress: string, - limit: number = 10 -): Promise => { - try { - const params = new URLSearchParams({ - wallet: walletAddress, - limit: limit.toString(), - }); - const url = `${BASE_URL}/wallet/transaction_history_multichain?${params.toString()}`; - - elizaLogger.info( - `Fetching multichain transaction history for wallet ${walletAddress} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - elizaLogger.warn(`Wallet not found: ${walletAddress}`); - return null; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error( - "Error fetching multichain transaction history:", - error - ); - return null; - } -}; - -const formatValue = (value: number): string => { - if (value >= 1_000_000_000) { - return `$${(value / 1_000_000_000).toFixed(2)}B`; - } - if (value >= 1_000_000) { - return `$${(value / 1_000_000).toFixed(2)}M`; - } - if (value >= 1_000) { - return `$${(value / 1_000).toFixed(2)}K`; - } - return `$${value.toFixed(2)}`; -}; - -const formatTokenAmount = (amount: number, decimals: number): string => { - const formattedAmount = amount / Math.pow(10, decimals); - if (formattedAmount >= 1_000_000) { - return `${(formattedAmount / 1_000_000).toFixed(2)}M`; - } - if (formattedAmount >= 1_000) { - return `${(formattedAmount / 1_000).toFixed(2)}K`; - } - return formattedAmount.toFixed(decimals > 6 ? 4 : 2); -}; - -const shortenAddress = (address: string): string => { - if (address.length <= 12) return address; - return `${address.slice(0, 6)}...${address.slice(-4)}`; -}; - -const formatTransactionType = (type: string): string => { - switch (type.toLowerCase()) { - case "send": - return "šŸ“¤ Sent"; - case "receive": - return "šŸ“„ Received"; - case "swap": - return "šŸ”„ Swapped"; - case "mint": - return "šŸŒŸ Minted"; - case "burn": - return "šŸ”„ Burned"; - default: - return "šŸ“ Other"; - } -}; - -const formatSingleChainHistory = ( - history: TransactionHistory, - chain: string -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - let response = `Transaction History on ${chainName}:\n\n`; - - if (history.transactions.length === 0) { - return response + "No transactions found."; - } - - history.transactions.forEach((tx, index) => { - const date = new Date(tx.timestamp * 1000).toLocaleString(); - response += `${index + 1}. ${formatTransactionType(tx.type)} - ${date}\n`; - response += `ā€¢ Hash: ${shortenAddress(tx.hash)}\n`; - response += `ā€¢ From: ${shortenAddress(tx.from)}\n`; - response += `ā€¢ To: ${shortenAddress(tx.to)}\n`; - response += `ā€¢ Value: ${formatValue(tx.value)}\n`; - response += `ā€¢ Fee: ${formatValue(tx.fee)}\n`; - response += `ā€¢ Status: ${tx.success ? "āœ… Success" : "āŒ Failed"}\n`; - - if (tx.transfers.length > 0) { - response += "ā€¢ Tokens:\n"; - tx.transfers.forEach((transfer) => { - const amount = formatTokenAmount( - transfer.amount, - transfer.decimals - ); - response += ` - ${amount} ${transfer.symbol} (${formatValue(transfer.value)})\n`; - }); - } - response += "\n"; - }); - - if (history.totalCount > history.transactions.length) { - response += `\nShowing ${history.transactions.length} of ${history.totalCount} total transactions.`; - } - - return response; -}; - -const formatMultichainHistory = ( - history: MultichainTransactionHistory -): string => { - let response = `Multichain Transaction History:\n\n`; - - const chains = Object.keys(history); - if (chains.length === 0) { - return response + "No transactions found on any chain."; - } - - chains.forEach((chain) => { - const chainData = history[chain]; - if (chainData.transactions.length > 0) { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - response += `${chainName} (${chainData.totalCount} total transactions):\n`; - - chainData.transactions - .slice(0, 5) // Show only the 5 most recent transactions per chain - .forEach((tx, index) => { - const date = new Date(tx.timestamp * 1000).toLocaleString(); - response += `${index + 1}. ${formatTransactionType(tx.type)} - ${date}\n`; - response += ` Value: ${formatValue(tx.value)} | Status: ${tx.success ? "āœ…" : "āŒ"}\n`; - }); - - if (chainData.transactions.length > 5) { - response += ` ... and ${chainData.totalCount - 5} more transactions\n`; - } - response += "\n"; - } - }); - - return response; -}; - -export const transactionHistoryProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsTransactionKeyword(messageText)) { - return null; - } - - const walletAddress = extractWalletAddress(messageText); - if (!walletAddress) { - return null; - } - - const isMultichain = isMultichainRequest(messageText); - - if (isMultichain) { - elizaLogger.info( - `MULTICHAIN TRANSACTION HISTORY provider activated for wallet ${walletAddress}` - ); - - const historyData = await getMultichainTransactionHistory( - apiKey, - walletAddress - ); - - if (!historyData) { - return null; - } - - return formatMultichainHistory(historyData); - } else { - const chain = extractChain(messageText); - - elizaLogger.info( - `TRANSACTION HISTORY provider activated for wallet ${walletAddress} on ${chain}` - ); - - const historyData = await getTransactionHistory( - apiKey, - walletAddress, - chain - ); - - if (!historyData) { - return null; - } - - return formatSingleChainHistory(historyData, chain); - } - }, -}; diff --git a/packages/plugin-birdeye/src/providers/wallet/wallet-portfolio-provider.ts b/packages/plugin-birdeye/src/providers/wallet/wallet-portfolio-provider.ts deleted file mode 100644 index 04adbfe98f..0000000000 --- a/packages/plugin-birdeye/src/providers/wallet/wallet-portfolio-provider.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { - IAgentRuntime, - Memory, - Provider, - State, - elizaLogger, -} from "@elizaos/core"; - -// Types -interface TokenBalance { - token: string; - symbol: string; - amount: number; - price: number; - value: number; - decimals: number; - logoURI?: string; -} - -interface PortfolioData { - totalValue: number; - tokens: TokenBalance[]; - lastUpdated: number; -} - -interface MultichainPortfolioData { - chains: Record; - totalValue: number; -} - -// Constants -const PORTFOLIO_KEYWORDS = [ - "portfolio", - "holdings", - "balance", - "assets", - "tokens", - "wallet", - "what do i own", - "what do i have", -] as const; - -const MULTICHAIN_KEYWORDS = [ - "all chains", - "multichain", - "multi-chain", - "cross chain", - "cross-chain", - "every chain", - "all networks", -] as const; - -const CHAIN_KEYWORDS = [ - "solana", - "ethereum", - "arbitrum", - "avalanche", - "bsc", - "optimism", - "polygon", - "base", - "zksync", - "sui", -] as const; - -const BASE_URL = "https://public-api.birdeye.so"; - -// Helper functions -const containsPortfolioKeyword = (text: string): boolean => { - return PORTFOLIO_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const isMultichainRequest = (text: string): boolean => { - return MULTICHAIN_KEYWORDS.some((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()) - ); -}; - -const extractChain = (text: string): string => { - const chain = CHAIN_KEYWORDS.find((chain) => - text.toLowerCase().includes(chain.toLowerCase()) - ); - return chain || "solana"; -}; - -const extractWalletAddress = (text: string): string | null => { - const words = text.split(/\s+/); - - for (const word of words) { - // Ethereum-like addresses (0x...) - if (/^0x[a-fA-F0-9]{40}$/i.test(word)) { - return word; - } - // Solana addresses (base58, typically 32-44 chars) - if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(word)) { - return word; - } - } - return null; -}; - -const getWalletPortfolio = async ( - apiKey: string, - walletAddress: string, - chain: string = "solana" -): Promise => { - try { - const params = new URLSearchParams({ - wallet: walletAddress, - }); - const url = `${BASE_URL}/wallet/portfolio?${params.toString()}`; - - elizaLogger.info( - `Fetching portfolio for wallet ${walletAddress} on ${chain} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - "x-chain": chain, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - elizaLogger.warn( - `Wallet not found: ${walletAddress} on ${chain}` - ); - return null; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data.data; - } catch (error) { - elizaLogger.error("Error fetching wallet portfolio:", error); - return null; - } -}; - -const getMultichainPortfolio = async ( - apiKey: string, - walletAddress: string -): Promise => { - try { - const params = new URLSearchParams({ - wallet: walletAddress, - }); - const url = `${BASE_URL}/wallet/portfolio_multichain?${params.toString()}`; - - elizaLogger.info( - `Fetching multichain portfolio for wallet ${walletAddress} from:`, - url - ); - - const response = await fetch(url, { - headers: { - "X-API-KEY": apiKey, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - elizaLogger.warn(`Wallet not found: ${walletAddress}`); - return null; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - // Transform the response to match our interface - const { totalValue, ...chains } = data.data; - return { - chains, - totalValue, - }; - } catch (error) { - elizaLogger.error("Error fetching multichain portfolio:", error); - return null; - } -}; - -const formatValue = (value: number): string => { - if (value >= 1_000_000_000) { - return `$${(value / 1_000_000_000).toFixed(2)}B`; - } - if (value >= 1_000_000) { - return `$${(value / 1_000_000).toFixed(2)}M`; - } - if (value >= 1_000) { - return `$${(value / 1_000).toFixed(2)}K`; - } - return `$${value.toFixed(2)}`; -}; - -const formatTokenAmount = (amount: number, decimals: number): string => { - const formattedAmount = amount / Math.pow(10, decimals); - if (formattedAmount >= 1_000_000) { - return `${(formattedAmount / 1_000_000).toFixed(2)}M`; - } - if (formattedAmount >= 1_000) { - return `${(formattedAmount / 1_000).toFixed(2)}K`; - } - return formattedAmount.toFixed(decimals > 6 ? 4 : 2); -}; - -const formatSingleChainPortfolio = ( - data: PortfolioData, - chain: string -): string => { - const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); - let response = `Portfolio on ${chainName}:\n\n`; - - response += `šŸ’° Total Value: ${formatValue(data.totalValue)}\n\n`; - - if (data.tokens.length === 0) { - response += "No tokens found in this wallet."; - return response; - } - - response += `Token Holdings:\n`; - data.tokens - .sort((a, b) => b.value - a.value) - .forEach((token) => { - const amount = formatTokenAmount(token.amount, token.decimals); - response += `ā€¢ ${token.symbol}: ${amount} (${formatValue(token.value)})\n`; - }); - - response += `\nLast Updated: ${new Date(data.lastUpdated * 1000).toLocaleString()}`; - return response; -}; - -const formatMultichainPortfolio = (data: MultichainPortfolioData): string => { - let response = `Multichain Portfolio Overview:\n\n`; - response += `šŸ’° Total Portfolio Value: ${formatValue(data.totalValue)}\n\n`; - - const chains = Object.keys(data.chains); - if (chains.length === 0) { - response += "No assets found across any chains."; - return response; - } - - chains - .sort((a, b) => data.chains[b].totalValue - data.chains[a].totalValue) - .forEach((chain) => { - const chainData = data.chains[chain]; - if (chainData.totalValue > 0) { - const chainName = - chain.charAt(0).toUpperCase() + chain.slice(1); - response += `${chainName} (${formatValue(chainData.totalValue)}):\n`; - chainData.tokens - .sort((a, b) => b.value - a.value) - .slice(0, 5) // Show top 5 tokens per chain - .forEach((token) => { - const amount = formatTokenAmount( - token.amount, - token.decimals - ); - response += `ā€¢ ${token.symbol}: ${amount} (${formatValue(token.value)})\n`; - }); - if (chainData.tokens.length > 5) { - response += ` ... and ${chainData.tokens.length - 5} more tokens\n`; - } - response += "\n"; - } - }); - - return response; -}; - -export const walletPortfolioProvider: Provider = { - get: async ( - runtime: IAgentRuntime, - message: Memory, - _state?: State - ): Promise => { - const apiKey = runtime.getSetting("BIRDEYE_API_KEY"); - if (!apiKey) { - elizaLogger.error("BIRDEYE_API_KEY not found in runtime settings"); - return null; - } - - const messageText = message.content.text; - - if (!containsPortfolioKeyword(messageText)) { - return null; - } - - const walletAddress = extractWalletAddress(messageText); - if (!walletAddress) { - return null; - } - - const isMultichain = isMultichainRequest(messageText); - - if (isMultichain) { - elizaLogger.info( - `MULTICHAIN PORTFOLIO provider activated for wallet ${walletAddress}` - ); - - const portfolioData = await getMultichainPortfolio( - apiKey, - walletAddress - ); - - if (!portfolioData) { - return null; - } - - return formatMultichainPortfolio(portfolioData); - } else { - const chain = extractChain(messageText); - - elizaLogger.info( - `PORTFOLIO provider activated for wallet ${walletAddress} on ${chain}` - ); - - const portfolioData = await getWalletPortfolio( - apiKey, - walletAddress, - chain - ); - - if (!portfolioData) { - return null; - } - - return formatSingleChainPortfolio(portfolioData, chain); - } - }, -}; diff --git a/packages/plugin-birdeye/src/services.ts b/packages/plugin-birdeye/src/services.ts new file mode 100644 index 0000000000..15b6decee3 --- /dev/null +++ b/packages/plugin-birdeye/src/services.ts @@ -0,0 +1,170 @@ +import { elizaLogger } from "@elizaos/core"; +import { + SearchToken, + SearchTokenResponse, + SearchTokensOptions, +} from "./types/search-token"; +import { BirdeyeChain } from "./types/shared"; +import { TokenMetadataResponse } from "./types/token-metadata"; +import { + WalletDataItem, + WalletDataOptions, + WalletDataResponse, +} from "./types/wallet"; +import { BASE_URL, makeApiRequest } from "./utils"; + +export const searchTokens = async ( + apiKey: string, + options: SearchTokensOptions +): Promise => { + try { + const { keyword, chain = "all", limit = 1, offset = 0, type } = options; + + const params = new URLSearchParams({ + keyword, + limit: limit.toString(), + offset: offset.toString(), + chain: chain, + }); + + const url = `${BASE_URL}/defi/v3/search?${params.toString()}`; + + elizaLogger.info("Searching tokens from:", url); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = (await response.json()) as SearchTokenResponse; + + elizaLogger.info("Birdeye response:", data); + + // Extract tokens from the response + // if the search type is address, we only want to return the token that matches the address + const tokens = + type === "address" + ? data.data.items + .filter( + (item) => + item.type === "token" && + item.result[0].address === keyword.toLowerCase() + ) + .flatMap((item) => item.result) + : data.data.items + .filter((item) => item.type === "token") + .flatMap((item) => item.result); + + elizaLogger.info("Found tokens:", tokens); + + return tokens; + } catch (error) { + elizaLogger.error("Error searching tokens:", error); + throw error; + } +}; + +export const searchWallets = async ( + apiKey: string, + options: WalletDataOptions +): Promise => { + try { + const { wallet, chain = "solana" } = options; + + const params = new URLSearchParams({ + wallet, + chain: chain, + }); + + const url = `${BASE_URL}/v1/wallet/token_list?${params.toString()}`; + + elizaLogger.info("Searching wallet data from:", url); + + const response = await fetch(url, { + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = (await response.json()) as WalletDataResponse; + + elizaLogger.info("Birdeye response:", data); + + // Extract tokens from the response + // if the search type is address, we only want to return the token that matches the address + const walletData = data.data.items; + + elizaLogger.info("Found wallet data:", walletData); + + return walletData; + } catch (error) { + elizaLogger.error("Error searching tokens:", error); + throw error; + } +}; + +export const getTokenMetadata = async ( + apiKey: string, + address: string, + chain: BirdeyeChain +): Promise => { + try { + // Validate address format based on chain + const isValidAddress = (() => { + switch (chain) { + case "solana": + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(address); + case "sui": + return /^0x[a-fA-F0-9]{64}$/i.test(address); + case "ethereum": + case "arbitrum": + case "avalanche": + case "bsc": + case "optimism": + case "polygon": + case "base": + case "zksync": + return /^0x[a-fA-F0-9]{40}$/i.test(address); + default: + return false; + } + })(); + + if (!isValidAddress) { + elizaLogger.error( + `Invalid address format for ${chain}: ${address}` + ); + return null; + } + + const params = new URLSearchParams({ + address: address, + }); + const url = `${BASE_URL}/defi/v3/token/meta-data/single?${params.toString()}`; + + elizaLogger.info( + `Fetching token metadata for ${address} on ${chain} from:`, + url + ); + + return await makeApiRequest(url, { + apiKey, + chain, + }); + } catch (error) { + if (error instanceof Error) { + elizaLogger.error("Error fetching token metadata:", error.message); + } + return null; + } +}; diff --git a/packages/plugin-birdeye/src/types/search-token.ts b/packages/plugin-birdeye/src/types/search-token.ts new file mode 100644 index 0000000000..10c150c1ec --- /dev/null +++ b/packages/plugin-birdeye/src/types/search-token.ts @@ -0,0 +1,43 @@ +export interface SearchToken { + address: string; + name: string; + symbol: string; + price: number; + fdv: number; + market_cap: number; + liquidity: number; + volume_24h_usd: number; + volume_24h_change_percent: number; + price_change_24h_percent: number; + network: string; + buy_24h: number; + buy_24h_change_percent: number; + sell_24h: number; + sell_24h_change_percent: number; + trade_24h: number; + trade_24h_change_percent: number; + unique_wallet_24h: number; + unique_view_24h_change_percent: number; + last_trade_human_time: string; + last_trade_unix_time: number; + logo_uri: string; + verified: boolean; +} + +export interface SearchTokenResponse { + data: { + items: Array<{ + type: string; + result: SearchToken[]; + }>; + }; + success: boolean; +} + +export interface SearchTokensOptions { + keyword: string; + chain?: string; + limit?: number; + offset?: number; + type?: "address" | "symbol"; +} diff --git a/packages/plugin-birdeye/src/types/shared.ts b/packages/plugin-birdeye/src/types/shared.ts new file mode 100644 index 0000000000..1106716e15 --- /dev/null +++ b/packages/plugin-birdeye/src/types/shared.ts @@ -0,0 +1,23 @@ +import { CHAIN_KEYWORDS } from "../utils"; + +// Types +export type BirdeyeChain = (typeof CHAIN_KEYWORDS)[number]; + +export interface BaseAddress { + type?: "wallet" | "token" | "contract"; + symbol?: string; + address: string; + chain: BirdeyeChain; +} + +export interface WalletAddress extends BaseAddress { + type: "wallet"; +} + +export interface TokenAddress extends BaseAddress { + type: "token"; +} + +export interface ContractAddress extends BaseAddress { + type: "contract"; +} diff --git a/packages/plugin-birdeye/src/types/token-metadata.ts b/packages/plugin-birdeye/src/types/token-metadata.ts new file mode 100644 index 0000000000..935837896d --- /dev/null +++ b/packages/plugin-birdeye/src/types/token-metadata.ts @@ -0,0 +1,18 @@ +// Define explicit interface instead of using typeof +export interface TokenMetadataResponse { + data: { + address: string; + symbol: string; + name: string; + decimals: number; + extensions: { + coingecko_id?: string; + website?: string; + twitter?: string; + discord?: string; + medium?: string; + }; + logo_uri?: string; + }; + success: boolean; +} diff --git a/packages/plugin-birdeye/src/types/wallet.ts b/packages/plugin-birdeye/src/types/wallet.ts new file mode 100644 index 0000000000..2d97d5a763 --- /dev/null +++ b/packages/plugin-birdeye/src/types/wallet.ts @@ -0,0 +1,24 @@ +export interface WalletDataResponse { + data: { + items: WalletDataItem[]; + }; + success: boolean; +} + +export interface WalletDataItem { + address: string; + name: string; + symbol: string; + decimals: number; + balance: string; + uiAmount: number; + chainId: string; + logoURI: string; + priceUsd: number; + valueUsd: number; +} + +export interface WalletDataOptions { + wallet: string; + chain?: string; +} diff --git a/packages/plugin-birdeye/src/utils.ts b/packages/plugin-birdeye/src/utils.ts new file mode 100644 index 0000000000..d93655cc93 --- /dev/null +++ b/packages/plugin-birdeye/src/utils.ts @@ -0,0 +1,546 @@ +import { elizaLogger } from "@elizaos/core"; +import { SearchToken } from "./types/search-token"; +import { BaseAddress, BirdeyeChain } from "./types/shared"; +import { TokenMetadataResponse } from "./types/token-metadata"; + +// Constants +export const BASE_URL = "https://public-api.birdeye.so"; + +export const CHAIN_KEYWORDS = [ + "solana", + "ethereum", + "arbitrum", + "avalanche", + "bsc", + "optimism", + "polygon", + "base", + "zksync", + "sui", + "solana", + "evm", // EVM-compatible chains but we don't know the chain +] as const; + +// Chain abbreviations and alternative names mapping +export const CHAIN_ALIASES: Record = { + // Solana + sol: "solana", + + // Ethereum + eth: "ethereum", + ether: "ethereum", + + // Arbitrum + arb: "arbitrum", + arbitrumone: "arbitrum", + + // Avalanche + avax: "avalanche", + + // BSC + bnb: "bsc", + binance: "bsc", + "binance smart chain": "bsc", + + // Optimism + op: "optimism", + opti: "optimism", + + // Polygon + matic: "polygon", + poly: "polygon", + + // Base + // no common abbreviations + + // zkSync + zks: "zksync", + zk: "zksync", + + // Sui + // no common abbreviations +} as const; + +export class BirdeyeApiError extends Error { + constructor( + public status: number, + message: string + ) { + super(message); + this.name = "BirdeyeApiError"; + } +} + +export interface ApiResponse { + success: boolean; + data: T; + error?: string; +} + +// Time-related types and constants +export const TIME_UNITS = { + second: 1, + minute: 60, + hour: 3600, + day: 86400, + week: 604800, + month: 2592000, +} as const; + +export const TIMEFRAME_KEYWORDS = { + "1m": 60, + "3m": 180, + "5m": 300, + "15m": 900, + "30m": 1800, + "1h": 3600, + "2h": 7200, + "4h": 14400, + "6h": 21600, + "12h": 43200, + "1d": 86400, + "1w": 604800, +} as const; + +export type TimeUnit = keyof typeof TIME_UNITS; +export type Timeframe = keyof typeof TIMEFRAME_KEYWORDS; + +// Helper functions +export const extractChain = (text: string): BirdeyeChain => { + // Check for SUI address (0x followed by 64 hex chars) + if (text.match(/0x[a-fA-F0-9]{64}/)) { + return "sui"; + } + // Check for EVM address (0x followed by 40 hex chars) + if (text.match(/0x[a-fA-F0-9]{40}/)) { + return "ethereum"; + } + // Default to solana + return "solana"; +}; + +export const extractContractAddresses = (text: string): BaseAddress[] => { + const addresses: BaseAddress[] = []; + + // EVM-compatible chains (Ethereum, Arbitrum, Avalanche, BSC, Optimism, Polygon, Base, zkSync) + const evmAddresses = text.match(/0x[a-fA-F0-9]{40}/g); + if (evmAddresses) { + addresses.push( + ...evmAddresses.map((address) => ({ + address, + chain: extractChain(address), + })) + ); + } + + // Solana addresses (base58 strings) + const solAddresses = text.match(/[1-9A-HJ-NP-Za-km-z]{32,44}/g); + if (solAddresses) { + addresses.push( + ...solAddresses.map((address) => ({ + address, + chain: "solana" as BirdeyeChain, + })) + ); + } + + // Sui addresses (0x followed by 64 hex chars) + const suiAddresses = text.match(/0x[a-fA-F0-9]{64}/g); + if (suiAddresses) { + addresses.push( + ...suiAddresses.map((address) => ({ + address, + chain: "sui" as BirdeyeChain, + })) + ); + } + + return addresses; +}; + +// Time extraction and analysis +export const extractTimeframe = (text: string): Timeframe => { + // First, check for explicit timeframe mentions + const timeframe = Object.keys(TIMEFRAME_KEYWORDS).find((tf) => + text.toLowerCase().includes(tf.toLowerCase()) + ); + if (timeframe) return timeframe as Timeframe; + + // Check for semantic timeframe hints + const semanticMap = { + "short term": "15m", + "medium term": "1h", + "long term": "1d", + intraday: "1h", + daily: "1d", + weekly: "1w", + detailed: "5m", + quick: "15m", + overview: "1d", + } as const; + + for (const [hint, tf] of Object.entries(semanticMap)) { + if (text.toLowerCase().includes(hint)) { + return tf as Timeframe; + } + } + + // Analyze for time-related words + if (text.match(/minute|min|minutes/i)) return "15m"; + if (text.match(/hour|hourly|hours/i)) return "1h"; + if (text.match(/day|daily|24h/i)) return "1d"; + if (text.match(/week|weekly/i)) return "1w"; + + // Default based on context + if (text.match(/trade|trades|trading|recent/i)) return "15m"; + if (text.match(/trend|analysis|analyze/i)) return "1h"; + if (text.match(/history|historical|long|performance/i)) return "1d"; + + return "1h"; // Default timeframe +}; + +export const extractTimeRange = ( + text: string +): { start: number; end: number } => { + const now = Math.floor(Date.now() / 1000); + + // Check for specific date ranges + const dateRangeMatch = text.match( + /from\s+(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})/i + ); + if (dateRangeMatch) { + const start = new Date(dateRangeMatch[1]).getTime() / 1000; + const end = new Date(dateRangeMatch[2]).getTime() / 1000; + return { start, end }; + } + + // Check for relative time expressions + const timeRegex = /(\d+)\s*(second|minute|hour|day|week|month)s?\s*ago/i; + const match = text.match(timeRegex); + if (match) { + const amount = parseInt(match[1]); + const unit = match[2].toLowerCase() as TimeUnit; + const start = now - amount * TIME_UNITS[unit]; + return { start, end: now }; + } + + // Check for semantic time ranges + const semanticRanges: Record = { + today: TIME_UNITS.day, + "this week": TIME_UNITS.week, + "this month": TIME_UNITS.month, + recent: TIME_UNITS.hour * 4, + latest: TIME_UNITS.hour, + "last hour": TIME_UNITS.hour, + "last day": TIME_UNITS.day, + "last week": TIME_UNITS.week, + "last month": TIME_UNITS.month, + }; + + for (const [range, duration] of Object.entries(semanticRanges)) { + if (text.toLowerCase().includes(range)) { + return { start: now - duration, end: now }; + } + } + + // Analyze context for appropriate default range + if (text.match(/trend|analysis|performance/i)) { + return { start: now - TIME_UNITS.week, end: now }; // 1 week for analysis + } + if (text.match(/trade|trades|trading|recent/i)) { + return { start: now - TIME_UNITS.day, end: now }; // 1 day for trading + } + if (text.match(/history|historical|long term/i)) { + return { start: now - TIME_UNITS.month, end: now }; // 1 month for history + } + + // Default to last 24 hours + return { start: now - TIME_UNITS.day, end: now }; +}; + +export const extractLimit = (text: string): number => { + // Check for explicit limit mentions + const limitMatch = text.match( + /\b(show|display|get|fetch|limit)\s+(\d+)\b/i + ); + if (limitMatch) { + const limit = parseInt(limitMatch[2]); + return Math.min(Math.max(limit, 1), 100); // Clamp between 1 and 100 + } + + // Check for semantic limit hints + if (text.match(/\b(all|everything|full|complete)\b/i)) return 100; + if (text.match(/\b(brief|quick|summary|overview)\b/i)) return 5; + if (text.match(/\b(detailed|comprehensive)\b/i)) return 50; + + // Default based on context + if (text.match(/\b(trade|trades|trading)\b/i)) return 10; + if (text.match(/\b(analysis|analyze|trend)\b/i)) return 24; + if (text.match(/\b(history|historical)\b/i)) return 50; + + return 10; // Default limit +}; + +// Formatting helpers +export const formatValue = (value: number): string => { + if (value >= 1_000_000_000) { + return `$${(value / 1_000_000_000).toFixed(2)}B`; + } + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(2)}M`; + } + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(2)}K`; + } + return `$${value.toFixed(2)}`; +}; + +export const formatPercentChange = (change?: number): string => { + if (change === undefined) return "N/A"; + const symbol = change >= 0 ? "šŸ“ˆ" : "šŸ“‰"; + return `${symbol} ${Math.abs(change).toFixed(2)}%`; +}; + +export const shortenAddress = (address: string): string => { + if (!address || address.length <= 12) return address || "Unknown"; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; + +export const formatTimestamp = (timestamp: number): string => { + return new Date(timestamp * 1000).toLocaleString(); +}; + +export const formatPrice = (price: number): string => { + return price < 0.01 ? price.toExponential(2) : price.toFixed(2); +}; + +// API helpers +export async function makeApiRequest( + url: string, + options: { + apiKey: string; + chain?: BirdeyeChain; + method?: "GET" | "POST"; + body?: any; + } +): Promise { + const { apiKey, chain = "solana", method = "GET", body } = options; + + try { + const response = await fetch(url, { + method, + headers: { + "X-API-KEY": apiKey, + "x-chain": chain, + ...(body && { "Content-Type": "application/json" }), + }, + ...(body && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + if (response.status === 404) { + throw new BirdeyeApiError(404, "Resource not found"); + } + if (response.status === 429) { + throw new BirdeyeApiError(429, "Rate limit exceeded"); + } + throw new BirdeyeApiError( + response.status, + `HTTP error! status: ${response.status}` + ); + } + + const responseJson: T = await response.json(); + + return responseJson; + } catch (error) { + if (error instanceof BirdeyeApiError) { + elizaLogger.error(`API Error (${error.status}):`, error.message); + } else { + elizaLogger.error("Error making API request:", error); + } + throw error; + } +} + +// Formatting helpers +export const formatTokenInfo = ( + token: SearchToken, + metadata?: TokenMetadataResponse +): string => { + const priceFormatted = + token.price != null + ? token.price < 0.01 + ? token.price.toExponential(2) + : token.price.toFixed(2) + : "N/A"; + + const volume = + token.volume_24h_usd != null + ? `$${(token.volume_24h_usd / 1_000_000).toFixed(2)}M` + : "N/A"; + + const liquidity = + token.liquidity != null + ? `$${(token.liquidity / 1_000_000).toFixed(2)}M` + : "N/A"; + + const fdv = + token.fdv != null ? `$${(token.fdv / 1_000_000).toFixed(2)}M` : "N/A"; + + const priceChange = + token.price_change_24h_percent != null + ? `${token.price_change_24h_percent > 0 ? "+" : ""}${token.price_change_24h_percent.toFixed(2)}%` + : "N/A"; + + const trades = token.trade_24h != null ? token.trade_24h.toString() : "N/A"; + + const age = token.last_trade_unix_time + ? `${Math.floor((Date.now() - new Date(token.last_trade_unix_time).getTime()) / (1000 * 60 * 60 * 24))}d` + : "N/A"; + + let output = + `šŸŖ™ ${token.name} @ ${token.symbol}\n` + + `šŸ’° USD: $${priceFormatted} (${priceChange})\n` + + `šŸ’Ž FDV: ${fdv}\n` + + `šŸ’¦ Liq: ${liquidity}\n` + + `šŸ“Š Vol: ${volume} šŸ•°ļø Age: ${age}\n` + + `šŸ”„ Trades: ${trades}\n` + + `šŸ”— Address: ${token.address}`; + + // Add metadata if available + if (metadata?.success) { + const { extensions } = metadata.data; + const links: string[] = []; + + if (extensions.website) + links.push(`šŸŒ [Website](${extensions.website})`); + if (extensions.twitter) + links.push(`šŸ¦ [Twitter](${extensions.twitter})`); + if (extensions.discord) + links.push(`šŸ’¬ [Discord](${extensions.discord})`); + if (extensions.medium) links.push(`šŸ“ [Medium](${extensions.medium})`); + if (extensions.coingecko_id) + links.push( + `šŸ¦Ž [CoinGecko](https://www.coingecko.com/en/coins/${extensions.coingecko_id})` + ); + + if (links.length > 0) { + output += "\n\nšŸ“± Social Links:\n" + links.join("\n"); + } + } + + return output; +}; + +// Extract symbols from text +export const extractSymbols = (text: string): string[] => { + const symbols = new Set(); + + // Match symbols after "a" or "an" (e.g., "a BTC" or "an ETH") + const afterArticles = text.matchAll(/\b(?:a|an)\s+([A-Z]{2,10})\b/gi); + for (const match of afterArticles) { + symbols.add(match[1].toUpperCase()); + } + + // Match standalone acronyms (2-10 chars, all caps) + const acronyms = text.matchAll(/\b[A-Z]{2,10}\b/g); + for (const match of acronyms) { + symbols.add(match[0]); + } + + // Match token symbols in quotes (e.g., "BTC" or 'ETH') + const quotedSymbols = text.matchAll(/["']([A-Z]{2,10})["']/gi); + for (const match of quotedSymbols) { + symbols.add(match[1].toUpperCase()); + } + + return Array.from(symbols); +}; + +export const formatMetadataResponse = ( + data: TokenMetadataResponse, + chain: BirdeyeChain +): string => { + const tokenData = data.data; + const chainName = chain.charAt(0).toUpperCase() + chain.slice(1); + const chainExplorer = (() => { + switch (chain) { + case "solana": + return `https://solscan.io/token/${tokenData.address}`; + case "ethereum": + return `https://etherscan.io/token/${tokenData.address}`; + case "arbitrum": + return `https://arbiscan.io/token/${tokenData.address}`; + case "avalanche": + return `https://snowtrace.io/token/${tokenData.address}`; + case "bsc": + return `https://bscscan.com/token/${tokenData.address}`; + case "optimism": + return `https://optimistic.etherscan.io/token/${tokenData.address}`; + case "polygon": + return `https://polygonscan.com/token/${tokenData.address}`; + case "base": + return `https://basescan.org/token/${tokenData.address}`; + case "zksync": + return `https://explorer.zksync.io/address/${tokenData.address}`; + case "sui": + return `https://suiscan.xyz/mainnet/object/${tokenData.address}`; + default: + return null; + } + })(); + + let response = `Token Metadata for ${tokenData.name} (${tokenData.symbol}) on ${chainName}\n\n`; + + // Basic Information + response += "šŸ“ Basic Information\n"; + response += `ā€¢ Name: ${tokenData.name}\n`; + response += `ā€¢ Symbol: ${tokenData.symbol}\n`; + response += `ā€¢ Address: ${tokenData.address}\n`; + response += `ā€¢ Decimals: ${tokenData.decimals}\n`; + if (chainExplorer) { + response += `ā€¢ Explorer: [View on ${chainName} Explorer](${chainExplorer})\n`; + } + + // Social Links + response += "\nšŸ”— Social Links & Extensions\n"; + response += formatSocialLinks(tokenData) + "\n"; + + // Logo + if (tokenData.logo_uri) { + response += "\nšŸ–¼ļø Logo\n"; + response += tokenData.logo_uri; + } + + return response; +}; + +const formatSocialLinks = (data: TokenMetadataResponse["data"]): string => { + const links: string[] = []; + const { extensions } = data; + + if (!extensions) { + return "No social links available"; + } + + if (extensions.website) { + links.push(`šŸŒ [Website](${extensions.website})`); + } + if (extensions.twitter) { + links.push(`šŸ¦ [Twitter](${extensions.twitter})`); + } + if (extensions.discord) { + links.push(`šŸ’¬ [Discord](${extensions.discord})`); + } + if (extensions.medium) { + links.push(`šŸ“ [Medium](${extensions.medium})`); + } + if (extensions.coingecko_id) { + links.push( + `šŸ¦Ž [CoinGecko](https://www.coingecko.com/en/coins/${extensions.coingecko_id})` + ); + } + + return links.length > 0 ? links.join("\n") : "No social links available"; +}; diff --git a/packages/plugin-solana/src/evaluators/trust.ts b/packages/plugin-solana/src/evaluators/trust.ts index 2c4f441cf5..1837cff970 100644 --- a/packages/plugin-solana/src/evaluators/trust.ts +++ b/packages/plugin-solana/src/evaluators/trust.ts @@ -1,22 +1,23 @@ import { + ActionExample, + booleanFooter, composeContext, + Content, + elizaLogger, + Evaluator, generateObjectArray, generateTrueOrFalse, - MemoryManager, - booleanFooter, - ActionExample, - Content, IAgentRuntime, Memory, + MemoryManager, ModelClass, - Evaluator, } from "@elizaos/core"; -import { TrustScoreManager } from "../providers/trustScoreProvider.ts"; -import { TokenProvider } from "../providers/token.ts"; -import { WalletProvider } from "../providers/wallet.ts"; import { TrustScoreDatabase } from "@elizaos/plugin-trustdb"; import { Connection } from "@solana/web3.js"; import { getWalletKey } from "../keypairUtils.ts"; +import { TokenProvider } from "../providers/token.ts"; +import { TrustScoreManager } from "../providers/trustScoreProvider.ts"; +import { WalletProvider } from "../providers/wallet.ts"; const shouldProcessTemplate = `# Task: Decide if the recent messages should be processed for token recommendations. @@ -80,6 +81,13 @@ Response should be a JSON object array inside a JSON markdown block. Correct res async function handler(runtime: IAgentRuntime, message: Memory) { console.log("Evaluating for trust"); + + // if the database type is postgres, we don't want to run this because it relies on sql queries that are currently specific to sqlite. This check can be removed once the trust score provider is updated to work with postgres. + if (runtime.getSetting("POSTGRES_URL")) { + elizaLogger.warn("skipping trust evaluator because db is postgres"); + return []; + } + const state = await runtime.composeState(message); const { agentId, roomId } = state; @@ -186,7 +194,6 @@ async function handler(runtime: IAgentRuntime, message: Memory) { } // create the trust score manager - const trustScoreDb = new TrustScoreDatabase(runtime.databaseAdapter.db); const trustScoreManager = new TrustScoreManager( runtime, diff --git a/packages/plugin-solana/src/index.ts b/packages/plugin-solana/src/index.ts index b207ed260b..cd632ee8e0 100644 --- a/packages/plugin-solana/src/index.ts +++ b/packages/plugin-solana/src/index.ts @@ -1,20 +1,18 @@ +export * from "./evaluators/trust.ts"; export * from "./providers/token.ts"; -export * from "./providers/wallet.ts"; export * from "./providers/trustScoreProvider.ts"; -export * from "./evaluators/trust.ts"; +export * from "./providers/wallet.ts"; import { Plugin } from "@elizaos/core"; -import { executeSwap } from "./actions/swap.ts"; -import take_order from "./actions/takeOrder"; -import pumpfun from "./actions/pumpfun.ts"; import fomo from "./actions/fomo.ts"; +import pumpfun from "./actions/pumpfun.ts"; +import { executeSwap } from "./actions/swap.ts"; import { executeSwapForDAO } from "./actions/swapDao"; +import take_order from "./actions/takeOrder"; import transferToken from "./actions/transfer.ts"; -import { walletProvider } from "./providers/wallet.ts"; -import { trustScoreProvider } from "./providers/trustScoreProvider.ts"; import { trustEvaluator } from "./evaluators/trust.ts"; import { TokenProvider } from "./providers/token.ts"; -import { WalletProvider } from "./providers/wallet.ts"; +import { walletProvider, WalletProvider } from "./providers/wallet.ts"; export { TokenProvider, WalletProvider }; @@ -30,7 +28,8 @@ export const solanaPlugin: Plugin = { take_order, ], evaluators: [trustEvaluator], - providers: [walletProvider, trustScoreProvider], + // providers: [walletProvider, trustScoreProvider], + providers: [walletProvider], }; export default solanaPlugin; diff --git a/packages/plugin-solana/src/providers/trustScoreProvider.ts b/packages/plugin-solana/src/providers/trustScoreProvider.ts index 931cd9b44d..4825511706 100644 --- a/packages/plugin-solana/src/providers/trustScoreProvider.ts +++ b/packages/plugin-solana/src/providers/trustScoreProvider.ts @@ -1,26 +1,25 @@ import { - ProcessedTokenData, - TokenSecurityData, - // TokenTradeData, - // DexScreenerData, - // DexScreenerPair, - // HolderData, -} from "../types/token.ts"; -import { Connection, PublicKey } from "@solana/web3.js"; -import { getAssociatedTokenAddress } from "@solana/spl-token"; -import { TokenProvider } from "./token.ts"; -import { WalletProvider } from "./wallet.ts"; -import { SimulationSellingService } from "./simulationSellingService.ts"; + elizaLogger, + IAgentRuntime, + Memory, + Provider, + settings, + State, +} from "@elizaos/core"; import { - TrustScoreDatabase, RecommenderMetrics, TokenPerformance, - TradePerformance, TokenRecommendation, + TradePerformance, + TrustScoreDatabase, } from "@elizaos/plugin-trustdb"; -import { settings } from "@elizaos/core"; -import { IAgentRuntime, Memory, Provider, State } from "@elizaos/core"; +import { getAssociatedTokenAddress } from "@solana/spl-token"; +import { Connection, PublicKey } from "@solana/web3.js"; import { v4 as uuidv4 } from "uuid"; +import { ProcessedTokenData, TokenSecurityData } from "../types/token.ts"; +import { SimulationSellingService } from "./simulationSellingService.ts"; +import { TokenProvider } from "./token.ts"; +import { WalletProvider } from "./wallet.ts"; const Wallet = settings.MAIN_WALLET_ADDRESS; interface TradeData { @@ -702,6 +701,14 @@ export const trustScoreProvider: Provider = { _state?: State ): Promise { try { + // if the database type is postgres, we don't want to run this because it relies on sql queries that are currently specific to sqlite. This check can be removed once the trust score provider is updated to work with postgres. + if (runtime.getSetting("POSTGRES_URL")) { + elizaLogger.warn( + "skipping trust evaluator because db is postgres" + ); + return ""; + } + const trustScoreDb = new TrustScoreDatabase( runtime.databaseAdapter.db ); diff --git a/src/providers/address-search.provider.ts b/src/providers/address-search.provider.ts new file mode 100644 index 0000000000..7e6f2f4342 --- /dev/null +++ b/src/providers/address-search.provider.ts @@ -0,0 +1,9 @@ +/** + * Searches message text for contract addresses, symbols, or wallet addresses and enriches them with: + * - Portfolio data from wallet addresses + * - Token metadata from contract addresses/symbols + * Queries endpoints in parallel and aggregates results for agent context. + */ +export class AddressSearchProvider { + // ... rest of code +} From 0c51d1180211dafd4b0159aa60c19069069ab9e4 Mon Sep 17 00:00:00 2001 From: "J. Brandon Johnson" Date: Sat, 28 Dec 2024 09:26:29 -0800 Subject: [PATCH 4/5] chore: cleanup filter logic --- .../src/providers/address-search-provider.ts | 13 +++-- packages/plugin-birdeye/src/services.ts | 10 ++-- packages/plugin-birdeye/src/utils.ts | 54 +++++++++++++------ 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/packages/plugin-birdeye/src/providers/address-search-provider.ts b/packages/plugin-birdeye/src/providers/address-search-provider.ts index 40f178a762..2f60ee08d6 100644 --- a/packages/plugin-birdeye/src/providers/address-search-provider.ts +++ b/packages/plugin-birdeye/src/providers/address-search-provider.ts @@ -51,9 +51,7 @@ export const addressSearchProvider: Provider = { }).then((results) => ({ searchTerm: address.address, address: address.address, - // find the result that matches the address - result: - results.find((r) => r.address === address.address) || null, + result: results[0] || null, })) ); @@ -66,8 +64,7 @@ export const addressSearchProvider: Provider = { searchTerm: symbol, symbol: results[0]?.symbol || null, address: results[0]?.address || null, - // find the result that matches the symbol - result: results.find((r) => r.symbol === symbol) || null, + result: results[0] || null, })) ); @@ -77,6 +74,12 @@ export const addressSearchProvider: Provider = { ]); const validResults = results.filter((r) => r.result !== null); + elizaLogger.info( + `Found ${validResults.length} valid results for ${addresses.length} addresses and ${symbols.length} symbols` + ); + + console.log(JSON.stringify(validResults, null, 2)); + // bail if no valid results if (validResults.length === 0) return null; diff --git a/packages/plugin-birdeye/src/services.ts b/packages/plugin-birdeye/src/services.ts index 15b6decee3..ef508b4d00 100644 --- a/packages/plugin-birdeye/src/services.ts +++ b/packages/plugin-birdeye/src/services.ts @@ -43,8 +43,6 @@ export const searchTokens = async ( const data = (await response.json()) as SearchTokenResponse; - elizaLogger.info("Birdeye response:", data); - // Extract tokens from the response // if the search type is address, we only want to return the token that matches the address const tokens = @@ -53,11 +51,17 @@ export const searchTokens = async ( .filter( (item) => item.type === "token" && + // only return the token that matches the address item.result[0].address === keyword.toLowerCase() ) .flatMap((item) => item.result) : data.data.items - .filter((item) => item.type === "token") + .filter( + (item) => + item.type === "token" && + // only return the token that matches the symbol + item.result[0].symbol === keyword + ) .flatMap((item) => item.result); elizaLogger.info("Found tokens:", tokens); diff --git a/packages/plugin-birdeye/src/utils.ts b/packages/plugin-birdeye/src/utils.ts index d93655cc93..ec8d559171 100644 --- a/packages/plugin-birdeye/src/utils.ts +++ b/packages/plugin-birdeye/src/utils.ts @@ -437,23 +437,43 @@ export const formatTokenInfo = ( export const extractSymbols = (text: string): string[] => { const symbols = new Set(); - // Match symbols after "a" or "an" (e.g., "a BTC" or "an ETH") - const afterArticles = text.matchAll(/\b(?:a|an)\s+([A-Z]{2,10})\b/gi); - for (const match of afterArticles) { - symbols.add(match[1].toUpperCase()); - } - - // Match standalone acronyms (2-10 chars, all caps) - const acronyms = text.matchAll(/\b[A-Z]{2,10}\b/g); - for (const match of acronyms) { - symbols.add(match[0]); - } - - // Match token symbols in quotes (e.g., "BTC" or 'ETH') - const quotedSymbols = text.matchAll(/["']([A-Z]{2,10})["']/gi); - for (const match of quotedSymbols) { - symbols.add(match[1].toUpperCase()); - } + // Common words to exclude (avoid false positives) + const excludeWords = new Set([ + "USD", + "APY", + "API", + "NFT", + "DEX", + "CEX", + "APR", + "TVL", + ]); + + // Match patterns: + const patterns = [ + // $SYMBOL format + /\$([A-Z0-9]{2,10})\b/gi, + // After articles (a/an) + /\b(?:a|an)\s+([A-Z0-9]{2,10})\b/gi, + // Standalone caps + /\b[A-Z0-9]{2,10}\b/g, + // Quoted symbols + /["']([A-Z0-9]{2,10})["']/gi, + // Common price patterns + /\b([A-Z0-9]{2,10})\/USD\b/gi, + /\b([A-Z0-9]{2,10})-USD\b/gi, + ]; + + // Extract all matches + patterns.forEach((pattern) => { + const matches = text.matchAll(pattern); + for (const match of matches) { + const symbol = (match[1] || match[0]).toUpperCase(); + if (!excludeWords.has(symbol)) { + symbols.add(symbol); + } + } + }); return Array.from(symbols); }; From 10122e24a756fd84e062d7ab6e1b434be4042bae Mon Sep 17 00:00:00 2001 From: "J. Brandon Johnson" Date: Sat, 28 Dec 2024 09:38:41 -0800 Subject: [PATCH 5/5] chore: incorporate from PR 1366 --- packages/plugin-birdeye/src/actions/report.ts | 179 ++++++++++ packages/plugin-birdeye/src/index.ts | 5 +- .../plugin-birdeye/src/providers/birdeye.ts | 324 ++++++++++++++++++ .../plugin-birdeye/src/tests/birdeye.test.ts | 292 ++++++++++++++++ 4 files changed, 799 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-birdeye/src/actions/report.ts create mode 100644 packages/plugin-birdeye/src/providers/birdeye.ts create mode 100644 packages/plugin-birdeye/src/tests/birdeye.test.ts diff --git a/packages/plugin-birdeye/src/actions/report.ts b/packages/plugin-birdeye/src/actions/report.ts new file mode 100644 index 0000000000..abb34dddb5 --- /dev/null +++ b/packages/plugin-birdeye/src/actions/report.ts @@ -0,0 +1,179 @@ +import { + Action, + ActionExample, + composeContext, + elizaLogger, + generateText, + ModelClass, + type IAgentRuntime, + type Memory, + type State, +} from "@elizaos/core"; +import { BirdeyeProvider } from "../providers/birdeye"; + +const extractTokenSymbolTemplate = `Given the recent message below: +{{recentMessages}} +Extract the 1 latest information about the requested token report: +- Input token symbol +- Extra about this symbol +When the symbol is specified in all lowered case, such as btc, eth, sol..., we should convert it into wellknown symbol. +E.g. btc instead of BTC, sol instead of SOL. +But when we see them in mixed form, such as SOl, DOl, eTH, except the case they're quoted (e.g. 'wEth', 'SOl',...) +When in doubt, specify the concern in the message field, include your suggested value with it. +Respond exactly a JSON object containing only the extracted values, no extra description or message needed. +Use null for any values that cannot be determined. The result should be a valid JSON object with the following schema: +{ + "symbol": string | null, + "message": string | null, +} +Examples: + Message: 'Tell me about BTC' + Response: '{ "symbol": "BTC", "message": null}' + Message: 'Do you know about SOl.' + Response: '{ "symbol": "SOl", "message": "We've found SOL seems match, is that what you want?"}' +`; + +const formatTokenReport = (data) => { + let output = `*Token Security and Trade Report*\n`; + output += `Token symbol: ${data.symbol}\n`; + output += `Token Address: ${data.tokenAddress}\n\n`; + + output += `*Ownership Distribution:*\n`; + output += `- Owner Balance: ${data.security.ownerBalance}\n`; + output += `- Creator Balance: ${data.security.creatorBalance}\n`; + output += `- Owner Percentage: ${data.security.ownerPercentage}%\n`; + output += `- Creator Percentage: ${data.security.creatorPercentage}%\n`; + output += `- Top 10 Holders Balance: ${data.security.top10HolderBalance}\n`; + output += `- Top 10 Holders Percentage: ${data.security.top10HolderPercent}%\n\n`; + + // Trade Data + output += `*Trade Data:*\n`; + output += `- Holders: ${data.volume.holder}\n`; + output += `- Unique Wallets (24h): ${data.volume.unique_wallet_24h}\n`; + output += `- Price Change (24h): ${data.volume.price_change_24h_percent}%\n`; + output += `- Price Change (12h): ${data.volume.price_change_12h_percent}%\n`; + output += `- Volume (24h USD): $${data.volume.volume_24h_usd}\n`; + output += `- Current Price: $${data.volume.price}\n\n`; + + return output; +}; + +const extractTokenSymbol = async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + options: any, + callback?: any +) => { + const context = composeContext({ + state, + template: extractTokenSymbolTemplate, + }); + + const response = await generateText({ + runtime, + context, + modelClass: ModelClass.LARGE, + }); + + elizaLogger.log("Response", response); + + try { + const regex = new RegExp(/\{(.+)\}/gms); + const normalized = response && regex.exec(response)?.[0]; + elizaLogger.debug("Normalized data", normalized); + return normalized && JSON.parse(normalized); + } catch { + callback?.({ text: response }); + return true; + } +}; + +export const reportToken = { + name: "REPORT_TOKEN", + similes: ["CHECK_TOKEN", "REVIEW_TOKEN", "TOKEN_DETAILS"], + description: "Check background data for a given token", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + options: any, + callback?: any + ) => { + try { + const params = await extractTokenSymbol( + runtime, + message, + state, + options, + callback + ); + + elizaLogger.debug("Params", params); + + if (!params?.symbol) { + callback?.({ text: "I need a token symbol to begin" }); + return true; + } + + if (params?.message) { + // show concern message + callback?.({ text: `*Warning*: ${params.message}` }); + } + + const symbol = params?.symbol; + elizaLogger.log("Fetching birdeye data", symbol); + const provider = new BirdeyeProvider(runtime.cacheManager); + + const [tokenAddress, security, volume] = await Promise.all([ + provider.getTokenAddress(symbol), + provider.fetchTokenSecurityBySymbol(symbol), + provider.fetchTokenTradeDataBySymbol(symbol), + ]); + + elizaLogger.log("Fetching birdeye done"); + const msg = formatTokenReport({ + symbol, + tokenAddress, + security: security.data, + volume: volume.data, + }); + callback?.({ text: msg }); + return true; + } catch (error) { + console.error("Error in reportToken handler:", error.message); + callback?.({ text: `Error: ${error.message}` }); + return false; + } + }, + validate: async (runtime: IAgentRuntime, message: Memory) => { + // todo: validate the token symbol + // for example, this action should not be triggered when the message is a wallet address + return true; + }, + examples: [ + [ + { + user: "user", + content: { + text: "Tell me what you know about SOL", + action: "CHECK_TOKEN", + }, + }, + { + user: "user", + content: { + text: "Do you know about SOL", + action: "TOKEN_DETAILS", + }, + }, + { + user: "user", + content: { + text: "Tell me about WETH", + action: "REVIEW_TOKEN", + }, + }, + ], + ] as ActionExample[][], +} as Action; diff --git a/packages/plugin-birdeye/src/index.ts b/packages/plugin-birdeye/src/index.ts index e997bfafb3..d4a835c48d 100644 --- a/packages/plugin-birdeye/src/index.ts +++ b/packages/plugin-birdeye/src/index.ts @@ -1,11 +1,14 @@ import { Plugin } from "@elizaos/core"; import { getSupportedNetworksAction } from "./actions/defi/networks"; +import { reportToken } from "./actions/report"; import { addressSearchProvider } from "./providers/address-search-provider"; +import { birdeyeProvider } from "./providers/birdeye"; export const birdeyePlugin: Plugin = { name: "birdeye", description: "Birdeye Plugin for token data and analytics", actions: [ + reportToken, getSupportedNetworksAction, // getTokenMetadataAction, // getPriceHistoryAction, @@ -13,7 +16,7 @@ export const birdeyePlugin: Plugin = { // getTokenTradesAction, ], evaluators: [], - providers: [addressSearchProvider], + providers: [addressSearchProvider, birdeyeProvider], }; export default birdeyePlugin; diff --git a/packages/plugin-birdeye/src/providers/birdeye.ts b/packages/plugin-birdeye/src/providers/birdeye.ts new file mode 100644 index 0000000000..e62cf22c9e --- /dev/null +++ b/packages/plugin-birdeye/src/providers/birdeye.ts @@ -0,0 +1,324 @@ +import { + elizaLogger, + IAgentRuntime, + ICacheManager, + Memory, + Provider, + settings, + State, +} from "@elizaos/core"; +import NodeCache from "node-cache"; +import * as path from "path"; +import { SearchTokensOptions } from "../types/search-token"; +import { BirdeyeChain } from "../types/shared"; +import { WalletDataOptions } from "../types/wallet"; + +const DEFAULT_MAX_RETRIES = 3; + +const DEFAULT_SUPPORTED_SYMBOLS = { + SOL: "So11111111111111111111111111111111111111112", + BTC: "qfnqNqs3nCAHjnyCgLRDbBtq4p2MtHZxw8YjSyYhPoL", + ETH: "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs", + Example: "2weMjPLLybRMMva1fM3U31goWWrCpF59CHWNhnCJ9Vyh", +}; + +const API_BASE_URL = "https://public-api.birdeye.so"; +const ENDPOINT_MAP = { + price: "/defi/price?address=", + security: "/defi/token_security?address=", + volume: "/defi/v3/token/trade-data/single?address=", + portfolio: "/v1/wallet/token_list?wallet=", + tokens: "/defi/tokenlist", +}; + +const RETRY_DELAY_MS = 2_000; + +const waitFor = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +class BaseCachedProvider { + private cache: NodeCache; + + constructor( + private cacheManager: ICacheManager, + private cacheKey, + ttl?: number + ) { + this.cache = new NodeCache({ stdTTL: ttl || 300 }); + } + + private readFsCache(key: string): Promise { + return this.cacheManager.get(path.join(this.cacheKey, key)); + } + + private writeFsCache(key: string, data: T): Promise { + return this.cacheManager.set(path.join(this.cacheKey, key), data, { + expires: Date.now() + 5 * 60 * 1000, + }); + } + + public async readFromCache(key: string): Promise { + // get memory cache first + const val = this.cache.get(key); + if (val) { + return val; + } + + const fsVal = await this.readFsCache(key); + if (fsVal) { + // set to memory cache + this.cache.set(key, fsVal); + } + + return fsVal; + } + + public async writeToCache(key: string, val: T): Promise { + // Set in-memory cache + this.cache.set(key, val); + + // Write to file-based cache + await this.writeFsCache(key, val); + } +} + +export class BirdeyeProvider extends BaseCachedProvider { + private symbolMap: Record; + private maxRetries: number; + + constructor( + cacheManager: ICacheManager, + symbolMap?: Record, + maxRetries?: number + ) { + super(cacheManager, "birdeye/data"); + this.symbolMap = symbolMap || DEFAULT_SUPPORTED_SYMBOLS; + this.maxRetries = maxRetries || DEFAULT_MAX_RETRIES; + } + + public getTokenAddress(symbol: string) { + const addr = this.symbolMap[symbol]; + + if (!addr) { + throw new Error(`Unsupported symbol ${symbol} in Birdeye provider`); + } + + return addr; + } + + private getUrlByType(type: string, address?: string) { + const path = ENDPOINT_MAP[type]; + + if (!path) { + throw new Error(`Unsupported symbol ${type} in Birdeye provider`); + } + + return `${API_BASE_URL}${path}${address || ""}`; + } + + private async fetchWithRetry( + url: string, + options: RequestInit = {} + ): Promise { + let attempts = 0; + + while (attempts < this.maxRetries) { + attempts++; + try { + const resp = await fetch(url, { + ...options, + headers: { + Accept: "application/json", + "x-chain": settings.BIRDEYE_CHAIN || "solana", + "X-API-KEY": settings.BIRDEYE_API_KEY || "", + ...options.headers, + }, + }); + + if (!resp.ok) { + const errorText = await resp.text(); + throw new Error( + `HTTP error! status: ${resp.status}, message: ${errorText}` + ); + } + + const data = await resp.json(); + return data; + } catch (error) { + if (attempts === this.maxRetries) { + // failed after all + throw error; + } + await waitFor(RETRY_DELAY_MS); + } + } + } + + private async fetchWithCacheAndRetry( + type: string, + address?: string + ): Promise { + const key = `${type}/${address}`; + const val = await this.readFromCache(key); + + if (val) { + return val; + } + + const url = this.getUrlByType(type, address); + const data = await this.fetchWithRetry(url); + + await this.writeToCache(key, data); + return data; + } + + public async fetchTokenList() { + return this.fetchWithCacheAndRetry("tokens"); + } + + public async fetchPriceBySymbol(symbol: string) { + return this.fetchPriceByAddress(this.getTokenAddress(symbol)); + } + public async fetchPriceByAddress(address: string) { + return this.fetchWithCacheAndRetry("price", address); + } + + public async fetchTokenSecurityBySymbol(symbol: string) { + return this.fetchTokenSecurityByAddress(this.getTokenAddress(symbol)); + } + public async fetchTokenSecurityByAddress(address: string) { + return this.fetchWithCacheAndRetry("security", address); + } + + public async fetchTokenTradeDataBySymbol(symbol: string) { + return this.fetchTokenTradeDataByAddress(this.getTokenAddress(symbol)); + } + public async fetchTokenTradeDataByAddress(address: string) { + return this.fetchWithCacheAndRetry("volume", address); + } + + public async fetchWalletPortfolio(address: string) { + return this.fetchWithCacheAndRetry("portfolio", address); + } + + public async fetchSearchTokens(options: SearchTokensOptions) { + const { keyword, chain = "all", limit = 1, offset = 0, type } = options; + const params = new URLSearchParams({ + keyword, + limit: limit.toString(), + offset: offset.toString(), + chain: chain, + }); + + const url = `${API_BASE_URL}/defi/v3/search?${params.toString()}`; + const data = await this.fetchWithRetry(url); + + return type === "address" + ? data.data.items + .filter( + (item) => + item.type === "token" && + item.result[0].address === keyword.toLowerCase() + ) + .flatMap((item) => item.result) + : data.data.items + .filter( + (item) => + item.type === "token" && + item.result[0].symbol === keyword + ) + .flatMap((item) => item.result); + } + + public async fetchSearchWallets(options: WalletDataOptions) { + const { wallet, chain = "solana" } = options; + const params = new URLSearchParams({ + wallet, + chain: chain, + }); + + const url = `${API_BASE_URL}/v1/wallet/token_list?${params.toString()}`; + const data = await this.fetchWithRetry(url, { + headers: { "x-chain": chain }, + }); + + return data.data.items; + } + + public async fetchTokenMetadata(address: string, chain: BirdeyeChain) { + const isValidAddress = (() => { + switch (chain) { + case "solana": + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(address); + case "sui": + return /^0x[a-fA-F0-9]{64}$/i.test(address); + case "ethereum": + case "arbitrum": + case "avalanche": + case "bsc": + case "optimism": + case "polygon": + case "base": + case "zksync": + return /^0x[a-fA-F0-9]{40}$/i.test(address); + default: + return false; + } + })(); + + if (!isValidAddress) { + elizaLogger.error( + `Invalid address format for ${chain}: ${address}` + ); + return null; + } + + const params = new URLSearchParams({ address }); + const url = `${API_BASE_URL}/defi/v3/token/meta-data/single?${params.toString()}`; + + return this.fetchWithRetry(url, { + headers: { "x-chain": chain }, + }).catch(() => null); + } +} + +export const birdeyeProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise => { + try { + const provider = new BirdeyeProvider(runtime.cacheManager); + + const walletAddr = runtime.getSetting("BIRDEYE_WALLET_ADDR"); + + const resp = await provider.fetchTokenList().catch((err) => { + elizaLogger.warn("Couldn't update symbol map", err); + }); + + resp?.data?.tokens?.forEach((item) => { + DEFAULT_SUPPORTED_SYMBOLS[item.symbol] = item.address; + }); + + const supportedTokens = Object.keys(DEFAULT_SUPPORTED_SYMBOLS).join( + ", " + ); + + if (!walletAddr) { + console.warn("No Birdeye wallet was specified"); + + return `Birdeye enabled, no wallet found, supported tokens: [${supportedTokens}]`; + } + const response = await provider.fetchWalletPortfolio(walletAddr); + const portfolio = response?.data.items + .map((e) => e.symbol) + .join(", "); + + return `Birdeye enabled, wallet addr: ${walletAddr}, portfolio: [${portfolio}], supported tokens: [${supportedTokens}]`; + } catch (error) { + console.error("Error fetching token data:", error); + return "Unable to fetch token information. Please try again later."; + } + }, +}; diff --git a/packages/plugin-birdeye/src/tests/birdeye.test.ts b/packages/plugin-birdeye/src/tests/birdeye.test.ts new file mode 100644 index 0000000000..7021926cb3 --- /dev/null +++ b/packages/plugin-birdeye/src/tests/birdeye.test.ts @@ -0,0 +1,292 @@ +import { ICacheManager } from "@elizaos/core"; +import NodeCache from "node-cache"; +import { BirdeyeProvider } from "../providers/birdeye"; + +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest"; + +describe("BirdeyeProvider", () => { + describe("basic fetching", () => { + let cacheManager: ICacheManager; + let provider: BirdeyeProvider; + + beforeEach(() => { + cacheManager = { + get: vi.fn(), + set: vi.fn(), + } as unknown as ICacheManager; + provider = new BirdeyeProvider(cacheManager); + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should fetch price by symbol", async () => { + const mockResponse = { price: 100 }; + (fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await provider.fetchPriceBySymbol("SOL"); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + "https://public-api.birdeye.so/defi/price?address=So11111111111111111111111111111111111111112", + expect.any(Object) + ); + }); + + it("should fetch token security by symbol", async () => { + const mockResponse = { security: "secure" }; + (fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await provider.fetchTokenSecurityBySymbol("SOL"); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + "https://public-api.birdeye.so/defi/token_security?address=So11111111111111111111111111111111111111112", + expect.any(Object) + ); + }); + + it("should fetch token trade data by symbol", async () => { + const mockResponse = { volume: 1000 }; + (fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await provider.fetchTokenTradeDataBySymbol("SOL"); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + "https://public-api.birdeye.so/defi/v3/token/trade-data/single?address=So11111111111111111111111111111111111111112", + expect.any(Object) + ); + }); + + it("should fetch wallet portfolio", async () => { + const mockResponse = { portfolio: [] }; + (fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await provider.fetchWalletPortfolio( + "some-wallet-address" + ); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + "https://public-api.birdeye.so/v1/wallet/token_list?wallet=some-wallet-address", + expect.any(Object) + ); + }); + }); + + describe("retries options", () => { + let cacheManager: ICacheManager; + let provider: BirdeyeProvider; + + beforeEach(() => { + cacheManager = { + get: vi.fn(), + set: vi.fn(), + } as unknown as ICacheManager; + provider = new BirdeyeProvider(cacheManager); + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should retry fetch on failure and succeed", async () => { + const mockResponse = { price: 100 }; + (fetch as Mock) + .mockRejectedValueOnce(new Error("Network error")) // First attempt fails + .mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); // Second attempt succeeds + + const result = await provider.fetchPriceBySymbol("SOL"); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(2); // Ensure it retried + }); + + it("should fail after max retries", async () => { + const error = new Error("Network error"); + (fetch as Mock) + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) // Second attempt fails + .mockRejectedValueOnce(error); // Third attempt also fails + + await expect(provider.fetchPriceBySymbol("SOL")).rejects.toThrow( + "Network error" + ); + + expect(fetch).toHaveBeenCalledTimes(3); // Ensure it retried + }); + }); + + describe("with custom symbols", () => { + let cacheManager: ICacheManager; + let provider: BirdeyeProvider; + + beforeEach(() => { + cacheManager = { + get: vi.fn(), + set: vi.fn(), + } as unknown as ICacheManager; + provider = new BirdeyeProvider(cacheManager, { + WETH: "0x32323232323232", + }); + global.fetch = vi.fn(); + }); + + it("should fetch price for a custom symbol WETH", async () => { + const mockResponse = { price: 2000 }; + (fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await provider.fetchPriceBySymbol("WETH"); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + "https://public-api.birdeye.so/defi/price?address=0x32323232323232", + expect.any(Object) + ); + }); + + it("should fetch token security for a custom symbol WETH", async () => { + const mockResponse = { security: "secure" }; + (fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await provider.fetchTokenSecurityBySymbol("WETH"); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + "https://public-api.birdeye.so/defi/token_security?address=0x32323232323232", + expect.any(Object) + ); + }); + + it("should fetch token trade data for a custom symbol WETH", async () => { + const mockResponse = { volume: 500 }; + (fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await provider.fetchTokenTradeDataBySymbol("WETH"); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + "https://public-api.birdeye.so/defi/v3/token/trade-data/single?address=0x32323232323232", + expect.any(Object) + ); + }); + }); + + describe("with cache", () => { + let cacheManager: ICacheManager; + let provider: BirdeyeProvider; + let nodeCache: NodeCache; + + beforeEach(() => { + nodeCache = new NodeCache(); + cacheManager = { + get: vi.fn(), + set: vi.fn(), + } as unknown as ICacheManager; + + provider = new BirdeyeProvider(cacheManager); + provider["cache"] = nodeCache; // Directly set the node cache + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + nodeCache.flushAll(); + }); + + it("should use memory cache when fetching price by symbol", async () => { + const mockResponse = { price: 100 }; + nodeCache.set( + "price/So11111111111111111111111111111111111111112", + mockResponse + ); // Pre-fill cache + + const result = await provider.fetchPriceBySymbol("SOL"); + + expect(result).toEqual(mockResponse); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("should fetch and cache price by symbol", async () => { + const mockResponse = { price: 100 }; + (fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + // First call - should fetch and cache the result + const result1 = await provider.fetchPriceBySymbol("SOL"); + expect(result1).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(1); + + // Second call - should use cache + const result2 = await provider.fetchPriceBySymbol("SOL"); + expect(result2).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(1); // No additional fetch call + }); + + it("should use file system cache when fetching price by symbol", async () => { + const mockResponse = { price: 100 }; + (cacheManager.get as Mock).mockResolvedValue(mockResponse); + + // Memory cache miss, should use file system cache + const result = await provider.fetchPriceBySymbol("SOL"); + expect(result).toEqual(mockResponse); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("should fetch and cache price by symbol using file system cache", async () => { + const mockResponse = { price: 100 }; + (fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + (cacheManager.get as Mock).mockResolvedValue(null); // File system cache miss + + // First call - should fetch and cache the result + const result1 = await provider.fetchPriceBySymbol("SOL"); + expect(result1).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(1); + expect(cacheManager.set).toHaveBeenCalledWith( + expect.stringContaining( + "birdeye/data/price/So11111111111111111111111111111111111111112" + ), + mockResponse, + expect.any(Object) + ); + + // Second call - should use cache + const result2 = await provider.fetchPriceBySymbol("SOL"); + expect(result2).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(1); // No additional fetch call + }); + }); +});