UNPKG

linea-mcp

Version:

A Model Context Protocol server for interacting with the Linea blockchain

585 lines (578 loc) 33.4 kB
import { isAddress, // WalletClient, // Unused // TransactionReceipt, // Unused formatEther, // Added for fee formatting zeroAddress, // Used in detectNftStandard fallback // readContract, // Use client.readContract // writeContract, // Use client.writeContract // multicall, // Use client.multicall } from 'viem'; import axios from 'axios'; import BlockchainService from '../../services/blockchain.js'; import KeyManagementService from '../../services/keyManagement.js'; import config from '../../config/index.js'; // --- ABIs (viem compatible) --- const ERC721_ABI = [ { name: 'balanceOf', type: 'function', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }], outputs: [{ name: '', type: 'uint256' }] }, { name: 'ownerOf', type: 'function', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ name: '', type: 'address' }] }, { name: 'tokenURI', type: 'function', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ name: '', type: 'string' }] }, { name: 'name', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'string' }] }, { name: 'symbol', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'string' }] }, { name: 'transferFrom', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'tokenId', type: 'uint256' }], outputs: [] }, { name: 'safeTransferFrom', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'tokenId', type: 'uint256' }], outputs: [] }, // Overload without data { name: 'safeTransferFrom', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'tokenId', type: 'uint256' }, { name: 'data', type: 'bytes' }], outputs: [] }, // Overload with data { name: 'approve', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'to', type: 'address' }, { name: 'tokenId', type: 'uint256' }], outputs: [] }, { name: 'supportsInterface', type: 'function', stateMutability: 'view', inputs: [{ name: 'interfaceId', type: 'bytes4' }], outputs: [{ name: '', type: 'bool' }] }, // For standard detection { type: 'event', name: 'Transfer', inputs: [{ indexed: true, name: 'from', type: 'address' }, { indexed: true, name: 'to', type: 'address' }, { indexed: true, name: 'tokenId', type: 'uint256' }] }, ]; const ERC1155_ABI = [ { name: 'balanceOf', type: 'function', stateMutability: 'view', inputs: [{ name: 'account', type: 'address' }, { name: 'id', type: 'uint256' }], outputs: [{ name: '', type: 'uint256' }] }, { name: 'balanceOfBatch', type: 'function', stateMutability: 'view', inputs: [{ name: 'accounts', type: 'address[]' }, { name: 'ids', type: 'uint256[]' }], outputs: [{ name: '', type: 'uint256[]' }] }, { name: 'uri', type: 'function', stateMutability: 'view', inputs: [{ name: 'id', type: 'uint256' }], outputs: [{ name: '', type: 'string' }] }, { name: 'name', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'string' }] }, // Optional in ERC1155 { name: 'symbol', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'string' }] }, // Optional in ERC1155 { name: 'safeTransferFrom', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'id', type: 'uint256' }, { name: 'amount', type: 'uint256' }, { name: 'data', type: 'bytes' }], outputs: [] }, { name: 'safeBatchTransferFrom', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'ids', type: 'uint256[]' }, { name: 'amounts', type: 'uint256[]' }, { name: 'data', type: 'bytes' }], outputs: [] }, { name: 'setApprovalForAll', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'operator', type: 'address' }, { name: 'approved', type: 'bool' }], outputs: [] }, { name: 'isApprovedForAll', type: 'function', stateMutability: 'view', inputs: [{ name: 'account', type: 'address' }, { name: 'operator', type: 'address' }], outputs: [{ name: '', type: 'bool' }] }, { name: 'supportsInterface', type: 'function', stateMutability: 'view', inputs: [{ name: 'interfaceId', type: 'bytes4' }], outputs: [{ name: '', type: 'bool' }] }, // For standard detection { type: 'event', name: 'TransferSingle', inputs: [{ indexed: true, name: 'operator', type: 'address' }, { indexed: true, name: 'from', type: 'address' }, { indexed: true, name: 'to', type: 'address' }, { indexed: false, name: 'id', type: 'uint256' }, { indexed: false, name: 'value', type: 'uint256' }] }, { type: 'event', name: 'TransferBatch', inputs: [{ indexed: true, name: 'operator', type: 'address' }, { indexed: true, name: 'from', type: 'address' }, { indexed: true, name: 'to', type: 'address' }, { indexed: false, name: 'ids', type: 'uint256[]' }, { indexed: false, name: 'values', type: 'uint256[]' }] }, ]; // Interface IDs for ERC165 standard detection const INTERFACE_IDS = { ERC721: '0x80ac58cd', ERC1155: '0xd9b67a26', }; // ----------------------------- /** * Get RPC URL based on network name - Helper function (Currently unused but kept for post-confirmation logic) */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function getRpcUrl(network) { switch (network) { case 'ethereum': return config.rpc.ethereum; case 'testnet': return config.rpc.testnet; case 'mainnet': default: return config.rpc.mainnet || 'https://rpc.linea.build'; } } /** * Detect NFT contract standard (ERC721 or ERC1155) using ERC165 supportsInterface * @param contractAddress Contract address to check * @param publicClient Viem PublicClient instance * @returns The detected standard or 'UNKNOWN' */ async function detectNftStandard(contractAddress, publicClient) { try { // First check if it's in our verified collections (from config) const verifiedCollection = config.nft.verifiedCollections.find(collection => collection.contractAddress.toLowerCase() === contractAddress.toLowerCase()); if (verifiedCollection) { console.log(`Detected standard ${verifiedCollection.standard} from verified list for ${contractAddress}`); return verifiedCollection.standard; } console.log(`Attempting ERC165 detection for ${contractAddress}...`); // Use multicall for efficiency const results = await publicClient.multicall({ contracts: [ { address: contractAddress, abi: ERC721_ABI, functionName: 'supportsInterface', args: [INTERFACE_IDS.ERC1155] }, // Check ERC1155 first { address: contractAddress, abi: ERC721_ABI, functionName: 'supportsInterface', args: [INTERFACE_IDS.ERC721] }, ], allowFailure: true, // Allow individual calls to fail }); const [supports1155Result, supports721Result] = results; if (supports1155Result.status === 'success' && supports1155Result.result === true) { console.log(`Detected standard ERC1155 via supportsInterface for ${contractAddress}`); return 'ERC1155'; } if (supports721Result.status === 'success' && supports721Result.result === true) { console.log(`Detected standard ERC721 via supportsInterface for ${contractAddress}`); return 'ERC721'; } console.warn(`ERC165 detection failed for ${contractAddress}. Falling back to method probing.`); // Fallback: Try calling a specific function (less reliable) try { await publicClient.readContract({ address: contractAddress, abi: ERC721_ABI, functionName: 'ownerOf', args: [0n] }); console.log(`Detected standard ERC721 via ownerOf call for ${contractAddress}`); return 'ERC721'; } catch (err721) { try { await publicClient.readContract({ address: contractAddress, abi: ERC1155_ABI, functionName: 'balanceOf', args: [zeroAddress, 0n] }); console.log(`Detected standard ERC1155 via balanceOf call for ${contractAddress}`); return 'ERC1155'; } catch (err1155) { console.error(`Could not detect standard for ${contractAddress} via probing.`); return 'UNKNOWN'; } } } catch (error) { console.error(`Error detecting NFT standard for ${contractAddress}:`, error); return 'UNKNOWN'; } } /** * Get NFTs for address using Alchemy API * (No changes needed here as it uses axios, not ethers) */ async function getNftsFromAlchemy(address, options) { // ... (Keep existing Alchemy logic) ... try { if (!config.apiKeys.alchemy) { throw new Error('Alchemy API key not configured'); } const apiUrl = `${config.nft.alchemy.apiUrl}${config.apiKeys.alchemy}${config.nft.alchemy.endpoints.getNFTs}`; const params = { owner: address, pageSize: options.limit || config.nft.batchSize, // chain: 'LINEA-MAINNET', // Alchemy might infer chain from API key/URL excludeFilters: [], withMetadata: true, // Request metadata }; if (options.cursor) { params.pageKey = options.cursor; } if (options.contractAddresses && options.contractAddresses.length > 0) { params.contractAddresses = options.contractAddresses; } // Alchemy uses 'tokenType' for standard filtering if (options.standard && options.standard !== 'ALL') { params.tokenType = options.standard; } console.log(`Fetching NFTs from Alchemy for ${address} with params:`, params); const response = await axios.get(apiUrl, { params }); console.log(`Alchemy response received. Total count: ${response.data?.totalCount}, Page key: ${response.data?.pageKey}`); return response.data; } catch (error) { console.error('Error fetching NFTs from Alchemy:', error); if (axios.isAxiosError(error) && error.response) { throw new Error(`Alchemy API error: ${error.response.status} - ${JSON.stringify(error.response.data)}`); } throw error; } } /** * List NFTs owned by an address using viem * @param params The parameters for listing NFTs * @returns The NFTs owned by the address */ export async function listNfts(params) { try { const { contractAddress, tokenId, standard, limit, cursor } = params; const blockchain = new BlockchainService('mainnet'); // Assuming mainnet const publicClient = blockchain.client; let ownerAddress; // Get owner address if (!params.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.'); } // --- Case 1: Specific NFT requested (contractAddress + tokenId) --- if (contractAddress && tokenId) { if (!isAddress(contractAddress)) throw new Error("Invalid contract address provided."); const tokenIdBigInt = BigInt(tokenId); // Convert tokenId to BigInt // Detect standard if not provided const nftStandard = standard && standard !== 'ALL' ? standard : await detectNftStandard(contractAddress, publicClient); if (nftStandard === 'UNKNOWN') { return { success: false, address: ownerAddress, nfts: [], message: `Could not detect NFT standard for contract ${contractAddress}` }; } try { if (nftStandard === 'ERC721') { const owner = await publicClient.readContract({ address: contractAddress, abi: ERC721_ABI, functionName: 'ownerOf', args: [tokenIdBigInt] }); const isOwner = owner.toLowerCase() === ownerAddress.toLowerCase(); if (isOwner) { const [name, symbol, tokenURI] = await publicClient.multicall({ contracts: [ { address: contractAddress, abi: ERC721_ABI, functionName: 'name' }, { address: contractAddress, abi: ERC721_ABI, functionName: 'symbol' }, { address: contractAddress, abi: ERC721_ABI, functionName: 'tokenURI', args: [tokenIdBigInt] }, ], allowFailure: true }).then(res => [res[0].result ?? 'Unknown', res[1].result ?? 'NFT', res[2].result ?? '']); return { success: true, address: ownerAddress, nfts: [{ contractAddress, tokenId, name, symbol, tokenURI, owner: ownerAddress, standard: 'ERC721' }] }; } else { return { success: true, address: ownerAddress, nfts: [], message: `Address does not own ERC721 NFT ID ${tokenId} in contract ${contractAddress}` }; } } else { // ERC1155 const balance = await publicClient.readContract({ address: contractAddress, abi: ERC1155_ABI, functionName: 'balanceOf', args: [ownerAddress, tokenIdBigInt] }); if (balance > 0n) { const [name, symbol, uri] = await publicClient.multicall({ contracts: [ { address: contractAddress, abi: ERC1155_ABI, functionName: 'name' }, { address: contractAddress, abi: ERC1155_ABI, functionName: 'symbol' }, { address: contractAddress, abi: ERC1155_ABI, functionName: 'uri', args: [tokenIdBigInt] }, ], allowFailure: true }).then(res => [res[0].result ?? 'Unknown', res[1].result ?? 'NFT', res[2].result ?? '']); return { success: true, address: ownerAddress, nfts: [{ contractAddress, tokenId, name, symbol, tokenURI: uri, owner: ownerAddress, balance: balance.toString(), standard: 'ERC1155' }] }; } else { return { success: true, address: ownerAddress, nfts: [], message: `Address does not own ERC1155 NFT ID ${tokenId} in contract ${contractAddress}` }; } } } catch (error) { console.error(`Error fetching specific ${nftStandard} NFT:`, error); return { success: false, address: ownerAddress, nfts: [], message: `Error fetching ${nftStandard} NFT: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } // --- Case 2: List NFTs (using Alchemy API) --- if (!config.nft.enabled) { return { success: true, address: ownerAddress, nfts: [], message: 'NFT indexing (via Alchemy) is disabled in config.' }; } if (!config.apiKeys.alchemy) { return { success: false, address: ownerAddress, nfts: [], message: 'Alchemy API key not configured. Cannot list all NFTs.' }; } try { const contractAddresses = contractAddress ? [contractAddress] : undefined; const alchemyData = await getNftsFromAlchemy(ownerAddress, { limit, cursor, contractAddresses, standard: standard, // Pass standard if provided }); // Map Alchemy response to desired format const mappedNfts = alchemyData.ownedNfts.map((nft) => ({ contractAddress: nft.contract.address, tokenId: nft.tokenId, name: nft.title || nft.contract.name || 'Unknown', symbol: nft.contract.symbol || 'NFT', tokenURI: nft.tokenUri?.raw || '', standard: nft.tokenType, // Alchemy provides this balance: nft.balance || (nft.tokenType === 'ERC721' ? '1' : '0'), // Default balance metadata: nft.raw?.metadata || nft.metadata || null, // Use raw.metadata or metadata owner: ownerAddress, media: nft.media || null, // Include media if available })); return { success: true, address: ownerAddress, nfts: mappedNfts, pageKey: alchemyData.pageKey, // For pagination totalCount: alchemyData.totalCount, }; } catch (error) { console.error('Error fetching NFTs from Alchemy in listNfts:', error); return { success: false, address: ownerAddress, nfts: [], message: `Error fetching NFTs from indexer: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } catch (error) { console.error('Error in listNfts:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; throw new Error(`Failed to list NFTs: ${errorMessage}`); } } /** * Transfer an NFT to another address using viem, with fee estimation and confirmation * @param params The parameters for transferring an NFT * @returns The transaction details or an abort message */ export async function transferNft(params) { try { const { contractAddress, tokenId, destination, amount = '1', data = '0x', standard } = params; // Default amount 1 for ERC1155 // Validate addresses if (!isAddress(contractAddress)) throw new Error('Invalid contract address'); if (!isAddress(destination)) throw new Error('Invalid destination address'); const tokenIdBigInt = BigInt(tokenId); const amountBigInt = BigInt(amount); // Initialize services const blockchain = new BlockchainService('mainnet'); // Assuming mainnet const publicClient = blockchain.client; const keyService = new KeyManagementService(); const account = keyService.getDefaultAccount(); // Detect standard if not provided const nftStandard = standard || await detectNftStandard(contractAddress, publicClient); if (nftStandard === 'UNKNOWN') { throw new Error(`Could not detect NFT standard for contract ${contractAddress}`); } // eslint-disable-next-line @typescript-eslint/no-unused-vars let txHash; // Kept for post-confirmation logic // eslint-disable-next-line @typescript-eslint/no-unused-vars const name = 'Unknown'; // Use const, kept for post-confirmation logic // eslint-disable-next-line @typescript-eslint/no-unused-vars const symbol = 'NFT'; // Use const, kept for post-confirmation logic let gasEstimate; let gasPrice; let estimatedFeeEther; let transferArgs; let transferAbi; if (nftStandard === 'ERC721') { console.log(`Estimating gas for transferring ERC721 ${contractAddress} token ${tokenId}...`); // Optional: Check ownership first (read operation) try { const owner = await publicClient.readContract({ address: contractAddress, abi: ERC721_ABI, functionName: 'ownerOf', args: [tokenIdBigInt] }); if (owner.toLowerCase() !== account.address.toLowerCase()) { throw new Error(`Account ${account.address} does not own ERC721 token ID ${tokenId}`); } } catch (ownerError) { throw new Error(`Failed to verify ownership for ERC721 token ID ${tokenId}: ${ownerError instanceof Error ? ownerError.message : ownerError}`); } transferArgs = [account.address, destination, tokenIdBigInt]; transferAbi = ERC721_ABI; } else { // ERC1155 console.log(`Estimating gas for transferring ${amount} of ERC1155 ${contractAddress} token ${tokenId}...`); // Optional: Check balance first (read operation) try { const balance = await publicClient.readContract({ address: contractAddress, abi: ERC1155_ABI, functionName: 'balanceOf', args: [account.address, tokenIdBigInt] }); if (balance < amountBigInt) { throw new Error(`Insufficient balance for ERC1155 token ID ${tokenId}. Have: ${balance}, Need: ${amountBigInt}`); } } catch (balanceError) { throw new Error(`Failed to verify balance for ERC1155 token ID ${tokenId}: ${balanceError instanceof Error ? balanceError.message : balanceError}`); } transferArgs = [account.address, destination, tokenIdBigInt, amountBigInt, data]; transferAbi = ERC1155_ABI; } // --- Estimate Gas Fee --- try { gasEstimate = await publicClient.estimateContractGas({ address: contractAddress, abi: transferAbi, // Use correct ABI based on standard functionName: 'safeTransferFrom', args: transferArgs, account, }); gasPrice = await publicClient.getGasPrice(); estimatedFeeEther = formatEther(gasEstimate * gasPrice); console.log(`Estimated Fee: ~${estimatedFeeEther} ETH`); } catch (estimationError) { console.error("Error estimating NFT transfer gas:", estimationError); throw new Error(`Failed to estimate gas fee for NFT transfer: ${estimationError instanceof Error ? estimationError.message : 'Unknown error'}`); } // --- End Estimation --- // --- Ask for Confirmation --- const nftDescription = nftStandard === 'ERC721' ? `NFT ID ${tokenId}` : `${amount} of NFT ID ${tokenId}`; throw new Error(`CONFIRMATION_REQUIRED: Estimated fee to transfer ${nftDescription} from ${contractAddress} to ${destination} is ~${estimatedFeeEther} ETH. Proceed? (Yes/No)`); // --- End Confirmation --- /* // --- Code to run *after* user confirms (Yes) --- const walletClient = createWalletClient({ account, chain: blockchain.currentChain, transport: http(getRpcUrl('mainnet')), }); console.log(`Proceeding with NFT transfer...`); txHash = await walletClient.writeContract({ address: contractAddress, abi: transferAbi, functionName: 'safeTransferFrom', args: transferArgs, gas: gasEstimate, gasPrice: gasPrice, }); console.log(`Transfer transaction submitted: ${txHash}. Waiting for confirmation...`); const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); console.log(`Transfer confirmed. Status: ${receipt.status}`); if (receipt.status === 'reverted') { throw new Error(`NFT transfer failed (reverted). Hash: ${txHash}`); } // Get name/symbol (best effort) after transfer [name, symbol] = await publicClient.multicall({ contracts: [ { address: contractAddress, abi: transferAbi, functionName: 'name' }, { address: contractAddress, abi: transferAbi, functionName: 'symbol' }, ], allowFailure: true }).then(res => [res[0].result ?? 'Unknown', res[1].result ?? 'NFT']); return { success: true, transactionHash: txHash, receipt: { blockNumber: receipt.blockNumber.toString(), gasUsed: receipt.gasUsed.toString(), status: receipt.status, }, from: account.address, to: destination, contractAddress, tokenId, name, symbol, amount: nftStandard === 'ERC1155' ? amount : undefined, standard: nftStandard, 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 transferNft:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; if (errorMessage.includes('reverted')) { throw new Error(`Failed to transfer NFT: Transaction reverted. Check ownership, approval, or parameters.`); } throw new Error(`Failed to transfer NFT: ${errorMessage}`); } } /** * Get NFT metadata using viem (fallback) and Alchemy * @param params The parameters for retrieving NFT metadata * @returns The NFT metadata */ export async function getNftMetadata(params) { try { const { contractAddress, tokenId, standard } = params; if (!isAddress(contractAddress)) throw new Error("Invalid contract address provided."); const tokenIdBigInt = BigInt(tokenId); // --- Try Alchemy First --- if (config.nft.enabled && config.apiKeys.alchemy) { try { const apiUrl = `${config.nft.alchemy.apiUrl}${config.apiKeys.alchemy}${config.nft.alchemy.endpoints.getNFTMetadata}`; console.log(`Fetching metadata from Alchemy for ${contractAddress} / ${tokenId}...`); const response = await axios.get(apiUrl, { params: { contractAddress, tokenId, tokenType: standard || undefined, // Let Alchemy detect if not specified refreshCache: 'false', // Use string 'false' // chain: 'LINEA-MAINNET' // Often inferred } }); // Check if Alchemy returned meaningful data if (response.data && (response.data.title || response.data.metadata || response.data.tokenUri)) { console.log("Successfully fetched metadata from Alchemy."); return { success: true, source: 'alchemy', contractAddress, tokenId, metadata: response.data.raw?.metadata || response.data.metadata || null, // Prefer raw metadata standard: response.data.tokenType || standard || 'UNKNOWN', // Use Alchemy's detected type name: response.data.title || response.data.contract?.name || 'Unknown', symbol: response.data.contract?.symbol || 'NFT', tokenURI: response.data.tokenUri?.raw || null, media: response.data.media || null, }; } else { console.warn("Alchemy response lacked expected metadata fields, falling back to contract calls."); } } catch (alchemyError) { console.warn(`Failed to get metadata from Alchemy (Status: ${alchemyError.response?.status}), falling back to contract calls:`, alchemyError.message); } } else { console.log("Alchemy NFT API not configured or disabled, using contract calls for metadata."); } // --- End Alchemy Attempt --- // --- Fallback to Contract Calls --- const blockchain = new BlockchainService('mainnet'); // Assuming mainnet const publicClient = blockchain.client; const nftStandard = standard || await detectNftStandard(contractAddress, publicClient); if (nftStandard === 'UNKNOWN') { throw new Error(`Could not detect NFT standard for contract ${contractAddress}`); } let name = 'Unknown'; // Type inferred let symbol = 'NFT'; // Type inferred let tokenURI = null; try { if (nftStandard === 'ERC721') { [name, symbol, tokenURI] = await publicClient.multicall({ contracts: [ { address: contractAddress, abi: ERC721_ABI, functionName: 'name' }, { address: contractAddress, abi: ERC721_ABI, functionName: 'symbol' }, { address: contractAddress, abi: ERC721_ABI, functionName: 'tokenURI', args: [tokenIdBigInt] }, ], allowFailure: true }).then(res => [res[0].result ?? 'Unknown', res[1].result ?? 'NFT', res[2].result ?? null]); } else { // ERC1155 [name, symbol, tokenURI] = await publicClient.multicall({ contracts: [ { address: contractAddress, abi: ERC1155_ABI, functionName: 'name' }, // Optional in 1155 { address: contractAddress, abi: ERC1155_ABI, functionName: 'symbol' }, // Optional in 1155 { address: contractAddress, abi: ERC1155_ABI, functionName: 'uri', args: [tokenIdBigInt] }, ], allowFailure: true }).then(res => [res[0].result ?? 'Unknown', res[1].result ?? 'NFT', res[2].result ?? null]); } } catch (readError) { console.error("Error reading basic metadata from contract:", readError); // Proceed even if basic reads fail, maybe tokenURI is still fetchable if read individually if (!tokenURI && nftStandard === 'ERC721') { try { tokenURI = await publicClient.readContract({ address: contractAddress, abi: ERC721_ABI, functionName: 'tokenURI', args: [tokenIdBigInt] }); } catch { /* ignore */ } } else if (!tokenURI && nftStandard === 'ERC1155') { try { tokenURI = await publicClient.readContract({ address: contractAddress, abi: ERC1155_ABI, functionName: 'uri', args: [tokenIdBigInt] }); } catch { /* ignore */ } } } // Try to fetch metadata from tokenURI if available let metadata = null; if (tokenURI && (tokenURI.startsWith('http') || tokenURI.startsWith('ipfs') || tokenURI.startsWith('data:application/json'))) { try { let metadataUrl = tokenURI; // Handle IPFS URIs if (metadataUrl.startsWith('ipfs://')) { const cid = metadataUrl.replace('ipfs://', ''); // Use a public gateway - replace with preferred one if needed metadataUrl = `https://ipfs.io/ipfs/${cid}`; } // Handle ERC1155 URI template {id} if (nftStandard === 'ERC1155' && metadataUrl.includes('{id}')) { // Pad token ID to 64 hex chars, lowercase, no 0x prefix const hexTokenId = tokenIdBigInt.toString(16).padStart(64, '0'); metadataUrl = metadataUrl.replace('{id}', hexTokenId); } console.log(`Fetching metadata from resolved URI: ${metadataUrl}`); if (metadataUrl.startsWith('data:application/json')) { // Handle base64 encoded JSON data URI const base64Data = metadataUrl.split(',')[1]; metadata = JSON.parse(Buffer.from(base64Data, 'base64').toString('utf-8')); console.log("Successfully parsed metadata from data URI."); } else { // Fetch from HTTP(S) or IPFS gateway const metadataResponse = await axios.get(metadataUrl, { timeout: 5000 }); // Add timeout metadata = metadataResponse.data; console.log("Successfully fetched metadata from external URI."); } } catch (metadataError) { console.warn(`Failed to fetch or parse metadata from URI (${tokenURI}):`, metadataError.message); } } else if (tokenURI) { console.warn(`Token URI format not recognized or fetchable: ${tokenURI}`); } else { console.warn(`No token URI found for ${contractAddress} / ${tokenId}`); } return { success: true, source: 'contract_fallback', contractAddress, tokenId, metadata, // May be null if fetching failed standard: nftStandard, name, symbol, tokenURI, // The raw URI from the contract }; } catch (error) { console.error('Error in getNftMetadata:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; throw new Error(`Failed to get NFT metadata: ${errorMessage}`); } }