Files
LexiChain/lib/services/blockchain.service.ts
2026-04-22 11:04:59 +01:00

402 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ═══════════════════════════════════════════════════════════════
// 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<string> {
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<BlockchainProof> {
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<BlockchainVerification> {
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<BlockchainProof> {
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<BlockchainStats> {
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();
}
}