From 0b2950fbe61083ec30321ae7bed8508ab2b4a0b4 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Mon, 9 Dec 2024 18:43:45 -0800 Subject: [PATCH] basic working evm-plugin functionality --- packages/plugin-evm/src/abis/erc20.ts | 23 ++ packages/plugin-evm/src/actions/bridge.ts | 295 ++++++++++++++---- .../plugin-evm/src/actions/getTokenInfo.ts | 230 ++++++++++++++ .../src/actions/getTokenMarketData.ts | 211 +++++++++++++ packages/plugin-evm/src/actions/getbalance.ts | 166 ++++++++++ packages/plugin-evm/src/actions/index.ts | 6 + packages/plugin-evm/src/actions/swap.ts | 245 ++++++++++++--- packages/plugin-evm/src/actions/transfer.ts | 245 +++++++++++++-- packages/plugin-evm/src/index.ts | 9 +- packages/plugin-evm/src/providers/wallet.ts | 62 ++-- .../src/templates/getTokenMarketData.ts | 21 ++ .../plugin-evm/src/templates/getbalance.ts | 42 +++ packages/plugin-evm/src/templates/index.ts | 49 +-- packages/plugin-evm/src/templates/swap.ts | 63 ++++ packages/plugin-evm/src/types/index.ts | 4 +- 15 files changed, 1461 insertions(+), 210 deletions(-) create mode 100644 packages/plugin-evm/src/abis/erc20.ts create mode 100644 packages/plugin-evm/src/actions/getTokenInfo.ts create mode 100644 packages/plugin-evm/src/actions/getTokenMarketData.ts create mode 100644 packages/plugin-evm/src/actions/getbalance.ts create mode 100644 packages/plugin-evm/src/actions/index.ts create mode 100644 packages/plugin-evm/src/templates/getTokenMarketData.ts create mode 100644 packages/plugin-evm/src/templates/getbalance.ts create mode 100644 packages/plugin-evm/src/templates/swap.ts diff --git a/packages/plugin-evm/src/abis/erc20.ts b/packages/plugin-evm/src/abis/erc20.ts new file mode 100644 index 00000000000..0659b86b621 --- /dev/null +++ b/packages/plugin-evm/src/abis/erc20.ts @@ -0,0 +1,23 @@ +export const erc20Abi = [ + { + constant: true, + inputs: [{ name: "_owner", type: "address" }], + name: "balanceOf", + outputs: [{ name: "balance", type: "uint256" }], + type: "function" + }, + { + constant: true, + inputs: [], + name: "decimals", + outputs: [{ name: "", type: "uint8" }], + type: "function" + }, + { + constant: true, + inputs: [], + name: "symbol", + outputs: [{ name: "", type: "string" }], + type: "function" + } +] as const; \ No newline at end of file diff --git a/packages/plugin-evm/src/actions/bridge.ts b/packages/plugin-evm/src/actions/bridge.ts index 3d0a38582d6..3f3994b848d 100644 --- a/packages/plugin-evm/src/actions/bridge.ts +++ b/packages/plugin-evm/src/actions/bridge.ts @@ -1,102 +1,262 @@ -import type { IAgentRuntime, Memory, State } from "@ai16z/eliza"; +import { + IAgentRuntime, + Memory, + State, + ModelClass, + composeContext, + generateObject, + HandlerCallback +} from "@ai16z/eliza"; import { ChainId, createConfig, executeRoute, ExtendedChain, getRoutes, + EVM, + EVMProviderOptions, } from "@lifi/sdk"; -import { getChainConfigs, WalletProvider } from "../providers/wallet"; +import { WalletProvider, evmWalletProvider, getChainConfigs } from "../providers/wallet"; import { bridgeTemplate } from "../templates"; -import type { BridgeParams, Transaction } from "../types"; +import type { BridgeParams, Transaction, SupportedChain } from "../types"; +import { parseEther, formatEther, Client } from "viem"; + export { bridgeTemplate }; +// Validate the generated content structure +function isBridgeContent(content: any): content is BridgeParams { + return ( + typeof content === "object" && + content !== null && + typeof content.fromChain === "string" && + typeof content.toChain === "string" && + ["ethereum", "base", "sepolia"].includes(content.fromChain) && + ["ethereum", "base", "sepolia"].includes(content.toChain) && + typeof content.amount === "string" && + !isNaN(Number(content.amount)) && + (content.toAddress === null || + (typeof content.toAddress === "string" && + content.toAddress.startsWith("0x") && + content.toAddress.length === 42)) + ); +} + export class BridgeAction { private config; constructor(private walletProvider: WalletProvider) { + // Configure EVM provider for LI.FI SDK + const evmProviderConfig: EVMProviderOptions = { + getWalletClient: async () => { + const client = this.walletProvider.getWalletClient(); + return client as unknown as Client; + }, + switchChain: async (chainId: number) => { + const chainName = Object.entries(getChainConfigs(this.walletProvider.runtime)) + .find(([_, config]) => config.chainId === chainId)?.[0] as SupportedChain; + + if (!chainName) { + throw new Error(`Chain ID ${chainId} not supported`); + } + + await this.walletProvider.switchChain( + this.walletProvider.runtime, + chainName + ); + const client = this.walletProvider.getWalletClient(); + return client as unknown as Client; + } + }; + this.config = createConfig({ integrator: "eliza", - chains: Object.values( - getChainConfigs(this.walletProvider.runtime) - ).map((config) => ({ - id: config.chainId, - name: config.name, - key: config.name.toLowerCase(), - chainType: "EVM", - nativeToken: { - ...config.nativeCurrency, - chainId: config.chainId, - address: "0x0000000000000000000000000000000000000000", - coinKey: config.nativeCurrency.symbol, - }, - metamask: { - chainId: `0x${config.chainId.toString(16)}`, - chainName: config.name, - nativeCurrency: config.nativeCurrency, - rpcUrls: [config.rpcUrl], - blockExplorerUrls: [config.blockExplorerUrl], - }, - diamondAddress: "0x0000000000000000000000000000000000000000", - coin: config.nativeCurrency.symbol, - mainnet: true, - })) as ExtendedChain[], + chains: Object.values(getChainConfigs(this.walletProvider.runtime)) + .map((config) => ({ + id: config.chainId, + name: config.name, + key: config.name.toLowerCase(), + chainType: "EVM" as const, + nativeToken: { + ...config.nativeCurrency, + chainId: config.chainId, + address: "0x0000000000000000000000000000000000000000", + coinKey: config.nativeCurrency.symbol, + }, + metamask: { + chainId: `0x${config.chainId.toString(16)}`, + chainName: config.name, + nativeCurrency: config.nativeCurrency, + rpcUrls: [config.rpcUrl], + blockExplorerUrls: [config.blockExplorerUrl], + }, + diamondAddress: "0x0000000000000000000000000000000000000000", + coin: config.nativeCurrency.symbol, + mainnet: true, + })) as ExtendedChain[], + providers: [ + EVM(evmProviderConfig) + ] }); } - async bridge(params: BridgeParams): Promise { + async bridge( + runtime: IAgentRuntime, + params: BridgeParams + ): Promise { + console.log("🌉 Starting bridge with params:", params); + + // Validate amount + if (!params.amount || isNaN(Number(params.amount)) || Number(params.amount) <= 0) { + throw new Error(`Invalid amount: ${params.amount}. Must be a positive number.`); + } + + // Get current balance const walletClient = this.walletProvider.getWalletClient(); const [fromAddress] = await walletClient.getAddresses(); + console.log("💳 From address:", fromAddress); - const routes = await getRoutes({ - fromChainId: getChainConfigs(this.walletProvider.runtime)[ - params.fromChain - ].chainId as ChainId, - toChainId: getChainConfigs(this.walletProvider.runtime)[ - params.toChain - ].chainId as ChainId, - fromTokenAddress: params.fromToken, - toTokenAddress: params.toToken, - fromAmount: params.amount, - fromAddress: fromAddress, - toAddress: params.toAddress || fromAddress, - }); + // Switch to source chain and check balance + await this.walletProvider.switchChain(runtime, params.fromChain); + const balance = await this.walletProvider.getWalletBalance(); + console.log("💰 Current balance:", balance ? formatEther(balance) : "0"); - if (!routes.routes.length) throw new Error("No routes found"); + // Validate sufficient balance + const amountInWei = parseEther(params.amount); + if (!balance || balance < amountInWei) { + throw new Error( + `Insufficient balance. Required: ${params.amount} ETH, Available: ${ + balance ? formatEther(balance) : "0" + } ETH` + ); + } - const execution = await executeRoute(routes.routes[0], this.config); - const process = execution.steps[0]?.execution?.process[0]; + console.log("💵 Amount to bridge (in Wei):", amountInWei.toString()); - if (!process?.status || process.status === "FAILED") { - throw new Error("Transaction failed"); - } + try { + console.log("🔍 Finding bridge routes..."); + const routes = await getRoutes({ + fromChainId: getChainConfigs(runtime)[params.fromChain].chainId as ChainId, + toChainId: getChainConfigs(runtime)[params.toChain].chainId as ChainId, + fromTokenAddress: params.fromToken ?? "0x0000000000000000000000000000000000000000", + toTokenAddress: params.toToken ?? "0x0000000000000000000000000000000000000000", + fromAmount: amountInWei.toString(), + fromAddress: fromAddress, + toAddress: params.toAddress || fromAddress, + }); - return { - hash: process.txHash as `0x${string}`, - from: fromAddress, - to: routes.routes[0].steps[0].estimate - .approvalAddress as `0x${string}`, - value: BigInt(params.amount), - chainId: getChainConfigs(this.walletProvider.runtime)[ - params.fromChain - ].chainId, - }; + if (!routes.routes.length) { + throw new Error("No bridge routes found. The requested bridge path might not be supported."); + } + + // Log route details + const selectedRoute = routes.routes[0]; + console.log("🛣️ Selected route:", { + steps: selectedRoute.steps.length, + estimatedGas: selectedRoute.gasCostUSD, + estimatedTime: selectedRoute.steps[0].estimate.executionDuration, + }); + + console.log("✨ Executing bridge transaction..."); + const execution = await executeRoute(selectedRoute, this.config); + const process = execution.steps[0]?.execution?.process[0]; + + if (!process?.status || process.status === "FAILED") { + throw new Error(`Bridge transaction failed. Status: ${process?.status}, Error: ${process?.error}`); + } + + console.log("✅ Bridge initiated successfully!", { + hash: process.txHash, + from: fromAddress, + to: selectedRoute.steps[0].estimate.approvalAddress, + value: params.amount, + estimatedTime: selectedRoute.steps[0].estimate.executionDuration + }); + + return { + hash: process.txHash as `0x${string}`, + from: fromAddress, + to: selectedRoute.steps[0].estimate.approvalAddress as `0x${string}`, + value: amountInWei.toString(), + chainId: getChainConfigs(runtime)[params.fromChain].chainId, + }; + } catch (error) { + console.error("❌ Bridge failed with error:", { + message: error.message, + code: error.code, + details: error.details, + stack: error.stack + }); + throw new Error(`Bridge failed: ${error.message}`); + } } } export const bridgeAction = { name: "bridge", - description: "Bridge tokens between different chains", + description: "Bridge tokens between different chains via the LiFi SDK", handler: async ( runtime: IAgentRuntime, message: Memory, state: State, - options: any + _options: any, + callback?: HandlerCallback ) => { - const walletProvider = new WalletProvider(runtime); - const action = new BridgeAction(walletProvider); - return action.bridge(options); + try { + // Compose state if not provided + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + // Get wallet info for context + const walletInfo = await evmWalletProvider.get(runtime, message, state); + state.walletInfo = walletInfo; + + // Generate structured content from natural language + const bridgeContext = composeContext({ + state, + template: bridgeTemplate, + }); + + const content = await generateObject({ + runtime, + context: bridgeContext, + modelClass: ModelClass.LARGE, + }); + + console.log("Generated content:", content); + + // Validate the generated content + if (!isBridgeContent(content)) { + throw new Error("Invalid content structure for bridge action"); + } + + const walletProvider = new WalletProvider(runtime); + const action = new BridgeAction(walletProvider); + const result = await action.bridge(runtime, content); + + if (callback) { + callback({ + text: `Successfully bridged ${content.amount} from ${content.fromChain} to ${content.toChain}. Transaction hash: ${result.hash}`, + content: { + transaction: { + ...result, + value: result.value.toString(), + } + } + }); + } + + return true; + } catch (error) { + console.error("Error in bridge handler:", error); + if (callback) { + callback({ text: `Error: ${error.message}` }); + } + return false; + } }, template: bridgeTemplate, validate: async (runtime: IAgentRuntime) => { @@ -113,6 +273,15 @@ export const bridgeAction = { }, }, ], + [ + { + user: "user", + content: { + text: "Send 0.5 ETH from Base to Ethereum", + action: "CROSS_CHAIN_TRANSFER", + }, + }, + ], ], similes: ["CROSS_CHAIN_TRANSFER", "CHAIN_BRIDGE", "MOVE_CROSS_CHAIN"], -}; // TODO: add more examples / similies +}; diff --git a/packages/plugin-evm/src/actions/getTokenInfo.ts b/packages/plugin-evm/src/actions/getTokenInfo.ts new file mode 100644 index 00000000000..c1b5d96f146 --- /dev/null +++ b/packages/plugin-evm/src/actions/getTokenInfo.ts @@ -0,0 +1,230 @@ +import { + IAgentRuntime, + Memory, + State, + ModelClass, + composeContext, + generateObject, + HandlerCallback +} from "@ai16z/eliza"; +import { WalletProvider, evmWalletProvider } from "../providers/wallet"; +import { SupportedChain } from "../types"; +import { formatUnits } from "viem"; +import { erc20Abi } from "../abis/erc20"; + +interface TokenInfoParams { + chain: SupportedChain; + tokenAddress: `0x${string}`; +} + +interface TokenInfo { + address: string; + name: string; + symbol: string; + decimals: number; + totalSupply: string; + holders?: number; + priceUSD?: string; + marketCap?: string; + verified?: boolean; +} + +function isTokenInfoContent(content: any): content is TokenInfoParams { + return ( + typeof content === "object" && + content !== null && + typeof content.chain === "string" && + ["ethereum", "base", "sepolia"].includes(content.chain) && + typeof content.tokenAddress === "string" && + content.tokenAddress.startsWith("0x") + ); +} + +export const getTokenInfoTemplate = `Given the recent messages and wallet information below: + +{{recentMessages}} + +{{walletInfo}} + +Extract the following information about the token info request: +- Chain to check on (must be exactly "ethereum", "base", or "sepolia", no other variations) - REQUIRED +- Token address - REQUIRED + +Common token addresses: +- USDC on Ethereum: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 +- USDC on Base: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 +- USDC on Sepolia: 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 + +Respond with a JSON markdown block containing only the extracted values: + +\`\`\`json +{ + "chain": "ethereum" | "base" | "sepolia", + "tokenAddress": string +} +\`\`\` +`; + +// Extended ERC20 ABI to include more token information +const extendedErc20Abi = [ + ...erc20Abi, + { + constant: true, + inputs: [], + name: "name", + outputs: [{ name: "", type: "string" }], + type: "function" + }, + { + constant: true, + inputs: [], + name: "totalSupply", + outputs: [{ name: "", type: "uint256" }], + type: "function" + } +] as const; + +export const getTokenInfoAction = { + name: "gettokeninfo", + description: "Get detailed information about an ERC20 token", + handler: async ( + runtime: IAgentRuntime, + message: Memory | null, + state: State | null, + options: any + ): Promise => { + try { + // If skipParsing flag is set, use options directly + const params = options.skipParsing ? options : await generateObject({ + runtime, + context: composeContext({ state, template: getTokenInfoTemplate }), + modelClass: ModelClass.LARGE + }); + + // Skip state composition if skipParsing is true + if (!options.skipParsing) { + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + const walletInfo = await evmWalletProvider.get(runtime, message, state); + state.walletInfo = walletInfo; + } + + const walletProvider = new WalletProvider(runtime); + const publicClient = walletProvider.getPublicClient(params.chain); + + // Validate contract first + const isContract = await publicClient.getBytecode({ address: params.tokenAddress }); + if (!isContract) { + throw new Error("Address is not a contract"); + } + + // Try to read basic ERC20 info first + try { + const symbol = await publicClient.readContract({ + address: params.tokenAddress, + abi: [{ + inputs: [], + name: 'symbol', + outputs: [{ type: 'string' }], + stateMutability: 'view', + type: 'function' + }], + functionName: 'symbol' + }); + + // If we can read symbol, proceed with full token info + const [name, decimals, totalSupply] = await Promise.all([ + publicClient.readContract({ + address: params.tokenAddress, + abi: extendedErc20Abi, + functionName: 'name' + }), + publicClient.readContract({ + address: params.tokenAddress, + abi: extendedErc20Abi, + functionName: 'decimals' + }), + publicClient.readContract({ + address: params.tokenAddress, + abi: extendedErc20Abi, + functionName: 'totalSupply' + }) + ]); + + // Get price information from CoinGecko (if available) + let priceInfo = null; + try { + const response = await fetch( + `https://api.coingecko.com/api/v3/simple/token_price/${params.chain}?contract_addresses=${params.tokenAddress}&vs_currencies=usd&include_market_cap=true` + ); + priceInfo = await response.json(); + } catch (error) { + console.warn("Failed to fetch price info:", error); + } + + const tokenInfo: TokenInfo = { + address: params.tokenAddress, + name: name as string, + symbol: symbol as string, + decimals: decimals as number, + totalSupply: formatUnits(totalSupply as bigint, decimals as number), + priceUSD: priceInfo?.[params.tokenAddress.toLowerCase()]?.usd?.toString(), + marketCap: priceInfo?.[params.tokenAddress.toLowerCase()]?.usd_market_cap?.toString() + }; + + // Try to get contract verification status from Etherscan/block explorer + if (params.chain === "ethereum") { + try { + const etherscanKey = runtime.getSetting("ETHERSCAN_API_KEY"); + if (etherscanKey) { + const response = await fetch( + `https://api.etherscan.io/api?module=contract&action=getabi&address=${params.tokenAddress}&apikey=${etherscanKey}` + ); + const data = await response.json(); + tokenInfo.verified = data.status === "1"; + } + } catch (error) { + console.warn("Failed to check verification status:", error); + } + } + + return tokenInfo; + } catch (error) { + throw new Error("Not a valid ERC20 token contract"); + } + } catch (error) { + console.error("Error in getTokenInfo handler:", error); + throw error; + } + }, + template: getTokenInfoTemplate, + validate: async (runtime: IAgentRuntime) => { + const privateKey = runtime.getSetting("EVM_PRIVATE_KEY"); + return typeof privateKey === "string" && privateKey.startsWith("0x"); + }, + examples: [ + [ + { + user: "user", + content: { + text: "Get info about USDC on Ethereum", + action: "GET_TOKEN_INFO", + }, + }, + ], + [ + { + user: "user", + content: { + text: "What are the details of the USDC token on Base?", + action: "GET_TOKEN_INFO", + }, + }, + ], + ], + similes: ["GET_TOKEN_INFO", "TOKEN_INFO", "TOKEN_DETAILS", "ERC20_INFO"], +}; \ No newline at end of file diff --git a/packages/plugin-evm/src/actions/getTokenMarketData.ts b/packages/plugin-evm/src/actions/getTokenMarketData.ts new file mode 100644 index 00000000000..fde15a6d206 --- /dev/null +++ b/packages/plugin-evm/src/actions/getTokenMarketData.ts @@ -0,0 +1,211 @@ +import { + IAgentRuntime, + Memory, + State, + ModelClass, + composeContext, + generateObject +} from "@ai16z/eliza"; +import { WalletProvider } from "../providers/wallet"; +import { SupportedChain } from "../types"; +import { getTokenMarketDataTemplate } from "../templates/getTokenMarketData"; + +interface TokenMarketDataParams { + chain: SupportedChain; + tokenAddress: `0x${string}`; + timeframe?: "24h" | "7d" | "30d"; +} + +interface PriceDataPoint { + timestamp: number; + price: number; + volume: number; +} + +interface OrderFlowData { + buys: number; + sells: number; + buyVolume: number; + sellVolume: number; + largestTrade: { + type: "buy" | "sell"; + amount: number; + priceUSD: number; + timestamp: number; + }; +} + +interface TokenMarketData { + priceHistory: PriceDataPoint[]; + orderFlow: OrderFlowData; + currentPrice: number; + priceChange24h: number; + volume24h: number; +} + +export const getTokenMarketDataAction = { + name: "gettokenmarketdata", + description: "Get historical price action and order flow data for an ERC20 token", + handler: async ( + runtime: IAgentRuntime, + message: Memory | null, + state: State | null, + options: any + ): Promise => { + try { + const params = options.skipParsing ? options : await generateObject({ + runtime, + context: composeContext({ state, template: getTokenMarketDataTemplate }), + modelClass: ModelClass.LARGE + }); + + const timeframe = params.timeframe || "24h"; + + // Convert timeframe to Unix timestamp + const now = Math.floor(Date.now() / 1000); + const timeframeMap = { + "24h": now - 86400, + "7d": now - 604800, + "30d": now - 2592000 + }; + const startTime = timeframeMap[timeframe]; + + // Fetch price history from CoinGecko + const cgResponse = await fetch( + `https://api.coingecko.com/api/v3/coins/${params.chain}/contract/${params.tokenAddress}/market_chart/range?vs_currency=usd&from=${startTime}&to=${now}` + ); + const cgData = await cgResponse.json(); + + // Process price history data + const priceHistory: PriceDataPoint[] = cgData.prices.map((item: [number, number], index: number) => ({ + timestamp: Math.floor(item[0] / 1000), + price: item[1], + volume: cgData.total_volumes[index]?.[1] || 0 + })); + + // Get order flow data from a DEX API (example using Etherscan API for Ethereum) + let orderFlow: OrderFlowData = { + buys: 0, + sells: 0, + buyVolume: 0, + sellVolume: 0, + largestTrade: { + type: "buy", + amount: 0, + priceUSD: 0, + timestamp: 0 + } + }; + + if (params.chain === "ethereum") { + const etherscanKey = runtime.getSetting("ETHERSCAN_API_KEY"); + if (etherscanKey) { + const txResponse = await fetch( + `https://api.etherscan.io/api?module=account&action=tokentx&contractaddress=${params.tokenAddress}&startblock=0&endblock=99999999&sort=desc&apikey=${etherscanKey}` + ); + const txData = await txResponse.json(); + + if (txData.status === "1") { + // Process transaction data to calculate order flow + orderFlow = processTransactions(txData.result, startTime); + } + } + } + + // Calculate current metrics + const currentPrice = priceHistory[priceHistory.length - 1]?.price || 0; + const startPrice = priceHistory[0]?.price || 0; + const priceChange24h = ((currentPrice - startPrice) / startPrice) * 100; + const volume24h = priceHistory.reduce((sum, point) => sum + point.volume, 0); + + return { + priceHistory, + orderFlow, + currentPrice, + priceChange24h, + volume24h + }; + } catch (error) { + console.error("Error in getTokenMarketData handler:", error); + throw error; + } + }, + template: getTokenMarketDataTemplate, + validate: async (runtime: IAgentRuntime) => { + const privateKey = runtime.getSetting("EVM_PRIVATE_KEY"); + return typeof privateKey === "string" && privateKey.startsWith("0x"); + }, + examples: [ + [ + { + user: "user", + content: { + text: "Show me USDC price history on Ethereum for the last 7 days", + action: "GET_TOKEN_MARKET_DATA", + }, + }, + ], + [ + { + user: "user", + content: { + text: "What's the trading activity for USDC on Base in the last 24 hours?", + action: "GET_TOKEN_MARKET_DATA", + }, + }, + ], + ], + similes: ["GET_TOKEN_MARKET_DATA", "TOKEN_PRICE_HISTORY", "TOKEN_TRADING_DATA", "ORDER_FLOW"], +}; + +function processTransactions(transactions: any[], startTime: number): OrderFlowData { + let orderFlow: OrderFlowData = { + buys: 0, + sells: 0, + buyVolume: 0, + sellVolume: 0, + largestTrade: { + type: "buy", + amount: 0, + priceUSD: 0, + timestamp: 0 + } + }; + + // Process each transaction + transactions.forEach(tx => { + if (tx.timeStamp < startTime) return; + + const value = parseFloat(tx.value) / Math.pow(10, parseInt(tx.tokenDecimal)); + + if (tx.to.toLowerCase() === tx.contractAddress.toLowerCase()) { + // This is a buy + orderFlow.buys++; + orderFlow.buyVolume += value; + + if (value > orderFlow.largestTrade.amount) { + orderFlow.largestTrade = { + type: "buy", + amount: value, + priceUSD: 0, // Would need price data at that timestamp + timestamp: parseInt(tx.timeStamp) + }; + } + } else { + // This is a sell + orderFlow.sells++; + orderFlow.sellVolume += value; + + if (value > orderFlow.largestTrade.amount) { + orderFlow.largestTrade = { + type: "sell", + amount: value, + priceUSD: 0, // Would need price data at that timestamp + timestamp: parseInt(tx.timeStamp) + }; + } + } + }); + + return orderFlow; +} \ No newline at end of file diff --git a/packages/plugin-evm/src/actions/getbalance.ts b/packages/plugin-evm/src/actions/getbalance.ts new file mode 100644 index 00000000000..3e4b29ad217 --- /dev/null +++ b/packages/plugin-evm/src/actions/getbalance.ts @@ -0,0 +1,166 @@ +import { + IAgentRuntime, + Memory, + State, + ModelClass, + composeContext, + generateObject, + HandlerCallback +} from "@ai16z/eliza"; +import { WalletProvider, evmWalletProvider } from "../providers/wallet"; +import { getbalanceTemplate } from "../templates"; +import { SupportedChain } from "../types"; +import { formatUnits } from "viem"; +import { erc20Abi } from "../abis/erc20"; + +export { getbalanceTemplate }; + +interface GetBalanceParams { + chain: SupportedChain; + tokenAddress?: `0x${string}`; + decimals?: number; +} + +// Validate the generated content structure +function isGetBalanceContent(content: any): content is GetBalanceParams { + return ( + typeof content === "object" && + content !== null && + typeof content.chain === "string" && + ["ethereum", "base", "sepolia"].includes(content.chain) && + (content.tokenAddress === null || + (typeof content.tokenAddress === "string" && content.tokenAddress.startsWith("0x"))) && + (content.decimals === undefined || typeof content.decimals === "number") + ); +} + +export const getbalanceAction = { + name: "getbalance", + description: "Get wallet balance on specified chain", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + _options: any, + callback?: HandlerCallback + ) => { + try { + // Compose state if not provided + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + // Get wallet info for context + const walletInfo = await evmWalletProvider.get(runtime, message, state); + state.walletInfo = walletInfo; + + // Generate structured content from natural language + const balanceContext = composeContext({ + state, + template: getbalanceTemplate, + }); + + const content = await generateObject({ + runtime, + context: balanceContext, + modelClass: ModelClass.LARGE, + }); + + console.log("Generated content:", content); + + // Validate the generated content + if (!isGetBalanceContent(content)) { + throw new Error("Invalid content structure for getbalance action"); + } + + const walletProvider = new WalletProvider(runtime); + await walletProvider.switchChain(runtime, content.chain); + const address = walletProvider.getAddress(); + let balance: string; + let symbol: string; + let decimals: number; + + if (content.tokenAddress) { + // Get ERC20 balance + const publicClient = walletProvider.getPublicClient(content.chain); + const tokenBalance = await publicClient.readContract({ + address: content.tokenAddress, + abi: erc20Abi, + functionName: 'balanceOf', + args: [address] + }); + + // Get token symbol and decimals if not provided + symbol = await publicClient.readContract({ + address: content.tokenAddress, + abi: erc20Abi, + functionName: 'symbol' + }) as string; + + decimals = content.decimals ?? await publicClient.readContract({ + address: content.tokenAddress, + abi: erc20Abi, + functionName: 'decimals' + }) as number; + + balance = formatUnits(tokenBalance as bigint, decimals); + } else { + // Get native token (ETH) balance + const rawBalance = await walletProvider.getWalletBalance() ?? BigInt(0); + decimals = 18; // ETH always has 18 decimals + + // Set appropriate symbol based on chain + symbol = content.chain === "base" ? "ETH" : + content.chain === "sepolia" ? "SEP" : "ETH"; + + // Format the balance from Wei to ETH + balance = formatUnits(rawBalance, decimals); + + // Round to 4 decimal places for display + balance = Number(balance).toFixed(4); + } + + if (callback) { + callback({ + text: `Your ${symbol} balance on ${content.chain} is ${balance} ${symbol} (Address: ${address})` + }); + } + + return true; + } catch (error) { + console.error("Error in getbalance handler:", error); + if (callback) { + callback({ text: `Error: ${error.message}` }); + } + return false; + } + }, + template: getbalanceTemplate, + validate: async (runtime: IAgentRuntime) => { + const privateKey = runtime.getSetting("EVM_PRIVATE_KEY"); + return typeof privateKey === "string" && privateKey.startsWith("0x"); + }, + examples: [ + [ + { + user: "user", + content: { + text: "Check my balance on Sepolia", + action: "GET_BALANCE", + }, + }, + ], + [ + { + user: "user", + content: { + text: "What's my ETH balance?", + action: "GET_BALANCE", + }, + }, + ], + ], + similes: ["GET_BALANCE", "CHECK_BALANCE", "WALLET_BALANCE"], +}; \ No newline at end of file diff --git a/packages/plugin-evm/src/actions/index.ts b/packages/plugin-evm/src/actions/index.ts new file mode 100644 index 00000000000..c54cb05c853 --- /dev/null +++ b/packages/plugin-evm/src/actions/index.ts @@ -0,0 +1,6 @@ +export * from "./getbalance"; +import { getTokenMarketDataAction } from './getTokenMarketData'; + +export const actions = { + getTokenMarketData: getTokenMarketDataAction, +}; \ No newline at end of file diff --git a/packages/plugin-evm/src/actions/swap.ts b/packages/plugin-evm/src/actions/swap.ts index 4bc23080942..963e5c7b7bd 100644 --- a/packages/plugin-evm/src/actions/swap.ts +++ b/packages/plugin-evm/src/actions/swap.ts @@ -1,21 +1,72 @@ -import type { IAgentRuntime, Memory, State } from "@ai16z/eliza"; +import { + IAgentRuntime, + Memory, + State, + ModelClass, + composeContext, + generateObject, + HandlerCallback +} from "@ai16z/eliza"; import { ChainId, createConfig, executeRoute, ExtendedChain, getRoutes, + type ExecutionOptions, + type Route, + type Token, + type EVMProviderOptions } from "@lifi/sdk"; -import { getChainConfigs, WalletProvider } from "../providers/wallet"; +import { WalletProvider, evmWalletProvider, getChainConfigs } from "../providers/wallet"; import { swapTemplate } from "../templates"; -import type { SwapParams, Transaction } from "../types"; +import type { SwapParams, Transaction, SupportedChain } from "../types"; +import { parseEther, formatEther, type WalletClient, type Client } from "viem"; +import { EVM } from "@lifi/sdk"; export { swapTemplate }; +// Validate the generated content structure +function isSwapContent(content: any): content is SwapParams { + return ( + typeof content === "object" && + content !== null && + typeof content.chain === "string" && + ["ethereum", "base", "sepolia"].includes(content.chain) && + typeof content.fromToken === "string" && + typeof content.toToken === "string" && + typeof content.amount === "string" && + !isNaN(Number(content.amount)) && + (content.slippage === null || typeof content.slippage === "number") + ); +} + export class SwapAction { private config; constructor(private walletProvider: WalletProvider) { + const evmProviderConfig: EVMProviderOptions = { + getWalletClient: async () => { + const client = await this.walletProvider.getWalletClient(); + return client as unknown as Client; + }, + switchChain: async (chainId: number) => { + const chainName = Object.entries(getChainConfigs(this.walletProvider.runtime)) + .find(([_, config]) => config.chainId === chainId)?.[0] as SupportedChain; + + if (!chainName) { + throw new Error(`Chain ID ${chainId} not supported`); + } + + await this.walletProvider.switchChain( + this.walletProvider.runtime, + chainName + ); + const client = await this.walletProvider.getWalletClient(); + return client as unknown as Client; + } + }; + this.config = createConfig({ integrator: "eliza", chains: Object.values( @@ -51,68 +102,167 @@ export class SwapAction { mainnet: true, diamondAddress: "0x0000000000000000000000000000000000000000", })) as ExtendedChain[], + providers: [ + EVM(evmProviderConfig) + ] }); } async swap(params: SwapParams): Promise { + console.log("Swapping params:", params); + + // Validate required parameters + if (!params.chain) throw new Error("Chain is required"); + if (!["ethereum", "base", "sepolia"].includes(params.chain)) { + throw new Error("Chain must be 'ethereum', 'base', or 'sepolia'"); + } + if (!params.fromToken) throw new Error("Input token is required"); + if (!params.toToken) throw new Error("Output token is required"); + if (!params.amount) throw new Error("Amount is required"); + if (params.slippage && (params.slippage < 1 || params.slippage > 500)) { + throw new Error("Slippage must be between 1 and 500 basis points"); + } + const walletClient = this.walletProvider.getWalletClient(); const [fromAddress] = await walletClient.getAddresses(); - const routes = await getRoutes({ - fromChainId: getChainConfigs(this.walletProvider.runtime)[ - params.chain - ].chainId as ChainId, - toChainId: getChainConfigs(this.walletProvider.runtime)[ - params.chain - ].chainId as ChainId, - fromTokenAddress: params.fromToken, - toTokenAddress: params.toToken, - fromAmount: params.amount, - fromAddress: fromAddress, - options: { - slippage: params.slippage || 0.5, - order: "RECOMMENDED", - }, - }); + // Switch chain first + await this.walletProvider.switchChain(this.walletProvider.runtime, params.chain); - if (!routes.routes.length) throw new Error("No routes found"); + try { + // Convert ETH amount to Wei + const amountInWei = parseEther(params.amount); + console.log("Amount in Wei:", amountInWei.toString()); - const execution = await executeRoute(routes.routes[0], this.config); - const process = execution.steps[0]?.execution?.process[0]; + // Convert basis points to decimal (e.g., 50 -> 0.005) + const slippageDecimal = (params.slippage || 50) / 10000; + console.log("Slippage decimal:", slippageDecimal); - if (!process?.status || process.status === "FAILED") { - throw new Error("Transaction failed"); - } + const routes = await getRoutes({ + fromChainId: getChainConfigs(this.walletProvider.runtime)[ + params.chain + ].chainId as ChainId, + toChainId: getChainConfigs(this.walletProvider.runtime)[ + params.chain + ].chainId as ChainId, + fromTokenAddress: params.fromToken, + toTokenAddress: params.toToken, + fromAmount: amountInWei.toString(), + fromAddress: fromAddress, + options: { + slippage: slippageDecimal, // Use decimal format + order: "RECOMMENDED", + }, + }); - return { - hash: process.txHash as `0x${string}`, - from: fromAddress, - to: routes.routes[0].steps[0].estimate - .approvalAddress as `0x${string}`, - value: BigInt(params.amount), - data: process.data as `0x${string}`, - chainId: getChainConfigs(this.walletProvider.runtime)[params.chain] - .chainId, - }; + console.log("Routes:", routes); + if (!routes.routes.length) throw new Error("No routes found"); + + // Configure execution options + const executionOptions: ExecutionOptions = { + updateRouteHook: (updatedRoute: Route) => { + console.log("Route updated:", updatedRoute); + }, + acceptExchangeRateUpdateHook: async (params: { + toToken: Token; + oldToAmount: string; + newToAmount: string; + }) => { + console.log("Exchange rate update:", { + token: params.toToken.symbol, + oldAmount: params.oldToAmount, + newAmount: params.newToAmount + }); + return true; + }, + infiniteApproval: false + }; + + const execution = await executeRoute(routes.routes[0], executionOptions); + console.log("Execution:", execution); + const process = execution.steps[0]?.execution?.process[0]; + + if (!process?.status || process.status === "FAILED") { + throw new Error("Transaction failed"); + } + console.log("Process:", process); + + return { + hash: process.txHash as `0x${string}`, + from: fromAddress, + to: routes.routes[0].steps[0].estimate.approvalAddress as `0x${string}`, + value: amountInWei.toString(), + data: process.data as `0x${string}`, + chainId: getChainConfigs(this.walletProvider.runtime)[params.chain].chainId, + }; + } catch (error) { + console.error("Swap error:", error); + throw new Error(`Swap failed: ${error.message}`); + } } } export const swapAction = { name: "swap", - description: "Swap tokens on the same chain", + description: "Swap tokens on the same chain using aggregated DEX routes via the LiFi SDK", handler: async ( runtime: IAgentRuntime, message: Memory, state: State, - options: any, - callback?: any + _options: any, + callback?: HandlerCallback ) => { try { + // Compose state if not provided + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + // Get wallet info for context + const walletInfo = await evmWalletProvider.get(runtime, message, state); + state.walletInfo = walletInfo; + + // Generate structured content from natural language + const swapContext = composeContext({ + state, + template: swapTemplate, + }); + + const content = await generateObject({ + runtime, + context: swapContext, + modelClass: ModelClass.LARGE, + }); + + console.log("Generated content:", content); + + // Validate the generated content + if (!isSwapContent(content)) { + throw new Error("Invalid content structure for swap action"); + } + + console.log("Swap handler content:", content); const walletProvider = new WalletProvider(runtime); const action = new SwapAction(walletProvider); - return await action.swap(options); + const result = await action.swap(content); + + if (callback) { + callback({ + text: `Successfully swapped tokens. Transaction hash: ${result.hash}`, + content: { + transaction: { + ...result, + value: result.value.toString(), + } + } + }); + } + + return true; } catch (error) { - console.error("Error in swap handler:", error.message); + console.error("Error in swap handler:", error); if (callback) { callback({ text: `Error: ${error.message}` }); } @@ -129,11 +279,20 @@ export const swapAction = { { user: "user", content: { - text: "Swap 1 ETH for USDC on Base", + text: "Swap 1 ETH for USDC on Ethereum", + action: "TOKEN_SWAP", + }, + }, + ], + [ + { + user: "user", + content: { + text: "Exchange 0.5 ETH for USDC on Base", action: "TOKEN_SWAP", }, }, ], ], - similes: ["TOKEN_SWAP", "EXCHANGE_TOKENS", "TRADE_TOKENS"], -}; // TODO: add more examples + similes: ["TOKEN_SWAP", "EXCHANGE_TOKENS", "TRADE_TOKENS", "SWAP"], +}; diff --git a/packages/plugin-evm/src/actions/transfer.ts b/packages/plugin-evm/src/actions/transfer.ts index 18321097fe9..8edc3fbe8a4 100644 --- a/packages/plugin-evm/src/actions/transfer.ts +++ b/packages/plugin-evm/src/actions/transfer.ts @@ -1,10 +1,36 @@ +import { + IAgentRuntime, + Memory, + State, + ModelClass, + composeContext, + generateObject, + HandlerCallback +} from "@ai16z/eliza"; import { ByteArray, parseEther, type Hex } from "viem"; -import { WalletProvider } from "../providers/wallet"; -import type { Transaction, TransferParams } from "../types"; +import { WalletProvider, evmWalletProvider } from "../providers/wallet"; import { transferTemplate } from "../templates"; -import type { IAgentRuntime, Memory, State } from "@ai16z/eliza"; +import type { Transaction, TransferParams } from "../types"; +import { privateKeyToAccount } from "viem/accounts"; export { transferTemplate }; + +// Validate the generated content structure +function isTransferContent(content: any): content is TransferParams { + return ( + typeof content === "object" && + content !== null && + typeof content.fromChain === "string" && + ["ethereum", "base", "sepolia"].includes(content.fromChain) && + typeof content.amount === "string" && + !isNaN(Number(content.amount)) && + typeof content.toAddress === "string" && + content.toAddress.startsWith("0x") && + content.toAddress.length === 42 && + (content.data === null || (typeof content.data === "string" && content.data.startsWith("0x"))) + ); +} + export class TransferAction { constructor(private walletProvider: WalletProvider) {} @@ -12,39 +38,149 @@ export class TransferAction { runtime: IAgentRuntime, params: TransferParams ): Promise { + console.log("🚀 Starting transfer with params:", { + fromChain: params.fromChain, + toAddress: params.toAddress, + amount: params.amount, + hasData: !!params.data + }); + + // Validate required parameters + if (!params.fromChain || !params.toAddress || !params.amount) { + console.error("❌ Missing required parameters:", { + fromChain: params.fromChain, + toAddress: params.toAddress, + amount: params.amount + }); + throw new Error( + `Transfer failed: Missing required parameters. Need fromChain, toAddress, and amount. Got: ${JSON.stringify(params)}` + ); + } + + // Validate amount format + if (isNaN(Number(params.amount)) || Number(params.amount) <= 0) { + console.error("❌ Invalid amount:", params.amount); + throw new Error( + `Transfer failed: Invalid amount. Must be a positive number. Got: ${params.amount}` + ); + } + + // Validate address format + if (!params.toAddress.startsWith('0x') || params.toAddress.length !== 42) { + console.error("❌ Invalid to address:", params.toAddress); + throw new Error( + `Transfer failed: Invalid to address. Must be a valid Ethereum address. Got: ${params.toAddress}` + ); + } + const walletClient = this.walletProvider.getWalletClient(); + console.log("📱 Got wallet client"); + const [fromAddress] = await walletClient.getAddresses(); + console.log("💳 From address:", fromAddress); + // Get chain configuration + const chainConfig = this.walletProvider.getChainConfig(params.fromChain); + console.log("🔗 Chain config:", { + name: chainConfig.name, + chainId: chainConfig.chainId, + rpcUrl: chainConfig.rpcUrl + }); + + // Switch chain and get updated wallet client await this.walletProvider.switchChain(runtime, params.fromChain); + const updatedWalletClient = this.walletProvider.getWalletClient(); + console.log("🔄 Switched to chain:", params.fromChain); try { - const hash = await walletClient.sendTransaction({ + const parsedValue = parseEther(params.amount); + console.log("💵 Parsed amount (in Wei):", parsedValue.toString()); + + // Get balance before transfer + const balance = await this.walletProvider.getWalletBalance(); + console.log("💰 Current wallet balance (in Wei):", balance?.toString()); + + // Prepare the transaction base + const transactionRequest = { + to: params.toAddress, + value: parsedValue, + data: params.data as Hex ?? "0x", + chain: chainConfig.chain + }; + + // Estimate gas + const publicClient = this.walletProvider.getPublicClient(params.fromChain); + const gasEstimate = await publicClient.estimateGas({ account: fromAddress, + ...transactionRequest + }); + + console.log("⛽ Estimated gas:", gasEstimate.toString()); + + // Get current gas price + const gasPrice = await publicClient.getGasPrice(); + console.log("💰 Current gas price:", gasPrice.toString()); + + // Get next nonce for the account + const nonce = await publicClient.getTransactionCount({ + address: fromAddress + }); + console.log("🔢 Next nonce:", nonce); + // setup account + const privateKey = runtime.getSetting("EVM_PRIVATE_KEY"); + const account = privateKeyToAccount(privateKey as `0x${string}`); + // Prepare the complete transaction + const transaction = { to: params.toAddress, - value: parseEther(params.amount), - data: params.data as Hex, - kzg: { - blobToKzgCommitment: function (blob: ByteArray): ByteArray { - throw new Error("Function not implemented."); - }, - computeBlobKzgProof: function ( - blob: ByteArray, - commitment: ByteArray - ): ByteArray { - throw new Error("Function not implemented."); - }, - }, - chain: undefined, + value: parsedValue, + data: params.data as Hex ?? "0x", + gas: gasEstimate, + gasPrice: gasPrice, + nonce: nonce, + chainId: chainConfig.chainId, + account, + chain: chainConfig.chain + }; + + console.log("📝 Prepared transaction:", { + ...transaction, + chainName: chainConfig.name, + }); + + // Sign the transaction locally with the account + const signedTx = await updatedWalletClient.signTransaction(transaction); + console.log("✍️ Signed transaction:", signedTx); + + // Send the raw transaction + const hash = await publicClient.sendRawTransaction({ + serializedTransaction: signedTx + }); + + console.log("✅ Raw transaction broadcast successfully!", { + hash, + from: fromAddress, + to: params.toAddress, + value: parsedValue.toString(), + chain: params.fromChain, + chainId: chainConfig.chainId }); return { hash, from: fromAddress, to: params.toAddress, - value: parseEther(params.amount), - data: params.data as Hex, + value: parsedValue.toString(), + data: params.data as Hex ?? "0x", }; } catch (error) { + console.error("❌ Transfer failed with error:", { + message: error.message, + code: error.code, + details: error.details, + stack: error.stack, + chainId: chainConfig.chainId, + chainName: chainConfig.name + }); throw new Error(`Transfer failed: ${error.message}`); } } @@ -57,11 +193,64 @@ export const transferAction = { runtime: IAgentRuntime, message: Memory, state: State, - options: any + _options: any, + callback?: HandlerCallback ) => { - const walletProvider = new WalletProvider(runtime); - const action = new TransferAction(walletProvider); - return action.transfer(runtime, options); + try { + // Compose state if not provided + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + // Get wallet info for context + const walletInfo = await evmWalletProvider.get(runtime, message, state); + state.walletInfo = walletInfo; + + // Generate structured content from natural language + const transferContext = composeContext({ + state, + template: transferTemplate, + }); + + const content = await generateObject({ + runtime, + context: transferContext, + modelClass: ModelClass.LARGE, + }); + + console.log("Generated content:", content); + + // Validate the generated content + if (!isTransferContent(content)) { + throw new Error("Invalid content structure for transfer action"); + } + + const walletProvider = new WalletProvider(runtime); + const action = new TransferAction(walletProvider); + const result = await action.transfer(runtime, content); + + if (callback) { + callback({ + text: `Successfully transferred ${content.amount} ETH to ${content.toAddress} on ${content.fromChain}. Transaction hash: ${result.hash}`, + content: { + transaction: { + ...result, + value: result.value.toString(), + } + } + }); + } + + return true; + } catch (error) { + console.error("Error in transfer handler:", error); + if (callback) { + callback({ text: `Error: ${error.message}` }); + } + return false; + } }, template: transferTemplate, validate: async (runtime: IAgentRuntime) => { @@ -71,16 +260,18 @@ export const transferAction = { examples: [ [ { - user: "assistant", + user: "user", content: { - text: "I'll help you transfer 1 ETH to 0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + text: "Transfer 1 ETH to 0x742d35Cc6634C0532925a3b844Bc454e4438f44e on Ethereum", action: "SEND_TOKENS", }, }, + ], + [ { user: "user", content: { - text: "Transfer 1 ETH to 0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + text: "Send 0.5 ETH to 0x742d35Cc6634C0532925a3b844Bc454e4438f44e on Base", action: "SEND_TOKENS", }, }, diff --git a/packages/plugin-evm/src/index.ts b/packages/plugin-evm/src/index.ts index dd6ccc3d1a8..f7ab8f6a4c9 100644 --- a/packages/plugin-evm/src/index.ts +++ b/packages/plugin-evm/src/index.ts @@ -1,14 +1,19 @@ export * from "./actions/bridge"; export * from "./actions/swap"; export * from "./actions/transfer"; +export * from "./actions/getbalance"; +export * from "./actions/getTokenInfo"; export * from "./providers/wallet"; export * from "./types"; +export * from "./abis/erc20"; import type { Plugin } from "@ai16z/eliza"; import { bridgeAction } from "./actions/bridge"; import { swapAction } from "./actions/swap"; import { transferAction } from "./actions/transfer"; import { evmWalletProvider } from "./providers/wallet"; +import { getbalanceAction } from "./actions/getbalance"; +import { getTokenInfoAction } from "./actions/getTokenInfo"; export const evmPlugin: Plugin = { name: "evm", @@ -16,7 +21,7 @@ export const evmPlugin: Plugin = { providers: [evmWalletProvider], evaluators: [], services: [], - actions: [transferAction, bridgeAction, swapAction], + actions: [transferAction, bridgeAction, swapAction, getbalanceAction, getTokenInfoAction], }; -export default evmPlugin; +export default evmPlugin; \ No newline at end of file diff --git a/packages/plugin-evm/src/providers/wallet.ts b/packages/plugin-evm/src/providers/wallet.ts index 01b934300df..0afe8da3953 100644 --- a/packages/plugin-evm/src/providers/wallet.ts +++ b/packages/plugin-evm/src/providers/wallet.ts @@ -11,7 +11,7 @@ import { type Address, Account, } from "viem"; -import { mainnet, base } from "viem/chains"; +import { mainnet, base, sepolia } from "viem/chains"; import type { SupportedChain, ChainConfig, ChainMetadata } from "../types"; import { privateKeyToAccount } from "viem/accounts"; @@ -40,6 +40,18 @@ export const DEFAULT_CHAIN_CONFIGS: Record = { }, blockExplorerUrl: "https://basescan.org", }, + sepolia: { + chainId: 11155111, + name: "Sepolia", + chain: sepolia, + rpcUrl: "https://rpc.sepolia.org", + nativeCurrency: { + name: "Sepolia Ether", + symbol: "ETH", + decimals: 18, + }, + blockExplorerUrl: "https://sepolia.etherscan.io", + }, } as const; export const getChainConfigs = (runtime: IAgentRuntime) => { @@ -66,14 +78,16 @@ export class WalletProvider { const createClients = (chain: SupportedChain): ChainConfig => { const transport = http(getChainConfigs(runtime)[chain].rpcUrl); + const chainConfig = getChainConfigs(runtime)[chain]; + return { - chain: getChainConfigs(runtime)[chain].chain, + chain: chainConfig.chain, publicClient: createPublicClient({ - chain: getChainConfigs(runtime)[chain].chain, + chain: chainConfig.chain, transport, }) as PublicClient, - walletClient: createWalletClient({ - chain: getChainConfigs(runtime)[chain].chain, + walletClient: createWalletClient({ + chain: chainConfig.chain, transport, account, }), @@ -83,6 +97,7 @@ export class WalletProvider { this.chainConfigs = { ethereum: createClients("ethereum"), base: createClients("base"), + sepolia: createClients("sepolia"), }; } @@ -90,14 +105,14 @@ export class WalletProvider { return this.address; } - async getWalletBalance(): Promise { + async getWalletBalance(): Promise { try { const client = this.getPublicClient(this.currentChain); const walletClient = this.getWalletClient(); const balance = await client.getBalance({ address: walletClient.account.address, }); - return formatUnits(balance, 18); + return balance; } catch (error) { console.error("Error getting wallet balance:", error); return null; @@ -112,39 +127,6 @@ export class WalletProvider { runtime: IAgentRuntime, chain: SupportedChain ): Promise { - const walletClient = this.chainConfigs[this.currentChain].walletClient; - if (!walletClient) throw new Error("Wallet not connected"); - - try { - await walletClient.switchChain({ - id: getChainConfigs(runtime)[chain].chainId, - }); - } catch (error: any) { - if (error.code === 4902) { - console.log( - "[WalletProvider] Chain not added to wallet (error 4902) - attempting to add chain first" - ); - await walletClient.addChain({ - chain: { - ...getChainConfigs(runtime)[chain].chain, - rpcUrls: { - default: { - http: [getChainConfigs(runtime)[chain].rpcUrl], - }, - public: { - http: [getChainConfigs(runtime)[chain].rpcUrl], - }, - }, - }, - }); - await walletClient.switchChain({ - id: getChainConfigs(runtime)[chain].chainId, - }); - } else { - throw error; - } - } - this.currentChain = chain; } diff --git a/packages/plugin-evm/src/templates/getTokenMarketData.ts b/packages/plugin-evm/src/templates/getTokenMarketData.ts new file mode 100644 index 00000000000..b4bfb1e71fe --- /dev/null +++ b/packages/plugin-evm/src/templates/getTokenMarketData.ts @@ -0,0 +1,21 @@ +export const getTokenMarketDataTemplate = `Given the recent messages and wallet information below: + +{{recentMessages}} + +{{walletInfo}} + +Extract the following information about the token market data request: +- Chain to check on (must be exactly "ethereum", "base", or "sepolia") - REQUIRED +- Token address - REQUIRED +- Timeframe (optional, must be "24h", "7d", or "30d", defaults to "24h") + +Respond with a JSON markdown block containing only the extracted values: + +\`\`\`json +{ + "chain": "ethereum" | "base" | "sepolia", + "tokenAddress": string, + "timeframe": "24h" | "7d" | "30d" +} +\`\`\` +`; \ No newline at end of file diff --git a/packages/plugin-evm/src/templates/getbalance.ts b/packages/plugin-evm/src/templates/getbalance.ts new file mode 100644 index 00000000000..d77767141fd --- /dev/null +++ b/packages/plugin-evm/src/templates/getbalance.ts @@ -0,0 +1,42 @@ +export const getbalanceTemplate = `Given the recent messages and wallet information below: + +{{recentMessages}} + +{{walletInfo}} + +Extract the following information about the balance check request: +- Chain to check balance on (must be exactly "ethereum", "base", or "sepolia", no other variations) - REQUIRED +- Token address (optional, if not provided will check ETH balance) + +Common token addresses: +- USDC on Ethereum: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 +- USDC on Base: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 +- USDC on Sepolia: 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 + +Respond with a JSON markdown block containing only the extracted values: + +\`\`\`json +{ + "chain": "ethereum" | "base" | "sepolia", + "tokenAddress": string | null +} +\`\`\` + +Example responses: + +For "Check my ETH balance on Sepolia": +\`\`\`json +{ + "chain": "sepolia", + "tokenAddress": null +} +\`\`\` + +For "What's my USDC balance on Ethereum?": +\`\`\`json +{ + "chain": "ethereum", + "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +} +\`\`\` +`; \ No newline at end of file diff --git a/packages/plugin-evm/src/templates/index.ts b/packages/plugin-evm/src/templates/index.ts index d8bccf17d3b..d46556b7a22 100644 --- a/packages/plugin-evm/src/templates/index.ts +++ b/packages/plugin-evm/src/templates/index.ts @@ -1,3 +1,6 @@ +export * from "./getbalance"; +export * from "./swap"; + export const transferTemplate = `Given the recent messages and wallet information below: {{recentMessages}} @@ -5,21 +8,26 @@ export const transferTemplate = `Given the recent messages and wallet informatio {{walletInfo}} Extract the following information about the requested transfer: -- Chain to execute on (ethereum or base) +- Chain to execute on (ethereum, base, or sepolia) - Amount to transfer - Recipient address -- Token symbol or address (if not native token) -Respond with a JSON markdown block containing only the extracted values: +Respond with a JSON markdown block containing only the extracted values in this exact format: \`\`\`json { - "chain": "ethereum" | "base" | null, - "amount": string | null, - "toAddress": string | null, - "token": string | null + "fromChain": "ethereum" | "base" | "sepolia", + "amount": "1.0", + "toAddress": "0x...", + "data": "0x" | null } \`\`\` + +Notes: +- fromChain must be exactly "ethereum", "base", or "sepolia" +- amount must be a string number like "1.0", "0.5", etc. +- toAddress must be a full ethereum address starting with 0x +- data is optional and defaults to null `; export const bridgeTemplate = `Given the recent messages and wallet information below: @@ -46,29 +54,4 @@ Respond with a JSON markdown block containing only the extracted values: "toAddress": string | null } \`\`\` -`; - -export const swapTemplate = `Given the recent messages and wallet information below: - -{{recentMessages}} - -{{walletInfo}} - -Extract the following information about the requested token swap: -- Input token symbol or address (the token being sold) -- Output token symbol or address (the token being bought) -- Amount to swap -- Chain to execute on (ethereum or base) - -Respond with a JSON markdown block containing only the extracted values. Use null for any values that cannot be determined: - -\`\`\`json -{ - "inputToken": string | null, - "outputToken": string | null, - "amount": string | null, - "chain": "ethereum" | "base" | null, - "slippage": number | null -} -\`\`\` -`; +`; \ No newline at end of file diff --git a/packages/plugin-evm/src/templates/swap.ts b/packages/plugin-evm/src/templates/swap.ts new file mode 100644 index 00000000000..3f7338c5bf6 --- /dev/null +++ b/packages/plugin-evm/src/templates/swap.ts @@ -0,0 +1,63 @@ +export const swapTemplate = `Given the recent messages and wallet information below: + +{{recentMessages}} + +{{walletInfo}} + +Extract the following information about the requested token swap: +- Chain to execute on (must be exactly "ethereum", "base", or "sepolia", no other variations) - REQUIRED +- Input token (ETH or token address) - REQUIRED +- Output token (token address) - REQUIRED +- Amount to swap - REQUIRED +- Slippage tolerance (optional, in basis points, default 50 = 0.5%) + +For common tokens, use these addresses: +- ETH: 0x0000000000000000000000000000000000000000 +- USDC on Ethereum: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 +- USDC on Base: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 +- USDC on Sepolia: 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 + +Important validation rules: +1. Chain MUST be exactly "ethereum", "base", or "sepolia" (lowercase) +2. Token addresses MUST be valid Ethereum addresses (0x...) +3. Amount MUST be a valid number as a string +4. Slippage MUST be a number between 1-500 (0.01% to 5%) + +If any required field cannot be confidently determined, respond with null for that field. + +Respond with a JSON markdown block containing only the extracted values: + +\`\`\`json +{ + "chain": "ethereum" | "base" | "sepolia", + "fromToken": string, + "toToken": string, + "amount": string, + "slippage": number | null +} +\`\`\` + +Example responses: + +For "Swap 1 ETH for USDC on Ethereum": +\`\`\`json +{ + "chain": "ethereum", + "fromToken": "0x0000000000000000000000000000000000000000", + "toToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "1", + "slippage": 50 +} +\`\`\` + +For "Swap 0.1 ETH for USDC on Sepolia": +\`\`\`json +{ + "chain": "sepolia", + "fromToken": "0x0000000000000000000000000000000000000000", + "toToken": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + "amount": "0.1", + "slippage": 50 +} +\`\`\` +`; \ No newline at end of file diff --git a/packages/plugin-evm/src/types/index.ts b/packages/plugin-evm/src/types/index.ts index d2bfbca0ede..3a3af718f5e 100644 --- a/packages/plugin-evm/src/types/index.ts +++ b/packages/plugin-evm/src/types/index.ts @@ -9,14 +9,14 @@ import type { WalletClient, } from "viem"; -export type SupportedChain = "ethereum" | "base"; +export type SupportedChain = "ethereum" | "base" | "sepolia"; // Transaction types export interface Transaction { hash: Hash; from: Address; to: Address; - value: bigint; + value: string; data?: `0x${string}`; chainId?: number; }