diff --git a/packages/components/nodes/tools/DexPaprika/DexPaprika.test.ts b/packages/components/nodes/tools/DexPaprika/DexPaprika.test.ts new file mode 100644 index 00000000000..81c90fc0ae6 --- /dev/null +++ b/packages/components/nodes/tools/DexPaprika/DexPaprika.test.ts @@ -0,0 +1,151 @@ +import { INodeData } from '../../../src/Interface' +import { GetTokenPriceTool, SearchPoolsTool, GetPoolOhlcvTool } from './core' + +const { nodeClass: DexPaprika_Tools } = require('./DexPaprika') + +jest.mock('node-fetch', () => jest.fn()) +const mockedFetch = require('node-fetch') as jest.Mock + +function createNodeData(inputs: any): INodeData { + return { + id: 'dexPaprika_0', + label: 'DexPaprika', + name: 'dexPaprika', + type: 'DexPaprika', + icon: 'dexpaprika.svg', + version: 1.0, + category: 'Tools', + baseClasses: ['DexPaprika', 'Tool'], + inputs + } +} + +function mockJsonResponse(body: string, ok = true, status = 200, statusText = 'OK') { + mockedFetch.mockResolvedValueOnce({ + ok, + status, + statusText, + text: async () => body + }) +} + +describe('DexPaprika node', () => { + beforeEach(() => { + mockedFetch.mockReset() + }) + + describe('Node initialization', () => { + it('should expose all three tools when no actions are selected', async () => { + const nodeClass = new DexPaprika_Tools() + const tools = await nodeClass.init(createNodeData({})) + + expect(Array.isArray(tools)).toBe(true) + expect(tools.map((t: any) => t.name)).toEqual([ + 'dexpaprika_get_token_price', + 'dexpaprika_search_pools', + 'dexpaprika_get_pool_ohlcv' + ]) + }) + + it('should only expose selected actions', async () => { + const nodeClass = new DexPaprika_Tools() + const tools = await nodeClass.init(createNodeData({ dexPaprikaActions: '["searchPools"]' })) + + expect(tools.map((t: any) => t.name)).toEqual(['dexpaprika_search_pools']) + }) + + it('should not require a credential', () => { + const nodeClass = new DexPaprika_Tools() + expect(nodeClass.credential).toBeUndefined() + }) + }) + + describe('GetTokenPriceTool', () => { + it('should call the token endpoint and strip the long-form description', async () => { + mockJsonResponse( + JSON.stringify({ + id: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + symbol: 'WETH', + description: 'A very long project description', + summary: { price_usd: 1840.53 } + }) + ) + + const tool = new GetTokenPriceTool() + const result = await tool._call({ + network: 'ethereum', + tokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' + }) + + expect(mockedFetch).toHaveBeenCalledWith( + 'https://api.dexpaprika.com/networks/ethereum/tokens/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + expect.objectContaining({ method: 'GET' }) + ) + const parsed = JSON.parse(result) + expect(parsed.symbol).toBe('WETH') + expect(parsed.summary.price_usd).toBe(1840.53) + expect(parsed.description).toBeUndefined() + }) + + it('should throw a descriptive error on a non-OK response', async () => { + mockJsonResponse('', false, 404, 'Not Found') + + const tool = new GetTokenPriceTool() + await expect(tool._call({ network: 'notachain', tokenAddress: '0x0' })).rejects.toThrow('DexPaprika API error 404: Not Found') + }) + }) + + describe('SearchPoolsTool', () => { + it('should build the search URL with filters and omit the token filter when unset', async () => { + mockJsonResponse('{"results":[]}') + + const tool = new SearchPoolsTool() + await tool._call({ network: 'solana', orderBy: 'liquidity_usd', sort: 'desc', limit: 5 }) + + const calledUrl = new URL(mockedFetch.mock.calls[0][0]) + expect(calledUrl.pathname).toBe('/networks/solana/pools/search') + expect(calledUrl.searchParams.get('order_by')).toBe('liquidity_usd') + expect(calledUrl.searchParams.get('sort')).toBe('desc') + expect(calledUrl.searchParams.get('limit')).toBe('5') + expect(calledUrl.searchParams.has('token_address')).toBe(false) + }) + + it('should pass the token filter when set', async () => { + mockJsonResponse('{"results":[]}') + + const tool = new SearchPoolsTool() + await tool._call({ + network: 'ethereum', + tokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + orderBy: 'volume_usd_24h', + sort: 'desc', + limit: 10 + }) + + const calledUrl = new URL(mockedFetch.mock.calls[0][0]) + expect(calledUrl.searchParams.get('token_address')).toBe('0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2') + }) + }) + + describe('GetPoolOhlcvTool', () => { + it('should build the OHLCV URL with start, interval and limit', async () => { + mockJsonResponse('[]') + + const tool = new GetPoolOhlcvTool() + await tool._call({ + network: 'ethereum', + poolAddress: '0xf6e72db5454dd049d0788e411b06cfaf16853042', + start: '2026-07-10', + interval: '1h', + limit: 24 + }) + + const calledUrl = new URL(mockedFetch.mock.calls[0][0]) + expect(calledUrl.pathname).toBe('/networks/ethereum/pools/0xf6e72db5454dd049d0788e411b06cfaf16853042/ohlcv') + expect(calledUrl.searchParams.get('start')).toBe('2026-07-10') + expect(calledUrl.searchParams.get('interval')).toBe('1h') + expect(calledUrl.searchParams.get('limit')).toBe('24') + expect(calledUrl.searchParams.has('end')).toBe(false) + }) + }) +}) diff --git a/packages/components/nodes/tools/DexPaprika/DexPaprika.ts b/packages/components/nodes/tools/DexPaprika/DexPaprika.ts new file mode 100644 index 00000000000..1de71fca127 --- /dev/null +++ b/packages/components/nodes/tools/DexPaprika/DexPaprika.ts @@ -0,0 +1,56 @@ +import { INode, INodeData, INodeParams } from '../../../src/Interface' +import { convertMultiOptionsToStringArray } from '../../../src/utils' +import { createDexPaprikaTools, desc } from './core' + +class DexPaprika_Tools implements INode { + label: string + name: string + version: number + description: string + type: string + icon: string + category: string + baseClasses: string[] + inputs: INodeParams[] + + constructor() { + this.label = 'DexPaprika' + this.name = 'dexPaprika' + this.version = 1.0 + this.type = 'DexPaprika' + this.icon = 'dexpaprika.svg' + this.category = 'Tools' + this.description = desc + this.baseClasses = [this.type, 'Tool'] + this.inputs = [ + { + label: 'Actions', + name: 'dexPaprikaActions', + type: 'multiOptions', + description: 'Actions the agent can perform. Leave empty to enable all actions', + options: [ + { + label: 'Get Token Price', + name: 'getTokenPrice' + }, + { + label: 'Search Pools', + name: 'searchPools' + }, + { + label: 'Get Pool OHLCV', + name: 'getPoolOhlcv' + } + ], + optional: true + } + ] + } + + async init(nodeData: INodeData): Promise { + const actions = convertMultiOptionsToStringArray(nodeData.inputs?.dexPaprikaActions) + return createDexPaprikaTools({ actions }) + } +} + +module.exports = { nodeClass: DexPaprika_Tools } diff --git a/packages/components/nodes/tools/DexPaprika/core.ts b/packages/components/nodes/tools/DexPaprika/core.ts new file mode 100644 index 00000000000..25e748d5c79 --- /dev/null +++ b/packages/components/nodes/tools/DexPaprika/core.ts @@ -0,0 +1,173 @@ +import { z } from 'zod/v3' +import fetch from 'node-fetch' +import { DynamicStructuredTool } from '../OpenAPIToolkit/core' + +export const desc = `Get onchain DEX market data from DexPaprika: token prices, liquidity pools and OHLCV candles across 36 blockchain networks. No API key required.` + +const BASE_URL = 'https://api.dexpaprika.com' + +const NETWORK_DESCRIPTION = `Network id in lowercase, e.g. "ethereum", "solana", "base", "arbitrum", "bsc", "polygon". Full list at ${BASE_URL}/networks` + +export interface DexPaprikaToolParameters { + actions?: string[] +} + +// Schema for fetching a token price by contract address +const GetTokenPriceSchema = z.object({ + network: z.string().describe(NETWORK_DESCRIPTION), + tokenAddress: z.string().describe('Token contract address on the given network (mint address on Solana)') +}) + +// Schema for searching pools on a network +const SearchPoolsSchema = z.object({ + network: z.string().describe(NETWORK_DESCRIPTION), + tokenAddress: z + .string() + .optional() + .describe('Optional token contract address. When set, only pools that include this token are returned'), + orderBy: z + .enum([ + 'volume_usd_24h', + 'volume_usd_7d', + 'volume_usd_30d', + 'liquidity_usd', + 'txns_24h', + 'created_at', + 'price_usd', + 'price_change_percentage_24h' + ]) + .optional() + .default('volume_usd_24h') + .describe('Field to order results by'), + sort: z.enum(['asc', 'desc']).optional().default('desc').describe('Sort direction'), + limit: z.number().int().min(1).max(100).optional().default(10).describe('Number of pools to return, 1 to 100') +}) + +// Schema for fetching OHLCV candles of a pool +const GetPoolOhlcvSchema = z.object({ + network: z.string().describe(NETWORK_DESCRIPTION), + poolAddress: z.string().describe('Pool contract address on the given network'), + start: z.string().describe('Start of the time range, e.g. "2026-07-01" or RFC3339 like "2026-07-01T00:00:00Z". Required'), + end: z.string().optional().describe('Optional end of the time range, same formats as start'), + interval: z.enum(['1m', '5m', '10m', '15m', '30m', '1h', '6h', '12h', '24h']).optional().default('1h').describe('Candle interval'), + limit: z.number().int().min(1).max(366).optional().default(24).describe('Number of candles to return, 1 to 366') +}) + +class BaseDexPaprikaTool extends DynamicStructuredTool { + async makeRequest(path: string, params?: Record): Promise { + const url = new URL(`${BASE_URL}${path}`) + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== '') { + url.searchParams.append(key, String(value)) + } + } + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { Accept: 'application/json', ...this.headers } + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`DexPaprika API error ${response.status}: ${errorText || response.statusText}`) + } + + return await response.text() + } +} + +export class GetTokenPriceTool extends BaseDexPaprikaTool { + constructor() { + super({ + name: 'dexpaprika_get_token_price', + description: + 'Get the current USD price and market stats (fully diluted valuation, liquidity, 24h volume and transactions) of a token by its contract address', + schema: GetTokenPriceSchema, + baseUrl: BASE_URL, + method: 'GET', + headers: {} + }) + } + + async _call(arg: z.infer): Promise { + const data = await this.makeRequest(`/networks/${encodeURIComponent(arg.network)}/tokens/${encodeURIComponent(arg.tokenAddress)}`) + try { + const parsed = JSON.parse(data) + // The long-form project description adds noise without market data value + delete parsed.description + return JSON.stringify(parsed) + } catch { + return data + } + } +} + +export class SearchPoolsTool extends BaseDexPaprikaTool { + constructor() { + super({ + name: 'dexpaprika_search_pools', + description: + 'List liquidity pools on a network ordered by volume, liquidity, transactions or price change. Optionally filter to pools that contain a specific token address', + schema: SearchPoolsSchema, + baseUrl: BASE_URL, + method: 'GET', + headers: {} + }) + } + + async _call(arg: z.infer): Promise { + return await this.makeRequest(`/networks/${encodeURIComponent(arg.network)}/pools/search`, { + token_address: arg.tokenAddress, + order_by: arg.orderBy, + sort: arg.sort, + limit: arg.limit + }) + } +} + +export class GetPoolOhlcvTool extends BaseDexPaprikaTool { + constructor() { + super({ + name: 'dexpaprika_get_pool_ohlcv', + description: + 'Get historical OHLCV (open, high, low, close, volume) candles for a liquidity pool. Useful for price history and technical analysis', + schema: GetPoolOhlcvSchema, + baseUrl: BASE_URL, + method: 'GET', + headers: {} + }) + } + + async _call(arg: z.infer): Promise { + return await this.makeRequest(`/networks/${encodeURIComponent(arg.network)}/pools/${encodeURIComponent(arg.poolAddress)}/ohlcv`, { + start: arg.start, + end: arg.end, + interval: arg.interval, + limit: arg.limit + }) + } +} + +export const createDexPaprikaTools = (params?: DexPaprikaToolParameters): DynamicStructuredTool[] => { + const actions = params?.actions ?? [] + const tools: DynamicStructuredTool[] = [] + + // When no actions are selected, enable all of them so the node works with zero configuration + const enableAll = actions.length === 0 + + if (enableAll || actions.includes('getTokenPrice')) { + tools.push(new GetTokenPriceTool()) + } + + if (enableAll || actions.includes('searchPools')) { + tools.push(new SearchPoolsTool()) + } + + if (enableAll || actions.includes('getPoolOhlcv')) { + tools.push(new GetPoolOhlcvTool()) + } + + return tools +} diff --git a/packages/components/nodes/tools/DexPaprika/dexpaprika.svg b/packages/components/nodes/tools/DexPaprika/dexpaprika.svg new file mode 100644 index 00000000000..facb53d1fac --- /dev/null +++ b/packages/components/nodes/tools/DexPaprika/dexpaprika.svg @@ -0,0 +1,11 @@ + + + + + + + + + + +