diff --git a/agent/package.json b/agent/package.json index fb4e80eb06..c934c6e9c4 100644 --- a/agent/package.json +++ b/agent/package.json @@ -60,6 +60,7 @@ "@elizaos/plugin-fuel": "workspace:*", "@elizaos/plugin-avalanche": "workspace:*", "@elizaos/plugin-web-search": "workspace:*", + "@elizaos/plugin-birdeye": "workspace:*", "readline": "1.3.0", "ws": "8.18.0", "yargs": "17.7.2" diff --git a/agent/src/index.ts b/agent/src/index.ts index aff7769468..7e2add010a 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -63,7 +63,7 @@ import { zksyncEraPlugin } from "@elizaos/plugin-zksync-era"; import { cronosZkEVMPlugin } from "@elizaos/plugin-cronoszkevm"; import { abstractPlugin } from "@elizaos/plugin-abstract"; import { avalanchePlugin } from "@elizaos/plugin-avalanche"; -import { webSearchPlugin } from "@elizaos/plugin-web-search"; +import { birdeyePlugin } from "@elizaos/plugin-birdeye"; import Database from "better-sqlite3"; import fs from "fs"; import path from "path"; @@ -608,6 +608,7 @@ export async function createAgent( getSecret(character, "AVALANCHE_PRIVATE_KEY") ? avalanchePlugin : null, + getSecret(character, "BIRDEYE_API_KEY") ? birdeyePlugin : null, ].filter(Boolean), providers: [], actions: [], 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 b/packages/plugin-birdeye/README new file mode 100644 index 0000000000..6d9c54b726 --- /dev/null +++ b/packages/plugin-birdeye/README @@ -0,0 +1,25 @@ +# Birdeye plugin for Eliza + +Basic API wrapper for inside our AI agent, almost readonly API only. +For more information please refer to their [docs](https://docs.birdeye.so/reference/get_defi-tokenlist) + +## What you need? + +``` +BIRDEYE_API_KEY=zzz-some-secret +BIRDEYE_WALLET_ADDR=your porfolio profile address +``` + +## Integrate with other plugin + +```js +// init your provider with custom support map +const provider = new BirdeyeProvider(runtime.cacheManager, { + 'WETH': '0xs0000000001231231', + ... +}) + +const price = await provider.fetchTokenPriceBySymbol('WETH') + +// further action based on this +``` 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..3cbaa22469 --- /dev/null +++ b/packages/plugin-birdeye/package.json @@ -0,0 +1,19 @@ +{ + "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:*", + "node-cache": "5.1.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" + } +} diff --git a/packages/plugin-birdeye/src/actions/report.ts b/packages/plugin-birdeye/src/actions/report.ts new file mode 100644 index 0000000000..cfba8bafce --- /dev/null +++ b/packages/plugin-birdeye/src/actions/report.ts @@ -0,0 +1,184 @@ +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) => { + 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/environment.ts b/packages/plugin-birdeye/src/environment.ts new file mode 100644 index 0000000000..47378f9b1b --- /dev/null +++ b/packages/plugin-birdeye/src/environment.ts @@ -0,0 +1,35 @@ +import { IAgentRuntime } from "@elizaos/core"; +import { z } from "zod"; + +export const birdeyeEnvSchema = z.object({ + BIRDEYE_API_KEY: z.string().min(1, "Birdeye API key is required"), +}); + +export type BirdeyeConfig = z.infer; + +export async function validateBirdeyeConfig( + runtime: IAgentRuntime +): Promise { + try { + const config = { + BIRDEYE_API_KEY: + runtime.getSetting("BIRDEYE_API_KEY") || + process.env.BIRDEYE_API_KEY, + BIRDEYE_WALLET_ADDR: + runtime.getSetting("BIRDEYE_WALLET_ADDR") || + process.env.BIRDEYE_WALLET_ADDR, + }; + + return birdeyeEnvSchema.parse(config); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors + .map((err) => `${err.path.join(".")}: ${err.message}`) + .join("\n"); + throw new Error( + `Birdeye configuration validation failed:\n${errorMessages}` + ); + } + throw error; + } +} diff --git a/packages/plugin-birdeye/src/index.ts b/packages/plugin-birdeye/src/index.ts new file mode 100644 index 0000000000..60d18a37f7 --- /dev/null +++ b/packages/plugin-birdeye/src/index.ts @@ -0,0 +1,15 @@ +import { Plugin } from "@elizaos/core"; + +import { birdeyeProvider, BirdeyeProvider } from "./providers/birdeye"; +import { reportToken } from "./actions/report"; + +export { BirdeyeProvider }; + +export const birdeyePlugin: Plugin = { + name: "birdeye", + description: "Birdeye Plugin for Eliza", + providers: [birdeyeProvider], + actions: [reportToken] +}; + +export default birdeyePlugin; diff --git a/packages/plugin-birdeye/src/providers/birdeye.ts b/packages/plugin-birdeye/src/providers/birdeye.ts new file mode 100644 index 0000000000..793dda44f7 --- /dev/null +++ b/packages/plugin-birdeye/src/providers/birdeye.ts @@ -0,0 +1,229 @@ +import NodeCache from "node-cache"; +import * as path from "path"; +import { elizaLogger, ICacheManager, settings } from "@elizaos/core"; +import { IAgentRuntime, Memory, Provider, State } from "@elizaos/core"; + +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); + } +} + +export const birdeyeProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise => { + try { + const provider = new BirdeyeProvider(runtime.cacheManager); + + const walletAddr = runtime.getSetting("BIRDEYE_WALLET_ADDR"); + + const resp = await provider.fetchTokenList().catch((err) => { + elizaLogger.warn("Couldn't update symbol map", err) + }) + + resp?.data?.tokens?.forEach(item => { + DEFAULT_SUPPORTED_SYMBOLS[item.symbol] = item.address + }) + + const supportedTokens = Object.keys(DEFAULT_SUPPORTED_SYMBOLS).join(', ') + + if (!walletAddr) { + console.warn("No Birdeye wallet was specified"); + + return `Birdeye enabled, no wallet found, supported tokens: [${supportedTokens}]`; + } + const response = await provider.fetchWalletPortfolio(walletAddr); + const portfolio = response?.data.items.map(e => e.symbol).join(', ') + + return `Birdeye enabled, wallet addr: ${walletAddr}, portfolio: [${portfolio}], supported tokens: [${supportedTokens}]`; + } catch (error) { + console.error("Error fetching token data:", error); + return "Unable to fetch token information. Please try again later."; + } + }, +}; diff --git a/packages/plugin-birdeye/src/tests/birdeye.test.ts b/packages/plugin-birdeye/src/tests/birdeye.test.ts new file mode 100644 index 0000000000..577fee36eb --- /dev/null +++ b/packages/plugin-birdeye/src/tests/birdeye.test.ts @@ -0,0 +1,293 @@ +import NodeCache from "node-cache"; +import { ICacheManager } from "@elizaos/core"; +import { BirdeyeProvider } from "../providers/birdeye"; + +import { describe, it, expect, beforeEach, afterEach, vi, Mock } 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/tsconfig.json b/packages/plugin-birdeye/tsconfig.json new file mode 100644 index 0000000000..005fbac9d3 --- /dev/null +++ b/packages/plugin-birdeye/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../core/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/plugin-birdeye/tsup.config.ts b/packages/plugin-birdeye/tsup.config.ts new file mode 100644 index 0000000000..047167c52d --- /dev/null +++ b/packages/plugin-birdeye/tsup.config.ts @@ -0,0 +1,14 @@ +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 + ], +}); diff --git a/packages/plugin-story/Readme.md b/packages/plugin-story/README.MX.md similarity index 100% rename from packages/plugin-story/Readme.md rename to packages/plugin-story/README.MX.md diff --git a/packages/plugin-story/README.md b/packages/plugin-story/README.md deleted file mode 100644 index 9f8661626d..0000000000 --- a/packages/plugin-story/README.md +++ /dev/null @@ -1,228 +0,0 @@ -# @elizaos/plugin-story - -The Story Protocol plugin enables interaction with Story Protocol's IP management and licensing system on the Odyssey testnet. - -## Overview - -This plugin provides functionality to: -- Register IP assets on Story Protocol -- License IP assets -- Attach license terms to IP assets -- Query IP asset details and available licenses -- Manage wallet interactions with Story Protocol - -## Installation - -```bash -npm install @elizaos/plugin-story -``` - -## Configuration - -The plugin requires the following environment variables: - -```env -STORY_PRIVATE_KEY=your_private_key -STORY_API_KEY=your_api_key -STORY_API_BASE_URL=https://api.story.xyz -PINATA_JWT=your_pinata_jwt_token -``` - -## Usage - -Import and register the plugin in your Eliza configuration: - -```typescript -import { storyPlugin } from "@elizaos/plugin-story"; - -export default { - plugins: [storyPlugin], - // ... other configuration -}; -``` - -## Features - -### Register IP - -Register a new IP asset on Story Protocol: - -```typescript -// Example conversation -User: "I want to register my IP titled 'My Story' with the description 'An epic tale'" -Assistant: "I'll help you register your IP on Story Protocol..." -``` - -### License IP - -License an existing IP asset: - -```typescript -// Example conversation -User: "I want to license IP Asset 0x1234...5678 with license terms ID 1" -Assistant: "I'll help you license that IP asset..." -``` - -### Attach Terms - -Attach license terms to an IP asset: - -```typescript -// Example conversation -User: "I want to attach commercial license terms with 10% revenue share to IP 0x1234...5678" -Assistant: "I'll help you attach those license terms..." -``` - -### Get IP Details - -Query details about an IP asset: - -```typescript -// Example conversation -User: "Get details for IP Asset 0x1234...5678" -Assistant: "Here are the details for that IP asset..." -``` - -### Get Available Licenses - -Query available licenses for an IP asset: - -```typescript -// Example conversation -User: "What licenses are available for IP Asset 0x1234...5678?" -Assistant: "Here are the available licenses..." -``` - -## API Reference - -### Actions - -- `REGISTER_IP`: Register a new IP asset -- `LICENSE_IP`: License an existing IP asset -- `ATTACH_TERMS`: Attach license terms to an IP -- `GET_IP_DETAILS`: Get details about an IP -- `GET_AVAILABLE_LICENSES`: Get available licenses for an IP - -### Providers - -- `storyWalletProvider`: Manages wallet interactions with Story Protocol - -## Development - -### Building - -```bash -npm run build -``` - -### Testing - -```bash -npm run test -``` - -## Dependencies - -- `@story-protocol/core-sdk`: Core SDK for Story Protocol -- `@pinata/sdk`: IPFS pinning service -- `viem`: Ethereum interaction library -- Other standard dependencies listed in package.json - -## Future Enhancements - -The following features and improvements are planned for future releases: - -1. **IP Management** - - Batch IP registration - - Advanced metadata management - - IP relationship mapping - - Automated IP verification - - Collection management - - IP analytics dashboard - -2. **Licensing Features** - - Custom license templates - - License negotiation tools - - Automated royalty distribution - - Usage tracking system - - License violation detection - - Bulk licensing tools - -3. **Rights Management** - - Advanced permission systems - - Rights transfer automation - - Usage rights tracking - - Derivative works management - - Rights verification tools - - Dispute resolution system - -4. **Smart Contract Integration** - - Contract deployment templates - - Automated verification - - Contract upgrade system - - Security analysis tools - - Gas optimization - - Multi-signature support - -5. **Content Management** - - Media file handling - - Content versioning - - Distribution tracking - - Content authentication - - Storage optimization - - Format conversion tools - -6. **Revenue Management** - - Automated payments - - Revenue sharing tools - - Payment tracking - - Financial reporting - - Tax documentation - - Audit trail system - -7. **Developer Tools** - - Enhanced SDK features - - Testing framework - - Documentation generator - - CLI improvements - - Integration templates - - Performance monitoring - -8. **Analytics and Reporting** - - Usage statistics - - Revenue analytics - - License tracking - - Performance metrics - - Custom reporting - - Market analysis tools - -We welcome community feedback and contributions to help prioritize these enhancements. - -## Contributing - -Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for more information. - -## Credits - -This plugin integrates with and builds upon several key technologies: - -- [Story Protocol](https://www.story.xyz/): IP management and licensing platform -- [@story-protocol/core-sdk](https://www.npmjs.com/package/@story-protocol/core-sdk): Official Story Protocol SDK -- [@pinata/sdk](https://www.npmjs.com/package/@pinata/sdk): IPFS pinning service -- [viem](https://www.npmjs.com/package/viem): Ethereum interaction library - -Special thanks to: -- The Story Protocol team for developing the IP management platform -- The Story Protocol Developer community -- The Pinata team for IPFS infrastructure -- The Eliza community for their contributions and feedback - -For more information about Story Protocol capabilities: -- [Story Protocol Documentation](https://docs.story.xyz/) -- [Story Protocol Dashboard](https://app.story.xyz/) -- [Story Protocol Blog](https://www.story.xyz/blog) -- [Story Protocol GitHub](https://github.com/storyprotocol) - -## License - -This plugin is part of the Eliza project. See the main project repository for license information. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b0f761e46..b6c6ae2a95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: '@elizaos/plugin-avalanche': specifier: workspace:* version: link:../packages/plugin-avalanche + '@elizaos/plugin-birdeye': + specifier: workspace:* + version: link:../packages/plugin-birdeye '@elizaos/plugin-bootstrap': specifier: workspace:* version: link:../packages/plugin-bootstrap @@ -1050,6 +1053,21 @@ importers: 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)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) + packages/plugin-birdeye: + dependencies: + '@elizaos/core': + specifier: workspace:* + version: link:../core + node-cache: + specifier: 5.1.2 + version: 5.1.2 + 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)(tsx@4.19.2)(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) + packages/plugin-bootstrap: dependencies: '@elizaos/core':