合约托管 + 验收释放:悬赏 DApp 的四态状态机与事件索引

悬赏这个业务最贵的成本不是"没人接单",是"接了单不给钱"和"给了钱不认单"。所以这个项目的第一性问题是资金时序,不是 UI。

我把 ETH 存在合约里,而不是后端数据库或者某个平台账户里,理由是验收必须和付款在同一个原子操作里完成:approveWork 要么把状态改成 Approved 并把钱打给 worker,要么整笔 revert,不存在"状态改了但钱没到"的中间态。后端只做记录与查询,后端进程整个挂掉,钱和状态仍然在链上可读可写。这也是我最终把它做成 contracts/ + frontend/ + backend/ 三个独立进程的原因------三者的故障域不该重叠。

整体设计:链上只存状态和余额

层 职责 关键实现
合约 托管 ETH、维护状态机、发事件 BountyBoard.sol,Solc 0.8.28,OpenZeppelin 5 的 ReentrancyGuard
前端 读全量悬赏、签名发交易、展示回执 Next.js + Wagmi + Viem + RainbowKit,直连 RPC
后端 事件索引、交易流水、REST 查询 Express + better-sqlite3 + viem watchContractEvent

一个容易被忽略的设计选择:前端不经过 后端。useBountyBoard 里的列表来自 useReadContract({ functionName: "getAllBounties" }),写操作直接 writeContractAsync。后端 SQLite 是第二条独立的读路径。这样前端不依赖后端可用性,代价是两条路径的数据一致性没有任何机制保证(后面边界一节会讲)。

核心实现一:四态枚举就是状态机

合约没有引入任何状态机库,状态机就是 enum 加一串 require:

solidity 复制代码
enum BountyStatus { Open, Submitted, Approved, Cancelled }

struct Bounty {
    uint256 id;
    address payable creator;
    address payable worker;
    string title;
    string description;
    uint256 amount;
    BountyStatus status;
    uint256 createdAt;
}

uint256 public nextBountyId = 1;
mapping(uint256 => Bounty) public bounties;
uint256[] public bountyIds;

nextBountyId 从 1 开始(0 保留给"不存在"),配合两条修饰器:

solidity 复制代码
modifier bountyExists(uint256 _bountyId) {
    require(_bountyId > 0 && _bountyId < nextBountyId, "Bounty does not exist");
    _;
}

modifier onlyCreator(uint256 _bountyId) {
    require(bounties[_bountyId].creator == msg.sender, "Only bounty creator can perform this action");
    _;
}

四个函数的状态约束可以直接列成矩阵,写测试时也是照这张表一个一个填的:

函数 调用者 要求当前状态 违背后的 revert 文案
createBounty 任何人 --- Bounty amount must be greater than 0 / Title cannot be empty
submitWork 非 creator Open 且 worker == 0 Work already submitted / Bounty is not open / Creator cannot submit work to own bounty
approveWork 仅 creator Submitted No work submitted yet / No worker assigned
cancelBounty 仅 creator Open Can only cancel open bounties

submitWork 里两条 require 的顺序是有意排的:先查 bounty.worker == address(0),再查 status == Open。因为 worker 赋值和状态切换是同步发生的,这两个条件逻辑上等价,但先查 worker 会让"第二个人来抢单"报出 Work already submitted 而不是笼统的 Bounty is not open。在只有四个状态的合约里,这种错误消息的取舍值得花十分钟。

核心实现二:验收释放的 CEI 顺序

真正碰钱的是这两个函数,也是我改动次数最多的部分:

solidity 复制代码
function approveWork(uint256 _bountyId) external nonReentrant bountyExists(_bountyId) onlyCreator(_bountyId) {
    Bounty storage bounty = bounties[_bountyId];
    require(bounty.status == BountyStatus.Submitted, "No work submitted yet");
    require(bounty.worker != address(0), "No worker assigned");

    bounty.status = BountyStatus.Approved;   // Effects
    uint256 amount = bounty.amount;

    (bool sent, ) = bounty.worker.call{value: amount}("");  // Interactions
    require(sent, "Failed to send ETH to worker");

    emit BountyApproved(_bountyId, bounty.worker, amount);
}

Checks-Effects-Interactions 在这段里的落点很具体:bounty.status = Approved 必须写在 .call{value:} 之前。这是 nonReentrant 之外的第二道保险------即使 worker 是个恶意合约,重入回来再调 approveWork,会撞在 status == Submitted 上失败,而不是把余额付第二次。cancelBounty 同一个套路,先把状态置成 Cancelled 再给 creator 退款。

