UNPKG

linea-mcp

Version:

A Model Context Protocol server for interacting with the Linea blockchain

375 lines (366 loc) 16.8 kB
import { isAddress, parseEther, parseAbi, // Helper to parse string ABIs if needed formatEther, // Needed for estimation & post-confirmation logic encodeDeployData, // Needed for deployment gas estimation } from 'viem'; import BlockchainService from '../../services/blockchain.js'; import KeyManagementService from '../../services/keyManagement.js'; /** * Find the ABI definition for a specific function name. * @param abi The contract ABI. * @param functionName The name of the function. * @returns The ABI item for the function. * @throws If the function is not found in the ABI. */ function findFunctionAbi(abi, functionName) { const functionAbi = abi.find((item) => item.type === 'function' && item.name === functionName); if (!functionAbi) { throw new Error(`Function "${functionName}" not found in the provided ABI.`); } // We need to assert the type because .find returns Abi[number] | undefined return functionAbi; } /** * Call a contract function using viem, with fee estimation for writes * @param params The parameters for calling a contract function * @returns The result of the function call or transaction details */ export async function callContract(params) { try { const { contractAddress, abi, functionName, params: functionParams = [], value } = params; // Validate address if (!isAddress(contractAddress)) { throw new Error('Invalid contract address provided.'); } // Initialize services const blockchain = new BlockchainService('mainnet'); // Assuming mainnet const publicClient = blockchain.client; const keyService = new KeyManagementService(); // --- Robust ABI Parsing --- let contractAbi; if (typeof abi === 'string') { try { const parsedJson = JSON.parse(abi); if (Array.isArray(parsedJson)) { if (parsedJson.length === 0 || (typeof parsedJson[0] === 'object' && parsedJson[0] !== null && 'type' in parsedJson[0])) { contractAbi = parsedJson; console.log("Interpreted ABI string as JSON ABI array."); } else { console.log("Parsed JSON string, but doesn't look like ABI. Trying human-readable parse."); contractAbi = parseAbi(abi.split('\n')); } } else { console.log("Parsed JSON string is not an array. Trying human-readable parse."); contractAbi = parseAbi(abi.split('\n')); } } catch (jsonError) { console.log("Failed to parse ABI string as JSON. Assuming human-readable format."); contractAbi = parseAbi(abi.split('\n')); } } else if (Array.isArray(abi)) { if (abi.every(item => typeof item === 'string')) { contractAbi = parseAbi(abi); console.log("Interpreted ABI input as array of human-readable strings."); } else { throw new Error('Invalid ABI format: Array must contain only strings for human-readable format.'); } } else { throw new Error('Invalid ABI format provided. Must be a JSON string, human-readable string(s), or an array of human-readable strings.'); } if (!Array.isArray(contractAbi) || (contractAbi.length > 0 && typeof contractAbi[0] !== 'object')) { throw new Error('Failed to parse ABI into a valid viem Abi format.'); } // --- End Robust ABI Parsing --- const functionAbi = findFunctionAbi(contractAbi, functionName); const isReadOnly = functionAbi.stateMutability === 'view' || functionAbi.stateMutability === 'pure'; let result; // Use const as txDetails is only assigned once in the (currently commented out) post-confirmation block const txDetails = {}; // Add estimatedFee if (isReadOnly) { // --- Read Operation --- console.log(`Reading from contract ${contractAddress}, function ${functionName}...`); result = await publicClient.readContract({ address: contractAddress, abi: contractAbi, functionName: functionName, args: functionParams, }); console.log('Read operation successful.'); } else { // --- Write Operation --- console.log(`Estimating gas for contract call: ${functionName}...`); const account = keyService.getDefaultAccount(); const txValue = value ? parseEther(value) : undefined; // Estimate Gas let gasEstimate; let gasPrice; let estimatedFeeEther; try { gasEstimate = await publicClient.estimateContractGas({ address: contractAddress, abi: contractAbi, functionName: functionName, args: functionParams, account, value: txValue, }); gasPrice = await publicClient.getGasPrice(); estimatedFeeEther = formatEther(gasEstimate * gasPrice); console.log(`Estimated Fee: ~${estimatedFeeEther} ETH`); } catch (estimationError) { console.error("Error estimating contract gas:", estimationError); throw new Error(`Failed to estimate gas fee: ${estimationError instanceof Error ? estimationError.message : 'Unknown error'}`); } // Ask for Confirmation throw new Error(`CONFIRMATION_REQUIRED: Estimated fee for calling ${functionName} on ${contractAddress} is ~${estimatedFeeEther} ETH. Proceed? (Yes/No)`); /* // --- Code to run *after* user confirms (Yes) --- const walletClient = createWalletClient({ account, chain: blockchain.currentChain, transport: http(config.rpc.mainnet || 'https://rpc.linea.build'), }); console.log(`Proceeding with contract call: ${functionName}...`); const hash = await walletClient.writeContract({ address: contractAddress, abi: contractAbi, functionName: functionName, args: functionParams, value: txValue, gas: gasEstimate, // Apply estimate gasPrice: gasPrice, // Apply price }); console.log(`Transaction submitted: ${hash}. Waiting...`); const receipt = await publicClient.waitForTransactionReceipt({ hash }); console.log(`Transaction confirmed. Status: ${receipt.status}`); if (receipt.status === 'reverted') { throw new Error(`Transaction failed (reverted). Hash: ${hash}`); } txDetails = { transactionHash: hash, receipt: receipt, from: account.address, estimatedFee: estimatedFeeEther }; result = txDetails; // Result for write op is tx details // --- End Post-Confirmation Code --- */ } return { success: true, contractAddress, functionName, isReadOnly, result: isReadOnly ? formatResult(result) : txDetails, }; } catch (error) { // Re-throw confirmation request errors if (error instanceof Error && error.message.startsWith('CONFIRMATION_REQUIRED:')) { throw error; } console.error('Error in callContract:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; if (errorMessage.includes('Function') && errorMessage.includes('not found')) { throw new Error(`Failed to call contract: ${errorMessage}`); } else if (errorMessage.includes('Invalid contract address')) { throw new Error(`Failed to call contract: Invalid contract address format.`); } else if (errorMessage.includes('Transaction failed (reverted)')) { throw new Error(`Failed to call contract: Transaction reverted. Check parameters, permissions, or funds.`); } throw new Error(`Failed to call contract: ${errorMessage}`); } } /** * Deploy a contract using viem, with fee estimation and confirmation * @param params The parameters for deploying a contract * @returns The deployed contract details */ export async function deployContract(params) { try { const { bytecode, abi, constructorArgs = [], value } = params; if (!bytecode || !bytecode.startsWith('0x')) { throw new Error('Invalid bytecode provided. Must be a hex string starting with 0x.'); } // Initialize services const blockchain = new BlockchainService('mainnet'); // Assuming mainnet const publicClient = blockchain.client; const keyService = new KeyManagementService(); // --- Robust ABI Parsing --- let contractAbi; if (typeof abi === 'string') { try { const parsedJson = JSON.parse(abi); if (Array.isArray(parsedJson)) { if (parsedJson.length === 0 || (typeof parsedJson[0] === 'object' && parsedJson[0] !== null && 'type' in parsedJson[0])) { contractAbi = parsedJson; console.log("Interpreted ABI string as JSON ABI array for deployment."); } else { throw new Error('Parsed JSON string does not appear to be a valid ABI array.'); } } else { throw new Error('Invalid ABI format for deployment: Expected a JSON string representing an array.'); } } catch (e) { throw new Error('Invalid ABI format for deployment: Failed to parse JSON string. Provide a valid JSON ABI array string.'); } } else if (Array.isArray(abi)) { if (abi.every(item => typeof item === 'string')) { contractAbi = parseAbi(abi); console.warn("Using human-readable ABI array for deployment. Ensure it includes the constructor if needed."); } else { throw new Error('Invalid ABI format: Array must contain only strings for human-readable format.'); } } else { throw new Error('Invalid ABI format provided for deployment. Must be a JSON string array or an array of human-readable strings.'); } if (!Array.isArray(contractAbi) || (contractAbi.length > 0 && typeof contractAbi[0] !== 'object')) { throw new Error('Failed to parse ABI into a valid viem Abi format for deployment.'); } // --- End Robust ABI Parsing --- const account = keyService.getDefaultAccount(); const deployValue = value ? parseEther(value) : undefined; // --- Estimate Gas Fee --- console.log(`Estimating gas for contract deployment...`); let gasEstimate; let gasPrice; let estimatedFeeEther; try { // Encode deployment data const deployData = encodeDeployData({ abi: contractAbi, bytecode: bytecode, args: constructorArgs, }); // Estimate gas using the encoded data gasEstimate = await publicClient.estimateGas({ data: deployData, account, value: deployValue, }); gasPrice = await publicClient.getGasPrice(); estimatedFeeEther = formatEther(gasEstimate * gasPrice); console.log(`Estimated Fee: ~${estimatedFeeEther} ETH`); } catch (estimationError) { console.error("Error estimating deployment gas:", estimationError); throw new Error(`Failed to estimate gas fee for deployment: ${estimationError instanceof Error ? estimationError.message : 'Unknown error'}`); } // --- End Estimation --- // --- Ask for Confirmation --- throw new Error(`CONFIRMATION_REQUIRED: Estimated fee for deploying the contract is ~${estimatedFeeEther} ETH. Proceed? (Yes/No)`); // --- End Confirmation --- /* // --- Code to run *after* user confirms (Yes) --- const walletClient = createWalletClient({ account, chain: blockchain.currentChain, transport: http(config.rpc.mainnet || 'https://rpc.linea.build'), }); console.log(`Proceeding with contract deployment from ${account.address}...`); const hash = await walletClient.deployContract({ abi: contractAbi, bytecode: bytecode as Hex, args: constructorArgs, value: deployValue, gas: gasEstimate, // Apply estimate gasPrice: gasPrice, // Apply price }); console.log(`Deployment transaction submitted: ${hash}. Waiting...`); const receipt = await publicClient.waitForTransactionReceipt({ hash }); console.log(`Contract deployment confirmed. Status: ${receipt.status}`); if (receipt.status === 'reverted') { throw new Error(`Contract deployment failed (reverted). Hash: ${hash}`); } if (!receipt.contractAddress) { throw new Error(`Contract deployment succeeded but no contract address found in receipt. Hash: ${hash}`); } console.log(`Contract deployed at address: ${receipt.contractAddress}`); return { success: true, contractAddress: receipt.contractAddress, transactionHash: hash, deployer: account.address, receipt: { blockNumber: receipt.blockNumber.toString(), gasUsed: receipt.gasUsed.toString(), status: receipt.status, }, abi: abi, // Return original ABI 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 deployContract:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; if (errorMessage.includes('Invalid bytecode')) { throw new Error(`Failed to deploy contract: ${errorMessage}`); } else if (errorMessage.includes('Invalid ABI')) { throw new Error(`Failed to deploy contract: ${errorMessage}`); } else if (errorMessage.includes('insufficient funds')) { throw new Error(`Failed to deploy contract: Insufficient funds for deployment.`); } else if (errorMessage.includes('reverted')) { throw new Error(`Failed to deploy contract: Deployment transaction reverted.`); } throw new Error(`Failed to deploy contract: ${errorMessage}`); } } /** * Format the result of a contract call to be JSON-serializable, handling BigInts. * @param result The result to format * @returns The formatted result */ function formatResult(result) { if (result === null || result === undefined) { return null; } // Handle BigInt if (typeof result === 'bigint') { return result.toString(); } // Handle Arrays if (Array.isArray(result)) { return result.map(formatResult); } // Handle Objects (including structs returned from contracts) if (typeof result === 'object') { const formatted = {}; for (const key in result) { // Include only string keys (ignore array indices if it's array-like) if (Object.prototype.hasOwnProperty.call(result, key) && isNaN(Number(key))) { formatted[key] = formatResult(result[key]); } } // If the object had numeric keys (like arrays), return the array map instead if (Object.keys(formatted).length === 0 && Array.isArray(result)) { return result.map(formatResult); } return formatted; } // Handle other primitives (string, number, boolean) return result; }