UNPKG

@elephant-xyz/cli

Version:
583 lines 26.7 kB
import { writeFileSync, mkdirSync } from 'fs'; import { readdir, rm } from 'fs/promises'; import { join, dirname, basename } from 'path'; import { CID } from 'multiformats/cid'; import { logger } from '../utils/logger.js'; import { ethers } from 'ethers'; import { CidHexConverterService } from './cid-hex-converter.service.js'; import { SchemaManifestService, } from './schema-manifest.service.js'; import AdmZip from 'adm-zip'; import { tmpdir } from 'os'; import { promises as fsPromises } from 'fs'; export class IPFSFetcherService { baseUrl; processedCids = new Set(); cidToFilename = new Map(); maxRetries = 3; rateLimitDelay = 5000; // Base delay for rate limiting provider = null; cidHexConverter; schemaManifestService; constructor(gatewayUrl = 'https://gateway.pinata.cloud/ipfs', rpcUrl) { this.baseUrl = gatewayUrl.endsWith('/') ? gatewayUrl.slice(0, -1) : gatewayUrl; if (rpcUrl) { this.provider = new ethers.JsonRpcProvider(rpcUrl); } this.cidHexConverter = new CidHexConverterService(); this.schemaManifestService = new SchemaManifestService(); } async loadSchemaManifest() { return this.schemaManifestService.loadSchemaManifest(); } isValidCid(cid) { // Basic validation for CIDv0 (starts with Qm) and CIDv1 (starts with ba) if (!cid || cid.length < 46) { return false; } // Validate using multiformats CID parser try { CID.parse(cid); return true; } catch { return false; } } async fetchContent(cid, attempt = 0) { const url = `${this.baseUrl}/${cid}`; try { logger.debug(`Fetching CID ${cid} from ${url} (attempt ${attempt + 1}/${this.maxRetries})`); // Create AbortController for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout const response = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'elephant-cli/1.0', }, }); clearTimeout(timeoutId); if (!response.ok) { if (response.status === 429 && attempt < this.maxRetries - 1) { // Rate limited - exponential backoff const waitTime = this.rateLimitDelay * Math.pow(2, attempt); logger.warn(`Rate limited. Waiting ${waitTime / 1000} seconds before retry...`); await new Promise((resolve) => setTimeout(resolve, waitTime)); return this.fetchContent(cid, attempt + 1); } throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const content = await response.json(); return content; } catch (error) { logger.error(`Error fetching CID ${cid}: ${error}`); throw error; } } replaceCidsWithPaths(content, cidToPath) { if (content === null || content === undefined) { return content; } if (typeof content === 'object' && !Array.isArray(content)) { // Check if this is a CID reference pattern {"/": "cid"} if ('/' in content && typeof content['/'] === 'string' && Object.keys(content).length === 1) { const cid = content['/']; if (cidToPath.has(cid)) { return { '/': cidToPath.get(cid) }; } return content; } // Process each key-value pair const newContent = {}; for (const [key, value] of Object.entries(content)) { if (typeof value === 'object' && value !== null && '/' in value && typeof value['/'] === 'string' && Object.keys(value).length === 1) { const cid = value['/']; if (cidToPath.has(cid)) { newContent[key] = { '/': cidToPath.get(cid) }; } else { newContent[key] = value; } } else if (typeof value === 'object') { newContent[key] = this.replaceCidsWithPaths(value, cidToPath); } else { newContent[key] = value; } } return newContent; } else if (Array.isArray(content)) { return content.map((item) => this.replaceCidsWithPaths(item, cidToPath)); } return content; } async processCidRecursive(cid, dataDir, cidToPath, parentRel, parentKey) { if (this.processedCids.has(cid)) { return cidToPath.get(cid) || null; } this.processedCids.add(cid); // Fetch content logger.info(`Fetching CID: ${cid}`); let content; try { content = await this.fetchContent(cid); } catch (error) { logger.error(`Failed to fetch CID ${cid}: ${error}`); return null; } // Determine filename let filename; if (parentRel) { // Child file: use parent relationship and key if (parentKey) { filename = `${parentRel}_${parentKey}_${cid}.json`; } else { filename = `${parentRel}.json`; } logger.debug(`Naming file for CID ${cid}: ${filename} (parentRel: ${parentRel}, parentKey: ${parentKey})`); } else { // Root file: check if we have a datagroup mapping in schema manifest let datagroupCid; if (typeof content === 'object' && content.label) { // Load manifest if not already loaded await this.loadSchemaManifest(); const label = content.label; // Try to find a matching dataGroup in the manifest datagroupCid = this.schemaManifestService.getDataGroupCidByLabel(label) || this.schemaManifestService.getDataGroupCidByLabel(label.replace(/ /g, '_')) || undefined; } if (datagroupCid) { filename = `${datagroupCid}.json`; } else { filename = `${cid}.json`; } } // Store the mapping this.cidToFilename.set(cid, filename); // Process nested CIDs with their relationship context if (typeof content === 'object' && content !== null) { // Check for relationships if (content.relationships) { for (const [relName, relValue] of Object.entries(content.relationships)) { if (typeof relValue === 'object' && relValue !== null) { // Check if it's a direct CID reference if ('/' in relValue && typeof relValue['/'] === 'string') { const nestedCid = relValue['/']; if (this.isValidCid(nestedCid) && !this.processedCids.has(nestedCid)) { await this.processCidRecursive(nestedCid, dataDir, cidToPath, relName, ''); } } else { // Check nested structure for (const [key, value] of Object.entries(relValue)) { if (typeof value === 'object' && value !== null && '/' in value && typeof value['/'] === 'string') { const nestedCid = value['/']; if (this.isValidCid(nestedCid) && !this.processedCids.has(nestedCid)) { await this.processCidRecursive(nestedCid, dataDir, cidToPath, relName, key); } } } } } else if (Array.isArray(relValue)) { for (let idx = 0; idx < relValue.length; idx++) { const item = relValue[idx]; if (typeof item === 'object' && item !== null && '/' in item && typeof item['/'] === 'string') { const nestedCid = item['/']; if (this.isValidCid(nestedCid) && !this.processedCids.has(nestedCid)) { await this.processCidRecursive(nestedCid, dataDir, cidToPath, relName, idx.toString()); } } } } } } // Also check for direct CID references in the content for (const [key, value] of Object.entries(content)) { if (key !== 'relationships') { if (typeof value === 'object' && value !== null && '/' in value && typeof value['/'] === 'string') { const nestedCid = value['/']; if (this.isValidCid(nestedCid) && !this.processedCids.has(nestedCid)) { // Use the parent's filename (without .json) as relationship name const parentName = parentRel || filename.replace('.json', '') || 'root'; await this.processCidRecursive(nestedCid, dataDir, cidToPath, parentName, key); } } } } } // Save to file const filePath = join(dataDir, filename); const relativePath = `./${filename}`; cidToPath.set(cid, relativePath); // Replace CIDs with paths const modifiedContent = this.replaceCidsWithPaths(content, cidToPath); // Ensure directory exists mkdirSync(dirname(filePath), { recursive: true }); // Write the file writeFileSync(filePath, JSON.stringify(modifiedContent, null, 2), 'utf-8'); logger.info(`Saved: ${filePath}`); return relativePath; } async fetchData(initialCid, baseDir) { // Validate CID if (!this.isValidCid(initialCid)) { throw new Error(`Invalid IPFS CID: ${initialCid}`); } // Load schema manifest await this.loadSchemaManifest(); // Create data directory const dataRoot = baseDir || 'data'; mkdirSync(dataRoot, { recursive: true }); const dataDir = join(dataRoot, initialCid); mkdirSync(dataDir, { recursive: true }); logger.info(`Created directory: ${dataDir}`); // Track CID to file path mappings const cidToPath = new Map(); // Reset state for new reconstruction this.processedCids.clear(); this.cidToFilename.clear(); // Process recursively const result = await this.processCidRecursive(initialCid, dataDir, cidToPath); // Check if processing succeeded if (!result) { // Clean up empty directory try { const files = await readdir(dataDir); if (files.length === 0) { await rm(dataDir, { recursive: true, force: true }); logger.info(`Removed empty directory: ${dataDir}`); } } catch (error) { logger.warn(`Could not clean up directory: ${error}`); } throw new Error(`Failed to fetch initial CID: ${initialCid}`); } logger.info(`Processing complete. Data saved in: ${dataDir}`); logger.info(`Total CIDs processed: ${this.processedCids.size}`); return dataDir; } async fetchFromTransaction(transactionHash, baseDir = 'data') { if (!this.provider) { throw new Error('RPC provider not initialized'); } logger.info(`Fetching transaction: ${transactionHash}`); // Fetch transaction const tx = await this.provider.getTransaction(transactionHash); if (!tx) { throw new Error(`Transaction not found: ${transactionHash}`); } // Ensure transaction is to a contract (has data) if (!tx.data || tx.data === '0x') { throw new Error('Transaction has no input data'); } logger.info(`Transaction found. Decoding input data...`); // Decode the transaction data // The submitBatchData function signature is: submitBatchData((bytes32,bytes32,bytes32)[]) const iface = new ethers.Interface([ { inputs: [ { components: [ { internalType: 'bytes32', name: 'propertyHash', type: 'bytes32', }, { internalType: 'bytes32', name: 'dataGroupHash', type: 'bytes32', }, { internalType: 'bytes32', name: 'dataHash', type: 'bytes32' }, ], internalType: 'struct IPropertyDataConsensus.DataItem[]', name: 'items', type: 'tuple[]', }, ], name: 'submitBatchData', outputs: [], stateMutability: 'nonpayable', type: 'function', }, ]); let decodedData; try { const decoded = iface.parseTransaction({ data: tx.data }); if (!decoded || decoded.name !== 'submitBatchData') { throw new Error('Transaction is not a submitBatchData call'); } decodedData = decoded.args[0].map((item) => ({ propertyHash: item.propertyHash, dataGroupHash: item.dataGroupHash, dataHash: item.dataHash, })); } catch (error) { throw new Error(`Failed to decode transaction data: ${error instanceof Error ? error.message : String(error)}`); } logger.info(`Found ${decodedData.length} data items in transaction`); // Load schema manifest await this.loadSchemaManifest(); // Group items by propertyHash const itemsByProperty = new Map(); for (const item of decodedData) { const items = itemsByProperty.get(item.propertyHash) || []; items.push(item); itemsByProperty.set(item.propertyHash, items); } logger.info(`Processing ${itemsByProperty.size} unique properties from transaction`); // Process each property for (const [propertyHash, items] of itemsByProperty) { // Convert property hash to CID const propertyCid = this.cidHexConverter.hexToCid(propertyHash); logger.info(`\nProcessing property: ${propertyCid} (hash: ${propertyHash})`); logger.info(` Found ${items.length} data groups`); // Create directory for this property const propertyDir = join(baseDir, propertyCid); mkdirSync(propertyDir, { recursive: true }); // Process each data item for this property for (const item of items) { try { // Convert hashes to CIDs const dataGroupCid = this.cidHexConverter.hexToCid(item.dataGroupHash); const dataCid = this.cidHexConverter.hexToCid(item.dataHash); logger.info(` Processing data group: ${dataGroupCid} with data: ${dataCid}`); // Fetch the data content const dataContent = await this.fetchContent(dataCid); if (!dataContent) { logger.warn(` ✗ Failed to fetch data for ${dataGroupCid}`); continue; } // Now process any nested CIDs in the content this.processedCids.clear(); this.cidToFilename.clear(); this.processedCids.add(dataCid); // Mark the main data CID as processed // Track CID to file path mappings const cidToPath = new Map(); cidToPath.set(dataCid, `./${dataGroupCid}.json`); // Process nested CIDs if any if (typeof dataContent === 'object' && dataContent !== null) { await this.processNestedCids(dataContent, propertyDir, cidToPath); } // Replace CIDs with paths in the content const modifiedContent = this.replaceCidsWithPaths(dataContent, cidToPath); // Save the dataGroup file with CID references replaced by paths const dataGroupFilePath = join(propertyDir, `${dataGroupCid}.json`); writeFileSync(dataGroupFilePath, JSON.stringify(modifiedContent, null, 2), 'utf-8'); logger.info(` Saved: ${dataGroupFilePath}`); logger.info(` ✓ Successfully processed data for ${dataGroupCid}`); } catch (error) { logger.error(` Error processing item: ${error instanceof Error ? error.message : String(error)}`); } } } logger.info(`\nTransaction fetch complete. Data saved in: ${baseDir}/`); } async processNestedCids(content, outputDir, cidToPath) { // Process direct CID references (but skip 'relationships' as it's handled separately) for (const [key, value] of Object.entries(content)) { if (key === 'relationships') { continue; // Skip relationships here, handled separately below } if (typeof value === 'object' && value !== null && '/' in value && typeof value['/'] === 'string') { const nestedCid = value['/']; if (this.isValidCid(nestedCid) && !this.processedCids.has(nestedCid)) { await this.processCidRecursive(nestedCid, outputDir, cidToPath, key, // Use the key as parentRel ''); } } else if (Array.isArray(value)) { // Process arrays that might contain CID references for (let idx = 0; idx < value.length; idx++) { const item = value[idx]; if (typeof item === 'object' && item !== null && '/' in item && typeof item['/'] === 'string') { const nestedCid = item['/']; if (this.isValidCid(nestedCid) && !this.processedCids.has(nestedCid)) { await this.processCidRecursive(nestedCid, outputDir, cidToPath, key, idx.toString()); } } } } else if (typeof value === 'object' && value !== null) { // Recursively process nested objects await this.processNestedCids(value, outputDir, cidToPath); } } // Also check relationships if present if (content.relationships) { for (const [relName, relValue] of Object.entries(content.relationships)) { if (typeof relValue === 'object' && relValue !== null) { if ('/' in relValue && typeof relValue['/'] === 'string') { const nestedCid = relValue['/']; if (this.isValidCid(nestedCid) && !this.processedCids.has(nestedCid)) { await this.processCidRecursive(nestedCid, outputDir, cidToPath, relName, ''); } } else { // Handle nested structure in relationships (e.g., {from: {"/": "CID"}, to: {"/": "CID"}}) for (const [subKey, subValue] of Object.entries(relValue)) { if (typeof subValue === 'object' && subValue !== null && '/' in subValue && typeof subValue['/'] === 'string') { const nestedCid = subValue['/']; if (this.isValidCid(nestedCid) && !this.processedCids.has(nestedCid)) { await this.processCidRecursive(nestedCid, outputDir, cidToPath, relName, subKey); } } } } } } } } /** * Fetch data from IPFS and save it as a ZIP file * @param initialCid The initial CID to fetch * @param outputZip Path to the output ZIP file */ async fetchDataToZip(initialCid, outputZip) { // Create a temporary directory for fetching data const tempDirBase = join(tmpdir(), 'elephant-cli-fetch-'); const tempDir = await fsPromises.mkdtemp(tempDirBase); try { // Fetch data to the temporary directory const dataDir = await this.fetchData(initialCid, tempDir); // Create ZIP file logger.info(`Creating ZIP file: ${outputZip}`); const zip = new AdmZip(); // Add the fetched directory to the ZIP with 'data' as the root folder const dirName = basename(dataDir); const zipRootPath = join('data', dirName); await this.addDirectoryToZip(zip, dataDir, zipRootPath); // Write the ZIP file zip.writeZip(outputZip); logger.info(`ZIP file created successfully: ${outputZip}`); } finally { // Clean up temporary directory try { await rm(tempDir, { recursive: true, force: true }); logger.debug(`Cleaned up temporary directory: ${tempDir}`); } catch (error) { logger.warn(`Failed to clean up temporary directory: ${error}`); } } } /** * Fetch data from transaction and save it as a ZIP file * @param transactionHash The transaction hash to fetch from * @param outputZip Path to the output ZIP file */ async fetchFromTransactionToZip(transactionHash, outputZip) { // Create a temporary directory for fetching data const tempDirBase = join(tmpdir(), 'elephant-cli-fetch-'); const tempDir = await fsPromises.mkdtemp(tempDirBase); try { // Fetch data to the temporary directory await this.fetchFromTransaction(transactionHash, tempDir); // Create ZIP file logger.info(`Creating ZIP file: ${outputZip}`); const zip = new AdmZip(); // Add all subdirectories to the ZIP under a 'data' root folder const entries = await readdir(tempDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const dirPath = join(tempDir, entry.name); const zipPath = join('data', entry.name); await this.addDirectoryToZip(zip, dirPath, zipPath); } else if (entry.isFile()) { // Add any files at the root level under 'data' folder const filePath = join(tempDir, entry.name); const content = await fsPromises.readFile(filePath); const zipPath = join('data', entry.name).replace(/\\/g, '/'); zip.addFile(zipPath, content); } } // Write the ZIP file zip.writeZip(outputZip); logger.info(`ZIP file created successfully: ${outputZip}`); } finally { // Clean up temporary directory try { await rm(tempDir, { recursive: true, force: true }); logger.debug(`Cleaned up temporary directory: ${tempDir}`); } catch (error) { logger.warn(`Failed to clean up temporary directory: ${error}`); } } } /** * Recursively add a directory to a ZIP file * @param zip The AdmZip instance * @param dirPath The directory path to add * @param zipPath The path inside the ZIP file */ async addDirectoryToZip(zip, dirPath, zipPath) { const entries = await readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dirPath, entry.name); const entryZipPath = join(zipPath, entry.name); if (entry.isDirectory()) { // Recursively add subdirectory await this.addDirectoryToZip(zip, fullPath, entryZipPath); } else if (entry.isFile()) { // Add file to ZIP const content = await fsPromises.readFile(fullPath); // Convert path separators to forward slashes for ZIP compatibility const normalizedPath = entryZipPath.replace(/\\/g, '/'); zip.addFile(normalizedPath, content); logger.debug(`Added to ZIP: ${normalizedPath}`); } } } } //# sourceMappingURL=ipfs-fetcher.service.js.map