402 lines
15 KiB
TypeScript
402 lines
15 KiB
TypeScript
// ═══════════════════════════════════════════════════════════════
|
||
// 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();
|
||
}
|
||
}
|