From 0d6e1d5b26cac6100849aaa30a3779f2cf3ab3f6 Mon Sep 17 00:00:00 2001 From: Philippe d'Argent Date: Fri, 21 Mar 2025 14:28:37 +0100 Subject: [PATCH 1/2] add tool to get Txs from etherscan --- README.md | 25 ++++- src/tools/etherscan/handlers.ts | 164 ++++++++++++++++++++++++++++++++ src/tools/etherscan/index.ts | 10 ++ src/tools/etherscan/schemas.ts | 11 +++ src/tools/index.ts | 2 + 5 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/tools/etherscan/handlers.ts create mode 100644 src/tools/etherscan/index.ts create mode 100644 src/tools/etherscan/schemas.ts diff --git a/README.md b/README.md index 1f7a3db..f489b59 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,24 @@ 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." + ## 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..6317606 --- /dev/null +++ b/src/tools/etherscan/handlers.ts @@ -0,0 +1,164 @@ +import type { PublicActions, WalletClient } from 'viem'; +import { base } from 'viem/chains'; +import { formatUnits, formatGwei } from 'viem'; +import type { z } from 'zod'; +import type { GetAddressTransactionsSchema } 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; + + // 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 = {}; + 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: any) => 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: any) => tx.hash)); + + tokenTxData.result.forEach((tokenTx: any) => { + 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), 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: any) => { + // 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 filteredResults; + } + + return txData; +} diff --git a/src/tools/etherscan/index.ts b/src/tools/etherscan/index.ts new file mode 100644 index 0000000..a5f6c7c --- /dev/null +++ b/src/tools/etherscan/index.ts @@ -0,0 +1,10 @@ +import { generateTool } from '../../utils.js'; +import { getAddressTransactionsHandler } from './handlers.js'; +import { GetAddressTransactionsSchema } 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, +}); diff --git a/src/tools/etherscan/schemas.ts b/src/tools/etherscan/schemas.ts new file mode 100644 index 0000000..08d6058 --- /dev/null +++ b/src/tools/etherscan/schemas.ts @@ -0,0 +1,11 @@ +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)'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index b254d32..3720a25 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,5 +1,6 @@ import { callContractTool } from './contracts/index.js'; import { erc20BalanceTool, erc20TransferTool } from './erc20/index.js'; +import { getAddressTransactionsTool } 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 +14,7 @@ export const baseMcpTools: ToolWithHandler[] = [ erc20BalanceTool, erc20TransferTool, buyOpenRouterCreditsTool, + getAddressTransactionsTool, ]; export const toolToHandler: Record = baseMcpTools.reduce< From 4dd5a05a6cbe7a9d97df0fc50776b20dba0db3c6 Mon Sep 17 00:00:00 2001 From: Philippe d'Argent Date: Fri, 21 Mar 2025 16:13:04 +0100 Subject: [PATCH 2/2] add tool to get contract info from etherscan --- README.md | 22 ++++ src/tools/etherscan/handlers.ts | 193 +++++++++++++++++++++++++------- src/tools/etherscan/index.ts | 17 ++- src/tools/etherscan/schemas.ts | 37 +++++- src/tools/index.ts | 6 +- 5 files changed, 226 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index f489b59..f2099a5 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,28 @@ 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 index 6317606..aaea8c8 100644 --- a/src/tools/etherscan/handlers.ts +++ b/src/tools/etherscan/handlers.ts @@ -1,8 +1,12 @@ import type { PublicActions, WalletClient } from 'viem'; +import { formatGwei, formatUnits, isAddress } from 'viem'; +import { getBalance, getCode } from 'viem/actions'; import { base } from 'viem/chains'; -import { formatUnits, formatGwei } from 'viem'; import type { z } from 'zod'; -import type { GetAddressTransactionsSchema } from './schemas.js'; +import type { + GetAddressTransactionsSchema, + GetContractInfoSchema, +} from './schemas.js'; // Etherscan API endpoint for all supported chains const ETHERSCAN_API_URL = 'https://api.etherscan.io/v2/api'; @@ -10,7 +14,7 @@ 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 { +): Promise> { // Add API key if available const apiKey = process.env.ETHERSCAN_API_KEY; if (apiKey) { @@ -18,7 +22,7 @@ async function makeEtherscanRequest( } else { throw new Error('ETHERSCAN_API_KEY is not set'); } - + // Build query string const queryParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { @@ -26,32 +30,41 @@ async function makeEtherscanRequest( }); try { - const response = await fetch(`${ETHERSCAN_API_URL}?${queryParams.toString()}`); - + 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)}`); + 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 { +): 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(), @@ -59,21 +72,29 @@ export async function getAddressTransactionsHandler( action: 'txlist', address: args.address, startblock: (args.startblock ?? 0).toString(), - endblock: (args.endblock ?? "latest").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 = {}; - if (txData.status === '1' && Array.isArray(txData.result) && txData.result.length > 0) { - + 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: any) => parseInt(tx.blockNumber)); + const blockNumbers = txData.result.map((tx: Record) => + parseInt(tx.blockNumber), + ); let minBlock: number; let maxBlock: number; @@ -84,57 +105,64 @@ export async function getAddressTransactionsHandler( 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(), + startblock: (minBlock - 1).toString(), + endblock: (maxBlock + 1).toString(), page: '1', - offset: '100', + 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: any) => tx.hash)); + const txHashes = new Set( + txData.result.map((tx: Record) => tx.hash), + ); - tokenTxData.result.forEach((tokenTx: any) => { + 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), tokenTx.tokenDecimal) + ' ' + tokenTx.tokenSymbol, + 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: any) => { + 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 + + // 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, @@ -150,15 +178,98 @@ export async function getAddressTransactionsHandler( feeInEth: feeInEth + ' ETH', methodId: tx.methodId, functionName: tx.functionName, - tokenTransfers: tokenTransfersByHash[tx.hash] || [] + tokenTransfers: tokenTransfersByHash[tx.hash] || [], }; - + return result; }); - + // Add debug information to the response - return filteredResults; + 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 txData; + + return JSON.stringify(result); } diff --git a/src/tools/etherscan/index.ts b/src/tools/etherscan/index.ts index a5f6c7c..e02f1c3 100644 --- a/src/tools/etherscan/index.ts +++ b/src/tools/etherscan/index.ts @@ -1,6 +1,12 @@ import { generateTool } from '../../utils.js'; -import { getAddressTransactionsHandler } from './handlers.js'; -import { GetAddressTransactionsSchema } from './schemas.js'; +import { + getAddressTransactionsHandler, + getContractInfoHandler, +} from './handlers.js'; +import { + GetAddressTransactionsSchema, + GetContractInfoSchema, +} from './schemas.js'; export const getAddressTransactionsTool = generateTool({ name: 'etherscan_address_transactions', @@ -8,3 +14,10 @@ export const getAddressTransactionsTool = generateTool({ 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 index 08d6058..28422b9 100644 --- a/src/tools/etherscan/schemas.ts +++ b/src/tools/etherscan/schemas.ts @@ -2,10 +2,37 @@ 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)'), + 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)'), + 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 3720a25..1656bef 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,9 @@ import { callContractTool } from './contracts/index.js'; import { erc20BalanceTool, erc20TransferTool } from './erc20/index.js'; -import { getAddressTransactionsTool } from './etherscan/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'; @@ -15,6 +18,7 @@ export const baseMcpTools: ToolWithHandler[] = [ erc20TransferTool, buyOpenRouterCreditsTool, getAddressTransactionsTool, + getContractInfoTool, ]; export const toolToHandler: Record = baseMcpTools.reduce<