Spark Program | HashThis Project

HashThis: Fix Report

Timestamp Integrity & Sustainable Fee Model


What I Set Out to Do

HashThis is a proof-of-existence dApp built on Nervos CKB. It lets users anchor a SHA-256 file hash on-chain as permanent, tamper-proof evidence that a file existed at a specific point in time. After completing the initial CCC SDK migration, the committee’s functional review flagged two critical issues that needed to be resolved before the project could be signed off.


The Issues

Issue 1 — Forged Timestamps (Critical)

The most fundamental problem: the timestamp recorded on-chain was coming from the client. The frontend sent a timestamp field in the request body, the backend accepted it without question, and it was encoded directly into the cell data on-chain. Anyone who knew how the API worked could set the timestamp to any date they wanted — past or future — making the entire proof-of-existence concept meaningless.

Issue 2 — Server Wallet Paying for Everything (Important)

Every transaction was being signed and paid for by a single server-side wallet funded with testnet CKB. On testnet this was workable, but on mainnet it would drain quickly and create a centralised payment bottleneck. The project needed users to sign and pay for their own transactions.


How I Solved It

Fix 1 — Server-Side Timestamps

The fix was straightforward once the problem was understood: remove timestamp from the API contract entirely. The field was deleted from the HashPayload TypeScript interface so the compiler would reject any future attempt to pass it in. Inside CKBService.submitHash(), the timestamp is now generated with new Date().toISOString() at the moment the server processes the request. The client has no influence over it whatsoever.

This was applied to both backends — the Vercel serverless functions (api-backend) and the Express server (backend).

Fix 2 — User Wallet Signs and Pays

This required a more significant architectural change. The flow was redesigned from:

client sends hash → server builds tx → server signs → server pays → server broadcasts

To:

client connects wallet → client sends hash + address → server builds unsigned tx → client wallet completes inputs, pays fees, signs → client broadcasts

A new POST /hashes/build endpoint was added to both backends. It accepts a fileHash and userAddress, encodes the hash with a server-generated timestamp, locks the output cell to the user’s address, and returns the unsigned transaction shell. The frontend — using the CCC connector — then completes the transaction and the user’s wallet handles signing and payment.

The server wallet is now completely out of the user transaction flow.

Fix 3 — Verification

Once cells were locked to user addresses instead of the server wallet, the verify endpoint also needed updating. It was previously searching cells under the server’s lock script, which meant it would never find anything anchored through the new flow. The fix was to pass userAddress as a query parameter from the frontend so the backend searches the right lock script.


Issues Encountered Along the Way

Getting to a working state involved a fair number of environmental obstacles:

  • CCC API mismatch — the connector package exported Provider, not CccProvider, and useCcc() returned signerInfo not signer directly. The code had to be adjusted to match the actual installed version.

  • Node polyfills in Vite@ckb-ccc/core uses Node built-ins (process, buffer, stream) that aren’t available in the browser by default. This required adding vite-plugin-node-polyfills.

  • Conflicting wallet extensions — OneKey and MetaMask were fighting over window.ethereum in the browser, causing noisy console errors. These were harmless but distracting.

  • Stale Vercel deployments — several times the live site was serving old code while local was already fixed, which caused confusion about whether fixes were working.

  • Fee rate too low — the initial fee rate of 1000 shannons/KW produced a 374-shannon fee, below the 504-shannon minimum required by the network. Bumping to 1500 shannons/KW resolved it.

  • Vercel routing — the vercel.json route for [hash].ts wasn’t forwarding the captured path segment as a query parameter, so req.query.hash was always undefined. Adding ?hash=$1 to the dest fixed it.

  • Accidental file corruption — a grep command was accidentally appended to api.ts during editing, causing a TypeScript syntax error on Vercel’s build.


Lessons Learned

Lock your API contract with types. Removing timestamp from HashPayload meant the TypeScript compiler became the enforcer — no future developer could accidentally reintroduce the vulnerability without the build failing.

Don’t trust the client with anything that affects integrity. It sounds obvious, but the original code accepted a timestamp from the client because it was convenient. The rule of thumb: if a value affects what gets written on-chain, the server owns it.

Test on the actual deployed environment early. A lot of time was spent debugging issues that only appeared on Vercel — stale builds, missing environment variables, routing config. Catching these earlier would have saved cycles.

Package versions matter more than you think. The CCC SDK is actively developed and the API changes between versions. Always check what’s actually exported by the installed version rather than assuming the docs match.

Fee rates need headroom. Setting a fee rate at exactly the minimum is fragile. A small transaction weight miscalculation drops you below the threshold. Adding 50% headroom is cheap and avoids pool rejections.



HashThis:修复报告

时间戳完整性与可持续费用模型


项目目标

