Skip to content

feat:etherscan api #28

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": []
Expand Down Expand Up @@ -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.
Expand Down
275 changes: 275 additions & 0 deletions src/tools/etherscan/handlers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
): Promise<Record<string, unknown>> {
// 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<typeof GetAddressTransactionsSchema>,
): Promise<string> {
// 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<string, string> = {
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<Record<string, string>>
> = {};
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<string, string>) =>
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<string, string> = {
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<string, string>) => tx.hash),
);

tokenTxData.result.forEach((tokenTx: Record<string, string>) => {
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<string, string>) => {
// 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<typeof GetContractInfoSchema>,
): Promise<string> {
// 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<string, string> = {
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<string, string> = {
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);
}
23 changes: 23 additions & 0 deletions src/tools/etherscan/index.ts
Original file line number Diff line number Diff line change
@@ -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,
});
Loading