// ═══════════════════════════════════════════════════════════════ // Blockchain Service — Server-Side Smart Contract Integration // ═══════════════════════════════════════════════════════════════ // // This service handles ALL interactions with the Ethereum blockchain // from the Next.js server (server actions / route handlers). // // KEY DESIGN DECISIONS: // ───────────────────── // 1. SERVER-SIDE ONLY: Uses ethers.js JsonRpcProvider + Wallet // (NOT BrowserProvider / MetaMask). Users don't need a wallet. // // 2. DUAL-NETWORK: Automatically connects to Hardhat (local dev) // or Sepolia (demo/production) based on env vars. // // 3. DOCUMENT HASHING: Computes SHA-256 of the contract PDF file // content, then converts to bytes32 for Solidity compatibility. // // FLOW: // Upload PDF → Download → SHA-256 Hash → Send to Smart Contract // → Store txHash + blockNumber in PostgreSQL // ═══════════════════════════════════════════════════════════════ import { ethers } from "ethers"; import { createHash } from "crypto"; import type { BlockchainProof, BlockchainVerification, BlockchainStats } from "./blockchain.types"; // ───────────────────────────────────────────────── // Smart Contract ABI (Application Binary Interface) // ───────────────────────────────────────────────── // This is the "API definition" of our Solidity contract. // It tells ethers.js what functions exist and their signatures. // Generated by `npx hardhat compile` from DocumentRegistry.sol const DOCUMENT_REGISTRY_ABI = [ "function registerDocument(bytes32 _docHash) external", "function verifyDocument(bytes32 _docHash) external view returns (bool exists, uint256 timestamp, address depositor)", "function getTimestamp(bytes32 _docHash) external view returns (uint256)", "function getDocumentsByDepositor(address _depositor) external view returns (bytes32[] memory)", "function getDocumentCount(address _depositor) external view returns (uint256)", "function totalDocuments() external view returns (uint256)", "function owner() external view returns (address)", "event DocumentRegistered(bytes32 indexed docHash, uint256 timestamp, address indexed depositor)", ]; // ───────────────────────────────────────────────── // CONFIGURATION // ───────────────────────────────────────────────── function getConfig() { const network = process.env.BLOCKCHAIN_NETWORK || "hardhat"; const rpcUrl = process.env.BLOCKCHAIN_RPC_URL || "http://127.0.0.1:8545"; const contractAddress = process.env.BLOCKCHAIN_CONTRACT_ADDRESS || ""; const privateKey = process.env.BLOCKCHAIN_PRIVATE_KEY || ""; return { network, rpcUrl, contractAddress, privateKey }; } function isBlockchainReadConfigured(): boolean { const config = getConfig(); return !!(config.contractAddress && config.rpcUrl); } function isBlockchainWriteConfigured(): boolean { const config = getConfig(); return !!(config.contractAddress && config.privateKey && config.rpcUrl); } // ───────────────────────────────────────────────── // PROVIDER & WALLET INSTANCES (lazy singletons) // ───────────────────────────────────────────────── let _provider: ethers.JsonRpcProvider | null = null; let _wallet: ethers.Wallet | null = null; let _readContract: ethers.Contract | null = null; let _writeContract: ethers.Contract | null = null; function getProvider(): ethers.JsonRpcProvider { if (!_provider) { const { rpcUrl } = getConfig(); _provider = new ethers.JsonRpcProvider(rpcUrl); } return _provider; } function getWallet(): ethers.Wallet { if (!_wallet) { const { privateKey } = getConfig(); if (!privateKey) { throw new Error("BLOCKCHAIN_PRIVATE_KEY not configured"); } _wallet = new ethers.Wallet(privateKey, getProvider()); } return _wallet; } function getReadContract(): ethers.Contract { if (!_readContract) { const { contractAddress } = getConfig(); if (!contractAddress) { throw new Error("BLOCKCHAIN_CONTRACT_ADDRESS not configured"); } _readContract = new ethers.Contract( contractAddress, DOCUMENT_REGISTRY_ABI, getProvider() ); } return _readContract; } function getWriteContract(): ethers.Contract { if (!_writeContract) { const { contractAddress } = getConfig(); if (!contractAddress) { throw new Error("BLOCKCHAIN_CONTRACT_ADDRESS not configured"); } _writeContract = new ethers.Contract( contractAddress, DOCUMENT_REGISTRY_ABI, getWallet() ); } return _writeContract; } // Reset singletons (useful when env changes at runtime) function resetInstances() { _provider = null; _wallet = null; _readContract = null; _writeContract = null; } // ═══════════════════════════════════════════════════════════════ // CORE SERVICE CLASS // ═══════════════════════════════════════════════════════════════ export class BlockchainService { /** * Check if blockchain is properly configured. * Returns false if env vars are missing — blockchain features * are gracefully disabled without breaking the rest of the app. */ static isConfigured(): boolean { return isBlockchainWriteConfigured(); } /** * Check if blockchain read operations are configured. * Read operations (stats/verify) do not require private key. */ static isReadConfigured(): boolean { return isBlockchainReadConfigured(); } /** * Compute SHA-256 hash of a document from its URL. * * HOW IT WORKS: * 1. Downloads the PDF file from UploadThing URL * 2. Computes SHA-256 hash of the raw bytes * 3. Prepends "0x" and pads to bytes32 for Solidity * * WHY SHA-256: * - Industry standard for document fingerprinting * - Collision resistant (practically impossible for two different * documents to produce the same hash) * - The hash is deterministic: same file → same hash, always * * @param fileUrl - The URL of the document to hash * @returns The SHA-256 hash as a 0x-prefixed hex string (bytes32) */ static async hashDocument(fileUrl: string): Promise { console.log("🔐 Computing document hash..."); // Download the file const response = await fetch(fileUrl); if (!response.ok) { throw new Error(`Failed to download file: ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); // Compute SHA-256 hash const hash = createHash("sha256").update(buffer).digest("hex"); // Convert to bytes32 format (0x-prefixed, 64 hex characters) const bytes32Hash = "0x" + hash; console.log(`✅ Document hash: ${bytes32Hash.slice(0, 18)}...`); return bytes32Hash; } /** * Register a document hash on the blockchain. * * HOW IT WORKS: * 1. Creates a transaction calling registerDocument() on the smart contract * 2. The server wallet signs the transaction with its private key * 3. The transaction is broadcast to the Ethereum network * 4. We wait for the transaction to be mined (included in a block) * 5. The block number and timestamp become the proof * * WHAT GETS STORED ON-CHAIN: * - The document hash (bytes32) * - The block timestamp (when the tx was mined) * - The depositor address (our server wallet) * - The file name (for reference) * * @param documentHash - SHA-256 hash of the document (bytes32) * @param fileName - Original file name * @returns Proof data including txHash, block info */ static async registerOnChain( documentHash: string, fileName: string ): Promise { if (!this.isConfigured()) { throw new Error("Blockchain not configured. Check your .env variables."); } console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); console.log("🔗 Registering document on blockchain..."); console.log(`📄 File: ${fileName}`); console.log(`🔐 Hash: ${documentHash.slice(0, 18)}...`); console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); const contract = getWriteContract(); const config = getConfig(); try { // Send the transaction to the smart contract (fileName is omitted on-chain) const tx = await contract.registerDocument(documentHash); console.log(`📤 Transaction sent: ${tx.hash}`); // Wait for the transaction to be mined const receipt = await tx.wait(); console.log(`✅ Transaction mined in block #${receipt.blockNumber}`); // Get the block to extract the timestamp const block = await getProvider().getBlock(receipt.blockNumber); const blockTimestamp = block ? new Date(block.timestamp * 1000) : new Date(); // Build explorer URL for Sepolia const explorerUrl = config.network === "sepolia" ? `https://sepolia.etherscan.io/tx/${tx.hash}` : null; const proof: BlockchainProof = { documentHash, txHash: tx.hash, blockNumber: receipt.blockNumber, blockTimestamp, network: config.network as "hardhat" | "sepolia", contractAddress: config.contractAddress, explorerUrl, }; console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); console.log("✅ Document registered on-chain!"); console.log(` Block: #${proof.blockNumber}`); console.log(` Time: ${proof.blockTimestamp.toISOString()}`); console.log(` Tx: ${proof.txHash}`); if (explorerUrl) { console.log(` View: ${explorerUrl}`); } console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); return proof; } catch (error: unknown) { // Check if the document was already registered const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes("already registered")) { console.log("ℹ️ Document already registered on-chain (idempotent)"); // Retrieve existing proof data const verification = await this.verifyOnChain(documentHash); return { documentHash, txHash: "already-registered", blockNumber: 0, blockTimestamp: new Date(verification.timestamp * 1000), network: config.network as "hardhat" | "sepolia", contractAddress: config.contractAddress, explorerUrl: null, }; } console.error("❌ Blockchain registration failed:", errorMessage); throw new Error(`Blockchain registration failed: ${errorMessage}`); } } /** * Verify if a document exists on the blockchain. * * This is a READ-ONLY operation (no gas cost, no transaction). * It queries the smart contract's state to check if a hash * was previously registered. * * @param documentHash - The SHA-256 hash to verify * @returns Verification result with existence, timestamp, depositor */ static async verifyOnChain( documentHash: string ): Promise { if (!this.isReadConfigured()) { throw new Error("Blockchain read access not configured"); } const contract = getReadContract(); const [exists, timestamp, depositor] = await contract.verifyDocument(documentHash); return { exists: exists as boolean, timestamp: Number(timestamp), depositor: depositor as string, }; } /** * Hash a document AND register it on-chain in one step. * This is the main entry point used by the contract analysis flow. */ static async hashAndRegister( fileUrl: string, fileName: string ): Promise { const documentHash = await this.hashDocument(fileUrl); return await this.registerOnChain(documentHash, fileName); } /** * Get blockchain network stats for the explorer page header. */ static async getNetworkStats(): Promise { if (!this.isReadConfigured()) { return { totalVerified: 0, latestBlockNumber: null, networkName: "Not Configured", networkStatus: "disconnected", walletAddress: "", }; } try { const provider = getProvider(); const contract = getReadContract(); const config = getConfig(); let walletAddress = ""; if (isBlockchainWriteConfigured()) { try { walletAddress = getWallet().address; } catch { walletAddress = ""; } } const [blockNumber, totalDocs, networkObj] = await Promise.all([ provider.getBlockNumber(), contract.totalDocuments(), provider.getNetwork() ]); return { totalVerified: Number(totalDocs), latestBlockNumber: blockNumber, networkName: config.network === "sepolia" ? "Ethereum Sepolia" : "Hardhat Local", networkStatus: "connected", walletAddress, chainId: Number(networkObj.chainId), }; } catch { const config = getConfig(); return { totalVerified: 0, latestBlockNumber: null, networkName: config.network === "sepolia" ? "Ethereum Sepolia" : "Hardhat Local", networkStatus: "disconnected", walletAddress: "", }; } } /** * Reset cached provider/wallet connections. * Useful if env vars change during development. */ static resetConnections() { resetInstances(); } }