diff --git a/README.md b/README.md index 1f7a3db..f2099a5 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,10 @@ COINBASE_PROJECT_ID=your_project_id # OpenRouter API Key (optional for buying OpenRouter credits) # You can obtain this from https://openrouter.ai/keys OPENROUTER_API_KEY=your_openrouter_api_key + +# Etherscan API Key (optional) +# You can obtain this from https://docs.etherscan.io/etherscan-v2/getting-started/getting-an-api-key +ETHERSCAN_API_KEY=your_etherscan_api_key ``` ## Testing @@ -179,7 +183,8 @@ You can easily access this file via the Claude Desktop app by navigating to Clau "COINBASE_API_PRIVATE_KEY": "your_private_key", "SEED_PHRASE": "your seed phrase here", "COINBASE_PROJECT_ID": "your_project_id", - "OPENROUTER_API_KEY": "your_openrouter_api_key" + "OPENROUTER_API_KEY": "your_openrouter_api_key", + "ETHERSCAN_API_KEY": "your_etherscan_api_key" }, "disabled": false, "autoApprove": [] @@ -329,6 +334,46 @@ Example query to Claude: > "Buy $20 worth of OpenRouter credits." +### etherscan_address_transactions + +Gets a list of transactions for an address using Etherscan API. + +Parameters: + +- `address`: The address to get transactions for +- `startblock`: Starting block number (defaults to 0) +- `endblock`: Ending block number (defaults to latest) +- `page`: Page number (defaults to 1) +- `offset`: Number of transactions per page (1-1000, defaults to 5) +- `sort`: Sort transactions by block number (asc or desc, defaults to desc) +- `chainId`: The chain ID (defaults to chain the wallet is connected to) + +Example query to Claude: + +> "Show me the most recent transactions for address 0xc5102fE9359FD9a28f877a67E36B0F050d81a3CC." + +### etherscan_contract_info + +Gets detailed information about a smart contract using Etherscan API. + +Parameters: + +- `address`: The contract address to get information for +- `chainId`: The chain ID (defaults to chain the wallet is connected to) + +The tool returns the following information: +- Contract name +- Contract address +- ABI +- Contract creator address +- Transaction hash where the contract was created +- Creation timestamp +- Current ETH balance of the contract + +Example query to Claude: + +> "Show me information about the contract at 0xc5102fE9359FD9a28f877a67E36B0F050d81a3CC." + ## Security Considerations - The configuration file contains sensitive information (API keys and seed phrases). Ensure it's properly secured and not shared. diff --git a/src/tools/etherscan/handlers.ts b/src/tools/etherscan/handlers.ts new file mode 100644 index 0000000..aaea8c8 --- /dev/null +++ b/src/tools/etherscan/handlers.ts @@ -0,0 +1,275 @@ +import type { PublicActions, WalletClient } from 'viem'; +import { formatGwei, formatUnits, isAddress } from 'viem'; +import { getBalance, getCode } from 'viem/actions'; +import { base } from 'viem/chains'; +import type { z } from 'zod'; +import type { + GetAddressTransactionsSchema, + GetContractInfoSchema, +} from './schemas.js'; + +// Etherscan API endpoint for all supported chains +const ETHERSCAN_API_URL = 'https://api.etherscan.io/v2/api'; + +// Helper function to handle Etherscan API requests using V2 API +async function makeEtherscanRequest( + params: Record, +): Promise> { + // Add API key if available + const apiKey = process.env.ETHERSCAN_API_KEY; + if (apiKey) { + params.apikey = apiKey; + } else { + throw new Error('ETHERSCAN_API_KEY is not set'); + } + + // Build query string + const queryParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + queryParams.append(key, value); + }); + + try { + const response = await fetch( + `${ETHERSCAN_API_URL}?${queryParams.toString()}`, + ); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const data = await response.json(); + + // Handle Etherscan API errors + if (data.status === '0' && data.message === 'NOTOK') { + throw new Error(`Etherscan API error: ${data.result}`); + } + + return data; + } catch (error) { + throw new Error( + `Failed to fetch from Etherscan API: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export async function getAddressTransactionsHandler( + wallet: WalletClient & PublicActions, + args: z.infer, +): Promise { + // Get chain ID from args or wallet + const chainId = args.chainId ?? wallet.chain?.id ?? base.id; + + // Validate address + if (!isAddress(args.address, { strict: false })) { + throw new Error(`Invalid address: ${args.address}`); + } + + // Request parameters for normal transactions + const txParams: Record = { + chainid: chainId.toString(), + module: 'account', + action: 'txlist', + address: args.address, + startblock: (args.startblock ?? 0).toString(), + endblock: (args.endblock ?? 'latest').toString(), + page: (args.page ?? 1).toString(), + offset: (args.offset ?? 5).toString(), + sort: args.sort ?? 'desc', + }; + + // API call to get 'normal' transaction data + const txData = await makeEtherscanRequest(txParams); + + // Get ERC20 token transfers data within block range and map to transaction hash + const tokenTransfersByHash: Record< + string, + Array> + > = {}; + if ( + txData.status === '1' && + Array.isArray(txData.result) && + txData.result.length > 0 + ) { + // Find min and max block numbers based on sort order + const blockNumbers = txData.result.map((tx: Record) => + parseInt(tx.blockNumber), + ); + + let minBlock: number; + let maxBlock: number; + if (args.sort === 'asc') { + minBlock = blockNumbers[0]; + maxBlock = blockNumbers[blockNumbers.length - 1]; + } else { + minBlock = blockNumbers[blockNumbers.length - 1]; + maxBlock = blockNumbers[0]; + } + + // Request parameters for ERC20 token transfers + const tokenTxParams: Record = { + chainid: chainId.toString(), + module: 'account', + action: 'tokentx', + address: args.address, + startblock: (minBlock - 1).toString(), + endblock: (maxBlock + 1).toString(), + page: '1', + offset: '100', + sort: args.sort ?? 'desc', + }; + + // API call to get ERC20 token transfer data + const tokenTxData = await makeEtherscanRequest(tokenTxParams); + + if (tokenTxData.status === '1' && Array.isArray(tokenTxData.result)) { + // Map token transfers that match transaction hashes + const txHashes = new Set( + txData.result.map((tx: Record) => tx.hash), + ); + + tokenTxData.result.forEach((tokenTx: Record) => { + if (txHashes.has(tokenTx.hash)) { + if (!tokenTransfersByHash[tokenTx.hash]) { + tokenTransfersByHash[tokenTx.hash] = []; + } + + tokenTransfersByHash[tokenTx.hash].push({ + from: tokenTx.from, + contractAddress: tokenTx.contractAddress, + to: tokenTx.to, + value: + formatUnits( + BigInt(tokenTx.value), + parseInt(tokenTx.tokenDecimal), + ) + + ' ' + + tokenTx.tokenSymbol, + tokenName: tokenTx.tokenName, + }); + } + }); + } + } + + // Format the transaction data + if (txData.status === '1' && Array.isArray(txData.result)) { + const filteredResults = txData.result.map((tx: Record) => { + // Convert Unix timestamp to human-readable date + const date = new Date(parseInt(tx.timeStamp) * 1000); + const formattedDate = date.toISOString(); + + // Calculate paid fee in ETH + const feeWei = BigInt(tx.gasUsed) * BigInt(tx.gasPrice); + const feeInEth = formatUnits(feeWei, 18); + + const result = { + timeStamp: formattedDate + ' UTC', + hash: tx.hash, + nonce: tx.nonce, + from: tx.from, + to: tx.to, + value: formatUnits(BigInt(tx.value), 18) + ' ETH', + gasPrice: formatGwei(BigInt(tx.gasPrice)) + ' gwei', + isError: tx.isError, + txreceipt_status: tx.txreceipt_status, + input: tx.input, + contractAddress: tx.contractAddress, + feeInEth: feeInEth + ' ETH', + methodId: tx.methodId, + functionName: tx.functionName, + tokenTransfers: tokenTransfersByHash[tx.hash] || [], + }; + + return result; + }); + + // Add debug information to the response + return JSON.stringify(filteredResults); + } + + return JSON.stringify(txData); +} + +export async function getContractInfoHandler( + wallet: WalletClient & PublicActions, + args: z.infer, +): Promise { + // Get chain ID from args or wallet + const chainId = args.chainId ?? wallet.chain?.id ?? base.id; + + // Validate address + if (!isAddress(args.address, { strict: false })) { + throw new Error(`Invalid address: ${args.address}`); + } + + // Check if address is a contract + const code = await getCode(wallet, { address: args.address }); + if (code === '0x') { + throw new Error(`Address is not a contract: ${args.address}`); + } + + // Get ETH balance of contract + const ethBalance = await getBalance(wallet, { address: args.address }); + + // Request parameters for contract source code + const sourceCodeParams: Record = { + chainid: chainId.toString(), + module: 'contract', + action: 'getsourcecode', + address: args.address, + }; + + // API call to get contract source code data + const sourceCodeData = await makeEtherscanRequest(sourceCodeParams); + + // Request parameters for contract creation info + const creationParams: Record = { + chainid: chainId.toString(), + module: 'contract', + action: 'getcontractcreation', + contractaddresses: args.address, + }; + + // API call to get contract creation data + const creationData = await makeEtherscanRequest(creationParams); + + // Extract and format the required information + const result = { + contractName: null as string | null, + contractAddress: args.address, + abi: null as string | null, + contractCreator: null as string | null, + txHash: null as string | null, + timestamp: null as string | null, + ethBalance: formatUnits(ethBalance, 18) + ' ETH', + }; + + if ( + sourceCodeData.status === '1' && + Array.isArray(sourceCodeData.result) && + sourceCodeData.result.length > 0 + ) { + const sourceCode = sourceCodeData.result[0]; + result.abi = sourceCode.ABI; + result.contractName = sourceCode.ContractName; + } + + if ( + creationData.status === '1' && + Array.isArray(creationData.result) && + creationData.result.length > 0 + ) { + const creation = creationData.result[0]; + result.contractCreator = creation.contractCreator; + result.txHash = creation.txHash; + + // Convert timestamp to human-readable date + if (creation.timestamp) { + const date = new Date(parseInt(creation.timestamp) * 1000); + result.timestamp = date.toISOString() + ' UTC'; + } + } + + return JSON.stringify(result); +} diff --git a/src/tools/etherscan/index.ts b/src/tools/etherscan/index.ts new file mode 100644 index 0000000..e02f1c3 --- /dev/null +++ b/src/tools/etherscan/index.ts @@ -0,0 +1,23 @@ +import { generateTool } from '../../utils.js'; +import { + getAddressTransactionsHandler, + getContractInfoHandler, +} from './handlers.js'; +import { + GetAddressTransactionsSchema, + GetContractInfoSchema, +} from './schemas.js'; + +export const getAddressTransactionsTool = generateTool({ + name: 'etherscan_address_transactions', + description: 'Gets a list of transactions for an address using Etherscan API', + inputSchema: GetAddressTransactionsSchema, + toolHandler: getAddressTransactionsHandler, +}); + +export const getContractInfoTool = generateTool({ + name: 'etherscan_contract_info', + description: 'Gets contract information using Etherscan API', + inputSchema: GetContractInfoSchema, + toolHandler: getContractInfoHandler, +}); diff --git a/src/tools/etherscan/schemas.ts b/src/tools/etherscan/schemas.ts new file mode 100644 index 0000000..28422b9 --- /dev/null +++ b/src/tools/etherscan/schemas.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +export const GetAddressTransactionsSchema = z.object({ + address: z.string().describe('The address to get transactions for'), + startblock: z + .number() + .optional() + .describe('Starting block number (defaults to 0)'), + endblock: z + .number() + .optional() + .describe('Ending block number (defaults to 99999999)'), + page: z.number().min(1).optional().describe('Page number (defaults to 1)'), + offset: z + .number() + .min(1) + .max(1000) + .optional() + .describe('Number of transactions per page (1-1000, defaults to 5)'), + sort: z + .enum(['asc', 'desc']) + .optional() + .describe( + 'Sort transactions by block number (asc or desc, defaults to desc)', + ), + chainId: z + .number() + .optional() + .describe('The chain ID (defaults to chain the wallet is connected to)'), +}); + +export const GetContractInfoSchema = z.object({ + address: z.string().describe('The contract address to get information for'), + chainId: z + .number() + .optional() + .describe('The chain ID (defaults to chain the wallet is connected to)'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index b254d32..1656bef 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,5 +1,9 @@ import { callContractTool } from './contracts/index.js'; import { erc20BalanceTool, erc20TransferTool } from './erc20/index.js'; +import { + getAddressTransactionsTool, + getContractInfoTool, +} from './etherscan/index.js'; import { getMorphoVaultsTool } from './morpho/index.js'; import { getOnrampAssetsTool, onrampTool } from './onramp/index.js'; import { buyOpenRouterCreditsTool } from './open-router/index.js'; @@ -13,6 +17,8 @@ export const baseMcpTools: ToolWithHandler[] = [ erc20BalanceTool, erc20TransferTool, buyOpenRouterCreditsTool, + getAddressTransactionsTool, + getContractInfoTool, ]; export const toolToHandler: Record = baseMcpTools.reduce<