Blockchain added
This commit is contained in:
401
lib/services/blockchain.service.ts
Normal file
401
lib/services/blockchain.service.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user