diff --git a/typescript/.changeset/famous-pugs-divide.md b/typescript/.changeset/famous-pugs-divide.md new file mode 100644 index 000000000..80ffd0ea8 --- /dev/null +++ b/typescript/.changeset/famous-pugs-divide.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": minor +--- + +adding defillama action provider for find protocol, get protocol, and get token price diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index 1584741f4..1a87bcb58 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -192,6 +192,23 @@ const agent = createReactAgent({
+DefiLlama + + + + + + + + + + + + + +
find_protocolSearches for DeFi protocols on DefiLlama by name, returning protocol metadata including TVL, chain, and category.
get_protocolFetches detailed information about a specific protocol from DefiLlama, including TVL, description, and historical data.
get_token_pricesFetches current token prices from DefiLlama for specified token addresses with chain prefixes.
+
+
ERC20 diff --git a/typescript/agentkit/src/action-providers/defillama/README.md b/typescript/agentkit/src/action-providers/defillama/README.md new file mode 100644 index 000000000..052776394 --- /dev/null +++ b/typescript/agentkit/src/action-providers/defillama/README.md @@ -0,0 +1,335 @@ +# DefiLlama Action Provider + +This directory contains the DefiLlama action provider implementation, which provides actions to interact with the DefiLlama API for fetching DeFi protocol information and token prices. + +## Directory Structure + +``` +defillama/ +├── constants.ts # API endpoints and other constants +├── defillamaActionProvider.test.ts # Tests for the provider +├── defillamaActionProvider.ts # Main provider with DefiLlama API functionality +├── index.ts # Main exports +├── README.md # Documentation +├── schemas.ts # DefiLlama action schemas +└── types.ts # Type definitions +``` + +## Actions + +- `find_protocol`: Search for protocols by name + + - Case-insensitive search across all DefiLlama protocols + - Returns array of matching protocols with metadata + - Returns descriptive message when no matches found + +- `get_protocol`: Get detailed information about a specific protocol + + - Fetches comprehensive protocol data including TVL + - Returns formatted JSON with protocol details + - Handles non-existent protocols gracefully + +- `get_token_prices`: Get current prices for specified tokens + - Accepts tokens with chain prefix (e.g., 'ethereum:0x...') + - Optional time range parameter for historical prices + - Returns formatted JSON with current token prices + +## Examples + +### Finding a Protocol + +```bash +Prompt: can you search for an eigen protocol? + +------------------- +[ + { + "id": "3107", + "name": "EigenLayer", + "address": "0xec53bf9167f50cdeb3ae105f56099aaab9061f83", + "symbol": "EIGEN", + "url": "https://www.eigenlayer.xyz/", + "description": "EigenLayer is a protocol built on Ethereum that introduces restaking, a new primitive in cryptoeconomic security. This primitive enables the reuse of ETH on the consensus layer", + "chain": "Ethereum", + "logo": "https://icons.llama.fi/eigenlayer.png", + "audits": "2", + "audit_note": null, + "gecko_id": "eigenlayer", + "cmcId": null, + "category": "Restaking", + "chains": [ + "Ethereum" + ], + "module": "eigenlayer/index.js", + "twitter": "eigenlayer", + "oracles": [], + "forkedFrom": [], + "audit_links": [ + "https://docs.eigenlayer.xyz/security/audits" + ], + "github": [ + "Layr-Labs" + ], + "listedAt": 1686776222, + "slug": "eigenlayer", + "tvl": 8951735130.458426, + "chainTvls": { + "Ethereum-staking": 522332864.5310426, + "Ethereum": 8951735130.458426, + "staking": 522332864.5310426 + }, + "change_1h": -0.8573676649778434, + "change_1d": -11.65410033967217, + "change_7d": -14.671214345715782, + "tokenBreakdowns": {}, + "mcap": 336877554.1087164, + "staking": 522332864.5310426 + }, + { + "id": "4075", + "name": "Eigenpie", + "address": null, + "symbol": "-", + "url": "https://www.eigenlayer.magpiexyz.io", + "description": "Eigenpie is an innovative SubDAO created by Magpie, focusing on the restaking of ETH LSTs via EigenLayer. As a liquid restaking platform for Ethereum, Eigenpie’s core mechanism enables users to convert their Liquid Staked ETH tokens into Isolated Liquid Restaked ETH tokens. These are restaked versions of ETH LSTs, created by Eigenpie. This process allows users to earn passive income from Ethereum staking and EigenLayer revenue simultaneously, without requiring a lockup period.", + "chain": "Multi-Chain", + "logo": "https://icons.llama.fi/eigenpie.jpg", + "audits": "2", + "audit_note": null, + "gecko_id": null, + "cmcId": null, + "category": "Liquid Restaking", + "chains": [ + "Zircuit", + "Ethereum" + ], + "oracles": [ + "Chainlink" + ], + "forkedFrom": [], + "module": "eigenpie/index.js", + "twitter": "Eigenpiexyz_io", + "parentProtocol": "parent#magpie-ecosystem", + "audit_links": [ + "https://github.com/peckshield/publications/blob/master/audit_reports/PeckShield-Audit-Report-Eigenpie-v1.0.pdf" + ], + "listedAt": 1706537731, + "slug": "eigenpie", + "tvl": 552741078.9743935, + "chainTvls": { + "Zircuit": 341269361.5768466, + "Ethereum": 211471717.39754686 + }, + "change_1h": 3.8755075071811547, + "change_1d": -7.644740844217878, + "change_7d": -13.991325628506374, + "tokenBreakdowns": {}, + "mcap": null + } +] +------------------- +I found two protocols related to "Eigen": + +1. **EigenLayer** + - **Description:** EigenLayer is a protocol built on Ethereum that introduces restaking, enabling the reuse of ETH on the consensus layer for cryptoeconomic security. + - **Symbol:** EIGEN + - **Chain:** Ethereum + - **TVL:** $8,951,735,130.46 + - **Market Cap:** $336,877,554.11 + - **1h Change:** -0.86% + - **1d Change:** -11.65% + - **URL:** [EigenLayer](https://www.eigenlayer.xyz/) + - **Logo:** ![EigenLayer Logo](https://icons.llama.fi/eigenlayer.png) + - **Audit Information:** [Audit Links](https://docs.eigenlayer.xyz/security/audits) + +2. **Eigenpie** + - **Description:** Eigenpie is an innovative SubDAO created by Magpie, focusing on the restaking of ETH LSTs via EigenLayer, allowing users to earn passive income from both Ethereum staking and EigenLayer revenue. + - **Chain:** Multi-Chain (Zircuit, Ethereum) + - **TVL:** $552,741,078.97 + - **1h Change:** +3.88% + - **1d Change:** -7.64% + - **URL:** [Eigenpie](https://www.eigenlayer.magpiexyz.io) + - **Logo:** ![Eigenpie Logo](https://icons.llama.fi/eigenpie.jpg) + - **Audit Information:** [Audit Report](https://github.com/peckshield/publications/blob/master/audit_reports/PeckShield-Audit-Report-Eigenpie-v1.0.pdf) + +Let me know if you would like more details on either protocol! +------------------- +``` + +### Getting Protocol Details +```bash +Prompt: please use get_protocol for jito + +------------------- +{ + "id": "2308", + "name": "Jito", + "address": "solana:jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL", + "symbol": "JTO", + "url": "https://jito.network", + "description": "MEV-Boosted Staking Rewards", + "chain": "Solana", + "logo": "https://icons.llama.fi/jito.png", + "audits": "2", + "audit_note": null, + "gecko_id": "jito-governance-token", + "cmcId": null, + "category": "Liquid Staking", + "chains": [ + "Solana" + ], + "module": "jito/index.js", + "twitter": "jito_sol", + "forkedFrom": [ + "SPL Stake Pool" + ], + "audit_links": [ + "https://spl.solana.com/stake-pool#security-audits", + "https://2926710696-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Ffrb9MGTK6eZJlEQJyylq%2Fuploads%2F1jfEDpGcd5YlnHusbKYO%2FNeodymeJito.pdf" + ], + "oracles": [], + "oraclesBreakdown": [ + { + "name": "Switchboard", + "type": "Secondary", + "proof": [ + "https://github.com/DefiLlama/defillama-server/pull/9193" + ] + } + ], + "listedAt": 1669265429, + "github": [ + "jito-foundation" + ], + "chainTvls": { + "Solana": { + "tvl": [ + { + "date": 1741842011, + "totalLiquidityUSD": 2050645658.69502 + }, + ... + ], + "tokensInUsd": [ + { + "date": 1741842011, + "tokens": { + "SOL": 2050645658.69502 + } + }, + ... + ], + "tokens": [ + { + "date": 1741842011, + "tokens": { + "SOL": 16533464.95763 + } + }, + ... + ] + } + }, + "tvl": [ + { + "date": 1741842011, + "totalLiquidityUSD": 2050645658.69502 + }, + ... + ], + "tokensInUsd": [ + { + "date": 1741842011, + "tokens": { + "SOL": 2050645658.69502 + } + }, + ... + ], + "tokens": [ + { + "date": 1741842011, + "tokens": { + "SOL": 16533464.95763 + } + }, + ... + ], + "currentChainTvls": { + "Solana": 2050645658.69502 + }, + "raises": [], + "metrics": {}, + "mcap": 642311894.5063655, + "methodology": "Uses the SPL Stake Pool SDK to fetch the total supply of deposited SOL into the Jito Stake Pool" +} +------------------- +Here are the details for the Jito protocol: + +- **Name:** Jito +- **Address:** [jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL](https://explorer.solana.com/address/jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL) +- **Symbol:** JTO +- **Description:** MEV-Boosted Staking Rewards +- **Category:** Liquid Staking +- **Chains:** Solana +- **Current TVL:** $2,050,645,658.70 +- **Market Cap:** $642,311,894.51 +- **Audits:** 2 +- **Audit Links:** + - [Audit 1](https://spl.solana.com/stake-pool#security-audits) + - [Audit 2](https://2926710696-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Ffrb9MGTK6eZJlEQJyylq%2Fuploads%2F1jfEDpGcd5YlnHusbKYO%2FNeodymeJito.pdf) +- **Twitter:** [@jito_sol](https://twitter.com/jito_sol) +- **Website:** [jito.network](https://jito.network) + +![Jito Logo](https://icons.llama.fi/jito.png) + +### Historical TVL Data: +- **Date:** 2023-08-07 - TVL: $2,050,645,658.70 +- **Date:** 2023-08-06 - TVL: $2,073,190,249.57 +- **Date:** 2023-08-05 - TVL: $1,878,593,066.31 +- **Date:** 2023-08-04 - TVL: $2,060,444,245.40 +- **Date:** 2023-08-03 - TVL: $2,266,226,563.53 + +For more technical inquiries, the protocol utilizes the SPL Stake Pool SDK for its operations. +------------------- +```` + +### Getting Token Prices +```bash +Prompt: What is the current price of USDC and WETH on Ethereum? USDC's token address is: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48, WETH's token address is: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 + +------------------- +{ + "coins": { + "ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": { + "decimals": 6, + "symbol": "USDC", + "price": 0.999958, + "timestamp": 1741115101, + "confidence": 0.99 + }, + "ethereum:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": { + "decimals": 18, + "symbol": "WETH", + "price": 2154.41, + "timestamp": 1741115124, + "confidence": 0.99 + } + } +} +------------------- +The current prices on Ethereum are as follows: +- USDC: approximately $0.999958 +- WETH: approximately $2154.41 +------------------- +```` + +## Adding New Actions + +To add new DefiLlama actions: + +1. Define your schema in `schemas.ts` +2. Implement your action in `defillamaActionProvider.ts` +3. Add corresponding tests in `defillamaActionProvider.test.ts` + +Note: The provider is network-agnostic and can be used with any blockchain network supported by DefiLlama. diff --git a/typescript/agentkit/src/action-providers/defillama/constants.ts b/typescript/agentkit/src/action-providers/defillama/constants.ts new file mode 100644 index 000000000..f12db2e8c --- /dev/null +++ b/typescript/agentkit/src/action-providers/defillama/constants.ts @@ -0,0 +1,9 @@ +/** + * Base URL for the DefiLlama API + */ +export const DEFILLAMA_BASE_URL = "https://api.llama.fi"; + +/** + * URL for the DefiLlama price API + */ +export const DEFILLAMA_PRICES_URL = "https://coins.llama.fi"; diff --git a/typescript/agentkit/src/action-providers/defillama/defillamaActionProvider.test.ts b/typescript/agentkit/src/action-providers/defillama/defillamaActionProvider.test.ts new file mode 100644 index 000000000..4127bfbd9 --- /dev/null +++ b/typescript/agentkit/src/action-providers/defillama/defillamaActionProvider.test.ts @@ -0,0 +1,134 @@ +import { defillamaActionProvider } from "./defillamaActionProvider"; +import { Protocol } from "./types"; + +describe("DefiLlamaActionProvider", () => { + const fetchMock = jest.fn(); + global.fetch = fetchMock; + + const provider = defillamaActionProvider(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("getTokenPrices", () => { + it("should return token prices when API call is successful", async () => { + const mockResponse = { + "ethereum:0x1234": { price: 1800 }, + }; + fetchMock.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(mockResponse) }); + + const result = await provider.getTokenPrices({ + tokens: ["ethereum:0x1234"], + }); + expect(JSON.parse(result)).toEqual(mockResponse); + }); + + it("should handle API errors gracefully", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 404 }); + const result = await provider.getTokenPrices({ + tokens: ["ethereum:0x1234"], + }); + expect(result).toContain("Error fetching token prices"); + }); + + it("should handle network errors", async () => { + const error = new Error("Network error"); + fetchMock.mockRejectedValue(error); + + const result = await provider.getTokenPrices({ + tokens: ["ethereum:0x1234"], + }); + expect(result).toContain("Error fetching token prices"); + expect(result).toContain("Network error"); + }); + }); + + describe("getProtocol", () => { + const mockProtocol: Protocol = { + name: "Uniswap", + symbol: "UNI", + description: "Decentralized exchange protocol", + category: "DEX", + chain: "Ethereum", + tvl: 1000000, + url: "https://uniswap.org", + logo: "https://example.com/logo.png", + }; + + it("should return protocol information when API call is successful", async () => { + fetchMock.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(mockProtocol) }); + + const result = await provider.getProtocol({ protocolId: "uniswap" }); + expect(JSON.parse(result)).toEqual(mockProtocol); + }); + + it("should handle API errors gracefully", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500 }); + const result = await provider.getProtocol({ protocolId: "invalid-protocol" }); + expect(result).toContain("Error fetching protocol information"); + expect(result).toContain("500"); + }); + + it("should handle empty response", async () => { + fetchMock.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(null) }); + const result = await provider.getProtocol({ protocolId: "non-existent" }); + expect(JSON.parse(result)).toEqual(null); + }); + }); + + describe("searchProtocols", () => { + const mockProtocols: Protocol[] = [ + { + name: "Uniswap", + symbol: "UNI", + category: "DEX", + chain: "Ethereum", + tvl: 1000000, + }, + { + name: "UniswapV3", + symbol: "UNI", + category: "DEX", + chain: "Ethereum", + tvl: 2000000, + }, + ]; + + it("should return matching protocols", async () => { + fetchMock.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(mockProtocols) }); + + const result = await provider.searchProtocols({ query: "uniswap" }); + expect(JSON.parse(result)).toEqual(mockProtocols); + }); + + it("should handle API errors gracefully", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 429 }); + const result = await provider.searchProtocols({ query: "uniswap" }); + expect(result).toContain("Error searching protocols"); + expect(result).toContain("429"); + }); + + it("should handle empty search results", async () => { + fetchMock.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue([]) }); + const result = await provider.searchProtocols({ query: "nonexistentprotocol" }); + expect(result).toContain('No protocols found matching "nonexistentprotocol"'); + }); + + it("should handle malformed response", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ invalid: "response" }), + }); + const result = await provider.searchProtocols({ query: "uniswap" }); + expect(result).toContain("Error searching protocols"); + }); + + it("should filter protocols case-insensitively", async () => { + fetchMock.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(mockProtocols) }); + + const result = await provider.searchProtocols({ query: "UNISWAP" }); + expect(JSON.parse(result)).toEqual(mockProtocols); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/defillama/defillamaActionProvider.ts b/typescript/agentkit/src/action-providers/defillama/defillamaActionProvider.ts new file mode 100644 index 000000000..d4027fd85 --- /dev/null +++ b/typescript/agentkit/src/action-providers/defillama/defillamaActionProvider.ts @@ -0,0 +1,168 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { GetTokenPricesSchema, GetProtocolSchema, SearchProtocolsSchema } from "./schemas"; +import { DEFILLAMA_BASE_URL, DEFILLAMA_PRICES_URL } from "./constants"; +import { Protocol, ProtocolResponse } from "./types"; +import { pruneGetProtocolResponse } from "./utils"; + +/** + * DefiLlamaActionProvider is an action provider for DefiLlama API interactions. + * Provides functionality to fetch token prices, protocol information, and search protocols. + */ +export class DefiLlamaActionProvider extends ActionProvider { + /** + * Constructor for the DefiLlamaActionProvider class. + */ + constructor() { + super("defillama", []); + } + + /** + * Searches for protocols on DefiLlama. + * Note: This performs a case-insensitive search on protocol names. + * Returns all protocols whose names contain the search query. + * + * @param args - The protocol search parameters + * @returns A JSON string containing matching protocols or error message + */ + @CreateAction({ + name: "find_protocol", + description: `This tool will search for DeFi protocols on DefiLlama by name. +It takes the following inputs: +- A search query string to match against protocol names + +Important notes: +- The search is case-insensitive +- Returns all protocols whose names contain the search query +- Returns metadata including TVL, chain, category, and other protocol details +- Returns a "No protocols found" message if no matches are found`, + schema: SearchProtocolsSchema, + }) + async searchProtocols(args: z.infer): Promise { + try { + const url = `${DEFILLAMA_BASE_URL}/protocols`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const protocols = await response.json(); + const searchResults = protocols.filter((protocol: Protocol) => + protocol.name.toLowerCase().includes(args.query.toLowerCase()), + ); + + if (searchResults.length === 0) { + return `No protocols found matching "${args.query}"`; + } + + return JSON.stringify(searchResults, null, 2); + } catch (error: unknown) { + return `Error searching protocols: ${error}`; + } + } + + /** + * Gets detailed information about a specific protocol. + * Note: Returns null if the protocol is not found. + * The response includes TVL, description, category, and other metadata. + * Time-series data is pruned to keep response size manageable. + * + * @param args - The protocol request parameters + * @returns A JSON string containing time-series pruned protocol information + */ + @CreateAction({ + name: "get_protocol", + description: `This tool will fetch detailed information about a specific protocol from DefiLlama. +It takes the following inputs: +- The protocol identifier from DefiLlama (e.g. uniswap) + +Important notes: +- Returns null if the protocol is not found +- Returns comprehensive data including TVL, description, category, and other metadata +- Includes historical TVL data and chain-specific breakdowns where available +- Returns error message if the protocol ID is invalid or the request fails +- Prunes time-series data to 5 most recent entries to make the response more manageable`, + schema: GetProtocolSchema, + }) + async getProtocol(args: z.infer): Promise { + try { + const url = `${DEFILLAMA_BASE_URL}/protocol/${args.protocolId}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = (await response.json()) as ProtocolResponse; + const prunedData = pruneGetProtocolResponse(data); + + return JSON.stringify(prunedData, null, 2); + } catch (error: unknown) { + return `Error fetching protocol information: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Gets current token prices from DefiLlama. + * Note: Token addresses must include chain prefix (e.g., 'ethereum:0x...') + * The searchWidth parameter can be used to specify a time range in minutes. + * + * @param args - The token price request parameters + * @returns A JSON string containing token prices or error message + */ + @CreateAction({ + name: "get_token_prices", + description: `This tool will fetch current token prices from DefiLlama. +It takes the following inputs: +- An array of token addresses with chain prefixes +- Optional time range in minutes for historical prices + +Important notes: +- Token addresses MUST include chain prefix (e.g., 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') +- The searchWidth parameter is optional, it's default api value is '4h', leave this blank if unspecified +- Returns current prices for all specified tokens +- Returns error message if any token address is invalid or the request fails`, + schema: GetTokenPricesSchema, + }) + async getTokenPrices(args: z.infer): Promise { + try { + const params = new URLSearchParams({}); + const tokens = args.tokens.join(","); + + if (args.searchWidth) { + params.set("searchWidth", args.searchWidth); + } + + const url = `${DEFILLAMA_PRICES_URL}/prices/current/${tokens}?${params.toString()}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return JSON.stringify(data, null, 2); + } catch (error: unknown) { + return `Error fetching token prices: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Checks if the DefiLlama action provider supports the given network. + * DefiLlama is network-agnostic, so this always returns true. + * + * @returns True, as DefiLlama actions are supported on all networks. + */ + supportsNetwork(): boolean { + return true; + } +} + +/** + * Creates a new instance of the DefiLlama action provider. + * + * @returns A new DefiLlamaActionProvider instance + */ +export const defillamaActionProvider = () => new DefiLlamaActionProvider(); diff --git a/typescript/agentkit/src/action-providers/defillama/index.ts b/typescript/agentkit/src/action-providers/defillama/index.ts new file mode 100644 index 000000000..611b92cc6 --- /dev/null +++ b/typescript/agentkit/src/action-providers/defillama/index.ts @@ -0,0 +1 @@ +export * from "./defillamaActionProvider"; diff --git a/typescript/agentkit/src/action-providers/defillama/schemas.ts b/typescript/agentkit/src/action-providers/defillama/schemas.ts new file mode 100644 index 000000000..59c4358e6 --- /dev/null +++ b/typescript/agentkit/src/action-providers/defillama/schemas.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +/** + * Input schema for getting protocol information + */ +export const GetProtocolSchema = z + .object({ + protocolId: z.string().describe("The protocol identifier from DefiLlama"), + }) + .strict(); + +/** + * Input schema for getting token prices + */ +export const GetTokenPricesSchema = z + .object({ + tokens: z + .array(z.string()) + .describe("Array of token addresses with chain prefix, e.g., ['ethereum:0x...']"), + searchWidth: z + .string() + .optional() + .describe("Optional time range in minutes to search for prices, default api value is '4h'"), + }) + .strict(); + +/** + * Input schema for searching protocols + */ +export const SearchProtocolsSchema = z + .object({ + query: z.string().describe("Search query to find protocols"), + }) + .strict(); diff --git a/typescript/agentkit/src/action-providers/defillama/types.ts b/typescript/agentkit/src/action-providers/defillama/types.ts new file mode 100644 index 000000000..6db5bc775 --- /dev/null +++ b/typescript/agentkit/src/action-providers/defillama/types.ts @@ -0,0 +1,85 @@ +/** + * Interface representing a DeFi protocol from DefiLlama + */ +export interface Protocol { + name: string; + address?: string; + symbol?: string; + url?: string; + description?: string; + chain?: string; + logo?: string; + audits?: string; + category?: string; + tvl?: number; +} + +/** + * Interface representing a time-series data point with a date + */ +export interface TimeSeriesDataPoint { + date: number; + totalLiquidityUSD: number; +} + +/** + * Interface representing a time-series data point with tokens + */ +export interface TokenTimeSeriesDataPoint { + date: number; + tokens: Record; +} + +/** + * Type for representing generic metadata values that can be returned by DefiLlama API + */ +export type MetadataValue = string | number | boolean | null | Record | unknown[]; + +/** + * Interface representing DefiLlama protocol response + */ +export interface ProtocolResponse { + id: string; + name: string; + address?: string; + symbol?: string; + url?: string; + description?: string; + chain?: string; + logo?: string; + audits?: string; + audit_note?: string | null; + gecko_id?: string | null; + cmcId?: string | null; + category?: string; + + chains?: string[]; + oracles?: string[]; + forkedFrom?: string[]; + audit_links?: string[]; + github?: string[]; + + tvl?: TimeSeriesDataPoint[]; + tokensInUsd?: TokenTimeSeriesDataPoint[]; + tokens?: TokenTimeSeriesDataPoint[]; + chainTvls?: Record< + string, + { + tvl?: TimeSeriesDataPoint[]; + tokensInUsd?: TokenTimeSeriesDataPoint[]; + tokens?: TokenTimeSeriesDataPoint[]; + } + >; + currentChainTvls?: Record; + + // Other metadata + raises?: Record[]; + metrics?: Record; + mcap?: number; + methodology?: string; +} + +/** + * Type for the pruned protocol response - same structure as the input + */ +export type PrunedProtocolResponse = ProtocolResponse; diff --git a/typescript/agentkit/src/action-providers/defillama/utils.test.ts b/typescript/agentkit/src/action-providers/defillama/utils.test.ts new file mode 100644 index 000000000..fcf66440d --- /dev/null +++ b/typescript/agentkit/src/action-providers/defillama/utils.test.ts @@ -0,0 +1,154 @@ +import { pruneGetProtocolResponse } from "./utils"; +import { ProtocolResponse, TimeSeriesDataPoint, TokenTimeSeriesDataPoint } from "./types"; + +describe("DefiLlama Utilities", () => { + describe("pruneGetProtocolResponse", () => { + it("should handle null input", () => { + expect(pruneGetProtocolResponse(null)).toBeNull(); + }); + + it("should not modify objects without time-series data", () => { + const input: ProtocolResponse = { + id: "test-protocol", + name: "Protocol", + symbol: "ABC", + }; + const result = pruneGetProtocolResponse(input); + expect(result).not.toBeNull(); + expect(result).toEqual(input); + }); + + it("should prune time-series arrays", () => { + const timeSeriesData: TimeSeriesDataPoint[] = Array.from({ length: 20 }, (_, i) => ({ + date: Date.now() - i * 86400000, + totalLiquidityUSD: 1000 + i, + })); + + const input: ProtocolResponse = { + id: "test-protocol", + name: "Protocol", + tvl: [...timeSeriesData], + }; + + const result = pruneGetProtocolResponse(input, 5); + expect(result).not.toBeNull(); + + if (result) { + expect(result.name).toBe("Protocol"); + expect(result.tvl).toHaveLength(5); + + const resultDates = result.tvl!.map(entry => entry.date); + const sortedDates = [...resultDates].sort((a, b) => b - a); + expect(resultDates).toEqual(sortedDates); + } + }); + + it("should prune nested time-series arrays in chainTvls", () => { + const timeSeriesData: TimeSeriesDataPoint[] = Array.from({ length: 20 }, (_, i) => ({ + date: Date.now() - i * 86400000, + totalLiquidityUSD: 1000 + i, + })); + + const tokenTimeSeriesData: TokenTimeSeriesDataPoint[] = Array.from( + { length: 20 }, + (_, i) => ({ + date: Date.now() - i * 86400000, + tokens: { ETH: 1000 + i }, + }), + ); + + const input: ProtocolResponse = { + id: "test-protocol", + name: "Protocol", + chainTvls: { + Ethereum: { + tvl: [...timeSeriesData], + tokens: [...tokenTimeSeriesData], + }, + Polygon: { + tvl: [...timeSeriesData], + tokensInUsd: [...tokenTimeSeriesData], + }, + }, + }; + + const result = pruneGetProtocolResponse(input, 3); + expect(result).not.toBeNull(); + + if (result) { + expect(result.name).toBe("Protocol"); + expect(result.chainTvls).toBeDefined(); + + if (result.chainTvls) { + expect(result.chainTvls.Ethereum.tvl).toHaveLength(3); + expect(result.chainTvls.Ethereum.tokens).toHaveLength(3); + expect(result.chainTvls.Polygon.tvl).toHaveLength(3); + expect(result.chainTvls.Polygon.tokensInUsd).toHaveLength(3); + + const ethereumTvlDates = result.chainTvls.Ethereum.tvl!.map(entry => entry.date); + const sortedDates = [...ethereumTvlDates].sort((a, b) => b - a); + expect(ethereumTvlDates).toEqual(sortedDates); + } + } + }); + + it("should respect the maxEntries parameter", () => { + const timeSeriesData: TimeSeriesDataPoint[] = Array.from({ length: 50 }, (_, i) => ({ + date: Date.now() - i * 86400000, + totalLiquidityUSD: 1000 + i, + })); + + const tokenTimeSeriesData: TokenTimeSeriesDataPoint[] = Array.from( + { length: 50 }, + (_, i) => ({ + date: Date.now() - i * 86400000, + tokens: { ETH: 1000 + i }, + }), + ); + + const input: ProtocolResponse = { + id: "test-protocol", + name: "Protocol", + tvl: [...timeSeriesData], + tokens: [...tokenTimeSeriesData], + }; + + const result1 = pruneGetProtocolResponse(input, 1); + expect(result1).not.toBeNull(); + + if (result1) { + expect(result1.tvl).toHaveLength(1); + expect(result1.tokens).toHaveLength(1); + } + + const result10 = pruneGetProtocolResponse(input, 10); + expect(result10).not.toBeNull(); + + if (result10) { + expect(result10.tvl).toHaveLength(10); + expect(result10.tokens).toHaveLength(10); + } + }); + + it("should handle arrays without date property", () => { + const nonDateArray = Array.from({ length: 20 }, (_, i) => ({ + value: 1000 + i, + name: `Item ${i}`, + })); + + // Using a type assertion for this test case as it's testing a specific behavior + const input = { + id: "test-protocol", + name: "Protocol", + tvl: [...nonDateArray], + } as unknown as ProtocolResponse; + + const result = pruneGetProtocolResponse(input, 5); + expect(result).not.toBeNull(); + + if (result) { + expect(result.tvl).toHaveLength(5); + } + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/defillama/utils.ts b/typescript/agentkit/src/action-providers/defillama/utils.ts new file mode 100644 index 000000000..d59597943 --- /dev/null +++ b/typescript/agentkit/src/action-providers/defillama/utils.ts @@ -0,0 +1,114 @@ +import { ProtocolResponse, PrunedProtocolResponse } from "./types"; + +/** + * Represents a data point in a time-series array with a date property + */ +interface TimeSeriesDataPoint { + [key: string]: unknown; + date: number; +} + +/** + * Processes a time-series array by sorting by date (newest first) and limiting to maxEntries + * + * @param array - The time-series array to process + * @param maxEntries - Maximum number of entries to keep + * @returns The processed array (sorted and limited) + */ +const processTimeSeriesArray = (array: unknown[], maxEntries: number): unknown[] => { + if (array.length <= maxEntries) { + return array; + } + + // Sort by date if array items have date property + if (array.length > 0 && typeof array[0] === "object" && array[0] !== null && "date" in array[0]) { + array.sort((a, b) => { + if ( + a && + b && + typeof a === "object" && + typeof b === "object" && + "date" in a && + "date" in b && + typeof (a as TimeSeriesDataPoint).date === "number" && + typeof (b as TimeSeriesDataPoint).date === "number" + ) { + return (b as TimeSeriesDataPoint).date - (a as TimeSeriesDataPoint).date; + } + return 0; + }); + } + + return array.slice(0, maxEntries); +}; + +/** + * Prunes the protocol response by limiting time-series data arrays + * to show only the most recent entries. + * + * @param data - The original protocol data from DefiLlama API + * @param maxEntries - The maximum number of time-series entries to keep (default: 5) + * @returns A pruned copy of the protocol data + */ +export const pruneGetProtocolResponse = ( + data: ProtocolResponse | null, + maxEntries = 5, +): PrunedProtocolResponse | null => { + if (!data) { + return null; + } + + const result: PrunedProtocolResponse = { ...data }; + + const timeSeriesArrayPaths = ["tvl", "tokens", "tokensInUsd"]; + + const processNestedObject = (obj: unknown, currentPath = ""): unknown => { + if (!obj || typeof obj !== "object") { + return obj; + } + + if (Array.isArray(obj)) { + const isTimeSeriesArray = timeSeriesArrayPaths.some( + path => currentPath === path || currentPath.endsWith(`.${path}`), + ); + + if (isTimeSeriesArray) { + return processTimeSeriesArray(obj, maxEntries); + } + + for (let i = 0; i < obj.length; i++) { + obj[i] = processNestedObject(obj[i], `${currentPath}[${i}]`); + } + } else if (obj !== null) { + // Safe to cast to Record since we know it's an object and not null + const record = obj as Record; + + for (const key of Object.keys(record)) { + const newPath = currentPath ? `${currentPath}.${key}` : key; + const value = record[key]; + if (value && typeof value === "object") { + record[key] = processNestedObject(value, newPath); + } + } + } + + return obj; + }; + + // Special handling for chainTvls if it exists + if (result.chainTvls) { + for (const chain of Object.keys(result.chainTvls)) { + const chainData = result.chainTvls[chain]; + + for (const timeSeriesKey of timeSeriesArrayPaths) { + if (chainData[timeSeriesKey] && Array.isArray(chainData[timeSeriesKey])) { + chainData[timeSeriesKey] = processTimeSeriesArray(chainData[timeSeriesKey], maxEntries); + } + } + } + } + + processNestedObject(result); + + return result; +}; diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index a4e9453dd..105b54314 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -7,6 +7,7 @@ export * from "./alchemy"; export * from "./basename"; export * from "./cdp"; export * from "./compound"; +export * from "./defillama"; export * from "./erc20"; export * from "./erc721"; export * from "./farcaster";