HashThis 是一个基于 Nervos CKB 构建的存在性证明 dApp。它允许用户将文件的 SHA-256 哈希值锚定到链上,作为文件在特定时间点存在的永久、防篡改证据。在完成初始 CCC SDK 迁移后,委员会的功能审查发现了两个关键问题,需要在项目最终验收前解决。


发现的问题

问题一 — 伪造时间戳(严重)

最根本的问题:链上记录的时间戳来自客户端。前端在请求体中发送 timestamp 字段,后端不加验证地接受,并将其直接编码到链上的 Cell 数据中。任何了解 API 工作原理的人都可以将时间戳设置为任意日期——过去或未来——这使得整个存在性证明的概念失去意义。

问题二 — 服务器钱包支付所有费用(重要)

每笔交易都由一个由测试网 CKB 资助的服务器端钱包签名并支付。在测试网上这是可行的,但在主网上会很快耗尽资金,并形成中心化的支付瓶颈。项目需要用户自己签名并支付交易费用。


解决方案

修复一 — 服务器端时间戳

一旦理解了问题,修复方案就很直接:完全从 API 合约中删除 timestamp。该字段从 HashPayload TypeScript 接口中删除,这样编译器会拒绝任何未来尝试传入该字段的行为。在 CKBService.submitHash() 内部,时间戳现在在服务器处理请求时通过 new Date().toISOString() 生成,客户端对此毫无影响。

此修复应用于两个后端——Vercel 无服务器函数(api-backend)和 Express 服务器(backend)。

修复二 — 用户钱包签名并支付

这需要更重大的架构变更。流程从:

客户端发送哈希 → 服务器构建交易 → 服务器签名 → 服务器支付 → 服务器广播

重新设计为:

客户端连接钱包 → 客户端发送哈希+地址 → 服务器构建未签名交易 → 客户端钱包完成输入、支付费用、签名 → 客户端广播

两个后端都新增了 POST /hashes/build 端点。它接受 fileHashuserAddress,使用服务器生成的时间戳编码哈希,将输出 Cell 锁定到用户的地址,并返回未签名的交易框架。前端使用 CCC 连接器完成交易,用户钱包处理签名和支付。

服务器钱包现在完全不参与用户交易流程。

修复三 — 验证功能

一旦 Cell 锁定到用户地址而非服务器钱包,验证端点也需要更新。之前它在服务器的锁定脚本下搜索 Cell,这意味着通过新流程锚定的内容永远找不到。修复方案是从前端将 userAddress 作为查询参数传递,让后端搜索正确的锁定脚本。


过程中遇到的问题

在达到可工作状态的过程中遇到了相当多的环境障碍:

  • CCC API 不匹配 — 连接器包导出的是 Provider 而非 CccProvideruseCcc() 返回的是 signerInfo 而非直接返回 signer。代码需要调整以匹配实际安装的版本。

  • Vite 中的 Node 垫片@ckb-ccc/core 使用了浏览器中默认不可用的 Node 内置模块(processbufferstream)。这需要添加 vite-plugin-node-polyfills

  • 钱包扩展冲突 — OneKey 和 MetaMask 在浏览器中争夺 window.ethereum,产生了大量控制台错误。这些错误无害但令人分心。

  • 过期的 Vercel 部署 — 多次出现线上服务提供旧代码而本地已修复的情况,导致对修复是否生效产生混淆。

  • 费率过低 — 初始费率 1000 shannons/KW 产生 374 shannon 的费用,低于网络要求的 504 shannon 最低值。将费率提高到 1500 shannons/KW 解决了问题。

  • Vercel 路由配置vercel.json[hash].ts 的路由没有将捕获的路径段作为查询参数转发,导致 req.query.hash 始终为 undefined。在目标路径中添加 ?hash=$1 解决了此问题。

  • 文件意外损坏 — 编辑过程中 grep 命令被意外追加到 api.ts,导致 Vercel 构建时出现 TypeScript 语法错误。


经验教训

用类型锁定 API 合约。HashPayload 中删除 timestamp 意味着 TypeScript 编译器成为执行者——未来任何开发者都无法在不导致构建失败的情况下意外重新引入该漏洞。

不要信任客户端提供任何影响完整性的内容。 听起来显而易见,但原始代码之所以接受客户端的时间戳,是因为这样做很方便。经验法则:如果一个值影响链上写入的内容,服务器应该拥有它。

尽早在实际部署环境中测试。 很多时间花在调试只在 Vercel 上出现的问题——过期构建、缺失环境变量、路由配置。早点发现这些问题会节省很多时间。

包版本比你想象的更重要。 CCC SDK 正在积极开发,API 在版本之间会发生变化。始终检查已安装版本实际导出的内容,而不是假设文档与之匹配。

费率需要留有余量。 将费率设置为恰好等于最低值是脆弱的。微小的交易权重计算误差就会让你低于阈值。增加 50% 的余量成本低廉,却能避免交易池拒绝。
@zz_tovarishch @Hanssen @yixiu.ckbfans.bit @xingtianchunyan

2 Likes