智能合约实现电子合同签署:从信任模型到DAPP落地与Gas优化
简介面向区块链初学者的去中心化电子合同签署项目基于Truffle框架与Node.js运行环境覆盖合同发布、双方签署与再确认、管理员审核、中止申请等完整业务闭环。项目代码中融入身份证号合法性、重复操作、合同序列号存在性等基础校验适合作为毕业设计、课程设计或工程实训的参考原型。资源共三十六个文件以页面脚本、逻辑脚本、智能合约为核心辅以样式表、配置文档及字体图片素材压缩包仅五百八十四KB体积小巧轻量且便于部署调试。目前已有八十九人学习下载。通过阅读源码与目录结构可快速掌握智能合约编写、前端交互与部署配置的配合方式理解区块链电子合同从创建到审核的完整实现思路是快速上手去中心化应用开发的实用素材。1. 电子合同签署用DAPP落地先拆信任模型把合同文件的哈希上链、原文留在链下仍然是电子合同上链最常见的工程形态。这不是妥协而是区块链在存储成本与可验证性之间的理性解耦。基于DAPP实现电子合同签署核心是把“谁签的、签的哪份文件、什么时候签、签到了哪一步”这四个追问交给链上状态机去回答DAPP的前端负责业务编排钱包负责签名动作合约负责存证与校验。下面按以太坊系技术栈的常规实现路径展开覆盖合约数据模型、部署与前端联调最后给出Gas优化与验证技巧。2. 电子合同 DAPP 的合约层设计数据模型与身份锚定2.1 为什么电子合同适合 DAPP 而不是 Web2 后端电子合同的核心属性是签字的不可抵赖性这从根子上对信任模型有要求。Web2 系统里业务数据库与操作日志都由服务方单方持有就算把全量审计日志导出也只能证明记录自洽无法证明历史记录没有被后台改写。DAPP 里的合约状态和事件日志是公开的服务方失去了对历史数据的最终解释权纠纷发生时的举证成本显著降低。但这不是说电子合同的所有逻辑都该上链。链上每多一个存储变量就多一份 Gas 固定成本和合约攻击面。常见做法是链上只保留合同哈希、签署人列表、签署状态与时间戳合同 PDF 本身放在对象存储或加密文件系统正文完整性由哈希做二次校验。这个职责切分决定了合约体积和业务弹性之间的平衡点。2.2 合约数据结构用三个状态覆盖签署生命周期电子合同最小数据模型并不复杂。一个 struct 加两个 mapping 就能支撑完整的签署流程字段类型说明contractIdbytes32业务方生成的全局唯一标识fileHashbytes32合同文件 SHA-256 摘要initiatoraddress合同发起方地址signersaddress[]签署人地址列表顺序即签署顺序statusenumDraft / Pending / CompletedcreatedAtuint256合同创建时间戳三个状态并不是应付差事Draft 阶段允许发起方修改签署人列表Pending 阶段冻结一切编辑只允许签名操作当最后一个签名人完成签名合约自动把状态推向 Completed。这个状态机必须由签署函数内部驱动不能提供任何外部修改入口否则会产生审计漏洞。signers 字段用数组而不是 mapping是刻意的选择。数组同时保留了签署顺序与签署人集合两个信息后者在审计时需要还原“谁先签、谁后签”。用 mapping 做签名人集合谁先谁后就只能靠事件日志推演合约状态本身不提供顺序信息。这里有一个工程折中数组的索引查找是 O(n)当签名人数量增长时签名函数的 Gas 会线性上涨。电子合同单份合同的签名人通常不会超过两位数O(n) 的开销可以接受。说清楚这个下面是合约骨架代码// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; contract ContractSigning { enum ContractStatus { Draft, Pending, Completed } struct Contract { bytes32 fileHash; address initiator; address[] signers; ContractStatus status; uint256 createdAt; } mapping(bytes32 Contract) private contracts; mapping(bytes32 mapping(address bool)) private signed; mapping(bytes32 uint256) private signCount; event ContractCreated(bytes32 indexed contractId, address indexed initiator); event ContractSigned(bytes32 indexed contractId, address indexed signer); event ContractCompleted(bytes32 indexed contractId); function getContract(bytes32 contractId) external view returns ( bytes32 fileHash, address initiator, ContractStatus status, uint256 createdAt ) { Contract storage c contracts[contractId]; return (c.fileHash, c.initiator, c.status, c.createdAt); } function getSigners(bytes32 contractId) external view returns (address[] memory) { return contracts[contractId].signers; } }这段代码仅做存储与读取。业务侧需要的查询比如“某个签名人签了没有”可以通过 signed mapping 的公共访问器实现也可以在 getSigners 返回后由前端做存在性判断。我倾向于在合约里再暴露一个只读数组而不是让前端直接遍历事件日志原因是链上节点的 RPC 查询延迟远低于前端自行扫描历史日志用户体验和可靠性都更好。2.3 身份锚定为什么签名主体是地址而不是身份证号如果把用户身份证号或手机号直接放进合约等于把身份信息刻在公开账本上。合适的设计是把“签名主体”定义为一个钱包地址地址到真实身份之间的对应关系放在链下业务数据库只做映射不写链上。这种设计有两个层面的收益。隐私层面链上公开数据中不包含任何证件信息或者手机号审计方只能看到一串地址工程层面实名的绳子断了可以重新绑定而链上合约不需要部署第二个版本。常见的实现路径是业务系统在建立合同与关联人时走一次实名认证把用户标识与钱包地址存入一张映射表之后发起签署直接引用该地址。这个映射表的最低要求是支持按时间范围导出供后续审计核对签名事件。提示地址可以按需更换但私钥一旦丢失历史签署记录就无法由业务系统找回。企业通常会让用户开启硬件钱包或分段密钥备份部分电子合同 DAPP 甚至把签名动作收敛到受信任的签名服务地址用户在前端点击确认后由后端持钥服务完成签署牺牲少量去中心化换可运维性。3. 签署逻辑的智能合约实现从创建到完成的函数路径3.1 创建合同的入口函数与输入约束创建合同是所有操作的起点。函数入参是 contractId、fileHash 和 signers 数组。发起人不传直接用 msg.sender 取。这样任何钱包地址都可以创建合同但只能以自己名义创建伪造发起方的通道被合约自身堵住。function createContract( bytes32 contractId, bytes32 fileHash, address[] calldata signers ) external returns (bool) { require(contracts[contractId].createdAt 0, contract id exists); require(signers.length 0 signers.length 10, invalid signer count); require(fileHash ! bytes32(0), invalid file hash); for (uint256 i 0; i signers.length; i) { require(signers[i] ! address(0), zero address); for (uint256 j i 1; j signers.length; j) { require(signers[i] ! signers[j], duplicate signer); } } Contract storage c contracts[contractId]; c.fileHash fileHash; c.initiator msg.sender; c.signers signers; c.status ContractStatus.Draft; c.createdAt block.timestamp; emit ContractCreated(contractId, msg.sender); return true; }入口校验有三个点需要展开。第一contractId 不能重复。这里用 createdAt 是否为零做存在性判断比单独维护一个 bool 标志省一条存储槽。这个判断在链上时间戳为零的极端情况下会失效实际网络里不会发生可以放心用。第二signers 数组上限 10。电子合同的签署方通常是个位数设置上限既有安全意义也有 Gas 意义防止调用者用一个上千长度的数组在一笔交易里撑爆区块 Gas间接保护全网的打包效率。第三内层循环去掉重复签名人。如果同一地址出现两次签一次后 signCount 永远追不平 signers.length整个合同状态就会卡在 Pending 直到链上治理介入。这种 bug 不常发生但一旦发生恢复成本远比在入口多做一次双层循环高。3.1.1 不要把业务流转号单独塞进合约存储很多业务希望链上同时看到“合同编号”“订单号”“批次号”。这些字段的共性是都被外部业务流程引用一旦拼进合约存储后续字段改名就要触发一次合约升级。电子合同行业的通行做法是把业务编号拼进 contractId 的生成逻辑比如keccak256(abi.encodePacked(tenantId, processId))既保留关联性又不给合约膨胀存储结构。3.2 签署函数的执行路径与安全检查顺序signContract 是整份合约的流量中心。每次调用都必须回答四个问题合同是否存在、流程是否已完成、该地址是否没签过、该地址是否在签名人名单中。这四个检查的顺序直接决定恶意调用时的 Gas 边界function signContract(bytes32 contractId) external returns (bool) { Contract storage c contracts[contractId]; require(c.createdAt ! 0, contract not found); require(c.status ! ContractStatus.Completed, contract completed); require(!signed[contractId][msg.sender], already signed); bool isSigner false; for (uint256 i 0; i c.signers.length; i) { if (c.signers[i] msg.sender) { isSigner true; break; } } require(isSigner, not a signer); signed[contractId][msg.sender] true; signCount[contractId] signCount[contractId] 1; if (signCount[contractId] c.signers.length) { c.status ContractStatus.Completed; emit ContractCompleted(contractId); } else { c.status ContractStatus.Pending; } emit ContractSigned(contractId, msg.sender); return true; }前三个 require 都是 O(1) 的 mapping 读取Gas 消耗很低拿来拦截大多数无意义请求。真正昂贵的是查询 signers 数组的循环所以把它放在最后非签名人地址最多只会触发三次 SLOAD 就被拒绝恶意调用想借请求打挂 RPC 节点攻击性价比很低。状态切换的逻辑要点是最后一个签名人完成签名时状态从 Draft 或 Pending 直接置为 Completed同时发出 ContractCompleted 事件没到最后一个则停在 Pending。注意合约没有任何“强制完成”函数。业务上确实会遇到发起方想单方面终止合同的情况正确做法是链下标记作废生成一条新的终止记录而不是让某个人在链上直接改状态。3.2.1 签名确认前的最后一道防线签名的不可抵赖性最终依赖私钥签名结果。链上 signed mapping 的值由 EVM 内置的 CALLER 机制写入不接受任何参数伪造用户钱包在弹窗确认时显示给用户查看的是合约方法名和参数摘要而不是原始十六进制编码。我建议在 DAPP 前端的签名确认页展示“合同编号 文件哈希 当前状态”让用户对链上即将发生的状态变化有直观认知。这一步不属于智能合约却是在正式环境里最容易成为投诉入口的地方。3.3 存证查询接口给审计方一条只读链路审计方通常不具备 Solidity 技能也未必想连 RPC 解析事件。为了让审计变成一次普通接口调用合约应该暴露一个语义化的只读接口function verifyContract( bytes32 contractId, bytes32 fileHash, address signer ) external view returns ( bool fileMatch, bool contractCompleted, bool signerSigned ) { Contract storage c contracts[contractId]; fileMatch (c.fileHash fileHash); contractCompleted (c.status ContractStatus.Completed); signerSigned signed[contractId][signer]; }一次调用返回三个独立布尔值分别对应“文件有没有变”“流程走完没有”“这个人签没签”。三个问题都回答到审计方不需要理解合约实现。如果业务上还需要指定时间戳范围内的查询那就改用第 5 章里的事件日志方案链上会有完整的信息。4. 从本地部署到前端调通电子合同签署DAPP的完整链路4.1 用 Hardhat 初始化部署环境要跑通智能合约第一步是本地环境。Hardhat 比 Truffle 更方便的地方是自带本地链还能在测试中直接拿到多个签名账户不用额外起 Ganache。mkdir dapp-contract-signing cd dapp-contract-signing npm init -y npm install --save-dev hardhat nomicfoundation/hardhat-ethers nomicfoundation/hardhat-chai-matchers chai ethers npx hardhat init初始化交互会询问要不要创建示例项目直接选 JavaScript 模板。随后把 contracts/ 目录下的示例代码替换成 ContractSigning.sol。对应的 hardhat.config.jsrequire(nomicfoundation/hardhat-ethers); require(nomicfoundation/hardhat-chai-matchers); module.exports { solidity: { version: 0.8.18, settings: { optimizer: { enabled: true, runs: 200 } } }, networks: { localhost: { url: http://127.0.0.1:8545 } } };solidity 配置里比较关键的是 optimizer 和 runs。runs200 表示代码为链上高频调用优化。runs 值越小部署时代码体积越小但每次调用 Gas 越高runs 值越大部署消耗越高调用越便宜。电子合同签署不是超高频 DEX 玩法runs200 比默认的 2000 更省部署成本。下面写部署脚本 scripts/deploy.jsconst hre require(hardhat); async function main() { const [deployer] await hre.ethers.getSigners(); console.log(Deployer:, deployer.address); const Factory await hre.ethers.getContractFactory(ContractSigning); const contract await Factory.deploy(); await contract.waitForDeployment(); console.log(Contract address:, await contract.getAddress()); } main().catch((err) { console.error(err); process.exitCode 1; });这里用的是 ethers v6 的 waitForDeployment()老教程里常见的 deployed() 方法在 v6 中已经废弃。跑部署命令之前记得先起一个本地节点npx hardhat node新开一个终端执行npx hardhat run scripts/deploy.js --network localhost4.2 前端 DAPP 调用合约的最小链路浏览器端的前端调用链路是页面先拿到 Provider然后获取 Signer构造合约实例再发起交易。下面是最小可运行代码import { ethers } from ethers; if (!window.ethereum) { // 这里应该渲染一个“请安装浏览器钱包”的阻断页 throw new Error(no wallet provider); } const provider new ethers.BrowserProvider(window.ethereum); const signer await provider.getSigner(); const contract new ethers.Contract( 0x你的合约地址, ContractSigningABI, signer ); // 计算合同文件的 keccak256 哈希 const fileBuffer await fetch(https://cdn.example.com/contracts/2024/001.pdf) .then((r) r.arrayBuffer()); const fileHash ethers.keccak256(new Uint8Array(fileBuffer)); // 发起合同 const contractId ethers.keccak256( ethers.toUtf8Bytes(tenant-001:process-2024-001) ); const createTx await contract.createContract( contractId, fileHash, [0x签名人A, 0x签名人B] ); await createTx.wait(); // 签署 const signTx await contract.signContract(contractId); await signTx.wait();这里有两个容易踩的坑必须点出来。第一PDF 的哈希必须用文件二进制计算。我把 fetch 的结果先转成 arrayBuffer再传进 keccak256。如果你直接对文本字符串做哈希得到的结果和链上的 fileHash 永远对不上验证接口会返回 false。第二transaction.wait() 不能省。createContract 和 signContract 返回的 tx 对象只代表这笔交易已经被钱包接受出块和打包是异步的。忽略 wait() 的话页面可能在交易失败时仍然提示成功这是电子合同系统里最不能接受的体验错误。4.3 端到端测试覆盖签名主路径自动化测试是部署前的最后一道防线。Hardhat 测试代码里可以直接拿到带有余额的测试地址不需要额外 mock。写一份覆盖主路径和一条失败路径的用例const { expect } require(chai); const { ethers } require(hardhat); describe(ContractSigning, function () { it(should complete signing flow, async function () { const [initiator, signerA, signerB] await ethers.getSigners(); const Contract await ethers.getContractFactory(ContractSigning); const contract await Contract.deploy(); const fileHash ethers.keccak256(ethers.toUtf8Bytes(contract-pdf-v1)); const contractId ethers.keccak256( ethers.toUtf8Bytes(tenant-001:process-2024-001) ); await contract.connect(initiator).createContract( contractId, fileHash, [signerA.address, signerB.address] ); // 签名人 A 对不存在的合同签名必须被拒绝 await expect( contract.connect(signerA).signContract( ethers.keccak256(ethers.toUtf8Bytes(nonexistent)) ) ).to.be.revertedWith(contract not found); await contract.connect(signerA).signContract(contractId); const afterFirst await contract.getContract(contractId); expect(afterFirst.status).to.equal(1); // Pending await contract.connect(signerB).signContract(contractId); const afterSecond await contract.getContract(contractId); expect(afterSecond.status).to.equal(2); // Completed const [fileMatch, completed, signerSigned] await contract.verifyContract( contractId, fileHash, signerA.address ); expect(fileMatch).to.be.true; expect(completed).to.be.true; expect(signerSigned).to.be.true; }); });运行npx hardhat test测试同样覆盖了状态枚举的两种取值。这里不使用字符串比较而用数字是因为枚举在链上的表示就是整数返回给前端也会被编码成数字。状态 1 对应 Pending状态 2 对应 Completed具体映射要看合约里枚举定义顺序。5. 链上验证技巧与 Gas 优化电子合同签署的最后一公里5.1 白名单校验的映射化改造第一节的签署函数今天看起来 Gas 并不敏感但如果你打算把它嵌入到企业级并发场景signers 数组的遍历会变成热点。优化方式在合约里增加一个映射 isSigner[contractId][addr]createContract 时写入signContract 时直接读开销恒定一次 SLOAD。mapping(bytes32 mapping(address bool)) private isSigner; function _verifySigner( bytes32 contractId, address account ) private view returns (bool) { return isSigner[contractId][account]; }初始化处同步写入for (uint256 i 0; i signers.length; i 1) { isSigner[contractId][signers[i]] true; }这个改造把 signContract 的 Gas 消耗从随签名人数量线性增长变成恒定开销。创建合同的 Gas 会多约 2 万但电子合同创建频率远低于签署频率整体是净收益。5.2 事件日志才是审计轨迹的主干如果你想在纠纷发生后回答“这份合同在哪个区块完成的”不要只依赖合约状态。ContractCompleted 事件本身携带了 contractId 和区块号。用 RPC 接口 eth_getLogs 就能把一批合同的事件全部拉出来不需要逐个调用合约curl -X POST http://127.0.0.1:8545 \ -H Content-Type: application/json \ --data { jsonrpc: 2.0, method: eth_getLogs, params: [{ fromBlock: 0x0, toBlock: latest, address: 0x你的合约地址, topics: [0x事件签名哈希] }], id: 1 }事件签名哈希可以从部署后的 ABI 里取到也可以自己用 ethers 计算const eventTopic ethers.id(ContractSigned(bytes32,address));用事件的 topic0 过滤得到的结果天然附带出块时间、区块号、交易哈希。相比直接读合约状态日志还有一个好处它是追加型的任何合约升级都不能抹除历史日志。5.3 文件哈希后端背书防止前端被篡改纯前端计算 PDF 哈希再上链在遭受 XSS 或者浏览器扩展劫持时攻击者可以把伪造文件哈希送到合约上。为防止这一点建议把“计算哈希 签名”放到业务后端做后端启动时加载自己的 ECDSA 私钥对外暴露一个文件哈希签名接口请求参数是文件内容字节数组返回{ fileHash, signature }前端拿到后把两个值作为元数据一并传到合约合约内增加对后端签名地址的校验require(ECDSA.recover(hash, sig) backendAddress)。这样即使前端环境被攻破攻击者也无法伪造后端私钥的签名伪造哈希就上不了链。后端签名接口本身需要做频率限制并记录访问日志用于事后的安全审计。验证环节的最终建议是不要只在链上存一个哈希完事完整的合同证据链分成三段——PDF 原文存企业存储、SHA-256 结果上链、后端签名映射留审计库。三条互相校验任何一段被删改另外两边都能立刻暴露出来。本文还有配套的精品资源点击获取