#项目背景
BVT 是一条以债券为核心的链上业务。用户以折价买入债券,折扣率随全网质押比例浮动;债券按持仓累计收益,到期可赎回本金;释放的 BVT 与 bBGT 可继续质押,退出时受每日赎回限额约束,只能进入队列按日等待。
这些状态全部记录在链上,且分散于多个合约:折扣率、APY 与发行总量、可领额度与解锁进度各在一处。合约只回答单点问题,而用户要做的判断 —— 此刻买入是否划算、今天能领多少、退出要等几天 —— 需要把这些单点答案拼成一条时间轴。平台承担的正是这层转换。
站点基于 React 19 + Vite 构建,10 条路由、5 种语言、约 2.2 万行。
#项目角色
独立承担该平台的全部前端开发:路由与页面、合约读写封装、数据分层、多语言资源、构建配置与四个环境的发布。
合约与索引服务由后端与合约侧提供,文中只在前端依赖其边界处提及。
#技术方案
#整体架构
分层依据「是否访问链」划分,越往下越不允许包含视图逻辑:页面负责编排,组件仅接收已解析字段,合约编码收敛于 contract/,读取时机决策收敛于 hooks/,跨页面共享状态收敛于 context/。不按页面划分的原因在于:同一批链上读取要服务多个页面 —— 首页的债券模块、/bond 交易页与统计页调用的是同一组合约方法,按页面切分会造成重复编码与口径漂移。
数据经两条链路进入:一条经 contract/ 直连链上,一条经 axios/ 调用后端索引服务。两条链路不合并,原因在于二者回答的问题不同 —— 链上回答「此刻是多少」,后端回答「排第几、属于谁」;后者依赖索引与遍历,链上不具备。结果在 context/ 中组装为页面所需结构,页面不感知数据来源。
#方案对比
| 选择 | 替代方案 | 理由 |
|---|---|---|
| React 19 + TypeScript | Vue 3 | 与同期站点共用同一份脚手架,组件与 hooks 可直接复用 |
| Vite | Webpack | 四个环境通过 --mode 与四份 .env 切换,配置收敛于单一文件 |
wagmi + viem 的 readContracts | ethers 逐条 call | 批量读取为原生能力:一次编码、一次往返,无需自行维护 multicall 合约地址 |
| Ant Design | 自研组件库 | 表单、表格、弹窗需求密集且模式化,自研成本不划算 |
| i18next + 五份手写 JSON 词条 | 接入翻译管理平台 | 词条由业务侧直接维护,无需翻译流程与外部服务;语言资源随包加载,切换不产生请求 |
#核心业务
首页(/):债券、BVT 兑换、社区与销售数据聚合为一页,承担新用户从了解规则到进入交易页的引导。
债券(/bond):核心交易页,买入前实时取得折扣率与单次可买上限;APY 与发行总量读链上,销售进度读后端。
BVT 转债券(首页兑换模块、/bond/burn-token):以 BVT 折价换取债券额度,操作入口与记录页分离。
质押(/stake、/bbgt):质押 BVT 或 bBGT 生息,年化与累计收益同屏;退出受每日限额约束,超出部分进入队列等待。
每日解锁(/unlock):锁仓按日线性释放,页面以可领、已领、剩余、已过天数四个数组织。
资产与账户(/bvt、/swap、/account):持仓视图、站内兑换,以及个人等级与邀请关系。
统计与赎回(/stats):全站销售与解锁数据;赎回队列位于此页,可在同一目标日内调整两条记录的先后。
#难点与解决方案
#难点一:赎回队列支持顺序改写
问题:链上仅返回「已申请数量」(GetUserRedeemRequests 给出数量与请求日期),不提供遍历接口,无法获知当前排位与前方人数。队列只能由后端按目标日分页提供,每行附带 queue_index 与 estimated_days。该列表还支持将两条记录互换位置 —— 对应链上 SwapQueueItem(a, b),一次交换一对。
约束全部位于前端:单次最多选择两条、等待不足 16 天的行不可选、仅可在同一目标日的查询结果内选择。任一约束未生效,用户签署的即为一笔必然失败的交易,而钱包仅返回「失败」提示。
解决方案:将约束收敛至选择状态,而非分散于按钮的点击事件。
const onChange = (row: RedemptionQueue) => setSwap(prev => {
if (prev.some(i => i.queue_index === row.queue_index)) {
return prev.filter(i => i.queue_index !== row.queue_index) // 再次点击取消选择
}
return prev.length >= 2 ? [...prev.slice(1), row] : [...prev, row] // 超出两条时替换最早选择
})
<Checkbox disabled={row.estimated_days < 16} onChange={() => onChange(row)} />
切换日期时清空已选项并重新拉取,避免跨查询残留;确认弹窗仅接受恰好两条,发交易前再次校验。
结果:互换操作仅有「选择两条 → 确认」一条路径,前端不可能发出越界请求。至于期间链上队列被第三方修改,前端无法拦截 —— 这类失败不会在发起前被发现,只在回执阶段以失败态呈现,由统一的交易通知收尾(复位按钮、弹窗置为失败),具体原因交由区块浏览器核对。
#难点二:解锁类页面需同时处理批量读与批量写
问题:单个地址可能持有多笔锁仓(getUserVestingIds 返回 id 数组),每笔需读取 getVestingInfo 与 getVestingStatus 两个方法,N 笔即 2N 次调用,每次一个 RPC 往返,页面数字逐项呈现。领取若按 id 逐个 release,用户需连续签名 N 次,中途任一拒绝都会留下部分领取状态。
解决方案:读写两侧均向合约的批量方法收敛。读侧以 readContracts 将 N × 2 摊平为一次 multicall,返回后按 index * 2 / index * 2 + 1 还原;写侧调用合约自带的 releaseMultiple(ids),一次完成。两侧遵循同一约束:摊平与还原仅存在于同一函数内,中间不插入其他调用,且不允许外部重排下标。
// 读:N 个锁仓 × 2 个方法,摊平为一次 multicall
const data = ids.flatMap((id) => [
{ ...props, functionName: 'getVestingInfo', args: [id] },
{ ...props, functionName: 'getVestingStatus', args: [id] },
])
const result = await readContracts(wagmiConfig, { contracts: data })
// 还原:结果与入参一一对应
return ids.map((id, index) => ({
vestingId: id,
...result[index * 2].result,
releasable: result[index * 2 + 1].result[0],
released: result[index * 2 + 1].result[1],
remaining: result[index * 2 + 1].result[2],
elapsedDays: result[index * 2 + 1].result[3],
}))
// 写:同一批 id 一次领取完毕,而非逐个 release
await ReleaseMultiple(ids)
结果:10 笔锁仓由 20 次读 + 10 次签名降为 1 次读 + 1 次签名。全站 21 处批量读取均为该结构 —— 债券奖励查询的 40 次 eth_call 亦以同样方式合并为 1 次。
#难点三:一次交易三段等待,一个转圈说不清卡在哪
问题:用户点下确认,实际有三段等待 —— 钱包弹窗等他签名、交易广播后等打包、打包完成等回执。三者若合并成一个转圈,界面上没有任何区别:用户不知道是自己还没签、还是签了链上没动、还是已经失败该重来。连续操作时更糟,上一笔还在等,下一笔的提示又弹出来,分不清哪条对应哪笔。
解决方案:提示按状态建模,而不是按「加载中 / 完成」两态。状态分四档 —— 等待签名、已广播待回执、成功、失败;授权与业务交易共用这套状态,但文案分开,同样是等待,授权那步说的是「请在钱包中确认授权」,业务那步说的是「请在钱包中确认交易」。连续操作时最多同时 3 条、6 秒自动关闭,不堆叠。失败在统一的 catch 里收尾:复位按钮的 loading、把该笔的弹窗置为失败态、清空挂起中的交易标识,避免按钮停在那转。
结果:用户能分辨「还没签」与「签了没回执」;一笔失败不会把下一笔的按钮带进 loading;连续下单时提示有数量与时长上限,不会糊成一片。这里有一处刻意的收敛 —— 前端不解析合约回滚原因:错误串的格式因钱包与链而异,自行解析会得到时对时错的文案,页面只呈现失败,具体原因交区块浏览器。省下的是解析逻辑,代价是用户要多跳一次。
#项目成果
- 业务收益:赎回在每日限额约束下仍具备调整空间 —— 队列内一次互换替代「取消申请 + 重新排队」,兑付时间不再由申请时序单向决定
- 操作成本:多笔锁仓由 N 次签名收敛为 1 次(
ReleaseMultiple);债券奖励查询由 40 次eth_call收敛为 1 次;10 笔锁仓的读取由 20 次收敛为 1 次 - 加载性能:10 条路由中 8 条按需加载,图表库不进入首屏;vendor 按用途分为 9 块,业务发布不使第三方库缓存失效;产物以内容哈希命名并在 10 KB 阈值上预压缩
- 交易可靠性:六个合约模块 21 处写操作在发交易前先比对链 ID,链不匹配先切换,拒绝即中止;交易反馈拆为四态(等待签名 / 待回执 / 成功 / 失败),授权与业务交易文案各异
- 交付规模:10 条路由、5 种语言、约 2.2 万行,四个环境(dev / test / prev / prod)各持独立配置
- 工程质量:
strict: true(app 与 node 两份 tsconfig);16 项VITE_*覆盖全部合约地址,源码零地址字面量
#项目链接
源码托管于公司私有 Git 仓库,受知识产权约束,不对外公开。替代说明材料为节点销售、债券、质押与解锁等页面的部分截图,以及本页的技术方案与难点复盘。