Files
LexiChain/lib/services/blockchain.service.ts

402 lines
15 KiB
TypeScript
Raw Permalink Normal View History

2026-04-22 11:04:59 +01:00
// ═══════════════════════════════════════════════════════════════
// 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();
}
}