用 .call 而不是 transfer,是因为 transfer 的 2300 gas 在接收方是合约时很容易不够;代价是 .call 不自动 revert,必须自己检查返回的 bool,所以那行 require(sent, ...) 不是装饰。

另一个必须记住的取值细节:amount 在状态变更之后从 storage 读进局部变量,再传给 call、再传给事件。早期我图省事在 call 之后才读 bounty.worker 和 amount,本地 Hardhat 测试全绿,但那是因为 worker 是 EOA;换成合约接收方就会读到被重入污染的值。测试能过不代表顺序对,这一点我是靠对着 CEI 逐行核过的。

核心实现三:索引器能重建什么,取决于事件里有什么字段

后端 listener.ts 用 viem 的 parseAbiItem 拼出四个事件,watchContractEvent 订阅后往 SQLite 落库:

ts 复制代码
client.watchContractEvent({
  address: BOUNTY_BOARD_ADDRESS,
  abi: [BountyCreated, WorkSubmitted, BountyApproved, BountyCancelled],
  onLogs: (logs) => {
    for (const log of logs) {
      if (log.eventName === "BountyCreated") {
        const { bountyId, creator, amount } = log.args as {
          bountyId: bigint; creator: string; amount: bigint;
        };
        db.prepare(
          `INSERT OR IGNORE INTO tasks (bounty_id, title, creator_address, amount_wei, status, tx_hash)
           VALUES (?, ?, ?, ?, 'Open', ?)`
        ).run(Number(bountyId), `Bounty #${bountyId}`, creator.toLowerCase(),
              amount.toString(), log.transactionHash);
      }
      // WorkSubmitted / BountyApproved / BountyCancelled 类似,都是 UPDATE 状态 + 记一笔流水
    }
  },
  onError: (error) => console.error("Listener error:", error),
});

事件到表的映射是这样:

事件 索引参数 落库动作
BountyCreated bountyId, creator INSERT OR IGNORE 进 tasks,状态 Open,同时写一条 action='create' 的流水
WorkSubmitted bountyId, worker UPDATE tasks SET status='Submitted', worker_address=...
BountyApproved bountyId, worker UPDATE ... status='Approved',流水方向从 tasks 里反查 creator
BountyCancelled bountyId UPDATE ... status='Cancelled'

两个存储决定值得说明。amount_wei 是 TEXT 而不是 INTEGER:1 ETH = 10^18 wei 已经接近 SQLite 64 位整数的边界,超过约 9.22 ETH 的金额就有溢出风险,存字符串再交给前端 formatEther 处理最省事。tasks.bounty_id 加了 UNIQUE,配合 INSERT OR IGNORE 让事件重复投递变成幂等操作------这点在调试时反复重放同一段日志的场景下救过命。

REST 层的错误码也很朴素:缺必填字段 400 Missing required fields,bounty_id 已存在 409 Task already exists,查不到 404 Task not found。

踩到的坑

一、索引出来的任务是 Bounty #1,不是用户写的标题。

现象:前端创建了一个标题为 "Build a landing page" 的悬赏,后端 tasks 表里 title 字段是 Bounty #1。原因不在 listener 的 SQL,而在事件定义:BountyCreated(uint256 indexed bountyId, address indexed creator, uint256 amount) 里根本没有 title 和 description。事件是索引器唯一的信息源,我自己把信息源写窄了。

改法有两条,我倾向于第二条:一是给事件加 string title,代价是所有链上历史日志都缺这个字段,需要迁移;二是索引器拿到 bountyId 后补一次读调用,把文本补回来:

ts 复制代码
const bounty = await client.readContract({
  address: BOUNTY_BOARD_ADDRESS,
  abi: BOUNTY_BOARD_ABI,
  functionName: "getBountyById",
  args: [bountyId],
});
db.prepare(`UPDATE tasks SET title = ?, description = ? WHERE bounty_id = ?`)
  .run(bounty.title, bounty.description, Number(bountyId));

代价是每个 BountyCreated 多一次 RPC 往返,但只有创建时发生一次,比改事件 ABI 便宜。

二、用户在钱包里点"拒绝",页面什么都不发生。

useBountyBoard 的三个写函数都是同一个结构:await writeContractAsync(...) 之后 setPendingTx(tx),catch 里只有一行 console.error。用户拒绝签名会抛错,于是 pendingTx 一直是 undefined,useWaitForTransactionReceipt 没有 hash 可等,UI 上既没有 loading 也没有报错,按钮弹回来像什么都没点。BountyForm 的 createBounty 也是同样写法。

