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