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 b0ac9dbe48..bc74b22730 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,15 @@ 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 { abstractPlugin } from "@elizaos/plugin-abstract";
import { aptosPlugin } from "@elizaos/plugin-aptos";
+import { birdeyePlugin } from "@elizaos/plugin-birdeye";
import {
advancedTradePlugin,
coinbaseCommercePlugin,
@@ -43,7 +45,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,17 +52,17 @@ 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";
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
@@ -481,6 +482,7 @@ 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/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/.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/actions/defi/get-ohlcv.ts b/packages/plugin-birdeye/src/actions/defi/get-ohlcv.ts
new file mode 100644
index 0000000000..7300cc224f
--- /dev/null
+++ b/packages/plugin-birdeye/src/actions/defi/get-ohlcv.ts
@@ -0,0 +1,388 @@
+import {
+ Action,
+ ActionExample,
+ Content,
+ elizaLogger,
+ Handler,
+ HandlerCallback,
+ IAgentRuntime,
+ Memory,
+ State,
+} from "@elizaos/core";
+import { getTokenMetadata } from "../../services";
+import { BirdeyeChain } from "../../types/shared";
+import { TokenMetadataResponse } from "../../types/token-metadata";
+import {
+ BASE_URL,
+ extractChain,
+ extractContractAddresses,
+ formatTimestamp,
+ formatValue,
+ makeApiRequest,
+} from "../../utils";
+
+// 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: BirdeyeChain,
+ 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: BirdeyeChain,
+ 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: string[] = [];
+ 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].toString(),
+ chain as BirdeyeChain
+ );
+
+ elizaLogger.info(
+ `OHLCV action activated for ${addresses[0]} on ${chain} with ${interval} interval`
+ );
+
+ const ohlcvData = await getOHLCVData(
+ apiKey,
+ addresses[0].toString(),
+ chain as BirdeyeChain,
+ 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 as BirdeyeChain,
+ 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..60ae76f0a0
--- /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 { getTokenMetadata } from "../../services";
+import { BirdeyeChain } from "../../types/shared";
+import { TokenMetadataResponse } from "../../types/token-metadata";
+import {
+ BASE_URL,
+ extractChain,
+ extractContractAddresses,
+ extractTimeRange,
+ formatTimestamp,
+ formatValue,
+ makeApiRequest,
+} from "../../utils";
+// 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: BirdeyeChain,
+ 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: BirdeyeChain,
+ 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: string[] = [];
+ 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].toString(),
+ chain as BirdeyeChain
+ );
+
+ 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].toString(),
+ timeRange.start,
+ timeRange.end,
+ chain as BirdeyeChain,
+ 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 as BirdeyeChain,
+ 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-token-metadata.ts b/packages/plugin-birdeye/src/actions/defi/get-token-metadata.ts
new file mode 100644
index 0000000000..cd8cca6815
--- /dev/null
+++ b/packages/plugin-birdeye/src/actions/defi/get-token-metadata.ts
@@ -0,0 +1,185 @@
+import {
+ Action,
+ ActionExample,
+ Content,
+ elizaLogger,
+ Handler,
+ HandlerCallback,
+ IAgentRuntime,
+ Memory,
+ State,
+} from "@elizaos/core";
+import { BirdeyeChain } from "../../types/shared";
+import {
+ CHAIN_ALIASES,
+ CHAIN_KEYWORDS,
+ extractChain,
+ extractContractAddresses,
+} from "../../utils";
+
+// 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 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 (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 as BirdeyeChain
+ )}`
+ : "I couldn't find a valid token address in your message.";
+ await callback(callbackData);
+ return callbackData;
+ }
+
+ 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: BirdeyeChain): 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/networks.ts b/packages/plugin-birdeye/src/actions/defi/networks.ts
new file mode 100644
index 0000000000..415d9f2080
--- /dev/null
+++ b/packages/plugin-birdeye/src/actions/defi/networks.ts
@@ -0,0 +1,108 @@
+import {
+ Action,
+ ActionExample,
+ Content,
+ elizaLogger,
+ Handler,
+ HandlerCallback,
+ IAgentRuntime,
+ Memory,
+ State,
+} from "@elizaos/core";
+import { BASE_URL, makeApiRequest } from "../../utils.ts";
+import { containsNetworkKeyword, NetworksResponse } from "./networks.utils.ts";
+
+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 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/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/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
new file mode 100644
index 0000000000..d4a835c48d
--- /dev/null
+++ b/packages/plugin-birdeye/src/index.ts
@@ -0,0 +1,22 @@
+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,
+ // getOHLCVAction,
+ // getTokenTradesAction,
+ ],
+ evaluators: [],
+ providers: [addressSearchProvider, birdeyeProvider],
+};
+
+export default birdeyePlugin;
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..2f60ee08d6
--- /dev/null
+++ b/packages/plugin-birdeye/src/providers/address-search-provider.ts
@@ -0,0 +1,114 @@
+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,
+ result: results[0] || 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,
+ result: results[0] || null,
+ }))
+ );
+
+ const results = await Promise.all([
+ ...searchAddressesForTokenMatch,
+ ...searchSymbolsForTokenMatch,
+ ]);
+ 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;
+
+ // 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/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/services.ts b/packages/plugin-birdeye/src/services.ts
new file mode 100644
index 0000000000..ef508b4d00
--- /dev/null
+++ b/packages/plugin-birdeye/src/services.ts
@@ -0,0 +1,174 @@
+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;
+
+ // 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" &&
+ // 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" &&
+ // only return the token that matches the symbol
+ item.result[0].symbol === keyword
+ )
+ .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/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
+ });
+ });
+});
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..ec8d559171
--- /dev/null
+++ b/packages/plugin-birdeye/src/utils.ts
@@ -0,0 +1,566 @@
+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();
+
+ // 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);
+};
+
+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-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
+ ],
+});
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/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=()
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
+}