UNPKG

plugin-hedera-dex

Version:

Comprehensive Hedera DEX plugin for SaucerSwap integration with ElizaOS. Provides real-time pool data, token swapping, and complete DEX functionality on Hedera blockchain.

1,079 lines (1,078 loc) 47.5 kB
import { ModelType, Service, logger, EventType, } from '@elizaos/core'; import { z } from 'zod'; import axios from 'axios'; import { ethers } from 'ethers'; import { Client, PrivateKey, AccountId, ContractExecuteTransaction, Hbar, HbarUnit } from '@hashgraph/sdk'; import { SAUCERSWAP_ROUTER_ABI, SAUCERSWAP_CONTRACTS, TOKEN_ADDRESSES, FEE_TIERS, hederaIdToEvmAddress, hexToUint8Array, encodeSwapPath } from './saucerswap-abi'; // Contract addresses for different networks const CONTRACT_ADDRESSES = { mainnet: { factory: '0.0.3946833', mirrorNode: 'https://mainnet-public.mirrornode.hedera.com', }, testnet: { factory: '0.0.1197038', mirrorNode: 'https://testnet.mirrornode.hedera.com', }, }; /** * Convert hex string to Hedera ID format * For pool addresses from contract data, we need to extract the actual contract address */ function hexToHederaId(hex) { const cleanHex = hex.replace('0x', ''); // For token IDs from event topics, take the last 8 hex chars (4 bytes) // This gives us reasonable entity numbers for Hedera tokens const entityHex = cleanHex.slice(-8); const entityNum = parseInt(entityHex, 16); // Skip if the entity number is 0 or unreasonably large if (entityNum === 0 || entityNum > 100000000) { return '0.0.0'; // Invalid ID that will be filtered out } return `0.0.${entityNum}`; } /** * Extract pool contract ID from pool creation event data * For testnet, we'll create a synthetic pool ID based on the token pair */ function extractPoolContractId(eventData, token0Id, token1Id) { try { // For testnet pools, create a synthetic but consistent pool ID // This allows us to show pool information even if we can't extract the exact contract address const token0Num = parseInt(token0Id.split('.')[2]); const token1Num = parseInt(token1Id.split('.')[2]); // Create a synthetic pool ID by combining token numbers // This ensures each unique pair gets a unique pool ID const syntheticPoolNum = Math.abs(token0Num + token1Num + 1000000); return `0.0.${syntheticPoolNum}`; } catch (error) { return '0.0.0'; } } /** * Fetch token information from Hedera Mirror Node */ async function fetchTokenInfo(tokenId, mirrorNodeUrl) { try { const response = await axios.get(`${mirrorNodeUrl}/api/v1/tokens/${tokenId}`); const token = response.data; return { decimals: token.decimals || 8, id: tokenId, name: token.name || 'Unknown Token', price: '0', priceUsd: 0, symbol: token.symbol || 'UNKNOWN', dueDiligenceComplete: false, isFeeOnTransferToken: false, timestampSecondsLastListingChange: 0, description: null, website: null, twitterHandle: null, sentinelReport: null, }; } catch (error) { logger.error(`Failed to fetch token info for ${tokenId}:`, error); return null; } } /** * Fetch pool liquidity by calling the contract's liquidity() function */ async function fetchPoolLiquidity(poolContractId, mirrorNodeUrl) { try { // Call the liquidity() function on the pool contract // Function selector for liquidity() is 0x1a686502 const functionData = '0x1a686502'; // liquidity() function selector const callResponse = await axios.post(`${mirrorNodeUrl}/api/v1/contracts/call`, { to: poolContractId, data: functionData, estimate: false }); if (callResponse.data && callResponse.data.result) { // Parse the returned liquidity value (uint128) const liquidityHex = callResponse.data.result; const liquidityBigInt = BigInt(liquidityHex); return liquidityBigInt.toString(); } return 'N/A'; } catch (error) { logger.debug(`Could not fetch liquidity for pool ${poolContractId}:`, error); return 'N/A'; } } /** * Fetch token balances in the pool to estimate liquidity */ async function fetchPoolTokenBalances(poolContractId, tokenA, tokenB, mirrorNodeUrl) { try { // Get token balances for the pool contract const [balanceAResponse, balanceBResponse] = await Promise.all([ axios.get(`${mirrorNodeUrl}/api/v1/accounts/${poolContractId}/tokens?token.id=${tokenA.id}`), axios.get(`${mirrorNodeUrl}/api/v1/accounts/${poolContractId}/tokens?token.id=${tokenB.id}`) ]); const balanceA = balanceAResponse.data.tokens?.[0]?.balance || '0'; const balanceB = balanceBResponse.data.tokens?.[0]?.balance || '0'; return { balanceA, balanceB }; } catch (error) { logger.debug(`Could not fetch token balances for pool ${poolContractId}:`, error); return { balanceA: '0', balanceB: '0' }; } } /** * Parse pool creation events from contract logs */ async function parsePoolCreationEvents(logs, mirrorNodeUrl) { const pools = []; const POOL_CREATED_TOPIC = '0x783cca1c0412dd0d695e784568c96da2e9c22ff989357a2e8b1d9b2b4e6b7118'; logger.info(`Processing ${logs.length} logs for pool creation events`); for (const log of logs) { if (log.topics && log.topics[0] === POOL_CREATED_TOPIC && log.topics.length >= 4) { try { logger.debug(`Found pool creation event with topics: ${log.topics.join(', ')}`); // Parse the event data const token0Id = hexToHederaId(log.topics[1]); const token1Id = hexToHederaId(log.topics[2]); const fee = parseInt(log.topics[3], 16); logger.debug(`Parsed token IDs: ${token0Id}, ${token1Id}, fee: ${fee}`); // Extract pool contract ID from event data const poolId = extractPoolContractId(log.data, token0Id, token1Id); // Skip invalid IDs (0.0.0 means parsing failed) if (token0Id === '0.0.0' || token1Id === '0.0.0' || poolId === '0.0.0') { logger.warn(`Skipping pool with invalid IDs: token0=${token0Id}, token1=${token1Id}, pool=${poolId}`); continue; } // Fetch token information const [tokenA, tokenB] = await Promise.all([ fetchTokenInfo(token0Id, mirrorNodeUrl), fetchTokenInfo(token1Id, mirrorNodeUrl) ]); if (tokenA && tokenB) { // Note: Pool contract ID conversion from hex is complex for Hedera // We'll show pool info without trying to fetch balances from invalid contract IDs pools.push({ id: pools.length + 1, contractId: poolId, // This may not be a valid Hedera contract ID tokenA, amountA: '0', // Would need valid contract ID to fetch tokenB, amountB: '0', // Would need valid contract ID to fetch fee, sqrtRatioX96: '79228162514264337593543950336', // Default value tickCurrent: 0, liquidity: 'Available' // Indicate pool exists without specific value }); logger.info(`Successfully parsed pool: ${tokenA.symbol}/${tokenB.symbol} (fee: ${fee / 10000}%)`); } else { logger.warn(`Failed to fetch token info for pool: ${token0Id}/${token1Id}`); } } catch (error) { logger.error('Error parsing pool creation event:', error); } } else if (log.topics && log.topics[0]) { logger.debug(`Skipping non-pool-creation event with topic: ${log.topics[0]}`); } } logger.info(`Successfully parsed ${pools.length} pools from ${logs.length} logs`); return pools; } /** * Defines the configuration schema for a plugin, including the validation rules for the plugin name. */ const configSchema = z.object({ HEDERA_NETWORK: z .string() .optional() .default('mainnet') .transform((val) => val || 'mainnet'), HEDERA_MIRROR_NODE_URL: z .string() .optional(), DEMO_MODE: z .string() .optional() .default('false'), }); /** * List Pools Action * Fetches all liquidity pools from SaucerSwap V2 with detailed information */ const listPoolsAction = { name: 'LIST_POOLS', similes: ['GET_POOLS', 'SHOW_POOLS', 'FETCH_POOLS', 'SAUCERSWAP_POOLS'], description: 'Lists all liquidity pools from SaucerSwap V2 with detailed information including tokens, liquidity, and fees', validate: async (_runtime, _message, _state) => { // Always valid - no specific validation needed for fetching pools return true; }, handler: async (runtime, message, _state, _options, callback, _responses) => { try { logger.info('Handling LIST_POOLS action'); // Get configuration const network = runtime.getSetting('HEDERA_NETWORK') || 'mainnet'; const mirrorNodeUrl = runtime.getSetting('HEDERA_MIRROR_NODE_URL') || CONTRACT_ADDRESSES[network]?.mirrorNode; if (!mirrorNodeUrl) { throw new Error(`Unsupported network: ${network}`); } const factoryContract = CONTRACT_ADDRESSES[network]?.factory; if (!factoryContract) { throw new Error(`Factory contract not found for network: ${network}`); } let pools; let dataSource; try { // Fetch pool creation events from the factory contract const logsResponse = await axios.get(`${mirrorNodeUrl}/api/v1/contracts/${factoryContract}/results/logs?limit=100`); const logs = logsResponse.data.logs || []; logger.info(`Found ${logs.length} contract events`); // Parse pool creation events to get real pool data pools = await parsePoolCreationEvents(logs, mirrorNodeUrl); if (pools.length === 0) { throw new Error('No pools found in contract events'); } dataSource = `Hedera Mirror Node (${mirrorNodeUrl}) - ${pools.length} pools from contract events`; } catch (error) { logger.error('Error fetching pools from contract events:', error); throw error; } // Format pools information for response let poolsText = `🌊 SaucerSwap V2 Liquidity Pools (${network})\n\n`; poolsText += `Found ${pools.length} active pools:\n\n`; pools.slice(0, 20).forEach((pool, index) => { const symbolA = pool.tokenA.symbol; const symbolB = pool.tokenB.symbol; const feeTier = (pool.fee / 10_000.0).toFixed(2); poolsText += `${index + 1}. ${symbolA}/${symbolB}\n`; poolsText += ` • Fee Tier: ${feeTier}%\n`; poolsText += ` • Contract ID: ${pool.contractId}\n`; // Show liquidity status if meaningful if (pool.liquidity === 'Available') { poolsText += ` • Liquidity: Available ✅\n`; } else if (pool.liquidity !== 'N/A' && pool.liquidity !== '0') { try { const liquidityValue = BigInt(pool.liquidity); if (liquidityValue > 0n) { const liquidityFormatted = liquidityValue.toLocaleString(); poolsText += ` • Liquidity: ${liquidityFormatted}\n`; } } catch { // If liquidity is not a valid number, show it as-is poolsText += ` • Liquidity: ${pool.liquidity}\n`; } } poolsText += ` • Token A: ${pool.tokenA.name} (${pool.tokenA.symbol})\n`; poolsText += ` • Token B: ${pool.tokenB.name} (${pool.tokenB.symbol})\n\n`; }); if (pools.length > 20) { poolsText += `... and ${pools.length - 20} more pools.\n`; } poolsText += `\nData source: ${dataSource}`; // Response content const responseContent = { text: poolsText, actions: ['LIST_POOLS'], source: message.content.source, }; // Call back with the pools information if (callback) { await callback(responseContent); } return { text: `Successfully fetched ${pools.length} pools from SaucerSwap`, values: { success: true, poolCount: pools.length, network: network, }, data: { actionName: 'LIST_POOLS', messageId: message.id, timestamp: Date.now(), pools: pools.slice(0, 20), // Return first 20 pools in data totalPools: pools.length, dataSource: dataSource, }, success: true, }; } catch (error) { logger.error('Error in LIST_POOLS action:', error); const errorMessage = error instanceof Error ? error.message : String(error); return { text: `Failed to fetch pools from SaucerSwap: ${errorMessage}`, values: { success: false, error: 'FETCH_POOLS_FAILED', }, data: { actionName: 'LIST_POOLS', error: errorMessage, timestamp: Date.now(), }, success: false, error: error instanceof Error ? error : new Error(String(error)), }; } }, examples: [ [ { name: '{{name1}}', content: { text: 'Show me all the liquidity pools on SaucerSwap', }, }, { name: '{{name2}}', content: { text: 'Here are the available liquidity pools on SaucerSwap V2...', actions: ['LIST_POOLS'], }, }, ], [ { name: '{{name1}}', content: { text: 'What pools are available for trading?', }, }, { name: '{{name2}}', content: { text: 'Let me fetch the current liquidity pools from SaucerSwap...', actions: ['LIST_POOLS'], }, }, ], ], }; /** * Get Pool Info Action * Fetches specific pool information by token pair (e.g., WHBAR/USDC) */ const getPoolInfoAction = { name: 'GET_POOL_INFO', similes: ['POOL_INFO', 'SHOW_POOL', 'POOL_DETAILS', 'GET_POOL_DETAILS', 'FIND_POOL'], description: 'Gets specific pool information by token pair (e.g., WHBAR/USDC, SAUCE/XSAUCE)', validate: async (_runtime, message, _state) => { const text = message.content.text?.toLowerCase(); if (!text) return false; // Check if the message contains pool-related keywords and token symbols const hasPoolKeyword = text.includes('pool') || text.includes('pair') || text.includes('details'); const hasTokenPair = /\b[a-z]{2,10}\/[a-z]{2,10}\b/i.test(text) || // matches TOKEN/TOKEN format /\b[a-z]{2,10}\s+(and|with)\s+[a-z]{2,10}\b/i.test(text); // matches "TOKEN and TOKEN" return hasPoolKeyword && hasTokenPair; }, handler: async (runtime, message, _state, _options, callback, _responses) => { try { logger.info('Handling GET_POOL_INFO action'); // Extract token pair from message const text = message.content.text; if (!text) { throw new Error('No text content found in message'); } const tokenPairMatch = text.match(/\b([a-z]{2,10})\/([a-z]{2,10})\b/i) || text.match(/\b([a-z]{2,10})\s+(?:and|with)\s+([a-z]{2,10})\b/i); if (!tokenPairMatch) { throw new Error('Could not extract token pair from message. Please specify tokens like "WHBAR/USDC" or "WHBAR and USDC"'); } const [, token0Symbol, token1Symbol] = tokenPairMatch; const normalizedToken0 = token0Symbol.toUpperCase(); const normalizedToken1 = token1Symbol.toUpperCase(); logger.info(`Looking for pool: ${normalizedToken0}/${normalizedToken1}`); // Get configuration const network = runtime.getSetting('HEDERA_NETWORK') || 'mainnet'; const mirrorNodeUrl = runtime.getSetting('HEDERA_MIRROR_NODE_URL') || CONTRACT_ADDRESSES[network]?.mirrorNode; if (!mirrorNodeUrl) { throw new Error(`Unsupported network: ${network}`); } const factoryContract = CONTRACT_ADDRESSES[network]?.factory; if (!factoryContract) { throw new Error(`Factory contract not found for network: ${network}`); } // Fetch all pools first (reusing the logic from LIST_POOLS) const logsResponse = await axios.get(`${mirrorNodeUrl}/api/v1/contracts/${factoryContract}/results/logs?limit=100`); const logs = logsResponse.data.logs || []; logger.info(`Found ${logs.length} contract events, searching for ${normalizedToken0}/${normalizedToken1} pool`); // Parse pool creation events to find the specific pool const allPools = await parsePoolCreationEvents(logs, mirrorNodeUrl); // Find pools that match the token pair (in either order) const matchingPools = allPools.filter(pool => { const poolToken0 = pool.tokenA.symbol.toUpperCase(); const poolToken1 = pool.tokenB.symbol.toUpperCase(); return (poolToken0 === normalizedToken0 && poolToken1 === normalizedToken1) || (poolToken0 === normalizedToken1 && poolToken1 === normalizedToken0); }); if (matchingPools.length === 0) { const availableTokens = [...new Set(allPools.flatMap(p => [p.tokenA.symbol, p.tokenB.symbol]))].sort(); throw new Error(`No pools found for ${normalizedToken0}/${normalizedToken1}. Available tokens: ${availableTokens.slice(0, 20).join(', ')}${availableTokens.length > 20 ? '...' : ''}`); } // Format pool information for response let poolInfoText = `🔍 Pool Information for ${normalizedToken0}/${normalizedToken1}\n\n`; if (matchingPools.length === 1) { const pool = matchingPools[0]; const feeTier = (pool.fee / 10_000.0).toFixed(2); poolInfoText += `📊 **${pool.tokenA.symbol}/${pool.tokenB.symbol} Pool**\n\n`; poolInfoText += `• **Fee Tier:** ${feeTier}%\n`; poolInfoText += `• **Contract ID:** ${pool.contractId}\n`; // Show liquidity status if meaningful if (pool.liquidity === 'Available') { poolInfoText += `• **Liquidity:** Available ✅\n`; } else if (pool.liquidity !== 'N/A' && pool.liquidity !== '0') { try { const liquidityValue = BigInt(pool.liquidity); if (liquidityValue > 0n) { const liquidityFormatted = liquidityValue.toLocaleString(); poolInfoText += `• **Liquidity:** ${liquidityFormatted}\n`; } } catch { // If liquidity is not a valid number, show it as-is poolInfoText += `• **Liquidity:** ${pool.liquidity}\n`; } } poolInfoText += `\n**Token Details:**\n`; poolInfoText += `• **${pool.tokenA.symbol}:** ${pool.tokenA.name} (${pool.tokenA.decimals} decimals)\n`; poolInfoText += `• **${pool.tokenB.symbol}:** ${pool.tokenB.name} (${pool.tokenB.decimals} decimals)\n`; if (pool.tokenA.description || pool.tokenB.description) { poolInfoText += `\n**Descriptions:**\n`; if (pool.tokenA.description) poolInfoText += `• **${pool.tokenA.symbol}:** ${pool.tokenA.description}\n`; if (pool.tokenB.description) poolInfoText += `• **${pool.tokenB.symbol}:** ${pool.tokenB.description}\n`; } } else { poolInfoText += `Found ${matchingPools.length} pools for this token pair:\n\n`; matchingPools.forEach((pool, index) => { const feeTier = (pool.fee / 10_000.0).toFixed(2); poolInfoText += `${index + 1}. **${pool.tokenA.symbol}/${pool.tokenB.symbol}** (${feeTier}% fee)\n`; poolInfoText += ` • Contract ID: ${pool.contractId}\n`; if (pool.liquidity !== 'N/A' && pool.liquidity !== '0') { try { const liquidityValue = BigInt(pool.liquidity); if (liquidityValue > 0n) { const liquidityFormatted = liquidityValue.toLocaleString(); poolInfoText += ` • Liquidity: ${liquidityFormatted}\n`; } } catch { poolInfoText += ` • Liquidity: ${pool.liquidity}\n`; } } poolInfoText += `\n`; }); } poolInfoText += `\nData source: Hedera Mirror Node (${mirrorNodeUrl})`; // Response content const responseContent = { text: poolInfoText, actions: ['GET_POOL_INFO'], source: message.content.source, }; // Call back with the pool information if (callback) { await callback(responseContent); } return { text: `Found ${matchingPools.length} pool(s) for ${normalizedToken0}/${normalizedToken1}`, values: { success: true, poolCount: matchingPools.length, tokenPair: `${normalizedToken0}/${normalizedToken1}`, network: network, }, data: { actionName: 'GET_POOL_INFO', messageId: message.id, timestamp: Date.now(), pools: matchingPools, tokenPair: `${normalizedToken0}/${normalizedToken1}`, dataSource: `Hedera Mirror Node (${mirrorNodeUrl})`, }, success: true, }; } catch (error) { logger.error('Error in GET_POOL_INFO action:', error); const errorMessage = error instanceof Error ? error.message : String(error); return { text: `Failed to get pool information: ${errorMessage}`, values: { success: false, error: 'GET_POOL_INFO_FAILED', }, data: { actionName: 'GET_POOL_INFO', error: errorMessage, timestamp: Date.now(), }, success: false, error: error instanceof Error ? error : new Error(String(error)), }; } }, examples: [ [ { name: '{{name1}}', content: { text: 'Show WHBAR/USDC pool details', }, }, { name: '{{name2}}', content: { text: 'Here are the details for the WHBAR/USDC pool...', actions: ['GET_POOL_INFO'], }, }, ], [ { name: '{{name1}}', content: { text: 'Get pool info for SAUCE and XSAUCE', }, }, { name: '{{name2}}', content: { text: 'Let me fetch the pool information for SAUCE/XSAUCE...', actions: ['GET_POOL_INFO'], }, }, ], [ { name: '{{name1}}', content: { text: 'What are the details of the WHBAR/BONZO pool?', }, }, { name: '{{name2}}', content: { text: 'I\'ll get the WHBAR/BONZO pool details for you...', actions: ['GET_POOL_INFO'], }, }, ], ], }; /** * Swap Tokens Action * Swaps tokens via SaucerSwap DEX (e.g., "Swap 10 HBAR for USDT") */ const swapTokensAction = { name: 'SWAP_TOKENS', similes: ['TRADE_TOKENS', 'EXCHANGE_TOKENS', 'SWAP', 'TRADE', 'EXCHANGE', 'BUY_TOKENS', 'SELL_TOKENS'], description: 'Swaps tokens via SaucerSwap DEX with specified amounts and token pairs', validate: async (_runtime, message, _state) => { const text = message.content.text?.toLowerCase(); if (!text) return false; // Check for swap-related keywords and token amounts const hasSwapKeyword = text.includes('swap') || text.includes('trade') || text.includes('exchange') || text.includes('buy') || text.includes('sell'); const hasAmount = /\d+(\.\d+)?\s*(hbar|whbar|usdt|usdc|sauce|bonzo|kbl)/i.test(text); const hasForKeyword = text.includes(' for ') || text.includes(' to ') || text.includes(' into '); return hasSwapKeyword && hasAmount && hasForKeyword; }, handler: async (runtime, message, _state, _options, callback, _responses) => { try { logger.info('Handling SWAP_TOKENS action'); // Extract swap details from message const text = message.content.text; if (!text) { throw new Error('No text content found in message'); } // Parse swap parameters const swapMatch = text.match(/swap\s+(\d+(?:\.\d+)?)\s+(\w+)\s+(?:for|to|into)\s+(\w+)/i) || text.match(/(\d+(?:\.\d+)?)\s+(\w+)\s+(?:for|to|into)\s+(\w+)/i); if (!swapMatch) { throw new Error('Could not parse swap parameters. Please use format like "Swap 10 HBAR for USDT"'); } const [, amountStr, fromToken, toToken] = swapMatch; const amount = parseFloat(amountStr); const fromTokenSymbol = fromToken.toUpperCase(); const toTokenSymbol = toToken.toUpperCase(); logger.info(`Parsed swap: ${amount} ${fromTokenSymbol} -> ${toTokenSymbol}`); // Get configuration const network = runtime.getSetting('HEDERA_NETWORK') || 'testnet'; // Default to testnet for real swaps const privateKeyString = runtime.getSetting('HEDERA_PRIVATE_KEY') || process.env.HEDERA_PRIVATE_KEY; const accountIdString = runtime.getSetting('HEDERA_ACCOUNT_ID') || process.env.HEDERA_ACCOUNT_ID; logger.info(`Network: ${network}, HasPrivateKey: ${!!privateKeyString}, HasAccountId: ${!!accountIdString}`); if (!privateKeyString || !accountIdString) { logger.warn('No wallet credentials provided, using simulation mode'); const swapDetails = await simulateSwap(amount, fromTokenSymbol, toTokenSymbol); return await handleSwapSimulation(swapDetails, amount, fromTokenSymbol, toTokenSymbol, network, message, callback); } // Real swap execution (works on both mainnet and testnet) logger.info(`Attempting real swap execution on ${network}`); const swapResult = await executeRealSwap(amount, fromTokenSymbol, toTokenSymbol, privateKeyString, accountIdString, network); if (swapResult.success) { // Format successful swap response let swapText = `✅ **Token Swap Executed Successfully!**\n\n`; swapText += `**Transaction Details:**\n`; swapText += `• **From:** ${amount} ${fromTokenSymbol}\n`; swapText += `• **To:** ${swapResult.amountOut || 'Processing...'} ${toTokenSymbol}\n`; swapText += `• **Transaction ID:** ${swapResult.transactionId}\n`; swapText += `• **Network:** ${network.toUpperCase()}\n\n`; swapText += `**🔗 View Transaction:**\n`; swapText += `• [HashScan](https://hashscan.io/${network}/transaction/${swapResult.transactionId})\n`; swapText += `• [SaucerSwap](https://app.saucerswap.finance)\n\n`; swapText += `**⚠️ Important Notes:**\n`; swapText += `• Transaction may take a few moments to confirm\n`; swapText += `• Check your wallet for updated balances\n`; swapText += `• Save the transaction ID for your records\n`; // Response content const responseContent = { text: swapText, actions: ['SWAP_TOKENS'], source: message.content.source, }; // Call back with the swap information if (callback) { await callback(responseContent); } return { text: `Successfully executed swap: ${amount} ${fromTokenSymbol}${toTokenSymbol}`, values: { success: true, amount: amount, fromToken: fromTokenSymbol, toToken: toTokenSymbol, transactionId: swapResult.transactionId, network: network, simulation: false, }, data: { actionName: 'SWAP_TOKENS', messageId: message.id, timestamp: Date.now(), swapResult: swapResult, network: network, simulation: false, }, success: true, }; } else { throw new Error(`Swap execution failed: ${swapResult.error}`); } } catch (error) { logger.error('Error in SWAP_TOKENS action:', error); const errorMessage = error instanceof Error ? error.message : String(error); return { text: `Failed to process token swap: ${errorMessage}`, values: { success: false, error: 'SWAP_TOKENS_FAILED', }, data: { actionName: 'SWAP_TOKENS', error: errorMessage, timestamp: Date.now(), }, success: false, error: error instanceof Error ? error : new Error(String(error)), }; } }, examples: [ [ { name: '{{name1}}', content: { text: 'Swap 10 HBAR for USDT', }, }, { name: '{{name2}}', content: { text: 'I\'ll simulate swapping 10 HBAR for USDT on SaucerSwap testnet...', actions: ['SWAP_TOKENS'], }, }, ], [ { name: '{{name1}}', content: { text: 'Trade 100 USDC for SAUCE', }, }, { name: '{{name2}}', content: { text: 'Let me simulate trading 100 USDC for SAUCE tokens...', actions: ['SWAP_TOKENS'], }, }, ], [ { name: '{{name1}}', content: { text: 'Exchange 5.5 WHBAR to BONZO', }, }, { name: '{{name2}}', content: { text: 'I\'ll simulate exchanging 5.5 WHBAR to BONZO tokens...', actions: ['SWAP_TOKENS'], }, }, ], ], }; /** * Execute a real token swap on SaucerSwap */ async function executeRealSwap(amount, fromToken, toToken, privateKeyString, accountIdString, network) { try { logger.info(`Executing real swap: ${amount} ${fromToken} -> ${toToken} on ${network}`); // Setup Hedera client const privateKey = PrivateKey.fromStringECDSA(privateKeyString); const accountId = AccountId.fromString(accountIdString); const client = network === 'mainnet' ? Client.forMainnet() : Client.forTestnet(); client.setOperator(accountId, privateKey); // Get contract addresses const routerAddress = SAUCERSWAP_CONTRACTS[network]?.router; if (!routerAddress) { throw new Error(`Router contract not found for network: ${network}`); } // Get token addresses - handle HBAR specially const networkTokens = TOKEN_ADDRESSES[network]; if (!networkTokens) { throw new Error(`Network ${network} not supported`); } let fromTokenAddress; let toTokenAddress; // Handle HBAR as native token if (fromToken === 'HBAR') { fromTokenAddress = networkTokens.WHBAR; // Use WHBAR for routing } else { fromTokenAddress = networkTokens[fromToken]; } if (toToken === 'HBAR') { toTokenAddress = networkTokens.WHBAR; // Use WHBAR for routing } else { toTokenAddress = networkTokens[toToken]; } if (!fromTokenAddress || !toTokenAddress) { throw new Error(`Token addresses not found for ${fromToken}/${toToken} on ${network}. Available tokens: ${Object.keys(networkTokens).join(', ')}`); } // Setup ethers interface for encoding const abiInterface = new ethers.Interface(SAUCERSWAP_ROUTER_ABI); // Calculate amounts (convert to smallest units) const amountIn = ethers.parseUnits(amount.toString(), 8); // Assuming 8 decimals for HBAR const amountOutMinimum = ethers.parseUnits('0', 6); // Minimum output (set to 0 for now) const deadline = Math.floor(Date.now() / 1000) + 1800; // 30 minutes from now // Encode swap path const swapPath = encodeSwapPath([fromTokenAddress, toTokenAddress], [FEE_TIERS.MEDIUM]); // Prepare swap parameters const swapParams = { path: swapPath, recipient: hederaIdToEvmAddress(accountIdString), deadline: deadline, amountIn: amountIn.toString(), amountOutMinimum: amountOutMinimum.toString() }; // Encode function calls const swapEncoded = abiInterface.encodeFunctionData('exactInput', [swapParams]); const refundEncoded = abiInterface.encodeFunctionData('refundETH'); // Prepare multicall const multicallData = [swapEncoded, refundEncoded]; const encodedData = abiInterface.encodeFunctionData('multicall', [multicallData]); const encodedDataBytes = hexToUint8Array(encodedData); // Execute the swap transaction const transaction = new ContractExecuteTransaction() .setContractId(routerAddress) .setGas(300000) // Adjust gas limit as needed .setFunctionParameters(encodedDataBytes); // Add payable amount if swapping HBAR if (fromToken === 'HBAR' || fromToken === 'WHBAR') { transaction.setPayableAmount(Hbar.from(amount, HbarUnit.Hbar)); } const response = await transaction.execute(client); const receipt = await response.getReceipt(client); if (receipt.status.toString() === 'SUCCESS') { const record = await response.getRecord(client); return { success: true, transactionId: response.transactionId.toString(), amountOut: 'Unknown', // Would need to parse from contract result }; } else { throw new Error(`Transaction failed with status: ${receipt.status.toString()}`); } } catch (error) { logger.error('Error executing real swap:', error); return { success: false, error: error instanceof Error ? error.message : String(error), }; } } /** * Handle swap simulation response */ async function handleSwapSimulation(swapDetails, amount, fromToken, toToken, network, message, callback) { // Format swap information for response let swapText = `🔄 **Token Swap Simulation** (${network})\n\n`; swapText += `**Swap Details:**\n`; swapText += `• **From:** ${amount} ${fromToken}\n`; swapText += `• **To:** ~${swapDetails.estimatedOutput} ${toToken}\n`; swapText += `• **Fee Tier:** ${swapDetails.feeTier}%\n`; swapText += `• **Price Impact:** ${swapDetails.priceImpact}%\n`; swapText += `• **Network:** ${network.toUpperCase()}\n\n`; swapText += `**⚠️ Simulation Mode**\n`; swapText += `This is a simulation. To execute real swaps:\n`; swapText += `1. Set HEDERA_PRIVATE_KEY environment variable\n`; swapText += `2. Set HEDERA_ACCOUNT_ID environment variable\n`; swapText += `3. Ensure sufficient ${fromToken} balance\n`; swapText += `4. Ensure target token is associated to account\n\n`; swapText += `**Next Steps:**\n`; swapText += `• Visit [SaucerSwap](https://app.saucerswap.finance) to execute manually\n`; swapText += `• Check pool liquidity for better rates\n`; swapText += `• Consider slippage tolerance settings\n`; // Response content const responseContent = { text: swapText, actions: ['SWAP_TOKENS'], source: message.content.source, }; // Call back with the swap information if (callback) { await callback(responseContent); } return { text: `Simulated swap: ${amount} ${fromToken}${swapDetails.estimatedOutput} ${toToken}`, values: { success: true, amount: amount, fromToken: fromToken, toToken: toToken, estimatedOutput: swapDetails.estimatedOutput, network: network, simulation: true, }, data: { actionName: 'SWAP_TOKENS', messageId: message.id, timestamp: Date.now(), swapDetails: swapDetails, network: network, simulation: true, }, success: true, }; } /** * Simulate a token swap to provide estimates */ async function simulateSwap(amount, fromToken, toToken) { try { // For simulation, we'll use approximate rates based on common pairs const mockRates = { 'HBAR': { 'USDT': 0.12, 'USDC': 0.12, 'SAUCE': 150, 'BONZO': 2000 }, 'WHBAR': { 'USDT': 0.12, 'USDC': 0.12, 'SAUCE': 150, 'BONZO': 2000 }, 'USDT': { 'HBAR': 8.33, 'WHBAR': 8.33, 'SAUCE': 1250, 'BONZO': 16667 }, 'USDC': { 'HBAR': 8.33, 'WHBAR': 8.33, 'SAUCE': 1250, 'BONZO': 16667 }, 'SAUCE': { 'HBAR': 0.0067, 'WHBAR': 0.0067, 'USDT': 0.0008, 'USDC': 0.0008 }, 'BONZO': { 'HBAR': 0.0005, 'WHBAR': 0.0005, 'USDT': 0.00006, 'USDC': 0.00006 }, }; const rate = mockRates[fromToken]?.[toToken]; if (!rate) { throw new Error(`No rate available for ${fromToken}/${toToken} pair`); } // Calculate estimated output with some slippage const baseOutput = amount * rate; const slippage = Math.min(amount * 0.001, 0.05); // 0.1% per unit, max 5% const estimatedOutput = baseOutput * (1 - slippage); // Determine fee tier based on pair popularity const popularPairs = ['HBAR/USDT', 'HBAR/USDC', 'WHBAR/USDT', 'WHBAR/USDC']; const pairKey = `${fromToken}/${toToken}`; const feeTier = popularPairs.includes(pairKey) ? '0.30' : '0.15'; return { estimatedOutput: estimatedOutput.toFixed(6), feeTier: feeTier, priceImpact: (slippage * 100).toFixed(3), route: `${fromToken}${toToken}`, }; } catch (error) { logger.error('Error in swap simulation:', error); return { estimatedOutput: '0', feeTier: '0.30', priceImpact: '0.000', route: `${fromToken}${toToken}`, }; } } /** * Hedera DEX Provider * Provides information about Hedera DEX capabilities and SaucerSwap integration */ const hederaDexProvider = { name: 'HEDERA_DEX_PROVIDER', description: 'Provides information about Hedera DEX capabilities and SaucerSwap integration', get: async (runtime, _message, _state) => { const network = runtime.getSetting('HEDERA_NETWORK') || 'mainnet'; const mirrorNodeUrl = runtime.getSetting('HEDERA_MIRROR_NODE_URL') || CONTRACT_ADDRESSES[network]?.mirrorNode; return { text: `Hedera DEX integration active on ${network} network using Mirror Node at ${mirrorNodeUrl}`, values: { network, mirrorNodeUrl, capabilities: ['list_pools', 'pool_information', 'liquidity_data'], }, data: { supportedNetworks: ['mainnet', 'testnet'], contractAddresses: CONTRACT_ADDRESSES, }, }; }, }; export class StarterService extends Service { static serviceType = 'starter'; capabilityDescription = 'This is a starter service which is attached to the agent through the starter plugin.'; constructor(runtime) { super(runtime); } static async start(runtime) { logger.info('Starting starter service'); const service = new StarterService(runtime); return service; } static async stop(runtime) { logger.info('Stopping starter service'); const service = runtime.getService(StarterService.serviceType); if (!service) { throw new Error('Starter service not found'); } if ('stop' in service && typeof service.stop === 'function') { await service.stop(); } } async stop() { logger.info('Starter service stopped'); } } export const hederaDexPlugin = { name: 'plugin-hedera-dex', description: 'Hedera DEX plugin for SaucerSwap integration with elizaOS', config: { HEDERA_NETWORK: process.env.HEDERA_NETWORK, HEDERA_MIRROR_NODE_URL: process.env.HEDERA_MIRROR_NODE_URL, }, async init(config) { logger.info('Initializing plugin-hedera-dex'); try { const validatedConfig = await configSchema.parseAsync(config); // Set all environment variables at once for (const [key, value] of Object.entries(validatedConfig)) { if (value) process.env[key] = value; } } catch (error) { if (error instanceof z.ZodError) { throw new Error(`Invalid plugin configuration: ${error.errors.map((e) => e.message).join(', ')}`); } throw error; } }, models: { [ModelType.TEXT_SMALL]: async (_runtime, { prompt, stopSequences = [] }) => { return 'Never gonna give you up, never gonna let you down, never gonna run around and desert you...'; }, [ModelType.TEXT_LARGE]: async (_runtime, { prompt, stopSequences = [], maxTokens = 8192, temperature = 0.7, frequencyPenalty = 0.7, presencePenalty = 0.7, }) => { return 'Never gonna make you cry, never gonna say goodbye, never gonna tell a lie and hurt you...'; }, }, routes: [ { name: 'health-check', path: '/', type: 'GET', handler: async (_req, res) => { res.json({ status: 'ok', service: 'Hedera DEX Agent', version: '1.0.0', timestamp: new Date().toISOString(), message: 'Hedera DEX Agent is running successfully! 🚀' }); }, }, { name: 'api-status', path: '/api/status', type: 'GET', handler: async (_req, res) => { res.json({ status: 'ok', plugin: 'hedera-dex-plugin', timestamp: new Date().toISOString(), }); }, }, ], events: { [EventType.MESSAGE_RECEIVED]: [ async (params) => { logger.debug('MESSAGE_RECEIVED event received'); logger.debug('Message:', params.message); }, ], [EventType.VOICE_MESSAGE_RECEIVED]: [ async (params) => { logger.debug('VOICE_MESSAGE_RECEIVED event received'); logger.debug('Message:', params.message); }, ], [EventType.WORLD_CONNECTED]: [ async (params) => { logger.debug('WORLD_CONNECTED event received'); logger.debug('World:', params.world); }, ], [EventType.WORLD_JOINED]: [ async (params) => { logger.debug('WORLD_JOINED event received'); logger.debug('World:', params.world); }, ], }, services: [StarterService], actions: [listPoolsAction, getPoolInfoAction, swapTokensAction], providers: [hederaDexProvider], // dependencies: ['@elizaos/plugin-knowledge'], <--- plugin dependencies go here (if requires another plugin) }; export default hederaDexPlugin;