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
JavaScript
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;