UNPKG

linea-mcp

Version:

A Model Context Protocol server for interacting with the Linea blockchain

463 lines (454 loc) 19.6 kB
import { isAddress, parseUnits, formatUnits, // Hex, // Unused formatEther, // Added for fee formatting } from 'viem'; import BlockchainService from '../../services/blockchain.js'; import KeyManagementService from '../../services/keyManagement.js'; import { TokenInfoSchema } from './schemas.js'; import { z } from 'zod'; // Import z for parsing API responses import axios from 'axios'; // Import axios for HTTP requests // ERC20 Token ABI (minimal for balance and transfer) - viem compatible const ERC20_ABI = [ { constant: true, inputs: [{ name: '_owner', type: 'address' }], name: 'balanceOf', outputs: [{ name: 'balance', type: 'uint256' }], type: 'function', stateMutability: 'view', }, { constant: true, inputs: [], name: 'decimals', outputs: [{ name: '', type: 'uint8' }], type: 'function', stateMutability: 'view', }, { constant: true, inputs: [], name: 'symbol', outputs: [{ name: '', type: 'string' }], type: 'function', stateMutability: 'view', }, { constant: true, inputs: [], name: 'name', outputs: [{ name: '', type: 'string' }], type: 'function', stateMutability: 'view', }, { constant: false, inputs: [ { name: '_to', type: 'address' }, { name: '_value', type: 'uint256' }, ], name: 'transfer', outputs: [{ name: '', type: 'bool' }], type: 'function', stateMutability: 'nonpayable', // Indicate it's a write function }, { constant: false, inputs: [ { name: 'spender', type: 'address' }, { name: 'amount', type: 'uint256' }, ], name: 'approve', outputs: [{ name: 'success', type: 'bool' }], type: 'function', stateMutability: 'nonpayable', }, { anonymous: false, inputs: [ { indexed: true, name: 'from', type: 'address' }, { indexed: true, name: 'to', type: 'address' }, { indexed: false, name: 'value', type: 'uint256' }, ], name: 'Transfer', type: 'event', }, ]; // Use 'as const' for better type inference with viem // Base URL for the Linea Token API const LINEA_TOKEN_API_BASE_URL = 'https://token-api.linea.build'; // Helper function to safely parse API response with Zod using Axios async function safeFetchAndParse(url, schema) { try { // Use axios.get instead of fetch const response = await axios.get(url, { headers: { 'Accept': 'application/json' // Ensure we request JSON } }); // Axios throws for non-2xx status codes by default, so no need to check response.ok const data = response.data; const parsed = schema.safeParse(data); if (!parsed.success) { console.error("Zod parsing error:", parsed.error.errors); throw new Error(`API response validation failed: ${parsed.error.message}`); } return parsed.data; } catch (error) { console.error(`Error fetching or parsing ${url}:`, error); // Type narrowing if (axios.isAxiosError(error)) { // Axios error handling const status = error.response?.status ?? 'N/A'; const responseData = error.response?.data ? JSON.stringify(error.response.data) : 'No data'; throw new Error(`Linea Token API request failed with status ${status}: ${error.message}. Response: ${responseData}`); } else if (error instanceof Error) { // Generic Error handling (includes our custom validation error) throw new Error(`Failed to process data from Linea Token API: ${error.message}`); } else { // Handle cases where error is not an Error object (e.g., string thrown) throw new Error(`An unknown error occurred while contacting the Linea Token API: ${String(error)}`); } } } /** * Get the balance of an ERC20 token for a wallet * @param params The parameters for getting the token balance * @returns The token balance and details */ export async function erc20Balance(params) { try { const { tokenAddress } = params; const blockchain = new BlockchainService('mainnet'); // Assuming mainnet for now const publicClient = blockchain.client; // Validate token address if (!isAddress(tokenAddress)) { throw new Error('Invalid token address provided.'); } let ownerAddress; if (!params.address) { // If no address is provided, use the default account's address const keyService = new KeyManagementService(); const account = keyService.getDefaultAccount(); ownerAddress = account.address; console.warn(`No address provided, using default account address: ${ownerAddress}`); } else if (isAddress(params.address)) { ownerAddress = params.address; } else { throw new Error('Invalid owner address provided.'); } // Create read-only contract instance using BlockchainService const tokenContract = blockchain.createContract(tokenAddress, ERC20_ABI); // Get token details using multicall for efficiency const results = await publicClient.multicall({ contracts: [ { ...tokenContract, functionName: 'balanceOf', args: [ownerAddress] }, { ...tokenContract, functionName: 'decimals' }, { ...tokenContract, functionName: 'symbol' }, { ...tokenContract, functionName: 'name' }, ], allowFailure: false, // Throw if any call fails }); // Explicitly type the results from multicall const [balance, decimals, symbol, name] = results; // Format balance based on token decimals const formattedBalance = formatUnits(balance, decimals); return { success: true, address: ownerAddress, token: { address: tokenAddress, name, symbol, decimals, }, balance: formattedBalance, }; } catch (error) { console.error('Error in erc20Balance:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; // Improve error message context if (errorMessage.includes('Invalid token address')) { throw new Error(`Failed to get token balance: Invalid token address format.`); } else if (errorMessage.includes('call revert')) { throw new Error(`Failed to get token balance: Contract call failed. Is the token address correct and on the Linea mainnet?`); } throw new Error(`Failed to get token balance: ${errorMessage}`); } } /** * Transfer ERC20 tokens from one wallet to another, with fee estimation and confirmation * @param params The parameters for transferring tokens * @returns The transaction details or an abort message */ export async function erc20Transfer(params) { try { const { tokenAddress, destination, amount } = params; // Validate addresses if (!isAddress(tokenAddress)) { throw new Error('Invalid token address'); } if (!isAddress(destination)) { throw new Error('Invalid destination address'); } // Initialize services const blockchain = new BlockchainService('mainnet'); // Assuming mainnet const publicClient = blockchain.client; const keyService = new KeyManagementService(); // Get the default account (sender) const account = keyService.getDefaultAccount(); // --- Get token details (read operations) --- const tokenContractReader = blockchain.createContract(tokenAddress, ERC20_ABI); const decimals = await publicClient.readContract({ ...tokenContractReader, functionName: 'decimals', }); const symbol = await publicClient.readContract({ ...tokenContractReader, functionName: 'symbol', }); const name = await publicClient.readContract({ ...tokenContractReader, functionName: 'name', }); // ----------------------------------------- // Parse amount based on token decimals (ensure decimals is treated as number) const parsedAmount = parseUnits(amount, decimals); // --- Estimate Gas Fee --- console.log(`Estimating gas for transferring ${amount} ${symbol} to ${destination}...`); let gasEstimate; let gasPrice; let estimatedFeeEther; try { // Use estimateContractGas for contract interactions gasEstimate = await publicClient.estimateContractGas({ address: tokenAddress, abi: ERC20_ABI, functionName: 'transfer', args: [destination, parsedAmount], account, // Account is needed for estimation context }); gasPrice = await publicClient.getGasPrice(); const estimatedFee = gasEstimate * gasPrice; estimatedFeeEther = formatEther(estimatedFee); console.log(`Estimated Gas: ${gasEstimate}, Gas Price: ${gasPrice}, Estimated Fee: ~${estimatedFeeEther} ETH`); } catch (estimationError) { console.error("Error estimating contract gas:", estimationError); // Provide more context in error message if (estimationError instanceof Error && estimationError.message.includes('insufficient funds')) { throw new Error(`Failed to estimate gas fee: Insufficient balance for gas. Sender: ${account.address}`); } else if (estimationError instanceof Error && estimationError.message.includes('transfer amount exceeds balance')) { throw new Error(`Failed to estimate gas fee: Transfer amount likely exceeds token balance.`); } throw new Error(`Failed to estimate gas fee: ${estimationError instanceof Error ? estimationError.message : 'Unknown error'}`); } // --- End Estimation --- // --- Ask for Confirmation --- throw new Error(`CONFIRMATION_REQUIRED: Estimated fee to transfer ${amount} ${symbol} (${name}) to ${destination} is ~${estimatedFeeEther} ETH. Proceed? (Yes/No)`); // --- End Confirmation --- /* // --- Code to run *after* user confirms (Yes) --- // Create a WalletClient instance to send transactions const walletClient = createWalletClient({ account, chain: blockchain.currentChain, transport: http(config.rpc.mainnet || 'https://rpc.linea.build'), }); console.log(`Proceeding with transfer of ${amount} ${symbol} from ${account.address} to ${destination}...`); // --- Execute the transfer (write operation) --- const hash = await walletClient.writeContract({ address: tokenAddress, abi: ERC20_ABI, functionName: 'transfer', args: [destination, parsedAmount], gas: gasEstimate, // Apply estimated gas gasPrice: gasPrice, // Apply fetched gas price // Account is implicitly used by walletClient }); console.log(`Transaction submitted with hash: ${hash}. Waiting for confirmation...`); // Wait for the transaction to be confirmed const receipt = await publicClient.waitForTransactionReceipt({ hash }); console.log(`Transaction confirmed in block ${receipt.blockNumber}. Status: ${receipt.status}`); if (receipt.status === 'reverted') { throw new Error(`Transaction failed (reverted). Hash: ${hash}`); } // ----------------------------------------- return { success: true, transactionHash: hash, receipt: { // Include receipt details blockNumber: receipt.blockNumber.toString(), gasUsed: receipt.gasUsed.toString(), status: receipt.status, }, from: account.address, to: destination, amount, // Return original amount string token: { address: tokenAddress, name, symbol, decimals, }, estimatedFee: estimatedFeeEther, // Include estimate }; // --- End Post-Confirmation Code --- */ } catch (error) { // Re-throw confirmation request errors if (error instanceof Error && error.message.startsWith('CONFIRMATION_REQUIRED:')) { throw error; } console.error('Error in erc20Transfer:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; // Add more specific error handling if (errorMessage.includes('insufficient funds')) { throw new Error(`Failed to transfer tokens: Insufficient funds for transaction.`); } else if (errorMessage.includes('Invalid destination address')) { throw new Error(`Failed to transfer tokens: Invalid destination address format.`); } else if (errorMessage.includes('Invalid token address')) { throw new Error(`Failed to transfer tokens: Invalid token address format.`); } else if (errorMessage.includes('Transaction failed (reverted)')) { throw new Error(`Failed to transfer tokens: Transaction was reverted by the contract. Check allowance or balance.`); } throw new Error(`Failed to transfer tokens: ${errorMessage}`); } } // --- New Handler Functions for Linea Token API --- /** * List available tokens on Linea, with optional search and pagination. * @param params Parameters including optional query, limit, page, and includePrice. * @returns A list of tokens matching the criteria. */ export async function listAvailableTokens(params) { try { const { query, limit, page, includePrice } = params; const urlParams = new URLSearchParams({ limit: limit.toString(), page: page.toString(), includePrice: includePrice.toString(), }); if (query) { urlParams.append('query', query); } const apiUrl = `${LINEA_TOKEN_API_BASE_URL}/tokens?${urlParams.toString()}`; // Define the expected API response structure (adjust based on actual API) const ApiResponseSchema = z.object({ // Adjust based on Swagger/API docs: Often it's { data: [], meta: {} } data: z.array(TokenInfoSchema), // Assume the token objects match our schema meta: z.object({ currentPage: z.number(), itemsPerPage: z.number(), totalItems: z.number().optional(), totalPages: z.number().optional(), }).optional(), }); const rawResponse = await safeFetchAndParse(apiUrl, ApiResponseSchema); // Map the response to our expected result structure return { success: true, tokens: rawResponse.data, page: rawResponse.meta?.currentPage ?? page, limit: rawResponse.meta?.itemsPerPage ?? limit, total: rawResponse.meta?.totalItems, }; } catch (error) { console.error('Error in listAvailableTokens:', error); if (error instanceof Error) { throw new Error(`Failed to list available tokens: ${error.message}`); } throw new Error('Failed to list available tokens due to an unknown error.'); } } /** * Get detailed information about a specific token by its contract address. * @param params Parameters including the contract address and includePrice flag. * @returns Detailed information about the specified token. */ export async function getTokenInfo(params) { try { const { contractAddress, includePrice } = params; if (!isAddress(contractAddress)) { throw new Error('Invalid contract address provided.'); } const urlParams = new URLSearchParams({ includePrice: includePrice.toString(), }); const apiUrl = `${LINEA_TOKEN_API_BASE_URL}/tokens/${contractAddress}?${urlParams.toString()}`; // The API endpoint GET /tokens/{contractAddress} likely returns the token object directly const tokenData = await safeFetchAndParse(apiUrl, TokenInfoSchema); return { success: true, token: tokenData, }; } catch (error) { console.error('Error in getTokenInfo:', error); if (axios.isAxiosError(error) && error.response?.status === 404) { // Handle 404 Not Found specifically console.log(`Token not found via API: ${params.contractAddress}`); return { success: true, token: null }; } if (error instanceof Error) { throw new Error(`Failed to get token info for ${params.contractAddress}: ${error.message}`); } throw new Error(`Failed to get token info for ${params.contractAddress} due to an unknown error.`); } } /** * Get historical hourly price data for a specific token. * @param params Parameters including the contract address. * @returns Historical price data for the token. */ export async function getTokenPriceHistory(params) { try { const { contractAddress } = params; if (!isAddress(contractAddress)) { throw new Error('Invalid contract address provided.'); } const apiUrl = `${LINEA_TOKEN_API_BASE_URL}/prices/${contractAddress}`; // Define the expected API response structure for price history const ApiResponseSchema = z.array(z.object({ // Assuming timestamp is Unix seconds. If ms, adjust schema or conversion. timestamp: z.number().int().positive(), price: z.number(), })); const historyData = await safeFetchAndParse(apiUrl, ApiResponseSchema); // Explicitly type the point parameter in map const formattedHistory = historyData.map((point) => ({ timestamp: point.timestamp, // Assuming API timestamp matches our schema price: point.price, })); // Ensure the return structure matches GetTokenPriceHistoryResultType return { success: true, address: contractAddress, history: formattedHistory, }; } catch (error) { console.error('Error in getTokenPriceHistory:', error); if (axios.isAxiosError(error) && error.response?.status === 404) { // Handle 404 Not Found specifically console.log(`Price history not found via API for: ${params.contractAddress}`); return { success: true, address: params.contractAddress, history: [] }; } if (error instanceof Error) { throw new Error(`Failed to get price history for ${params.contractAddress}: ${error.message}`); } throw new Error(`Failed to get price history for ${params.contractAddress} due to an unknown error.`); } }