
1. 实验背景与核心目标从“记账本”到“智能合约”这次实验七我们不再停留在区块链的“记账”层面而是真正动手去触碰它的灵魂——智能合约。很多同学在学完前几个实验了解了区块结构、哈希、共识机制后可能会觉得区块链就是一个分布式的、不可篡改的数据库这没错但远不止于此。智能合约的出现才真正让区块链从一个“账本”进化成了一个“可编程的价值网络”。简单来说你可以把智能合约想象成一个自动售货机。你投入特定的“代币”比如以太币选择商品编号调用合约的特定函数机器就会自动执行预设的逻辑验证你的支付、扣除余额、弹出商品并记录下这次交易。整个过程无需售货员中心化机构介入代码即法律规则公开透明且不可篡改。实验七的目标就是让我们亲手编写、部署并调用这样一个“自动售货机”理解其从代码到链上服务的完整生命周期。为什么这个实验如此重要因为在当前主流的区块链应用生态尤其是以太坊及其兼容链上智能合约是构建去中心化应用DApp的基石。无论是DeFi去中心化金融中的借贷、交易还是NFT非同质化代币的铸造与流转其核心业务逻辑都封装在一个个智能合约中。理解智能合约是理解整个Web3世界运作方式的关键一步。本次实验我们将聚焦于使用Solidity语言编写一个简单的“存证”合约模拟现实世界中合同、版权或重要信息的链上存证场景。2. 实验环境搭建与工具链选型工欲善其事必先利其器。区块链开发与传统Web开发的环境有显著不同我们需要一套专门适配的工具。2.1 开发框架为什么选择Hardhat市面上智能合约的开发框架有很多比如Truffle、BrowniePython、Foundry。我们选择Hardhat主要基于以下几点考量开发者体验极佳Hardhat基于Node.js拥有丰富的插件生态其本地网络启动速度极快内置了强大的测试运行器和调试工具特别是console.log功能能让Solidity调试像JavaScript一样直观这对初学者排查逻辑错误至关重要。灵活性与现代性Hardhat不强制使用特定的目录结构或测试框架给予开发者更大的自由度。它也更受新兴项目和资深开发者的青睐社区活跃迭代迅速。TypeScript原生支持虽然本次实验我们用JavaScript但Hardhat对TypeScript的支持非常好为未来开发大型、复杂的DApp打下了基础。注意安装Node.js版本建议在16.x或18.x LTS版本避免使用过新或过旧的版本导致依赖兼容性问题。2.2 合约编写与测试环境配置让我们一步步搭建环境。首先创建一个新的项目目录并初始化。mkdir blockchain-lab7 cd blockchain-lab7 npm init -y接下来安装Hardhat。这里我们采用本地项目安装而非全局安装以保证项目依赖的纯净和可复现性。npm install --save-dev hardhat安装完成后运行npx hardhat来初始化一个新的Hardhat项目。在交互式命令行中我们选择“Create a JavaScript project”并同意后续的选项安装示例依赖、添加.gitignore等。这个过程会自动为我们生成一个基础的项目结构。关键目录和文件说明contracts/: 存放Solidity智能合约源文件.sol。scripts/: 存放部署脚本或与其他合约交互的脚本。test/: 存放测试用例文件。hardhat.config.js: Hardhat的主配置文件可以在这里配置网络、编译器版本等。接下来我们需要安装编写Solidity合约所需的依赖主要是OpenZeppelin合约库它提供了经过严格审计的、标准化的安全合约组件如ERC20代币标准实现能极大提升我们开发的安全性和效率。npm install --save-dev openzeppelin/contracts同时为了在测试和脚本中能够方便地与部署的合约进行交互我们还需要安装以太坊Web3库的封装——ethers.jsHardhat已经将其集成在hardhat包中但为了编写脚本我们通常也会显式安装。npm install --save-dev nomiclabs/hardhat-ethers ethers最后在hardhat.config.js中我们需要引入hardhat-ethers插件这样我们才能在任务和脚本中使用ethers。require(nomiclabs/hardhat-ethers); // ... 其他配置 module.exports { solidity: 0.8.19, // 指定Solidity编译器版本需与合约中声明的版本匹配 networks: { // 可以在这里配置测试网或主网本地开发使用默认的hardhat网络即可 } };至此一个最小化但功能完整的智能合约开发环境就搭建好了。这个环境包含了编写、编译、测试、部署合约所需的一切。3. 智能合约开发实战构建一个链上存证系统现在进入核心环节——编写智能合约。我们设计一个名为DocumentNotary的存证合约。它的核心功能很简单允许用户提交一个文档的哈希值例如对一份PDF文件进行SHA256计算得到的结果和描述信息将其永久记录在区块链上并可以随时根据ID查询存证记录。3.1 合约数据结构设计在Solidity中我们首先需要定义存储数据的状态变量和结构体。// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract DocumentNotary { // 定义存证记录的结构体 struct Notarization { address owner; // 存证提交者地址 string documentHash; // 文档哈希值 string description; // 描述信息 uint256 timestamp; // 存证时间戳 } // 状态变量 Notarization[] private _notarizations; // 存证记录数组 mapping(address uint256[]) private _ownerToIds; // 地址到其存证ID列表的映射 // 事件用于前端监听存证成功 event DocumentNotarized( uint256 indexed id, address indexed owner, string documentHash, uint256 timestamp ); }设计解析结构体Notarization这是存证记录的核心数据单元。包含所有者、文档哈希、描述和时间戳。这里存储的是文档的哈希而非文档本身。这是区块链应用的典型模式链上存储指纹哈希链下存储原文件。既保护了隐私和版权原文件不公开又利用哈希的唯一性和不可逆性实现了防篡改验证。动态数组_notarizations所有存证记录按顺序存储在此数组索引自然成为每个存证的唯一ID。映射_ownerToIds这是一个非常重要的优化设计。如果只有数组要查询某个地址的所有存证需要遍历整个数组Gas消耗会随着数据量增长而急剧增加成本不可接受。通过这个映射我们可以实现O(1)时间复杂度的查询直接根据地址获取其存证ID列表这是典型的空间换时间策略在合约设计中非常常见。事件DocumentNotarized事件是合约与外部世界特别是前端应用通信的廉价方式。当存证成功后我们触发这个事件。前端DApp可以监听此事件实时更新UI而无需不断轮询查询合约状态。事件日志虽然也存储在链上但比直接修改状态变量便宜得多。3.2 核心函数实现与安全考量接下来我们实现两个核心函数notarizeDocument提交存证和getNotarization查询存证。/** * dev 提交文档存证 * param documentHash 文档的哈希值如SHA256 * param description 存证描述 * return 新创建的存证记录ID */ function notarizeDocument(string memory documentHash, string memory description) external returns (uint256) { // 输入验证确保文档哈希非空 require(bytes(documentHash).length 0, Document hash cannot be empty); uint256 newId _notarizations.length; _notarizations.push(Notarization({ owner: msg.sender, documentHash: documentHash, description: description, timestamp: block.timestamp })); // 更新所有者到ID的映射 _ownerToIds[msg.sender].push(newId); // 触发事件 emit DocumentNotarized(newId, msg.sender, documentHash, block.timestamp); return newId; } /** * dev 根据ID查询存证记录公开只读函数 * param id 存证记录ID * return 存证记录的所有字段 */ function getNotarization(uint256 id) external view returns ( address owner, string memory documentHash, string memory description, uint256 timestamp ) { require(id _notarizations.length, Notarization does not exist); Notarization storage record _notarizations[id]; return (record.owner, record.documentHash, record.description, record.timestamp); } /** * dev 查询指定地址的所有存证ID公开只读函数 * param owner 要查询的地址 * return 该地址拥有的存证ID数组 */ function getNotarizationsByOwner(address owner) external view returns (uint256[] memory) { return _ownerToIds[owner]; }安全与设计要点分析msg.sender与block.timestampmsg.sender是Solidity全局变量代表当前函数调用者的地址它是交易签名验证后自动得出的无法伪造用于准确记录存证所有者。block.timestamp是当前区块的时间戳由矿工/验证者设定虽然有一定误差约几秒到十几秒但对于存证场景已足够它提供了不可篡改的时间证明。输入验证require在notarizeDocument中我们使用require检查文档哈希是否为空。这是智能合约安全的第一道防线防止无效或恶意数据消耗Gas并污染存储。在getNotarization中检查ID是否有效防止数组越界访问。函数可见性notarizeDocument被标记为external意味着它只能从合约外部被调用通过交易这会略微节省一些Gas。getNotarization和getNotarizationsByOwner被标记为view表示它们只读取状态而不修改调用这些函数是免费的在本地节点执行时无需发送交易和支付Gas。Gas成本考虑存储操作_notarizations.push和_ownerToIds[].push是合约中最昂贵的操作。我们设计的结构体字段使用了string类型它属于动态大小类型存储成本比固定大小的bytes32更高。如果文档哈希确定是SHA25632字节可以优化为bytes32 documentHash能显著降低存储Gas。这里为了演示通用性保留了string。4. 合约的编译、部署与本地测试代码写完了但它还只是文本。我们需要将其编译成EVM以太坊虚拟机字节码部署到一个区块链网络上才能运行。4.1 编译与本地网络启动Hardhat使得编译非常简单。在项目根目录下运行npx hardhat compile如果合约语法正确你会在终端看到“Compilation finished successfully”的提示并在项目根目录下生成一个artifacts/文件夹里面包含了合约的ABI应用二进制接口和字节码这是与合约交互的桥梁。接下来我们需要一个区块链环境来部署合约。Hardhat内置了一个本地开发网络它会在后台启动一个独立的、仅存在于内存中的以太坊节点非常适合开发和测试。npx hardhat node这个命令会启动一个本地JSON-RPC服务器默认在http://127.0.0.1:8545并打印出20个预充值了测试ETH的账户地址和私钥。请务必注意这些私钥仅用于开发测试绝对不要用于主网或任何有价值资产的测试网。4.2 编写部署脚本在scripts/目录下我们创建一个部署脚本deploy.js。const hre require(hardhat); async function main() { // 1. 获取部署合约的工厂Factory const DocumentNotary await hre.ethers.getContractFactory(DocumentNotary); // 2. 部署合约 // 这里会使用hardhat node提供的第一个账户进行部署 const documentNotary await DocumentNotary.deploy(); // 3. 等待合约部署交易被确认挖矿 await documentNotary.deployed(); // 4. 打印合约地址 console.log(DocumentNotary合约已部署至地址:, documentNotary.address); } // 执行部署并处理可能的错误 main() .then(() process.exit(0)) .catch((error) { console.error(error); process.exit(1); });脚本解析getContractFactory这个方法连接到你的本地Hardhat环境通过合约名找到编译后的artifacts生成一个可用于部署的合约工厂实例。deploy()这是实际发起部署交易的操作。它会返回一个合约对象但此时交易还未上链。deployed()这是一个Promise它会等待部署交易被打包进区块并确认。对于本地网络几乎是瞬间完成但对于公共测试网可能需要十几秒到几十秒。documentNotary.address部署成功后合约在区块链上会有一个唯一的地址所有后续的交互都需要通过这个地址。4.3 执行部署与验证保持npx hardhat node终端运行打开另一个终端运行部署脚本npx hardhat run scripts/deploy.js --network localhost如果一切顺利你会在终端看到类似这样的输出DocumentNotary合约已部署至地址: 0x5FbDB2315678afecb367f032d93F642f64180aa3。此时你可以回到运行hardhat node的终端看到里面多出了几条交易日志其中就包含了合约创建eth_sendTransaction的交易。这证明合约已经成功部署到了你的本地区块链。4.4 编写自动化测试用例在将合约部署到更重要的环境如测试网之前充分的测试是必须的。我们在test/目录下创建DocumentNotary.js测试文件。const { expect } require(chai); const { ethers } require(hardhat); describe(DocumentNotary 合约测试, function () { let DocumentNotary; let documentNotary; let owner; let addr1; // 在每个测试用例运行前执行 beforeEach(async function () { // 获取合约工厂并部署 DocumentNotary await ethers.getContractFactory(DocumentNotary); [owner, addr1] await ethers.getSigners(); // 获取测试账户 documentNotary await DocumentNotary.deploy(); await documentNotary.deployed(); }); describe(存证功能, function () { it(应该允许用户提交存证并返回正确的ID, async function () { const testHash 0x1234567890abcdef; const testDesc 实验报告PDF; // 使用addr1账户提交存证 const tx await documentNotary.connect(addr1).notarizeDocument(testHash, testDesc); await tx.wait(); // 等待交易确认 // 查询ID为0的记录 const record await documentNotary.getNotarization(0); expect(record.owner).to.equal(addr1.address); expect(record.documentHash).to.equal(testHash); expect(record.description).to.equal(testDesc); expect(record.timestamp).to.be.a(bigint); // 时间戳应为BigInt类型 }); it(提交空哈希应该被拒绝, async function () { await expect( documentNotary.notarizeDocument(, 无效存证) ).to.be.revertedWith(Document hash cannot be empty); }); }); describe(查询功能, function () { beforeEach(async function () { // 预先为owner和addr1各创建一条存证 await documentNotary.notarizeDocument(hash1, owner的存证); await documentNotary.connect(addr1).notarizeDocument(hash2, addr1的存证); await documentNotary.connect(addr1).notarizeDocument(hash3, addr1的另一个存证); }); it(应该能根据ID正确查询存证, async function () { const record await documentNotary.getNotarization(1); expect(record.owner).to.equal(addr1.address); expect(record.documentHash).to.equal(hash2); }); it(查询不存在的ID应该被拒绝, async function () { await expect(documentNotary.getNotarization(999)).to.be.revertedWith(Notarization does not exist); }); it(应该能正确查询某个地址的所有存证ID, async function () { const ids await documentNotary.getNotarizationsByOwner(addr1.address); expect(ids).to.have.lengthOf(2); expect(ids[0]).to.equal(1); // addr1的第一条存证ID是1 expect(ids[1]).to.equal(2); // addr1的第二条存证ID是2 }); }); });运行测试npx hardhat test如果所有测试用例都通过你会看到绿色的对勾和“passing”提示。这个测试套件覆盖了正向功能、异常输入和边界情况是保障合约逻辑正确性的重要环节。在实际项目中测试覆盖率应尽可能高尤其是涉及资产转移的合约。5. 与合约交互从命令行到前端构想部署和测试完成后我们的合约已经是一个在链上运行的、可用的服务了。如何与它交互呢5.1 使用Hardhat Console进行手动交互Hardhat提供了一个交互式控制台REPL可以直接连接到你部署的网络并像在脚本里一样操作合约。这对于快速调试和验证非常方便。npx hardhat console --network localhost在控制台中你可以执行以下命令// 1. 获取已部署合约的实例需要替换为你的实际合约地址 const DocumentNotary await ethers.getContractFactory(DocumentNotary); const contractAddress 0x5FbDB2315678afecb367f032d93F642f64180aa3; const notary await DocumentNotary.attach(contractAddress); // 2. 获取当前可用账户 const [signer] await ethers.getSigners(); console.log(当前操作账户:, signer.address); // 3. 提交一条存证 const tx await notary.notarizeDocument(0xabcdef123456, 控制台测试存证); await tx.wait(); console.log(存证提交成功); // 4. 查询刚提交的存证ID为0 const record await notary.getNotarization(0); console.log(查询结果:, record);5.2 构建一个简单的前端DApp概念与架构要让普通用户使用这个存证服务我们需要一个前端界面。这里简述其核心架构具体实现涉及前端框架如React, Vue不在本次实验代码范围内。连接钱包使用如MetaMask、WalletConnect等浏览器插件或SDK让用户连接其区块链钱包如小狐狸钱包。这解决了身份认证和交易签名的问题。初始化合约实例前端使用ethers.js或web3.js库。需要合约地址contractAddress和ABI编译后生成的artifacts/contracts/DocumentNotary.sol/DocumentNotary.json文件中的abi字段。import { ethers } from ethers; import DocumentNotaryABI from ./artifacts/DocumentNotary.json; const provider new ethers.providers.Web3Provider(window.ethereum); // 使用用户钱包的Provider const signer provider.getSigner(); const contractAddress YOUR_DEPLOYED_CONTRACT_ADDRESS; const contract new ethers.Contract(contractAddress, DocumentNotaryABI.abi, signer);调用合约函数写操作存证需要发送交易支付Gas费。用户会在钱包中弹出确认窗口。async function handleNotarize() { const documentHash await calculateFileHash(file); // 前端计算文件哈希 const tx await contract.notarizeDocument(documentHash, description); await tx.wait(); // 等待交易确认 console.log(存证成功); }读操作查询直接调用view函数无需Gas费立即返回结果。async function fetchMyRecords() { const myAddress await signer.getAddress(); const myRecordIds await contract.getNotarizationsByOwner(myAddress); // 再根据ID数组逐个查询详情 const records await Promise.all(myRecordIds.map(id contract.getNotarization(id))); setRecords(records); }监听事件为了获得更好的用户体验可以监听存证成功事件实时更新列表而不是让用户手动刷新。contract.on(DocumentNotarized, (id, owner, documentHash, timestamp) { if (owner currentUserAddress) { // 更新前端状态添加新记录 } });通过这样一个前后端分离的架构前端是传统Web技术后端是智能合约一个去中心化的存证应用就初具雏形了。用户完全掌控自己的私钥和数据合约代码公开透明执行结果由区块链网络共识保障。6. 实验总结与进阶思考完成这个实验你实际上走完了一个智能合约项目从零到一的核心流程环境搭建、合约设计、编码、编译、测试、部署和基础交互。这比单纯理解概念要深刻得多。有几个关键点值得再次强调和延伸思考Gas与成本意识每一次链上状态修改存储、写入都需要支付Gas费这迫使开发者必须精心设计数据结构和算法追求极致的效率。我们的_ownerToIds映射就是典型的优化案例。在正式部署前务必使用Hardhat的Gas报告功能npx hardhat test --gas-reporter或类似工具分析合约的Gas消耗。安全是生命线我们只涉及了最简单的require验证。真实的合约安全是一个深水区包括重入攻击、整数溢出/下溢、权限检查、随机数生成、预言机使用等无数陷阱。建议深入学习“智能合约安全最佳实践”并使用Slither、Mythril等静态分析工具辅助审计。升级性与可维护性以太坊合约一旦部署代码原则上不可更改。这意味着如果发现bug或需要新增功能会非常棘手。为了解决这个问题社区发展出了多种升级模式如代理模式Proxy Pattern将逻辑合约与存储合约分离允许逻辑部分升级。OpenZeppelin提供了完整的Upgradeable合约库这是开发严肃应用必须了解的。从本地到测试网下一步你可以尝试将合约部署到如Sepolia、Goerli已弃用或Polygon Mumbai这样的公共测试网。这需要在hardhat.config.js中配置测试网的RPC URL和部署账户的私钥务必使用从测试网水龙头获取了测试币的账户切勿使用主网私钥体验真实网络环境下的交易确认和Gas费波动。我个人在最初学习智能合约开发时最大的误区是试图把所有的逻辑和数据都塞进合约里。后来才明白区块链应该只做它最擅长的事保证核心资产所有权和关键业务逻辑的不可篡改与自动执行。复杂的计算、大量的数据存储、频繁的交互应该放在链下前端或中心化服务器或Layer 2解决方案中。这种“链上链下协同”的架构思维是设计一个可行、可用且成本可控的DApp的关键。本次实验的存证合约只将最重要的“指纹-时间戳-所有者”三元组上链就是这种思维的一个简单体现。