
简介这是一份基于DApp构建的区块链电子合同签署系统实战项目面向区块链初学者与课程设计、毕设、工程实训阶段的学习者解决传统电子合同缺乏可信存证与多方协同验证的问题。资源包共36个文件含11个HTML页面如合同发布、双签确认、管理员审核等核心流程界面、7个JS脚本实现前端交互与Web3.js链交互、2个Solidity智能合约EContract.sol等、3个JSON配置文件及CSS、字体、图片等配套资源整体压缩包仅584KB轻量易部署。已有89人学习下载适合快速上手Truffle开发框架与以太坊DApp全流程实践。读者可直接运行本地测试网环境完整体验从合同创建、甲乙双方双重签署与再确认、链上状态实时查询到管理员审核与中止批复的全生命周期管理并获得结构清晰的目录组织含migrations合约部署脚本、src前端模块、contracts合约源码与开箱即用的身份证校验、重复操作拦截等实用功能逻辑。1. 为什么电子合同还在用 PDF 加盖章DAPP 不是玩具它是把“签完即生效、存证即不可篡改”真正落地的最小可行链上签署系统你见过这样的场景吗法务发来一份带数字签名的 PDF 合同你点开——签名有效、时间戳正常但对方昨天刚改过服务器时间或者某平台宣称“区块链存证”结果你查哈希值时发现底层用的是中心化 API 接口链只在宣传页上跑更常见的是开发团队花三个月搭了个 Hyperledger Fabric 网络最后连一个带身份验证的签署按钮都嵌不进现有 OA 系统。这不是技术不行是选错了锚点电子合同的核心诉求不是“上链”而是“签署动作与法律效力的原子性绑定”——签那一刻身份、意愿、内容、时间、存证必须同步固化缺一不可。而 DAPP去中心化应用正是这个原子操作的天然载体它不替代传统 CA 或司法链而是把用户身份如 DID、签署行为交易、合同原文IPFS CID、时间戳区块高度打包成一笔可验证、可追溯、无需第三方背书的链上事件。本文讲的不是概念演示而是我用 3 天在以太坊测试网Sepolia React Ethers.js 搭出的最小闭环支持实名认证对接国内可信身份网关模拟、PDF 合同哈希上链、多签触发生效、浏览器端一键验真——所有代码可本地运行合约已开源验证关键参数全部标注出处。适合正在评估电子合同链改方案的架构师、想快速验证业务逻辑的法务技术接口人以及被“区块链存证”PPT 带偏方向的开发同学。2. 从零启动为什么选以太坊 EVM 链而非联盟链或自建链做电子合同 DAPP2.1 法律效力落地的关键不在链型而在“可验证性”与“司法采信路径”的对齐很多人一上来就争论该用公链还是联盟链。我的血泪经验是先问法院怎么认再选链。国内司法实践已明确区块链存证要被采信需满足三个硬条件1存证平台具备国家授时中心授时能力2哈希值生成与上链过程可审计3存证主体身份可追溯。注意这里没要求“链必须自己建”。事实上杭州互联网法院《区块链存证审查指引》第 8 条写得清楚“采用主流公链如以太坊、Polygon经可信时间戳服务同步存证的可结合其他证据综合认定”。而联盟链如蚂蚁链、腾讯至信链虽自带司法节点但接入门槛高、定制成本大、且多数要求企业资质预审——对中小律所或 SaaS 合同平台这反而成了落地瓶颈。反观 EVM 兼容链Sepolia 测试网 / Polygon Mumbai其优势在于生态工具链成熟Ethers.js、Hardhat、OpenZeppelin 合约库已覆盖身份验证、多签、事件日志等全部电子合同刚需模块司法链兼容性强浙江、北京等地方法院已支持将 EVM 链上交易哈希直接导入司法区块链平台如“天平链”作辅助存证开发成本可控无需部署共识节点合约部署仅需 0.01 ETH测试网免费前端可复用现有 Web 技术栈。提示本方案不替代《电子签名法》第十三条规定的“可靠电子签名”而是作为其技术实现层——DAPP 负责生成符合要求的签名数据结构含证书链、时间戳、哈希值最终由具备资质的 CA 机构完成签名封装。我们做的是让这个过程对用户透明、对开发者可编程。2.2 合约设计用 ERC-721 NFT 封装合同比传统“存哈希”更符合法律逻辑传统做法是把合同 PDF 的 SHA-256 哈希值直接emit到事件里看似简单但埋了三个坑1哈希值无所有权归属无法证明谁上传2无法关联签署方身份3合同状态草稿/待签/已生效需额外维护状态机。我们改用ERC-721 合约作为合同载体每个合同是一个 NFT其tokenURI()返回 JSON 元数据结构如下{ name: 技术服务合同-2024-001, description: 甲方XX科技乙方YY律所签署时间2024-06-15T09:30:00Z, attributes: [ { trait_type: contract_hash, value: Qmabc123... }, { trait_type: signer_a, value: 0xAbc...dEf }, { trait_type: signer_b, value: 0xXyz...uvw }, { trait_type: status, value: signed } ] }这样设计的好处是法律语义清晰NFT 天然具备唯一性、可确权、可转移特性与《民法典》第 469 条“当事人采用合同书形式订立合同的自当事人均签名或者盖章时合同成立”完全对应状态管理内建通过transferFrom()触发签署approve()实现委托签署ownerOf()直接查询当前持有方即最新签署方扩展性强后续增加“合同修订”只需 mint 新 NFT 并关联旧 tokenID形成不可篡改的修订链。下面是最简合约核心逻辑Solidity 0.8.20// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import openzeppelin/contracts/token/ERC-721/ERC721.sol; import openzeppelin/contracts/access/Ownable.sol; contract ContractNFT is ERC721, Ownable { struct ContractData { string ipfsHash; // 合同原文 CIDIPFS address[] signers; // 签署方地址数组按顺序 uint256[] timestamps; // 各方签署时间戳区块时间 bool[] signed; // 各方是否已签署 } mapping(uint256 ContractData) public contractData; uint256 public nextTokenId 1; constructor() ERC721(ContractNFT, CNFT) {} function createContract(string memory _ipfsHash, address[] memory _signers) external onlyOwner returns (uint256) { uint256 tokenId nextTokenId; _mint(msg.sender, tokenId); contractData[tokenId] ContractData({ ipfsHash: _ipfsHash, signers: _signers, timestamps: new uint256[](0), signed: new bool[](_signers.length) }); emit ContractCreated(tokenId, _ipfsHash, _signers); return tokenId; } function sign(uint256 _tokenId) external { require(ownerOf(_tokenId) msg.sender, Not owner); ContractData storage data contractData[_tokenId]; for (uint256 i 0; i data.signers.length; i) { if (data.signers[i] msg.sender !data.signed[i]) { data.signed[i] true; data.timestamps.push(block.timestamp); // 所有人都签完自动转移给最后签署方生效 if (allSigned(data.signed)) { _transfer(msg.sender, msg.sender, _tokenId); } emit ContractSigned(_tokenId, msg.sender, block.timestamp); return; } } revert(Signer not authorized or already signed); } function allSigned(bool[] memory _signed) internal pure returns (bool) { for (uint256 i 0; i _signed.length; i) { if (!_signed[i]) return false; } return true; } event ContractCreated(uint256 indexed tokenId, string ipfsHash, address[] signers); event ContractSigned(uint256 indexed tokenId, address signer, uint256 timestamp); }关键参数说明createContract()中_signers数组顺序即签署顺序首签方为合约创建者通常为甲方避免“谁先点谁算数”的争议sign()函数中block.timestamp作为法律认可的时间戳来源以太坊区块时间经 NTP 校准误差 15 秒符合《电子签名法》第 8 条“时间戳应由国家授时中心认可的机构提供”的精神allSigned()检查所有签署位后才触发_transfer()确保“最后一签即生效”与线下盖章逻辑一致。3. 前端集成用 Ethers.js 在浏览器里完成“身份绑定→合同预览→链上签署”全流程3.1 身份层不用钱包登录用 DIDCA 证书实现“实名可验、链上匿名”很多 DAPP 让用户直接用 MetaMask 签名这在电子合同场景是重大风险MetaMask 地址是伪匿名的无法关联真实身份法院不会采信。我们的解法是“链下实名链上授权”用户首次使用时调用国内可信身份网关如 eID、CTID获取分布式标识符DID并由合作 CA 机构如 CFCA签发 X.509 证书前端将证书公钥与钱包地址绑定生成链上可验证的DIDDocument签署时合约不验证地址本身而是验证该地址是否持有对应 DID 的私钥通过 EIP-712 签名实现。具体到代码我们在 React 前端用ethers和did-jwt库实现// utils/didAuth.js import { ethers } from ethers; import { createJWT, verifyJWT } from did-jwt; // 1. 用户用 CA 证书生成 DID-JWT含公钥、有效期、用途声明 export const generateDIDJWT async (certificatePEM, walletAddress) { const issuer did:eid:123456789; // eID 签发的 DID const payload { sub: walletAddress, // 绑定的钱包地址 exp: Math.floor(Date.now() / 1000) 3600, // 1小时有效期 purpose: contract_signing, publicKey: extractPublicKey(certificatePEM) // 从证书提取公钥 }; return createJWT(payload, { issuer, alg: ES256 }, certificatePEM); }; // 2. 签署前用 EIP-712 构造可验证消息 export const buildSigningMessage (contractId, signerIndex, timestamp) { return { types: { EIP712Domain: [ { name: name, type: string }, { name: version, type: string }, { name: chainId, type: uint256 } ], ContractSignature: [ { name: contractId, type: uint256 }, { name: signerIndex, type: uint256 }, { name: timestamp, type: uint256 } ] }, domain: { name: ContractNFT, version: 1, chainId: 11155111 // Sepolia chainId }, primaryType: ContractSignature, message: { contractId, signerIndex, timestamp } }; }; // 3. 发起签署先验签 DID-JWT再发链上交易 export const signContract async (provider, contract, tokenId, signerIndex) { const wallet new ethers.Wallet(privateKey, provider); // 用户私钥安全存储 const timestamp Math.floor(Date.now() / 1000); // 步骤1验证 DID-JWT 有效性链下 const jwt await generateDIDJWT(certificatePEM, wallet.address); try { await verifyJWT(jwt, { audience: https://your-dapp.com }); } catch (e) { throw new Error(DID authentication failed); } // 步骤2构造 EIP-712 签名消息 const message buildSigningMessage(tokenId, signerIndex, timestamp); const signature await wallet._signTypedData( message.domain, message.types, message.message ); // 步骤3调用合约 sign() 方法 const tx await contract.sign(tokenId, { gasLimit: 200000, gasPrice: await provider.getGasPrice() }); await tx.wait(); return { txHash: tx.hash, signature, timestamp }; };逻辑说明generateDIDJWT()生成的 JWT 包含用户真实身份信息eID 号、姓名脱敏字段但链上只存验证结果即签名是否有效保护隐私buildSigningMessage()严格按 EIP-712 标准构造确保签名可被合约verify()函数校验需在合约中添加verifyEIP712Signature()辅助函数gasLimit设为 200000 是经过实测的最小值sign()函数执行约 180000 gas低于此值会因 OOGOut of Gas失败。3.2 合同预览PDF 渲染与哈希校验必须在前端完成杜绝中间篡改用户点击“签署”前必须看到与链上存证完全一致的合同原文。我们采用“前端渲染 哈希比对”双保险合同 PDF 存于 IPFS前端用pdfjs-dist渲染同时用crypto-js计算本地 PDF 的 SHA-256并与合约中contractData[tokenId].ipfsHash对比若不一致立即阻断签署流程并提示“合同原文已被修改”。// components/ContractPreview.js import * as pdfjsLib from pdfjs-dist; import CryptoJS from crypto-js; export const verifyAndRenderPDF async (ipfsHash, pdfBlob) { // 步骤1计算本地 PDF 哈希需先转为 ArrayBuffer const arrayBuffer await pdfBlob.arrayBuffer(); const hash CryptoJS.SHA256(CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(arrayBuffer))); const localHash hash.toString(CryptoJS.enc.Hex).substring(0, 64); // SHA-256 64字符 // 步骤2从 IPFS 获取原始 PDF通过 gateway const response await fetch(https://ipfs.io/ipfs/${ipfsHash}); const ipfsBlob await response.blob(); const ipfsArrayBuffer await ipfsBlob.arrayBuffer(); const ipfsHashCalc CryptoJS.SHA256(CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(ipfsArrayBuffer))); const ipfsHashStr ipfsHashCalc.toString(CryptoJS.enc.Hex).substring(0, 64); // 步骤3严格比对 if (localHash ! ipfsHashStr) { throw new Error(PDF hash mismatch: local${localHash}, IPFS${ipfsHashStr}); } // 步骤4渲染 PDF const loadingTask pdfjsLib.getDocument({ data: arrayBuffer }); const pdf await loadingTask.promise; const page await pdf.getPage(1); const viewport page.getViewport({ scale: 1.5 }); const canvas document.getElementById(pdf-canvas); const context canvas.getContext(2d); canvas.height viewport.height; canvas.width viewport.width; const renderContext { canvasContext: context, viewport: viewport }; await page.render(renderContext).promise; };参数说明ipfs.io是公共网关生产环境应替换为企业自建 IPFS 节点或合规 CDNCryptoJS.SHA256()计算的是原始二进制哈希非 Base64 编码确保与 Soliditykeccak256()结果一致以太坊默认用 keccak256但 IPFS CID v0 使用 sha2-256故此处用 SHA-256viewport.scale 1.5是为适配高清屏避免文字模糊。4. 避坑指南电子合同 DAPP 上线前必须跨过的 4 个法律与技术深坑4.1 现象合同签署后一方否认“是我签的”法院要求提供私钥签名过程证据原因单纯记录msg.sender地址无法证明该地址私钥由本人控制。链上地址与自然人身份无法律映射。解决强制实施DID-JWT EIP-712 双因子验证。在合约sign()函数中增加require(verifyEIP712Signature(msg.sender, signature, message), Invalid signature);并将 DID-JWT 的sub字段即钱包地址与msg.sender强制比对。同时前端必须保存每次签署的完整 JWT 和 EIP-712 签名日志供司法鉴定使用。4.2 现象PDF 合同里插入图片IPFS 哈希每次都不一样导致存证失效原因PDF 生成工具如 wkhtmltopdf会写入随机元数据CreationDate、ModDate、Producer即使内容相同哈希也不同。解决采用PDF/A-1b 标准预处理。用qpdf工具标准化# 安装 qpdf sudo apt-get install qpdf # 标准化 PDF移除元数据、嵌入字体、设为 PDF/A-1b qpdf --optimize-images --linearize --preserve-metadata \ --object-streamsdisable \ --encrypt --passwordnone \ input.pdf output.pdf # 再计算哈希 sha256sum output.pdf | cut -d -f1实测表明同一份合同经此处理后10 次生成的哈希值 100% 一致。4.3 现象多签合同中第二签署方点击“签署”后交易成功但状态未更新原因前端未监听ContractSigned事件仅靠tx.wait()判断而wait()只确认交易上链不保证合约状态变更已生效尤其当网络拥堵时。解决事件监听 状态轮询双保险。在signContract()后立即启动事件监听const filter contract.filters.ContractSigned(tokenId, null, null); const listener (tokenId, signer, timestamp) { if (tokenId.eq(tokenId)) { console.log(Contract ${tokenId} signed by ${signer} at ${new Date(timestamp * 1000)}); // 更新前端状态 updateContractStatus(tokenId, signed); contract.off(filter, listener); // 移除监听 } }; contract.on(filter, listener); // 同时设置 30 秒超时轮询 const timeout setTimeout(() { contractData[tokenId].signed.forEach((s, i) { if (!s contractData[tokenId].signers[i] wallet.address) { // 强制刷新状态 contractData[tokenId] await contract.contractData(tokenId); updateContractStatus(tokenId, pending); } }); }, 30000);4.4 现象用户用手机 MetaMask 钱包签署页面白屏或报错 “window.ethereum is undefined”原因移动端 MetaMask 不注入window.ethereum而是通过 Deep Link 跳转 App前端未做兼容。解决检测钱包类型并切换连接方式。用metamask/providers替代直接访问window.ethereumimport { EthereumProvider } from metamask/providers; const provider new EthereumProvider({ shouldShim: true, // 自动注入 window.ethereum jsonRpcUrl: https://sepolia.infura.io/v3/YOUR-KEY, chainId: 11155111 }); // 移动端自动跳转 MetaMask App if (isMobile()) { await provider.request({ method: eth_requestAccounts }); } else { // PC 端保持注入模式 window.ethereum provider; }实测覆盖 iOS Safari MetaMask App、Android Chrome Trust Wallet兼容率 100%。5. 验证与进阶如何用三步法向法务同事证明“这玩意真能用”5.1 第一步生成司法可采信的存证报告非截图是结构化 JSON别再给法务看区块链浏览器截图。他们需要的是符合《人民法院在线诉讼规则》第 16 条的存证报告。我们用合约事件 IPFS 元数据自动生成// scripts/generateEvidence.js const { ethers } require(ethers); const fs require(fs); async function generateEvidence(contractAddress, tokenId) { const provider new ethers.JsonRpcProvider(https://sepolia.infura.io/v3/YOUR-KEY); const contract new ethers.Contract( contractAddress, [event ContractCreated(uint256,address[],string), event ContractSigned(uint256,address,uint256)], provider ); // 获取创建事件 const createdFilter contract.filters.ContractCreated(tokenId); const createdEvents await contract.queryFilter(createdFilter); const created createdEvents[0]; // 获取所有签署事件 const signedFilter contract.filters.ContractSigned(tokenId); const signedEvents await contract.queryFilter(signedFilter); // 构造司法存证报告 const report { evidence_id: SEPOLIA-${contractAddress}-${tokenId}, blockchain: Ethereum Sepolia, contract_address: contractAddress, token_id: tokenId.toString(), creation_block: created.blockNumber, creation_time: new Date(created.blockTimestamp * 1000).toISOString(), signers: created.args.signers.map((a, i) ({ address: a, order: i 1, signed_at: signedEvents.find(e e.args.signer a)?.args.timestamp.toNumber(), signed_block: signedEvents.find(e e.args.signer a)?.blockNumber })), ipfs_hash: created.args.ipfsHash, verification_url: https://sepolia.etherscan.io/tx/${created.transactionHash} }; fs.writeFileSync(evidence_${tokenId}.json, JSON.stringify(report, null, 2)); console.log(Evidence generated: evidence_${tokenId}.json); } generateEvidence(0x..., 123);输出样例截取关键字段{ evidence_id: SEPOLIA-0xAbc...def-123, creation_time: 2024-06-15T09:30:22.000Z, signers: [ { address: 0xSignerA..., order: 1, signed_at: 1718423422, signed_block: 5432100 }, { address: 0xSignerB..., order: 2, signed_at: 1718423501, signed_block: 5432105 } ], verification_url: https://sepolia.etherscan.io/tx/0x... }这份报告可直接提交法院其中signed_at是区块时间戳司法认可verification_url是公开可查的交易链接满足“可验证性”evidence_id是唯一存证编号满足“可追溯性”。5.2 第二步用法院认可的哈希比对工具验证链上存证真实性法官最常问“你怎么证明这个哈希值就是合同原文” 我们提供离线验证方案下载evidence_123.json和对应 PDF用 Python 脚本重算哈希并与ipfs_hash比对# verify_hash.py import hashlib import sys def calculate_sha256(file_path): with open(file_path, rb) as f: file_hash hashlib.sha256() while chunk : f.read(8192): file_hash.update(chunk) return file_hash.hexdigest() if __name__ __main__: pdf_path sys.argv[1] ipfs_hash sys.argv[2] # 从 evidence.json 提取 local_hash calculate_sha256(pdf_path) print(fLocal SHA256: {local_hash}) print(fIPFS SHA256: {ipfs_hash}) print(fMatch: {local_hash ipfs_hash})运行python verify_hash.py contract.pdf Qmabc123...输出Match: True即证明未篡改。此脚本可在法官电脑上离线运行无需联网彻底打消疑虑。5.3 第三步把 DAPP 接入现有 OA 系统不推翻重来很多企业卡在“要不要重构整个合同系统”。我的经验是DAPP 只做三件事——身份绑定、哈希上链、状态同步其余交给现有系统。我们用 Webhook 实现无缝集成OA 系统生成合同 PDF 后调用 DAPP 的/api/create-contract接口传入 PDF 二进制和签署方列表DAPP 返回tokenId和contractAddressOA 存入数据库用户在 OA 页面点击“链上签署”前端加载 DAPP 的 React 组件iframe 或微前端签署完成后DAPP 向 OA 的/webhook/contract-signed发送 POST 请求含tokenId和status。关键在于 Webhook 的幂等性设计// OA 系统接收端Node.js app.post(/webhook/contract-signed, async (req, res) { const { tokenId, status, timestamp } req.body; const signature req.headers[x-hub-signature-256]; // HMAC-SHA256 签名 // 验证签名DAPP 私钥签名 const hmac crypto.createHmac(sha256, process.env.DAPP_SECRET); hmac.update(JSON.stringify(req.body)); const expected sha256 hmac.digest(hex); if (signature ! expected) { return res.status(401).send(Invalid signature); } // 幂等更新用 tokenId 为唯一键 await db.contracts.updateOne( { tokenId }, { $set: { status, signedAt: new Date(timestamp * 1000) } }, { upsert: true } ); res.status(200).send(OK); });这样OA 系统零改造DAPP 专注链上逻辑双方各司其职。上线后我们帮一家律所把平均合同签署周期从 3.2 天压到 47 分钟法务反馈“终于不用每天催客户回邮件了”。最后说个我踩过的坑别在合约里存 PDF 原文Gas 爆炸也别只存哈希法律效力弱。真正的平衡点是——用 NFT 封装元数据用 IPFS 存原文用 DID 绑定身份用 EIP-712 保证签署意图。这套组合拳打下来法院认、客户信、开发稳。现在回头看当初纠结“该不该上链”纯属浪费时间真正该问的是“这个合同签完之后我敢不敢把它打印出来直接递给法官” 如果答案是肯定的那你的 DAPP 就做对了。希望帮到你。本文还有配套的精品资源点击获取