
简介一套基于以太坊的通用电子优惠券系统项目资料面向区块链方向学习者、高校相关专业学生及开发者适配毕业设计、课程设计、作业演示与二次开发等场景。压缩包共485个文件大小约8.17MB以Java源文件173个与XML配置107个为主体辅以Solidity合约.sol、JSP页面、JavaScript脚本及Android依赖库.aar等覆盖智能合约、后端服务与移动端展示多个层次目录结构完整清晰。项目代码已经测试运行通过包含详细说明文档能直观呈现电子优惠券的创建、分配、核销等核心流程对以太坊链上交互、合约部署及工程整合均有参考价值。已有53人学习浏览适合从零搭建区块链应用或在此基础上扩展功能。1. 一张跨店通用的电子券为什么最后落在了以太坊上线下发券的团队都遇到过同一个尴尬品牌方印了一批满减券A 商户核销了却在月底对账时被质疑重复结算B 商户手里那批券到期没发完想回收重发又怕和已流出的券撞号。中心化数据库能解决单商户的问题一旦要跨商户、跨品牌互认就得靠一个所有参与方都能读、都不能单方面改的账本。以太坊给出的方案很直接把优惠券做成通证发行量、有效期、持有者、核销记录全部落到链上核销动作就是一笔交易成功一次之后状态位翻转第二次必然是 revert。适合做这件事的是需要多方对账的营销平台、连锁品牌联盟和积分互通场景纯粹单体内的发券业务用数据库更划算。2. ERC-1155 还是 ERC-721优惠券的资产建模与合约骨架链上券的第一个决策不是写代码而是选标准。选错了后面每一次发券都在为 gas 和扩展性买单。2.1 三种券型与通证标准的对照优惠券在业务上天然分层同一批次的满减券彼此等价收藏型纪念券每张唯一积分型券要能随手拆分。这三类对应的链上模型完全不同。券型业务特征推荐标准链上表达批量发行成本批次满减券一万张面值相同的券ERC-1155一个 id 对应一个批次balance 表示持有数量极低一次 mintBatch 发完唯一权益券每张带独立编号、可转让收藏ERC-721一张券一个 tokenIdmetadata 独立高逐张 mint积分/储值券可分割、可部分核销ERC-20 或 ERC-1155 可分割余额balance 记账低选型结论通常落在 ERC-1155它同时支持同质化余额和批量操作一个合约就能承载多个券种核销时只改一个mapping状态位不需要像 ERC-721 那样转移所有权。只有当券本身要作为收藏品流转、每张的元数据都不同时才拆出 ERC-721。2.2 券合约的存储结构与发行接口下面这段合约骨架把券种参数、发行权限和核销状态位放在一起可以直接作为起点。// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import openzeppelin/contracts/token/ERC1155/ERC1155.sol; import openzeppelin/contracts/access/AccessControl.sol; contract CouponBook is ERC1155, AccessControl { bytes32 public constant ISSUER_ROLE keccak256(ISSUER_ROLE); struct CouponType { uint64 validFrom; // 生效时间戳0 表示立即生效 uint64 validUntil; // 失效时间戳0 表示永不过期 uint32 totalSupply; // 该批次发行上限 uint32 minted; // 已发行数量 bool transferable; // 是否允许二级转让 } mapping(uint256 CouponType) public couponTypes; // 券种 持有者 是否已核销核销后永久为 true mapping(uint256 mapping(address bool)) public redeemed; event CouponIssued(uint256 indexed id, address indexed to, uint256 amount); event CouponRedeemed(uint256 indexed id, address indexed holder, address indexed merchant); constructor() ERC1155(https://api.example.com/coupon/{id}.json) { _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); _grantRole(ISSUER_ROLE, msg.sender); } function createType(uint256 id, uint64 from, uint64 until, uint32 cap, bool transferable) external onlyRole(ISSUER_ROLE) { require(couponTypes[id].totalSupply 0, type exists); couponTypes[id] CouponType(from, until, cap, 0, transferable); } }关键点在redeemed这个双层 mapping它不用 NFT 的销毁来表达核销而是保留持有记录、只翻转布尔位链下索引时既能查到谁核销过也不会因为销毁丢掉历史。CouponType用uint64存时间戳、uint32存数量是为了把整个结构体压进一个 256 位槽附近读取时省一次 SLOAD。transferable字段决定是否重写_update钩子来拦截转让非转让券能有效防止黄牛囤券。2.3 有效期不该只靠链上时间判断把validUntil写进合约很自然但真正扣券的时刻是在商户 POS 上链上时间戳由打包节点决定存在十几秒的偏差。常见做法是链上做兜底校验、链下做即时判定POS 先按本地时间确认券在有效期内再发起核销交易合约里再校验一次block.timestamp。两边都过才算成功前端把即将过期的券提前 10 分钟标灰避免用户在收银台前交易 revert 的糟糕体验。元数据里的有效期文案同样要和合约字段保持一致否则会出现页面显示可用、链上拒绝的矛盾。3. 核销只能成功一次状态机、签名凭证与防重放券系统最核心的安全属性只有一条同一张券不能被核销两次。链上天然防双花但要防的是同一持有者换商户重复核销和签名被截获后重放。3.1 券的生命周期与核销前置条件券从发行到终结会经过四个状态合约里对应不同的判断分支。状态触发条件合约判断允许动作未生效当前时间 validFromblock.timestamp validFrom可转让不可核销生效validFrom ≤ 时间 ≤ validUntil区间内可核销、可转让已核销redeemed[id][holder] true布尔位为真拒绝再次核销已过期时间 validUntil超出上界仅可查询不可核销核销函数要按顺序检查这四件事任何一步不通过就 revert 并带上明确原因方便前端区分提示。function redeem(uint256 id, address holder) external { CouponType memory t couponTypes[id]; require(t.totalSupply ! 0, unknown coupon); require(block.timestamp t.validFrom, not started); require(t.validUntil 0 || block.timestamp t.validUntil, expired); require(balanceOf(holder, id) 0, no balance); require(!redeemed[id][holder], already redeemed); redeemed[id][holder] true; _burn(holder, id, 1); // 扣掉持有的那一张 emit CouponRedeemed(id, holder, msg.sender); }redeemed先写再_burn是为了让重入调用在require处就被挡下而不是依赖外部调用返回后的状态。_burn用 1 而不是全部余额允许同一持有者持有多张同批次券时逐张核销如果业务上一人只能有一张就在发行时把balanceOf上限写成 1。3.2 用 EIP-712 签名做离线领券凭证让每个用户都先发一笔交易去领券gas 成本高、体验差。更常见的路径是平台用 EIP-712 签一张离线凭证用户在核销或转赠时把签名提交上链合约用ecrecover校验签发方。struct RedeemIntent { uint256 couponId; address holder; address merchant; uint256 nonce; // 每个 holder 递增防同一意图重复提交 uint256 deadline; // 签名过期时间 } bytes32 constant INTENT_TYPEHASH keccak256( RedeemIntent(uint256 couponId,address holder,address merchant,uint256 nonce,uint256 deadline) ); function redeemWithSig(RedeemIntent calldata it, bytes calldata sig, address holder) external { require(block.timestamp it.deadline, sig expired); require(it.nonce nonces[holder], bad nonce); require(it.merchant msg.sender, merchant mismatch); bytes32 digest _hashTypedDataV4(keccak256(abi.encode( INTENT_TYPEHASH, it.couponId, holder, it.merchant, it.nonce, it.deadline ))); require(hasRole(ISSUER_ROLE, ECDSA.recover(digest, sig)), bad signer); _redeemInternal(it.couponId, holder); }nonce必须按持有者单调递增任何一次提交都会把它推进签名被第三方截获后再次提交会因为nonce不匹配而失败。deadline设多久取决于业务线下快消场景 5 分钟足够用户还能当场改主意换商户。前后端对digest的计算必须完全一致链下用ethers.TypedDataEncoder.hash生成字段顺序和类型与合约里的INTENT_TYPEHASH逐字对应一个空格差异就会导致签名校验失败。3.3 核销事件的链下对账查询链上不存商户对账单对账靠事件。每次核销 emitCouponRedeemed链下索引按merchant聚合就能出日报。用 JSON-RPC 拉事件时按区块区间分批公链上单次查询跨度别超过几千个区块。// 按商户聚合核销记录merchant 是第二个 indexed 参数 const filter contract.filters.CouponRedeemed(null, null, merchant); const logs await contract.queryFilter(filter, fromBlock, toBlock); const daily logs.reduce((acc, log) { const day new Date(log.args.timestamp ?? 0).toISOString().slice(0, 10); acc[day] (acc[day] ?? 0) 1; return acc; }, {});事件里放indexed的字段才能被高效过滤couponId、holder、merchant三个都设成 indexed查询时可以只填想过滤的那个位置其余传null。注意 indexed 参数不能是动态类型字符串形式的券码要先哈希成bytes32再进事件。4. 本地跑通全套流程Hardhat 部署、批量发券与核销脚本合约写得再对没在本地跑过一遍并发、过期、重复核销的场景上线就是赌运气。这一章给一条从零到核销成功的完整命令链。4.1 工程初始化与依赖mkdir coupon-chain cd coupon-chain npm init -y npm i -D hardhat nomicfoundation/hardhat-toolbox npm i openzeppelin/contracts merkletreejs keccak256 npx hardhat init # 选 TypeScript 或 JavaScript 项目生成 contracts/ scripts/ test/hardhat-toolbox一次性带回 ethers、chai、network-helpers 和 gas reporter本地测试链用的是内存版 Hardhat Network出块即时、不需要等确认。把hardhat.config.js里的solidity版本与合约pragma对齐OpenZeppelin 5.x 要求 0.8.20 以上版本不匹配会直接编译报Source file requires different compiler version。4.2 部署与批量发券脚本// scripts/deploy.js const hre require(hardhat); async function main() { const [admin, merchantA, user1, user2] await hre.ethers.getSigners(); const Book await hre.ethers.getContractFactory(CouponBook); const book await Book.deploy(); await book.waitForDeployment(); console.log(CouponBook:, await book.getAddress()); const now Math.floor(Date.now() / 1000); // 券种 1满 100 减 20有效期 30 天不可转让 await book.createType(1, now, now 30 * 86400, 10000, false); // 批量发给两个用户每人 3 张 await book.mintBatch(1, [user1.address, user2.address], [3, 3], 0x); console.log(minted); } main().catch((e) { console.error(e); process.exit(1); });createType的四个参数依次是生效时间、失效时间、发行上限、可否转让。上线前至少用now 30 * 86400这种显式时间戳不要依赖前端本地时区换算。mintBatch的参数是三个数组一一对应第三个参数data在不需要回调时传空0x即可。发行上限cap要在 mint 时校验minted amount totalSupply否则批次可以被无限超发这是优惠券系统最容易被审计挑出来的问题。4.3 核销调用与 gas 参数// scripts/redeem.js const book await hre.ethers.getContractAt(CouponBook, BOOK_ADDR); const tx await book.connect(merchantA).redeem(1, user1.address); const receipt await tx.wait(); console.log(gasUsed:, receipt.gasUsed.toString()); const [, ev] receipt.logs.map((l) { try { return book.interface.parseLog(l); } catch { return null; } }).filter(Boolean); console.log(event:, ev?.name, ev?.args?.holder);核销前先调用book.redeemed(1, user1.address)做只读预检返回true就别发交易省下一笔必然 revert 的 gas。gasUsed一般落在 5 万到 8 万之间主要开销是_burn引发的 balance 槽位改写和事件写入。批量核销一个商户一次提交多个持有者能把固定开销摊薄但要留意单笔交易 gas 上限超过 30 笔建议拆批。4.4 常见报错与排查方向报错常见原因排查动作already redeemed同一(id, holder)重复核销查redeemed(id, holder)只读返回值expired本地时间与链上block.timestamp不一致打印本地与链上时间差前端提前 10 分钟禁用no balance券被转让或已核销查balanceOf(holder, id)bad nonce签名 nonce 与链上nonces[holder]不同步重新向链下服务请求最新 noncebad signerEIP-712 字段顺序或类型不匹配对比链下TypedDataEncoder.hash与合约 typehashtype exists重复创建同 id 券种换 id或先读couponTypes(id).totalSupply本地用npx hardhat test跑一遍边界用例重点是同一张券连核销两次必须失败和过期券核销必须失败这两条用例过了链上逻辑的主干基本站得住。5. 让用户不掏 gas 也能领券元交易与 Merkle 白名单空投真正卡住用户增长的不是功能是第一次交互需要钱包里有余额。营销活动场景里领券动作必须做到零门槛否则转化率会掉一个量级。5.1 转发调用与 gas 代付的最小改造思路是让中继方代替用户提交交易合约信任中继方并从请求里还原出真实用户。EIP-2771 的做法是在合约里识别附加在 calldata 末尾的 20 字节真实发送者地址再用一个受信任的trustedForwarder合约地址做校验。address private immutable _trustedForwarder; constructor(address forwarder) { _trustedForwarder forwarder; } // 包装 _msgSender核销逻辑里所有 msg.sender 都换成这个 function _msgSender() internal view override returns (address) { if (msg.sender _trustedForwarder msg.data.length 20) { return address(bytes20(msg.data[msg.data.length - 20:])); } return msg.sender; }改造要点只有两处构造函数注入trustedForwarder以及把业务代码里所有msg.sender换成_msgSender()。必须校验_trustedForwarder之外的调用方不能伪造尾部地址否则任何人都能冒充任意持有者。中继服务给用户代付 gas 的成本要设上限额度用完就停止代付并记录每个地址的代付次数防止被批量刷号薅走。5.2 用 Merkle 白名单做批量空投活动发券往往限定白名单把几千个地址全写进合约成本很高。Merkle 白名单只把树根写进链上用户领券时提交自己的分支证明。const leaves whitelist.map((a) ethers.keccak256(a)); // 地址直接哈希做叶子 const tree new MerkleTree(leaves, keccak256, { sortPairs: true }); const root tree.getHexRoot(); // 用户领券时构造证明 const proof tree.getHexProof(ethers.keccak256(user1.address)); await book.claim(1, proof); // 合约里校验 proof address 能否还原出 rootsortPairs: true让同一层节点排序后再哈希避免叶子顺序不同导致链上链下算出的根不一致。合约侧在claim里用bytes32数组做哈希归并每轮把当前值与本层分支元素排序拼接后哈希最终结果等于merkleRoot才允许发券同时把已领取地址标记为true防止同一地址重复领取。白名单更新的做法是重新生成整棵树并调用setRoot所以setRoot必须限制在ISSUER_ROLE下且建议记录每次 root 变更的事件出争议时能回溯是哪一版名单。验证整套流程是否可靠可以只做两件事在本地把claim连续调用两次第二次必须失败把 Merkle 证明中的任意一个字节改掉合约必须 revert。这两条通过白名单空投和链上核销的信任边界就基本闭环了。本文还有配套的精品资源点击获取