ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

区块链电子合同:用DAPP实现用户自主签名与链上存证

区块链电子合同:用DAPP实现用户自主签名与链上存证 简介这是一份基于DApp构建的区块链电子合同签署系统实战项目面向区块链初学者与课程设计、毕设、工程实训学习者解决传统电子合同可信存证与多方协同签署流程不透明的问题。资源包共36个文件涵盖11个HTML页面如合同发布、双签确认、管理员审核等核心交互界面、7个JS脚本实现前端逻辑与Web3.js链交互、2个Solidity智能合约EContract.sol等、3个JSON配置及1个truffle-config.js辅以CSS样式、字体资源与README说明整体压缩包仅584KB轻量易部署。已有89人学习下载内容结构清晰前端页面按用户角色发布方、甲乙双方、管理员分层组织合约层支持身份证校验、重复操作拦截、合同状态全生命周期管理新创建→签署→再确认→审核→中止并内置链上查询与状态自动同步功能开箱即可运行Truffle开发环境v5.1.26 Node.js v12.16.3是理解区块链业务落地的典型教学范例。1. 为什么电子合同还在用 PDF 加盖“电子章”——DAPP 不是炫技是把签名权真正交还给签署方你有没有签过这样的“电子合同”点开一个网页输入手机号收到短信验证码勾选“我已阅读”最后弹出一个带红色印章的 PDF它叫电子合同但本质上只是把纸质流程搬上网签名动作由平台代劳私钥不出浏览器签名过程不链上哈希不上链状态不可验证。一旦发生纠纷平台说“系统记录显示你点了确认”你却拿不出自己控制私钥、自主签名、链上存证的完整证据链。基于 DAPP 实现区块链的电子合同签署不是给传统电子签加个“区块链”前缀而是重构信任模型合同内容哈希上链锚定时间戳签署动作由用户本地钱包如 MetaMask调用智能合约完成每一次签名都生成可验证的链上交易所有参与方甲方、乙方、存证方、司法节点都能独立验证签名有效性与顺序且无法被中心化平台单方面篡改或删除。它适合对法律效力、过程可审计、多方协同有硬性要求的场景——比如供应链上下游对账协议、SaaS 服务 SLA 条款确认、科研合作知识产权归属约定。如果你正在评估是否值得投入答案很直接当你的合同纠纷成本 部署一个轻量级合约 改造前端签名流程的成本时就该做了。2. 从零跑通第一份链上签名用 Hardhat 搭建本地测试链 编写可验证签名合约要让电子合同真正“上链”第一步不是对接公链而是先在本地闭环验证整个签名逻辑是否成立。我们用 Hardhat 搭建一个本地 Ethereum 兼容链部署一个极简但具备法律关键能力的ContractSigner合约它不存储合同全文避免链上冗余只接收并验证 ECDSA 签名、记录签署者地址与时间戳并提供verifySignature(bytes32 hash, address signer, bytes memory signature)公共方法供链下系统调用验证。2.1 初始化 Hardhat 项目并配置本地网络mkdir contract-dapp cd contract-dapp npm init -y npm install --save-dev hardhat npx hardhat init # 选择 Create an empty hardhat.config.js其余默认编辑hardhat.config.js启用本地网络和 Ethers.js 插件require(nomicfoundation/hardhat-toolbox); /** type import(hardhat/config).HardhatUserConfig */ module.exports { solidity: 0.8.20, networks: { localhost: { url: http://127.0.0.1:8545 } } };启动本地节点新开终端npx hardhat node提示npx hardhat node默认监听8545端口生成 20 个预 funded 账户私钥明文打印在控制台——仅限本地开发切勿用于任何真实资产环境。2.2 编写核心签名验证合约 ContractSigner.sol在contracts/目录下新建ContractSigner.sol// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract ContractSigner { // 记录每份合同哈希对应的已签署地址列表 mapping(bytes32 address[]) public signedBy; // 记录每份合同哈希的首次签署时间戳 mapping(bytes32 uint256) public firstSignedAt; event Signed(bytes32 indexed contractHash, address indexed signer, uint256 timestamp); // 外部函数供前端调用提交签名 function signContract(bytes32 contractHash, bytes memory signature) external { address signer recoverSigner(contractHash, signature); require(signer msg.sender, Invalid signature: signer mismatch); // 防重放同一份合同哈希同一地址只记一次 bool alreadySigned false; for (uint i 0; i signedBy[contractHash].length; i) { if (signedBy[contractHash][i] signer) { alreadySigned true; break; } } if (!alreadySigned) { signedBy[contractHash].push(signer); if (signedBy[contractHash].length 1) { firstSignedAt[contractHash] block.timestamp; } emit Signed(contractHash, signer, block.timestamp); } } // 内部函数ECDSA 签名恢复地址兼容 EIP-191 标准 function recoverSigner(bytes32 hash, bytes memory signature) internal pure returns (address) { bytes32 ethSignedHash keccak256( abi.encodePacked(\x19Ethereum Signed Message:\n32, hash) ); return ECDSA.recover(ethSignedHash, signature); } // 公共视图函数供链下系统验证任意签名是否有效 function verifySignature(bytes32 hash, address expectedSigner, bytes memory signature) external view returns (bool) { address recovered recoverSigner(hash, signature); return recovered expectedSigner; } }关键参数说明keccak256(abi.encodePacked(\x19Ethereum Signed Message:\n32, hash))这是 EIP-191 标准签名前缀确保签名与以太坊钱包MetaMask原生签名格式一致否则recover会失败signedBy[contractHash].push(signer)链上只存地址不存合同原文符合 GDPR 和链上成本最优原则firstSignedAt字段为后续司法存证提供不可篡改的时间锚点比中心化服务器时间戳更具法律采信力。2.3 编译、部署并获取合约地址创建部署脚本scripts/deploy.jsconst { ethers } require(hardhat); async function main() { const [deployer] await ethers.getSigners(); console.log(Deploying contracts with account:, deployer.address); const ContractSigner await ethers.getContractFactory(ContractSigner); const contract await ContractSigner.deploy(); await contract.waitForDeployment(); console.log(ContractSigner deployed to:, await contract.getAddress()); } main() .then(() process.exit(0)) .catch((error) { console.error(error); process.exit(1); });执行部署npx hardhat run scripts/deploy.js --network localhost输出类似Deploying contracts with account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 ContractSigner deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3记下这个合约地址后续前端交互将指向它。此时你已拥有一份可运行、可验证、完全链上留痕的电子合同签名基础设施最小原型。3. 前端签名集成用 wagmi viem 在 React 中调用钱包完成链上签署合约写好了链跑起来了但用户不能手动去 Etherscan 手输 ABI 调用。必须把签名动作无缝嵌入业务流程用户点击“我同意”按钮 → 前端生成合同哈希 → 调用钱包签名 → 发送交易上链 → 显示“已上链区块确认中”。这里我们选用wagmiReact hooks 封装viem底层类型安全的以太坊客户端组合替代过重的 Web3.js兼顾开发效率与类型安全。3.1 初始化 React 项目并安装依赖npx create-react-app contract-frontend --template typescript cd contract-frontend npm install wagmi viem tanstack/react-query配置src/wagmi.ts连接本地 Hardhat 链import { configureChains, createConfig, WagmiConfig } from wagmi; import { mainnet, sepolia } from wagmi/chains; import { jsonRpcProvider } from wagmi/providers/jsonRpc; import { ConnectKitProvider, ConnectKitButton } from connectkit; // 本地 Hardhat 链配置对应 hardhat.config.js 中的 localhost const { chains, publicClient, webSocketPublicClient } configureChains( [ { id: 31337, // Hardhat 默认 chainId name: Hardhat, network: hardhat, nativeCurrency: { name: Ether, symbol: ETH, decimals: 18 }, rpcUrls: { default: { http: [http://127.0.0.1:8545] }, }, } ], [ jsonRpcProvider({ rpc: (chain) ({ http: chain.rpcUrls.default.http[0] }), }), ] ); const config createConfig({ autoConnect: true, publicClient, webSocketPublicClient, connectors: [], }); export { config, chains };3.2 构建签署逻辑从合同文本到链上交易假设你有一份待签合同 JSON{ title: 技术服务协议, parties: [0xAb8483F64d9C6d1EcF9b849Ae677dC320d5Cf7FB, 0x70997970C51812dc3A010C7d01b50e0d17dc79C8], effectiveDate: 2024-06-01, contentHash: 0xabc123... }前端需做三件事①生成链上唯一哈希非简单keccak256(content)需结构化防篡改②调用钱包签名使用signMessage非signTransaction③调用合约signContract方法发送交易。在组件中实现import { useContractWrite, useWaitForTransaction, useSignMessage } from wagmi; import { parseEther, zeroAddress } from viem; import { useState } from react; // 合约 ABI精简版仅需 signContract 函数 const contractABI [ { inputs: [ { name: contractHash, type: bytes32 }, { name: signature, type: bytes } ], name: signContract, outputs: [], stateMutability: nonpayable, type: function } ] as const; // 合约地址来自上一步部署结果 const CONTRACT_ADDRESS 0x5FbDB2315678afecb367f032d93F642f64180aa3; export default function SignContract() { const [contractHash, setContractHash] useState0x${string} | null(null); const [isSigning, setIsSigning] useState(false); // 步骤1生成结构化哈希模拟后端传入实际应由后端统一计算 const generateContractHash () { const raw JSON.stringify({ title: 技术服务协议, parties: [0xAb8483F64d9C6d1EcF9b849Ae677dC320d5Cf7FB, 0x70997970C51812dc3A010C7d01b50e0d17dc79C8], effectiveDate: 2024-06-01 }); // 使用 viem 的 keccak256注意前端哈希必须与合约中 recoverSigner 用的完全一致 const hash keccak256(toBytes(raw)); setContractHash(hash as 0x${string}); }; // 步骤2调用钱包签名EIP-191 格式 const { signMessageAsync } useSignMessage(); const [signature, setSignature] useStatestring | null(null); const handleSignMessage async () { if (!contractHash) return; try { setIsSigning(true); // 注意此处必须加 EIP-191 前缀与合约中 recoverSigner 逻辑严格对齐 const message \x19Ethereum Signed Message:\n32${contractHash.slice(2)}; const sig await signMessageAsync({ message }); setSignature(sig); setIsSigning(false); } catch (err) { console.error(签名失败:, err); setIsSigning(false); } }; // 步骤3发送交易调用合约 const { writeAsync, data: txData } useContractWrite({ address: CONTRACT_ADDRESS, abi: contractABI, functionName: signContract, args: [contractHash!, signature!], }); const { isLoading: isTxLoading } useWaitForTransaction({ hash: txData?.hash, onSuccess(data) { console.log(交易上链成功区块号:, data.blockNumber.toString()); alert(签署成功交易哈希${data.hash}); } }); return ( div button onClick{generateContractHash}生成合同哈希/button {contractHash p哈希: {contractHash}/p} {contractHash !signature ( button onClick{handleSignMessage} disabled{isSigning} {isSigning ? 钱包签名中... : 调用钱包签名} /button )} {signature ( button onClick{() writeAsync?.()} disabled{isTxLoading} {isTxLoading ? 上链中... : 提交至区块链} /button )} /div ); }关键细节说明message字符串必须与合约中keccak256(abi.encodePacked(...))的输入完全一致否则recoverSigner返回错误地址useContractWrite的args传入contractHash32 字节和signature字节数组viem会自动编码为 ABI 格式useWaitForTransaction是必备环节只有交易被区块确认才代表法律意义上的“签署完成”未确认交易可被矿工丢弃。4. 验证与存证如何向法院/仲裁委证明“这份签名确实由他本人完成”链上签署完成 ≠ 法律效力闭环。司法实践中电子证据需满足真实性、完整性、关联性三要素。DAPP 方案的优势在于它天然支持出具可独立验证的“链上存证报告”无需依赖平台自证清白。这一章聚焦三个刚性需求① 如何向非技术人员解释链上签名不可抵赖② 如何生成一份司法认可的存证摘要③ 如何用最简代码批量验证 N 个签署方。4.1 链上签名不可抵赖性的三层验证路径验证层级验证对象验证方式司法采信依据链层交易收据Receipt查询tx.hash→ 检查status 1、blockNumber 0、to 合约地址《人民法院在线诉讼规则》第十六条区块链存证的哈希值、时间戳、交易ID等元数据可作为真实性辅助证据合约层signedBy[contractHash]数组调用contract.signedBy(contractHash)返回地址列表合约代码开源、状态公开可由第三方审计机构复现执行逻辑密码学层签名本身有效性用ecrecover算法输入contractHashsignature→ 输出地址是否等于声称签署方符合 GB/T 39786-2021《信息安全技术 信息系统密码应用基本要求》中“数字签名不可伪造性”条款注意三者缺一不可。仅链层有效无法证明签名者身份仅合约层返回地址无法证明该地址确为用户控制仅密码学验证通过无法证明该签名已被写入不可篡改的区块链。4.2 生成司法存证摘要一个 Python 脚本搞定全部关键字段存证报告不是截图而是一份包含原始数据、计算过程、验证方法的结构化 JSON。以下脚本generate-evidence.py自动抓取链上数据并组装#!/usr/bin/env python3 from web3 import Web3 import json from eth_account.messages import encode_defunct # 配置本地节点 w3 Web3(Web3.HTTPProvider(http://127.0.0.1:8545)) # 已知参数由业务系统提供 CONTRACT_ADDRESS 0x5FbDB2315678afecb367f032d93F642f64180aa3 CONTRACT_HASH 0xabc123... # 合同内容哈希 SIGNATURE 0x... # 用户钱包返回的签名 hex 字符串 # 1. 获取交易收据需先知道哪笔交易写了这个 contractHash # 实际生产中应由前端在 writeAsync 后记录 tx.hash 并传给后端 tx_hash 0x... # 示例0x123... receipt w3.eth.get_transaction_receipt(tx_hash) # 2. 调用合约读取 signedBy 数组需 ABI contract_abi [...] # 同前端所用 ABI contract w3.eth.contract(addressCONTRACT_ADDRESS, abicontract_abi) signed_addresses contract.functions.signedBy(CONTRACT_HASH).call() # 3. 密码学验证用 ecrecover 验证 signature 是否真能恢复出某地址 def verify_signature(hash_bytes32: bytes, signature: str, expected_address: str) - bool: # EIP-191 前缀 message b\x19Ethereum Signed Message:\n32 hash_bytes32 encoded_msg encode_defunct(message) recovered w3.eth.account.recover_message(encoded_msg, signaturesignature) return recovered.lower() expected_address.lower() # 4. 组装存证摘要 evidence { evidence_id: fev-{int(time.time())}, timestamp: int(time.time()), contract_hash: CONTRACT_HASH, block_number: receipt[blockNumber], transaction_hash: receipt[transactionHash].hex(), status: success if receipt[status] 1 else failed, signed_by: [addr for addr in signed_addresses], verification_results: [ { signer: addr, valid: verify_signature(bytes.fromhex(CONTRACT_HASH[2:]), SIGNATURE, addr) } for addr in signed_addresses ], notes: 本报告所有数据均直接读取自区块链节点未经过任何中心化平台加工 } print(json.dumps(evidence, indent2, ensure_asciiFalse))运行后输出即为可提交法院的存证摘要。重点看verification_results字段——它明确列出每个签署方的签名是否通过密码学验证法官或鉴定机构可自行用相同算法复现。4.3 批量验证签署方用 viem 写一个 CLI 工具当合同有 50 家供应商需逐个验证时人工点 Etherscan 效率极低。下面是一个verify-batch.tsCLI输入 CSV 文件含 address,signature,contractHash 三列输出验证报告import { createPublicClient, http } from viem; import { mainnet } from viem/chains; import * as fs from fs; import * as csv from csv-parser; const client createPublicClient({ chain: { ...mainnet, rpcUrls: { default: { http: [http://127.0.0.1:8545] } } }, transport: http(), }); async function verifyAll(csvPath: string) { const results: Array{ address: string; contractHash: string; signature: string; valid: boolean } []; const stream fs.createReadStream(csvPath); const parser stream.pipe(csv()); for await (const row of parser) { const valid await client.verifyMessage({ address: row.address as 0x${string}, message: { raw: \x19Ethereum Signed Message:\n32${row.contractHash.slice(2)} }, signature: row.signature as 0x${string}, }); results.push({ ...row, valid }); } console.table(results); fs.writeFileSync(verification-report.json, JSON.stringify(results, null, 2)); } verifyAll(./signatures.csv);只需准备signatures.csvaddress,contractHash,signature 0xAb84...,0xabc123...,0x123... 0x7099...,0xabc123...,0x456...执行npx ts-node verify-batch.ts秒级输出全量验证结果。这才是工程化落地的底气。5. 避坑指南那些让我重写三次签名逻辑的血泪经验DAPP 电子合同看似流程清晰实则处处是“玄学”陷阱。很多团队卡在“签名总失败”“验证始终不通过”“上链后查不到”上数周。以下是我在 7 个真实项目中踩出的 5 个高频坑按出现频率排序每条都附可立即执行的排查命令。5.1 现象recoverSigner返回0x000...000合约内require(signer msg.sender)失败原因前端签名时未加 EIP-191 前缀或前缀字符串拼错如少\n32、多空格、用双引号包裹\x19。解决前端签名必须用signMessage非signTransaction且 message 字符串严格等于const message \x19Ethereum Signed Message:\n32${contractHash.slice(2)};合约中recoverSigner必须用相同前缀bytes32 ethSignedHash keccak256(abi.encodePacked(\x19Ethereum Signed Message:\n32, hash));验证用console.log(message)打印前端 message用console.log(ethSignedHash)打印合约中计算的哈希二者十六进制必须完全一致。5.2 现象交易成功上链但signedBy[contractHash]为空数组原因合约signContract函数被标记为view或pure或msg.sender是合约地址而非外部账户。解决检查合约函数声明必须是external且无view/pure检查前端useContractWrite的address是否填错常见误填为zeroAddress检查钱包是否切换到正确网络Hardhat 链 ID 是31337不是1或11155111排查命令# 查看交易输入数据是否正确编码 npx hardhat console --network localhost const tx await ethers.provider.getTransaction(0x...) console.log(ethers.AbiCoder.defaultAbiCoder().decode([bytes32,bytes], tx.data))5.3 现象本地测试一切正常部署到 Sepolia 后签名验证失败原因Sepolia 上eth_sign行为与本地 Hardhat 不一致部分钱包如旧版 MetaMask对 EIP-191 支持不完整。解决强制使用personal_sign兼容性更好// 前端替换为 const sig await window.ethereum.request({ method: personal_sign, params: [message, account] });合约中recoverSigner改用ecrecover原生函数绕过ECDSA.recover的封装function recoverSigner(bytes32 hash, bytes memory signature) internal pure returns (address) { bytes32 ethSignedHash keccak256(abi.encodePacked(\x19Ethereum Signed Message:\n32, hash)); (bytes32 r, bytes32 s, uint8 v) abi.decode(signature, (bytes32, bytes32, uint8)); return ecrecover(ethSignedHash, v, r, s); }5.4 现象多个签署方签名后signedBy[contractHash]只存了第一个地址原因未处理signedBy[contractHash]数组的重复写入Solidity 循环遍历耗 Gas 超限导致交易 revert。解决改用mapping(address bool)记录已签署状态空间换时间mapping(bytes32 mapping(address bool)) public hasSigned; function signContract(bytes32 contractHash, bytes memory signature) external { address signer recoverSigner(contractHash, signature); require(signer msg.sender, Invalid signature); require(!hasSigned[contractHash][signer], Already signed); hasSigned[contractHash][signer] true; signedBy[contractHash].push(signer); // 仍保留数组供遍历 emit Signed(contractHash, signer, block.timestamp); }部署前用 Hardhat 测试 Gasit(should not revert on duplicate sign, async () { await expect(contract.signContract(hash, sig)).not.to.be.reverted; await expect(contract.signContract(hash, sig)).to.be.revertedWith(Already signed); });5.5 现象合同内容变更后旧签名仍能通过verifySignature验证原因verifySignature函数只校验签名本身未绑定合同当前状态。攻击者可截获旧签名用于新合同。解决必须引入状态绑定机制在合约中增加contractVersion或updatedAt字段verifySignature需同时传入版本号更优方案签名时对struct { bytes32 contentHash; uint256 version; }整体哈希而非仅contentHash法律兜底在业务层强制要求“合同哈希版本号”双重校验前端展示时同步显示version用户点击签署即视为认可该版本。6. 进阶技巧用事件日志替代状态查询把存证性能提升 10 倍当一份采购合同涉及 200 家供应商signedBy[contractHash]数组长度达 200每次contract.signedBy(contractHash).call()都要遍历整个数组Gas 消耗线性增长最终可能因超限而失败。我曾在一个能源结算 DAPP 中遇到此问题第 187 个签署方上链失败报错out of gas。解决方案不是优化循环而是彻底换范式——放弃链上状态查询改用事件日志Event Log做存证索引。6.1 重构合约用 Event 替代 Storage Array修改ContractSigner.sol移除signedBymapping改为只发事件// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract ContractSigner { mapping(bytes32 uint256) public firstSignedAt; // 事件记录每一次有效签署 event Signed(bytes32 indexed contractHash, address indexed signer, uint256 timestamp); function signContract(bytes32 contractHash, bytes memory signature) external { address signer recoverSigner(contractHash, signature); require(signer msg.sender, Invalid signature); // 首次签署记录时间戳 if (firstSignedAt[contractHash] 0) { firstSignedAt[contractHash] block.timestamp; } emit Signed(contractHash, signer, block.timestamp); // 关键只发事件不存数组 } function verifySignature(bytes32 hash, address expectedSigner, bytes memory signature) external view returns (bool) { address recovered recoverSigner(hash, signature); return recovered expectedSigner; } // 内部函数保持不变 function recoverSigner(bytes32 hash, bytes memory signature) internal pure returns (address) { bytes32 ethSignedHash keccak256( abi.encodePacked(\x19Ethereum Signed Message:\n32, hash) ); return ECDSA.recover(ethSignedHash, signature); } }6.2 后端存证服务监听事件并构建索引数据库事件日志天然支持高效过滤Web3 客户端可精准订阅Signed(contractHash, null, null)无需遍历全链。我们用 Node.js PostgreSQL 构建轻量存证服务// indexer.ts import { createPublicClient, http, parseEther } from viem; import { drizzle } from drizzle-orm/node-postgres; import { migrate } from drizzle-orm/node-postgres/migrator; import { Pool } from pg; import * as schema from ./schema; const pool new Pool({ connectionString: process.env.DB_URL! }); const db drizzle(pool, { schema }); const client createPublicClient({ transport: http(http://127.0.0.1:8545), }); // 监听指定 contractHash 的所有 Signed 事件 async function watchSignatures(contractHash: 0x${string}) { const filter await client.createEventFilter({ address: CONTRACT_ADDRESS, event: event Signed(bytes32 indexed contractHash, address indexed signer, uint256 timestamp), args: { contractHash }, }); while (true) { const logs await client.getFilterLogs({ filter }); for (const log of logs) { const { args } log; // 写入 PostgreSQL建立 contractHash signer 复合索引 await db.insert(schema.signatures).values({ contract_hash: args.contractHash, signer: args.signer, block_number: log.blockNumber, timestamp: new Date(Number(args.timestamp) * 1000), }); } await new Promise(r setTimeout(r, 2000)); // 2s 轮询 } } watchSignatures(0xabc123...);schema.ts定义表结构import { pgTable, text, bigint, timestamp } from drizzle-orm/pg-core; export const signatures pgTable(signatures, { id: serial(id).primaryKey(), contract_hash: text(contract_hash).notNull().index(), signer: text(signer).notNull().index(), block_number: bigint(block_number, { mode: number }).notNull(), timestamp: timestamp(timestamp, { withTimezone: true }).notNull(), });6.3 查询性能对比从 O(n) 到 O(log n)查询方式SQL / Web3 调用平均耗时200 签署方备注contract.signedBy(contractHash).call()Solidity 调用1200msGas 消耗随数组长度线性增长超 300 人必失败SELECT * FROM signatures WHERE contract_hash ?PostgreSQL 查询3ms复合索引命中毫秒级响应client.getFilterLogs({ filter })Web3 事件过滤80ms底层 LevelDB 按 topic 索引无需遍历区块我在某省电力交易中心项目中上线此方案后万级合同的存证查询平均延迟从 2.1s 降至 4ms审计方导入存证报告时间从小时级缩短至秒级。真正的性能瓶颈从来不在链上而在你怎么用它。最后说句实在话做 DAPP 电子合同最难的不是写合约而是说服法务同事接受“链上哈希即原件”的逻辑最值得坚持的是每次签名都让用户亲手点钱包——那一下点击才是信任真正的起点。希望帮到你。本文还有配套的精品资源点击获取
返回列表