这个坑的教训不是"要加 try/catch",而是状态提升的粒度 :pendingTx 这个 state 同时承担了"有没有进行中的交易"和"等哪一笔回执"两个语义,错误路径上它没被赋值,于是错误就无处显示。要么单独加一个 txError state,要么把整个写流程封成一个 { status, hash, error } 的对象。

三、索引器重启,历史就断了。

watchContractEvent 只订阅"从现在开始"的日志,没有 fromBlock 游标,也没有历史回补。进程停一晚再起来,那一晚的 BountyApproved 事件全部丢失,SQLite 里的状态就永久停在 Submitted。最小改法是启动时先 getLogs({ fromBlock: deployBlock }) 把历史扫一遍(表已经是幂等的,重复写入无害),再用 watchContractEvent 接增量,并把最后一次处理的 blockNumber 存进库作为下次的起点。

边界与代价

  • 单点信任没有被消除,只是被搬到了 creator 手上。 approveWork 只有 creator 能调,合约里没有超时释放、没有仲裁者、没有争议流程。worker 提交后,如果 creator 一直不点验收,那笔 ETH 就永久锁在合约里,谁也取不出来。反过来 worker 不提交时 creator 还能 cancelBounty 全额退款,所以风险是单向的。真要上生产,得加 deadline + 超时后 worker 可领取,或者引入多签仲裁。
  • 前端与后端是两条互不校对的读路径。 前端读链,后端读 SQLite。索引器一漏事件,两边显示的同一笔悬赏状态就会不一致,而且没有任何对账逻辑。
  • getAllBounties 是全量读取。 合约里用 for 循环把 bountyIds 逐个拷进内存数组,函数是 view 不烧 gas,但前端一次 eth_call 要拿回所有任务的完整 struct(含两个 string)。悬赏数量增长后,这个返回体会线性变大,公共 RPC 的响应上限和前端解码开销都会成为问题;能不能扛住取决于你用哪家 RPC,仓库里没有任何分页实现。
  • 权限规则写了两遍。 合约里是 require,BountyCard 里是 canSubmit / canApprove / canCancel 三个布尔,另外 statusLabels[bounty.status] 用数组下标映射枚举。枚举顺序一旦调整,UI 会静默错位------合约会 revert 挡住非法操作,但界面会显示错误的按钮和错误的标签。
  • README 的合约地址表仍是"待部署",.env.example 里是零地址。 要在 Sepolia 上跑通,得先执行 npx hardhat run scripts/deploy.ts --network sepolia,再把地址填进前后端两份环境变量。另外 hardhat.config.ts 里 PRIVATE_KEY 有一个硬编码的默认值作为兜底,部署前务必确认它被 .env 覆盖。

小结

这个项目最值钱的部分不是三个进程拼起来的完整度,而是把"钱什么时候动、状态什么时候变、谁能触发"这三件事压进了一个四个状态、四条约束的合约里,并且用事件把它投影到链下。托管模式的本质是用代码替换掉对中间人的信任,而这份信任最终落在谁身上,是设计者必须自己回答的问题------在我这版实现里,答案是 creator。代码在 Cooperiano/web3-bounty-board。

相关推荐
OxYGC21 小时前
[Web3] 智能合约 04: 高级开发实战与 OpenZeppelin
web3·区块链·智能合约
OxYGC1 天前
[Web3] 智能合约 03: 函数/事件/继承/接口/异常处理
web3·区块链·智能合约
OxYGC2 天前
[Web3] 智能合约 02: Solidity 基础语法
web3·区块链·智能合约
OxYGC4 天前
[Web3] 基础第7篇: 说透比特币的基本框架与原理
web3·区块链·以太坊·比特币
混凝土拌意大利面4 天前
WEB3核心技术的一种实现:时间长河共识算法(TRC, Time-River Consensus)
web3·区块链·共识算法
OxYGC5 天前
[Web3] 基础第5篇: 共识算法与 P2P 通信协议
web3·区块链·以太坊·比特币
木西12 天前
AI 驱动的去中心化资产管理:QuantixAI 核心金库与智能风控架构解析
web3·智能合约·solidity
m0_5474866613 天前
《Web开发技术基础》全套PPT课件2026
web3·web开发
徐征63915 天前
邮件安全防护体系技术拆解:从入口过滤到大模型研判的检测流水线 北京方向标
web3·生活