diff --git a/packages/plugin-evm/package.json b/packages/plugin-evm/package.json index c041a142159..b24d1045c71 100644 --- a/packages/plugin-evm/package.json +++ b/packages/plugin-evm/package.json @@ -15,7 +15,8 @@ }, "scripts": { "build": "tsup --format esm --dts", - "dev": "tsup --format esm --dts --watch" + "dev": "tsup --format esm --dts --watch", + "test": "vitest run" }, "peerDependencies": { "whatwg-url": "7.1.0" diff --git a/packages/plugin-evm/src/actions/bridge.ts b/packages/plugin-evm/src/actions/bridge.ts index 4d92018e96a..eeb888486c7 100644 --- a/packages/plugin-evm/src/actions/bridge.ts +++ b/packages/plugin-evm/src/actions/bridge.ts @@ -1,13 +1,11 @@ import type { IAgentRuntime, Memory, State } from "@ai16z/eliza"; import { - ChainId, createConfig, executeRoute, ExtendedChain, getRoutes, } from "@lifi/sdk"; import { WalletProvider } from "../providers/wallet"; -import { getChainConfigs } from "../providers/chainConfigs"; import { bridgeTemplate } from "../templates"; import type { BridgeParams, Transaction } from "../types"; @@ -19,25 +17,23 @@ export class BridgeAction { constructor(private walletProvider: WalletProvider) { this.config = createConfig({ integrator: "eliza", - chains: Object.values( - getChainConfigs(this.walletProvider.runtime) - ).map((config) => ({ - id: config.chainId, + chains: Object.values(this.walletProvider.chains).map((config) => ({ + id: config.id, name: config.name, key: config.name.toLowerCase(), chainType: "EVM", nativeToken: { ...config.nativeCurrency, - chainId: config.chainId, + chainId: config.id, address: "0x0000000000000000000000000000000000000000", coinKey: config.nativeCurrency.symbol, }, metamask: { - chainId: `0x${config.chainId.toString(16)}`, + chainId: `0x${config.id.toString(16)}`, chainName: config.name, nativeCurrency: config.nativeCurrency, - rpcUrls: [config.rpcUrl], - blockExplorerUrls: [config.blockExplorerUrl], + rpcUrls: [config.rpcUrls.default.http[0]], + blockExplorerUrls: [config.blockExplorers.default.url], }, diamondAddress: "0x0000000000000000000000000000000000000000", coin: config.nativeCurrency.symbol, @@ -47,16 +43,15 @@ export class BridgeAction { } async bridge(params: BridgeParams): Promise { - const walletClient = this.walletProvider.getWalletClient(); + const walletClient = this.walletProvider.getWalletClient( + params.fromChain + ); const [fromAddress] = await walletClient.getAddresses(); const routes = await getRoutes({ - fromChainId: getChainConfigs(this.walletProvider.runtime)[ - params.fromChain - ].chainId as ChainId, - toChainId: getChainConfigs(this.walletProvider.runtime)[ - params.toChain - ].chainId as ChainId, + fromChainId: this.walletProvider.getChainConfigs(params.fromChain) + .id, + toChainId: this.walletProvider.getChainConfigs(params.toChain).id, fromTokenAddress: params.fromToken, toTokenAddress: params.toToken, fromAmount: params.amount, @@ -79,9 +74,7 @@ export class BridgeAction { to: routes.routes[0].steps[0].estimate .approvalAddress as `0x${string}`, value: BigInt(params.amount), - chainId: getChainConfigs(this.walletProvider.runtime)[ - params.fromChain - ].chainId, + chainId: this.walletProvider.getChainConfigs(params.fromChain).id, }; } } @@ -95,7 +88,10 @@ export const bridgeAction = { state: State, options: any ) => { - const walletProvider = new WalletProvider(runtime); + const privateKey = runtime.getSetting( + "EVM_PRIVATE_KEY" + ) as `0x${string}`; + const walletProvider = new WalletProvider(privateKey); const action = new BridgeAction(walletProvider); return action.bridge(options); }, diff --git a/packages/plugin-evm/src/actions/swap.ts b/packages/plugin-evm/src/actions/swap.ts index 3b22916cb35..1c66f43f14b 100644 --- a/packages/plugin-evm/src/actions/swap.ts +++ b/packages/plugin-evm/src/actions/swap.ts @@ -7,7 +7,6 @@ import { getRoutes, } from "@lifi/sdk"; import { WalletProvider } from "../providers/wallet"; -import { getChainConfigs } from "../providers/chainConfigs"; import { swapTemplate } from "../templates"; import type { SwapParams, Transaction } from "../types"; @@ -19,16 +18,14 @@ export class SwapAction { constructor(private walletProvider: WalletProvider) { this.config = createConfig({ integrator: "eliza", - chains: Object.values( - getChainConfigs(this.walletProvider.runtime) - ).map((config) => ({ - id: config.chainId, + chains: Object.values(this.walletProvider.chains).map((config) => ({ + id: config.id, name: config.name, key: config.name.toLowerCase(), chainType: "EVM" as const, nativeToken: { ...config.nativeCurrency, - chainId: config.chainId, + chainId: config.id, address: "0x0000000000000000000000000000000000000000", coinKey: config.nativeCurrency.symbol, priceUSD: "0", @@ -38,15 +35,15 @@ export class SwapAction { name: config.nativeCurrency.name, }, rpcUrls: { - public: { http: [config.rpcUrl] }, + public: { http: [config.rpcUrls.default.http[0]] }, }, - blockExplorerUrls: [config.blockExplorerUrl], + blockExplorerUrls: [config.blockExplorers.default.url], metamask: { - chainId: `0x${config.chainId.toString(16)}`, + chainId: `0x${config.id.toString(16)}`, chainName: config.name, nativeCurrency: config.nativeCurrency, - rpcUrls: [config.rpcUrl], - blockExplorerUrls: [config.blockExplorerUrl], + rpcUrls: [config.rpcUrls.default.http[0]], + blockExplorerUrls: [config.blockExplorers.default.url], }, coin: config.nativeCurrency.symbol, mainnet: true, @@ -56,16 +53,12 @@ export class SwapAction { } async swap(params: SwapParams): Promise { - const walletClient = this.walletProvider.getWalletClient(); + const walletClient = this.walletProvider.getWalletClient(params.chain); 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, + fromChainId: this.walletProvider.getChainConfigs(params.chain).id, + toChainId: this.walletProvider.getChainConfigs(params.chain).id, fromTokenAddress: params.fromToken, toTokenAddress: params.toToken, fromAmount: params.amount, @@ -92,8 +85,7 @@ export class SwapAction { .approvalAddress as `0x${string}`, value: BigInt(params.amount), data: process.data as `0x${string}`, - chainId: getChainConfigs(this.walletProvider.runtime)[params.chain] - .chainId, + chainId: this.walletProvider.getChainConfigs(params.chain).id, }; } } @@ -109,7 +101,10 @@ export const swapAction = { callback?: any ) => { try { - const walletProvider = new WalletProvider(runtime); + const privateKey = runtime.getSetting( + "EVM_PRIVATE_KEY" + ) as `0x${string}`; + const walletProvider = new WalletProvider(privateKey); const action = new SwapAction(walletProvider); return await action.swap(options); } catch (error) { diff --git a/packages/plugin-evm/src/actions/transfer.ts b/packages/plugin-evm/src/actions/transfer.ts index 18321097fe9..5c3cb71957b 100644 --- a/packages/plugin-evm/src/actions/transfer.ts +++ b/packages/plugin-evm/src/actions/transfer.ts @@ -1,25 +1,34 @@ -import { ByteArray, parseEther, type Hex } from "viem"; -import { WalletProvider } from "../providers/wallet"; +import { ByteArray, formatEther, parseEther, type Hex } from "viem"; +import { + composeContext, + generateObjectDEPRECATED, + HandlerCallback, + ModelClass, + type IAgentRuntime, + type Memory, + type State, +} from "@ai16z/eliza"; + +import { initWalletProvider, WalletProvider } from "../providers/wallet"; import type { Transaction, TransferParams } from "../types"; import { transferTemplate } from "../templates"; -import type { IAgentRuntime, Memory, State } from "@ai16z/eliza"; export { transferTemplate }; export class TransferAction { constructor(private walletProvider: WalletProvider) {} - async transfer( - runtime: IAgentRuntime, - params: TransferParams - ): Promise { - const walletClient = this.walletProvider.getWalletClient(); - const [fromAddress] = await walletClient.getAddresses(); + async transfer(params: TransferParams): Promise { + console.log( + `Transferring: ${params.amount} tokens to (${params.toAddress} on ${params.fromChain})` + ); - await this.walletProvider.switchChain(runtime, params.fromChain); + const walletClient = this.walletProvider.getWalletClient( + params.fromChain + ); try { const hash = await walletClient.sendTransaction({ - account: fromAddress, + account: walletClient.account, to: params.toAddress, value: parseEther(params.amount), data: params.data as Hex, @@ -39,7 +48,7 @@ export class TransferAction { return { hash, - from: fromAddress, + from: walletClient.account.address, to: params.toAddress, value: parseEther(params.amount), data: params.data as Hex, @@ -50,6 +59,43 @@ export class TransferAction { } } +const buildTransferDetails = async ( + state: State, + runtime: IAgentRuntime, + wp: WalletProvider +): Promise => { + const context = composeContext({ + state, + template: transferTemplate, + }); + + const chains = Object.keys(wp.chains); + + const contextWithChains = context.replace( + "SUPPORTED_CHAINS", + chains.toString() + ); + + const transferDetails = (await generateObjectDEPRECATED({ + runtime, + context: contextWithChains, + modelClass: ModelClass.SMALL, + })) as TransferParams; + + const existingChain = wp.chains[transferDetails.fromChain]; + + if (!existingChain) { + throw new Error( + "The chain " + + transferDetails.fromChain + + " not configured yet. Add the chain or choose one from configured: " + + chains.toString() + ); + } + + return transferDetails; +}; + export const transferAction = { name: "transfer", description: "Transfer tokens between addresses on the same chain", @@ -57,11 +103,43 @@ 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 { + const walletProvider = initWalletProvider(runtime); + const action = new TransferAction(walletProvider); + const transferDetails = await buildTransferDetails( + state, + runtime, + walletProvider + ); + const tx = await action.transfer(transferDetails); + + if (callback) { + callback({ + text: `Successfully transferred ${formatEther(tx.value)} tokens to ${tx.to}\nTransaction hash: ${tx.hash}\nChain: ${transferDetails.fromChain}`, + content: { + success: true, + hash: tx.hash, + amount: formatEther(tx.value), + recipient: tx.to, + chain: transferDetails.fromChain, + }, + }); + } + + return true; + } catch (error) { + console.error("Error during token transfer:", error); + if (callback) { + callback({ + text: `Error transferring tokens: ${error.message}`, + content: { error: error.message }, + }); + } + return false; + } }, template: transferTemplate, validate: async (runtime: IAgentRuntime) => { diff --git a/packages/plugin-evm/src/providers/chainConfigs.ts b/packages/plugin-evm/src/providers/chainConfigs.ts deleted file mode 100644 index a410c56b962..00000000000 --- a/packages/plugin-evm/src/providers/chainConfigs.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { - mainnet, - base, - sepolia, - bsc, - arbitrum, - avalanche, - polygon, - optimism, - cronos, - gnosis, - fantom, - klaytn, - celo, - moonbeam, - aurora, - harmonyOne, - moonriver, - arbitrumNova, - mantle, - linea, - scroll, - filecoin, - taiko, - zksync, - canto, -} from "viem/chains"; -import type { ChainMetadata, SupportedChain } from "../types"; -import type { IAgentRuntime } from "@ai16z/eliza"; - -export const DEFAULT_CHAIN_CONFIGS: Record = { - ethereum: { - chainId: 1, - name: "Ethereum", - chain: mainnet, - rpcUrl: "https://eth.llamarpc.com", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://etherscan.io", - }, - base: { - chainId: 8453, - name: "Base", - chain: base, - rpcUrl: "https://base.llamarpc.com", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - 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", - }, - bsc: { - chainId: 56, - name: "BNB Smart Chain", - chain: bsc, - rpcUrl: "https://bsc-dataseed1.binance.org/", - nativeCurrency: { - name: "Binance Coin", - symbol: "BNB", - decimals: 18, - }, - blockExplorerUrl: "https://bscscan.com", - }, - arbitrum: { - chainId: 42161, - name: "Arbitrum One", - chain: arbitrum, - rpcUrl: "https://arb1.arbitrum.io/rpc", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://arbiscan.io", - }, - avalanche: { - chainId: 43114, - name: "Avalanche C-Chain", - chain: avalanche, - rpcUrl: "https://api.avax.network/ext/bc/C/rpc", - nativeCurrency: { - name: "Avalanche", - symbol: "AVAX", - decimals: 18, - }, - blockExplorerUrl: "https://snowtrace.io", - }, - polygon: { - chainId: 137, - name: "Polygon", - chain: polygon, - rpcUrl: "https://polygon-rpc.com", - nativeCurrency: { - name: "MATIC", - symbol: "MATIC", - decimals: 18, - }, - blockExplorerUrl: "https://polygonscan.com", - }, - optimism: { - chainId: 10, - name: "Optimism", - chain: optimism, - rpcUrl: "https://mainnet.optimism.io", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://optimistic.etherscan.io", - }, - cronos: { - chainId: 25, - name: "Cronos", - chain: cronos, - rpcUrl: "https://evm.cronos.org", - nativeCurrency: { - name: "Cronos", - symbol: "CRO", - decimals: 18, - }, - blockExplorerUrl: "https://cronoscan.com", - }, - gnosis: { - chainId: 100, - name: "Gnosis", - chain: gnosis, - rpcUrl: "https://rpc.gnosischain.com", - nativeCurrency: { - name: "xDAI", - symbol: "XDAI", - decimals: 18, - }, - blockExplorerUrl: "https://gnosisscan.io", - }, - fantom: { - chainId: 250, - name: "Fantom", - chain: fantom, - rpcUrl: "https://rpc.ftm.tools", - nativeCurrency: { - name: "Fantom", - symbol: "FTM", - decimals: 18, - }, - blockExplorerUrl: "https://ftmscan.com", - }, - klaytn: { - chainId: 8217, - name: "Klaytn", - chain: klaytn, - rpcUrl: "https://public-node-api.klaytnapi.com/v1/cypress", - nativeCurrency: { - name: "KLAY", - symbol: "KLAY", - decimals: 18, - }, - blockExplorerUrl: "https://scope.klaytn.com", - }, - celo: { - chainId: 42220, - name: "Celo", - chain: celo, - rpcUrl: "https://forno.celo.org", - nativeCurrency: { - name: "Celo", - symbol: "CELO", - decimals: 18, - }, - blockExplorerUrl: "https://celoscan.io", - }, - moonbeam: { - chainId: 1284, - name: "Moonbeam", - chain: moonbeam, - rpcUrl: "https://rpc.api.moonbeam.network", - nativeCurrency: { - name: "Glimmer", - symbol: "GLMR", - decimals: 18, - }, - blockExplorerUrl: "https://moonscan.io", - }, - aurora: { - chainId: 1313161554, - name: "Aurora", - chain: aurora, - rpcUrl: "https://mainnet.aurora.dev", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://aurorascan.dev", - }, - harmonyOne: { - chainId: 1666600000, - name: "harmonyOne", - chain: harmonyOne, - rpcUrl: "https://api.harmonyOne.one", - nativeCurrency: { - name: "ONE", - symbol: "ONE", - decimals: 18, - }, - blockExplorerUrl: "https://explorer.harmonyOne.one", - }, - moonriver: { - chainId: 1285, - name: "Moonriver", - chain: moonriver, - rpcUrl: "https://rpc.api.moonriver.moonbeam.network", - nativeCurrency: { - name: "Moonriver", - symbol: "MOVR", - decimals: 18, - }, - blockExplorerUrl: "https://moonriver.moonscan.io", - }, - arbitrumNova: { - chainId: 42170, - name: "Arbitrum Nova", - chain: arbitrumNova, - rpcUrl: "https://nova.arbitrum.io/rpc", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://nova-explorer.arbitrum.io", - }, - mantle: { - chainId: 5000, - name: "Mantle", - chain: mantle, - rpcUrl: "https://rpc.mantle.xyz", - nativeCurrency: { - name: "Mantle", - symbol: "MNT", - decimals: 18, - }, - blockExplorerUrl: "https://explorer.mantle.xyz", - }, - linea: { - chainId: 59144, - name: "Linea", - chain: linea, - rpcUrl: "https://linea-mainnet.rpc.build", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://lineascan.build", - }, - scroll: { - chainId: 534353, - name: "Scroll Alpha Testnet", - chain: scroll, - rpcUrl: "https://alpha-rpc.scroll.io/l2", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://blockscout.scroll.io", - }, - filecoin: { - chainId: 314, - name: "Filecoin", - chain: filecoin, - rpcUrl: "https://api.node.glif.io/rpc/v1", - nativeCurrency: { - name: "Filecoin", - symbol: "FIL", - decimals: 18, - }, - blockExplorerUrl: "https://filfox.info/en", - }, - taiko: { - chainId: 167005, - name: "Taiko (Alpha-3) Testnet", - chain: taiko, - rpcUrl: "https://rpc.a3.taiko.xyz", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://explorer.a3.taiko.xyz", - }, - zksync: { - chainId: 324, - name: "zksync Era", - chain: zksync, - rpcUrl: "https://mainnet.era.zksync.io", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://explorer.zksync.io", - }, - canto: { - chainId: 7700, - name: "Canto", - chain: canto, - rpcUrl: "https://canto.slingshot.finance", - nativeCurrency: { - name: "CANTO", - symbol: "CANTO", - decimals: 18, - }, - blockExplorerUrl: "https://tuber.build", - }, -} as const; - -export const getChainConfigs = (runtime: IAgentRuntime) => { - return ( - (runtime.character.settings.chains?.evm as ChainMetadata[]) || - DEFAULT_CHAIN_CONFIGS - ); -}; diff --git a/packages/plugin-evm/src/providers/chainUtils.ts b/packages/plugin-evm/src/providers/chainUtils.ts deleted file mode 100644 index 377aa3f3631..00000000000 --- a/packages/plugin-evm/src/providers/chainUtils.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createPublicClient, createWalletClient, http } from "viem"; -import type { IAgentRuntime } from "@ai16z/eliza"; -import type { - Account, - Chain, - HttpTransport, - PublicClient, - WalletClient, -} from "viem"; -import type { SupportedChain, ChainConfig } from "../types"; -import { DEFAULT_CHAIN_CONFIGS } from "./chainConfigs"; - -export const createChainClients = ( - chain: SupportedChain, - runtime: IAgentRuntime, - account: Account -): ChainConfig => { - const chainConfig = DEFAULT_CHAIN_CONFIGS[chain]; - const transport = http(chainConfig.rpcUrl); - - return { - chain: chainConfig.chain, - publicClient: createPublicClient({ - chain: chainConfig.chain, - transport, - }) as PublicClient, - walletClient: createWalletClient({ - chain: chainConfig.chain, - transport, - account, - }), - }; -}; - -export const initializeChainConfigs = ( - runtime: IAgentRuntime, - account: Account -): Record => { - return Object.keys(DEFAULT_CHAIN_CONFIGS).reduce( - (configs, chain) => { - const supportedChain = chain as SupportedChain; - configs[supportedChain] = createChainClients( - supportedChain, - runtime, - account - ); - return configs; - }, - {} as Record - ); -}; diff --git a/packages/plugin-evm/src/providers/wallet.ts b/packages/plugin-evm/src/providers/wallet.ts index a317a861879..2b816c461bb 100644 --- a/packages/plugin-evm/src/providers/wallet.ts +++ b/packages/plugin-evm/src/providers/wallet.ts @@ -1,4 +1,9 @@ -import { formatUnits } from "viem"; +import { + createPublicClient, + createWalletClient, + formatUnits, + http, +} from "viem"; import { privateKeyToAccount } from "viem/accounts"; import type { IAgentRuntime, Provider, Memory, State } from "@ai16z/eliza"; import type { @@ -8,39 +13,73 @@ import type { Chain, HttpTransport, Account, + PrivateKeyAccount, } from "viem"; -import type { SupportedChain, ChainConfig } from "../types"; -import { getChainConfigs } from "./chainConfigs"; -import { initializeChainConfigs } from "./chainUtils"; +import * as viemChains from "viem/chains"; -export class WalletProvider { - private chainConfigs: Record; - private currentChain: SupportedChain = "ethereum"; - private address: Address; - runtime: IAgentRuntime; +import type { SupportedChain } from "../types"; - constructor(runtime: IAgentRuntime) { - const privateKey = runtime.getSetting("EVM_PRIVATE_KEY"); - if (!privateKey) throw new Error("EVM_PRIVATE_KEY not configured"); +export class WalletProvider { + private currentChain: SupportedChain = "mainnet"; + chains: Record = { mainnet: viemChains.mainnet }; + account: PrivateKeyAccount; - this.runtime = runtime; - const account = privateKeyToAccount(privateKey as `0x${string}`); - this.address = account.address; + constructor(privateKey: `0x${string}`, chains?: Record) { + this.setAccount(privateKey); + this.setChains(chains); - // Initialize all chain configs at once - this.chainConfigs = initializeChainConfigs(runtime, account); + if (chains && Object.keys(chains).length > 0) { + this.setCurrentChain(Object.keys(chains)[0] as SupportedChain); + } } getAddress(): Address { - return this.address; + return this.account.address; + } + + getCurrentChain(): Chain { + return this.chains[this.currentChain]; + } + + getPublicClient( + chainName: SupportedChain + ): PublicClient { + const transport = this.createHttpTransport(chainName); + + const publicClient = createPublicClient({ + chain: this.chains[chainName], + transport, + }); + return publicClient; + } + + getWalletClient(chainName: SupportedChain): WalletClient { + const transport = this.createHttpTransport(chainName); + + const walletClient = createWalletClient({ + chain: this.chains[chainName], + transport, + account: this.account, + }); + + return walletClient; + } + + getChainConfigs(chainName: SupportedChain): Chain { + const chain = viemChains[chainName]; + + if (!chain?.id) { + throw new Error("Invalid chain name"); + } + + return chain; } async getWalletBalance(): Promise { try { const client = this.getPublicClient(this.currentChain); - const walletClient = this.getWalletClient(); const balance = await client.getBalance({ - address: walletClient.account.address, + address: this.account.address, }); return formatUnits(balance, 18); } catch (error) { @@ -49,70 +88,125 @@ export class WalletProvider { } } - async connect(): Promise<`0x${string}`> { - return this.runtime.getSetting("EVM_PRIVATE_KEY") as `0x${string}`; - } - - async switchChain( - runtime: IAgentRuntime, - chain: SupportedChain - ): Promise { - const walletClient = this.chainConfigs[this.currentChain].walletClient; - if (!walletClient) throw new Error("Wallet not connected"); - + async getWalletBalanceForChain( + chainName: SupportedChain + ): Promise { try { - await walletClient.switchChain({ - id: getChainConfigs(runtime)[chain].chainId, + const client = this.getPublicClient(chainName); + const balance = await client.getBalance({ + address: this.account.address, }); - } 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; - } + return formatUnits(balance, 18); + } catch (error) { + console.error("Error getting wallet balance:", error); + return null; } + } - this.currentChain = chain; + addChain(chain: Record) { + this.setChains(chain); } - getPublicClient( - chain: SupportedChain - ): PublicClient { - return this.chainConfigs[chain].publicClient; + switchChain(chainName: SupportedChain, customRpcUrl?: string) { + if (!this.chains[chainName]) { + const chain = WalletProvider.genChainFromName( + chainName, + customRpcUrl + ); + this.addChain({ [chainName]: chain }); + } + this.setCurrentChain(chainName); } - getWalletClient(): WalletClient { - const walletClient = this.chainConfigs[this.currentChain].walletClient; - if (!walletClient) throw new Error("Wallet not connected"); - return walletClient; + private setAccount = (pk: `0x${string}`) => { + this.account = privateKeyToAccount(pk); + }; + + private setChains = (chains?: Record) => { + if (!chains) { + return; + } + Object.keys(chains).forEach((chain: string) => { + this.chains[chain] = chains[chain]; + }); + }; + + private setCurrentChain = (chain: SupportedChain) => { + this.currentChain = chain; + }; + + private createHttpTransport = (chainName: SupportedChain) => { + const chain = this.chains[chainName]; + + if (chain.rpcUrls.custom) { + return http(chain.rpcUrls.custom.http[0]); + } + return http(chain.rpcUrls.default.http[0]); + }; + + static genChainFromName( + chainName: string, + customRpcUrl?: string | null + ): Chain { + const baseChain = viemChains[chainName]; + + if (!baseChain?.id) { + throw new Error("Invalid chain name"); + } + + const viemChain: Chain = customRpcUrl + ? { + ...baseChain, + rpcUrls: { + ...baseChain.rpcUrls, + custom: { + http: [customRpcUrl], + }, + }, + } + : baseChain; + + return viemChain; } +} - getCurrentChain(): SupportedChain { - return this.currentChain; +const genChainsFromRuntime = ( + runtime: IAgentRuntime +): Record => { + const chainNames = + (runtime.character.settings.chains?.evm as SupportedChain[]) || []; + const chains = {}; + + chainNames.forEach((chainName) => { + const rpcUrl = runtime.getSetting( + "ETHEREUM_PROVIDER_" + chainName.toUpperCase() + ); + const chain = WalletProvider.genChainFromName(chainName, rpcUrl); + chains[chainName] = chain; + }); + + const mainnet_rpcurl = runtime.getSetting("EVM_PROVIDER_URL"); + if (mainnet_rpcurl) { + const chain = WalletProvider.genChainFromName( + "mainnet", + mainnet_rpcurl + ); + chains["mainnet"] = chain; } - getChainConfig(chain: SupportedChain) { - return getChainConfigs(this.runtime)[chain]; + return chains; +}; + +export const initWalletProvider = (runtime: IAgentRuntime) => { + const privateKey = runtime.getSetting("EVM_PRIVATE_KEY"); + if (!privateKey) { + throw new Error("EVM_PRIVATE_KEY is missing"); } -} + + const chains = genChainsFromRuntime(runtime); + + return new WalletProvider(privateKey as `0x${string}`, chains); +}; export const evmWalletProvider: Provider = { async get( @@ -120,15 +214,12 @@ export const evmWalletProvider: Provider = { message: Memory, state?: State ): Promise { - if (!runtime.getSetting("EVM_PRIVATE_KEY")) { - return null; - } - try { - const walletProvider = new WalletProvider(runtime); + const walletProvider = initWalletProvider(runtime); const address = walletProvider.getAddress(); const balance = await walletProvider.getWalletBalance(); - return `EVM Wallet Address: ${address}\nBalance: ${balance} ETH`; + const chain = walletProvider.getCurrentChain(); + return `EVM Wallet Address: ${address}\nBalance: ${balance} ${chain.nativeCurrency.symbol}\nChain ID: ${chain.id}, Name: ${chain.name}`; } catch (error) { console.error("Error in EVM wallet provider:", error); return null; diff --git a/packages/plugin-evm/src/templates/index.ts b/packages/plugin-evm/src/templates/index.ts index a8c7f1fcc3e..20d6ef19af8 100644 --- a/packages/plugin-evm/src/templates/index.ts +++ b/packages/plugin-evm/src/templates/index.ts @@ -5,19 +5,17 @@ export const transferTemplate = `Given the recent messages and wallet informatio {{walletInfo}} Extract the following information about the requested transfer: -- Chain to execute on -- Amount to transfer +- Chain to execute on (like in viem/chains) +- Amount to transfer (only number without coin symbol) - Recipient address -- Token symbol or address (if not native token) Respond with a JSON markdown block containing only the extracted values: \`\`\`json { - "chain": "ethereum" | "base" | "sepolia" | "bsc" | "arbitrum" | "avalanche" | "polygon" | "optimism" | "cronos" | "gnosis" | "fantom" | "klaytn" | "celo" | "moonbeam" | "aurora" | "harmonyOne" | "moonriver" | "arbitrumNova" | "mantle" | "linea" | "scroll" | "filecoin" | "taiko" | "zksync" | "canto" | null, - "amount": string | null, - "toAddress": string | null, - "token": string | null + "fromChain": SUPPORTED_CHAINS, + "amount": string, + "toAddress": string } \`\`\` `; diff --git a/packages/plugin-evm/src/tests/transfer.test.ts b/packages/plugin-evm/src/tests/transfer.test.ts new file mode 100644 index 00000000000..d637a6a3d9d --- /dev/null +++ b/packages/plugin-evm/src/tests/transfer.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { Account, Chain } from "viem"; + +import { TransferAction } from "../actions/transfer"; +import { WalletProvider } from "../providers/wallet"; + +describe("Transfer Action", () => { + let wp: WalletProvider; + + beforeEach(async () => { + const pk = generatePrivateKey(); + const customChains = prepareChains(); + wp = new WalletProvider(pk, customChains); + }); + describe("Constructor", () => { + it("should initialize with wallet provider", () => { + const ta = new TransferAction(wp); + + expect(ta).to.toBeDefined(); + }); + }); + describe("Transfer", () => { + let ta: TransferAction; + let receiver: Account; + + beforeEach(() => { + ta = new TransferAction(wp); + receiver = privateKeyToAccount(generatePrivateKey()); + }); + + it("throws if not enough gas", async () => { + await expect( + ta.transfer({ + fromChain: "iotexTestnet", + toAddress: receiver.address, + amount: "1", + }) + ).rejects.toThrow( + "Transfer failed: The total cost (gas * gas fee + value) of executing this transaction exceeds the balance of the account." + ); + }); + }); +}); + +const prepareChains = () => { + let customChains: Record = {}; + const chainNames = ["iotexTestnet"]; + chainNames.forEach( + (chain) => + (customChains[chain] = WalletProvider.genChainFromName(chain)) + ); + + return customChains; +}; diff --git a/packages/plugin-evm/src/tests/wallet.test.ts b/packages/plugin-evm/src/tests/wallet.test.ts new file mode 100644 index 00000000000..a8e5a3ee872 --- /dev/null +++ b/packages/plugin-evm/src/tests/wallet.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { mainnet, iotex, arbitrum, Chain } from "viem/chains"; + +import { WalletProvider } from "../providers/wallet"; + +const customRpcUrls = { + mainnet: "custom-rpc.mainnet.io", + arbitrum: "custom-rpc.base.io", + iotex: "custom-rpc.iotex.io", +}; + +describe("Wallet provider", () => { + let walletProvider: WalletProvider; + let pk: `0x${string}`; + let customChains: Record = {}; + + beforeAll(() => { + pk = generatePrivateKey(); + + const chainNames = ["iotex", "arbitrum"]; + chainNames.forEach( + (chain) => + (customChains[chain] = WalletProvider.genChainFromName(chain)) + ); + }); + + describe("Constructor", () => { + it("sets address", () => { + const account = privateKeyToAccount(pk); + const expectedAddress = account.address; + + walletProvider = new WalletProvider(pk); + + expect(walletProvider.getAddress()).to.be.eq(expectedAddress); + }); + it("sets default chain to ethereum mainnet", () => { + walletProvider = new WalletProvider(pk); + + expect(walletProvider.chains.mainnet.id).to.be.eq(mainnet.id); + expect(walletProvider.getCurrentChain().id).to.be.eq(mainnet.id); + }); + it("sets custom chains", () => { + walletProvider = new WalletProvider(pk, customChains); + + expect(walletProvider.chains.iotex.id).to.be.eq(iotex.id); + expect(walletProvider.chains.arbitrum.id).to.be.eq(arbitrum.id); + }); + it("sets the first provided custom chain as current chain", () => { + walletProvider = new WalletProvider(pk, customChains); + + expect(walletProvider.getCurrentChain().id).to.be.eq(iotex.id); + }); + }); + describe("Clients", () => { + beforeEach(() => { + walletProvider = new WalletProvider(pk); + }); + it("generates public client", () => { + const client = walletProvider.getPublicClient("mainnet"); + expect(client.chain.id).to.be.equal(mainnet.id); + expect(client.transport.url).toEqual( + mainnet.rpcUrls.default.http[0] + ); + }); + it("generates public client with custom rpcurl", () => { + const chain = WalletProvider.genChainFromName( + "mainnet", + customRpcUrls.mainnet + ); + const wp = new WalletProvider(pk, { ["mainnet"]: chain }); + + const client = wp.getPublicClient("mainnet"); + expect(client.chain.id).to.be.equal(mainnet.id); + expect(client.chain.rpcUrls.default.http[0]).to.eq( + mainnet.rpcUrls.default.http[0] + ); + expect(client.chain.rpcUrls.custom.http[0]).to.eq( + customRpcUrls.mainnet + ); + expect(client.transport.url).toEqual(customRpcUrls.mainnet); + }); + it("generates wallet client", () => { + const account = privateKeyToAccount(pk); + const expectedAddress = account.address; + + const client = walletProvider.getWalletClient("mainnet"); + + expect(client.account.address).to.be.equal(expectedAddress); + expect(client.transport.url).toEqual( + mainnet.rpcUrls.default.http[0] + ); + }); + it("generates wallet client with custom rpcurl", () => { + const account = privateKeyToAccount(pk); + const expectedAddress = account.address; + const chain = WalletProvider.genChainFromName( + "mainnet", + customRpcUrls.mainnet + ); + const wp = new WalletProvider(pk, { ["mainnet"]: chain }); + + const client = wp.getWalletClient("mainnet"); + + expect(client.account.address).to.be.equal(expectedAddress); + expect(client.chain.id).to.be.equal(mainnet.id); + expect(client.chain.rpcUrls.default.http[0]).to.eq( + mainnet.rpcUrls.default.http[0] + ); + expect(client.chain.rpcUrls.custom.http[0]).to.eq( + customRpcUrls.mainnet + ); + expect(client.transport.url).toEqual(customRpcUrls.mainnet); + }); + }); + describe("Balance", () => { + beforeEach(() => { + walletProvider = new WalletProvider(pk, customChains); + }); + it("should fetch balance", async () => { + const bal = await walletProvider.getWalletBalance(); + + expect(bal).to.be.eq("0"); + }); + it("should fetch balance for a specific added chain", async () => { + const bal = await walletProvider.getWalletBalanceForChain("iotex"); + + expect(bal).to.be.eq("0"); + }); + it("should return null if chain is not added", async () => { + const bal = await walletProvider.getWalletBalanceForChain("base"); + expect(bal).to.be.null; + }); + }); + describe("Chain", () => { + beforeEach(() => { + walletProvider = new WalletProvider(pk, customChains); + }); + it("generates chains from chain name", () => { + const chainName = "iotex"; + const chain: Chain = WalletProvider.genChainFromName(chainName); + + expect(chain.rpcUrls.default.http[0]).to.eq( + iotex.rpcUrls.default.http[0] + ); + }); + it("generates chains from chain name with custom rpc url", () => { + const chainName = "iotex"; + const customRpcUrl = "custom.url.io"; + const chain: Chain = WalletProvider.genChainFromName( + chainName, + customRpcUrl + ); + + expect(chain.rpcUrls.default.http[0]).to.eq( + iotex.rpcUrls.default.http[0] + ); + expect(chain.rpcUrls.custom.http[0]).to.eq(customRpcUrl); + }); + it("switches chain", () => { + const initialChain = walletProvider.getCurrentChain().id; + expect(initialChain).to.be.eq(iotex.id); + + walletProvider.switchChain("mainnet"); + + const newChain = walletProvider.getCurrentChain().id; + expect(newChain).to.be.eq(mainnet.id); + }); + it("switches chain (by adding new chain)", () => { + const initialChain = walletProvider.getCurrentChain().id; + expect(initialChain).to.be.eq(iotex.id); + + walletProvider.switchChain("arbitrum"); + + const newChain = walletProvider.getCurrentChain().id; + expect(newChain).to.be.eq(arbitrum.id); + }); + it("adds chain", () => { + const initialChains = walletProvider.chains; + expect(initialChains.base).to.be.undefined; + + const base = WalletProvider.genChainFromName("base"); + walletProvider.addChain({ base }); + const newChains = walletProvider.chains; + expect(newChains.arbitrum.id).to.be.eq(arbitrum.id); + }); + it("gets chain configs", () => { + const chain = walletProvider.getChainConfigs("iotex"); + + expect(chain.id).to.eq(iotex.id); + }); + it("throws if tries to switch to an invalid chain", () => { + const initialChain = walletProvider.getCurrentChain().id; + expect(initialChain).to.be.eq(iotex.id); + + // @ts-ignore + expect(() => walletProvider.switchChain("eth")).to.throw(); + }); + it("throws if unsupported chain name", () => { + // @ts-ignore + expect(() => + WalletProvider.genChainFromName("ethereum") + ).to.throw(); + }); + it("throws if invalid chain name", () => { + // @ts-ignore + expect(() => WalletProvider.genChainFromName("eth")).to.throw(); + }); + }); +}); diff --git a/packages/plugin-evm/src/types/index.ts b/packages/plugin-evm/src/types/index.ts index 885f3994fce..8fa8247dcdb 100644 --- a/packages/plugin-evm/src/types/index.ts +++ b/packages/plugin-evm/src/types/index.ts @@ -8,33 +8,10 @@ import type { PublicClient, WalletClient, } from "viem"; +import * as viemChains from "viem/chains"; -export type SupportedChain = - | "ethereum" - | "base" - | "sepolia" - | "bsc" - | "arbitrum" - | "avalanche" - | "polygon" - | "optimism" - | "cronos" - | "gnosis" - | "fantom" - | "klaytn" - | "celo" - | "moonbeam" - | "aurora" - | "harmonyOne" - | "moonriver" - | "arbitrumNova" - | "mantle" - | "linea" - | "scroll" - | "filecoin" - | "taiko" - | "zksync" - | "canto"; +const SupportedChainList = Object.keys(viemChains) as Array; +export type SupportedChain = (typeof SupportedChainList)[number]; // Transaction types export interface Transaction {