稳定币支付确认数如何设计
解释确认数、reorg、finalized 状态与商户履约的关系。
稳定币支付看起来像一笔简单转账,但商户系统真正需要的是一个可以履约、可以审计、可以回放的业务事件。确认数设计的目标不是追求绝对速度,而是在用户体验和链上回滚风险之间给出清晰边界。
这篇文章给支付、订单和风控团队一个共同的决策框架:什么时候只更新前端状态,什么时候可以发放低风险权益,什么时候才可以把交易写入不可逆的业务账本。链的具体确认数会随网络和平台配置变化,因此不要把某个数字硬编码在业务服务里;让业务动作跟随统一的支付事件。
为什么不能只看一笔交易
钱包广播成功只说明交易进入网络传播阶段。即使交易已经出现在区块浏览器中,也可能遇到交易替换、链重组或节点短暂不一致。商户系统如果在这一刻就开通权益,后续很容易出现“用户已经拿到商品,但付款从账上消失”的难以解释的状态。
StableOps 把链上转账拆成四个明确状态:
detected:扫描器已经在区块中看到可匹配的候选转账。它适合驱动“已收到,确认中”的用户界面,但不代表资金已经可靠。confirmed:交易达到当前链的业务确认阈值。它适合低风险、可撤销的履约动作。finalized:交易达到更强的最终性阈值。它是对账、记账和不可逆履约的默认触发点。reverted:确认跟踪期间发现链上事实被回滚,或收据执行失败。之前基于这笔交易做的乐观处理都需要被撤销或复核。
这个模型的关键是把“链上看到交易”和“业务可以承诺结果”分开。前者是技术观察,后者是产品承诺;两者之间必须有状态机,而不是一个布尔值。
确认阈值是产品策略
不同业务可以接受的等待时间不同。低额 API 余额充值可能在较低确认数后可用,高额订阅、提币额度提升或交易入金则应等待更强确认。这里需要先回答的不是“某条链要等几块”,而是“这次动作一旦做错,是否能低成本撤销”。
可以用三个问题为产品动作分级:
- 这笔钱对应的权益能否撤销,例如临时试用、站内余额或优惠券?
- 发生回滚时,商户是否能追回价值,还是会变成真实损失?
- 用户是否能接受等待,还是必须先看到“正在确认”的明确反馈?
第一版建议把确认策略放在平台层统一配置,业务系统只消费标准化事件。这样下游不需要理解每条链的出块时间、RPC 延迟和重组特征,只需要处理稳定的 Webhook 状态机。具体确认和最终性定义可随时参考确认状态说明。
一个常见的策略划分如下:
| 业务场景 | 推荐触发状态 | 原因 |
|---|---|---|
| 支付页提示、客服通知 | detected | 提前反馈,但不改变资产或权益 |
| 小额、可撤销的站内余额 | confirmed | 在体验和风险之间折中 |
| 数字商品、账户升级、订阅开通 | finalized | 用户拿到的权益不应依赖可回滚交易 |
| 实物发货、提币、法币结算 | finalized | 风险高且补偿成本高 |
不要把这个表当成唯一规则。高风险行业、受监管资金流或异常账户,可以在同一状态机上叠加风控审核;反过来,促销活动中的小额权益也可以选择在 confirmed 后先行发放。重要的是让例外成为显式策略,而不是散落在各个订单服务里的临时判断。
把事件状态映射到履约动作
订单系统应保存自己的业务状态,但支付状态应以 StableOps 的事件流为事实来源。每个事件都应该只有一个清晰的业务动作和一个明确禁止的动作:
| 事件 | 对用户展示 | 后端应做什么 | 此时不要做什么 |
|---|---|---|---|
payment.detected | 已收到支付,确认中 | 记录链上交易、刷新订单页 | 发货、开通不可逆权益 |
payment.confirmed | 支付已确认 | 允许低风险履约或进入风控队列 | 假设交易已经最终不可逆 |
payment.finalized | 支付完成 | 写入收款账本、触发正式履约 | 再次依赖前端轮询决定结果 |
payment.reverted | 支付未完成 | 停止后续履约、执行补偿或人工复核 | 静默忽略已经发出的乐观动作 |
payment.expired | 订单已过期 | 关闭收款入口、引导重新下单 | 继续等待原订单 |
一个实用的边界是:前端可以用 detected 降低等待焦虑,订单服务可以用 confirmed 做可撤销的预处理,而账务和不可逆履约只消费 finalized。这样即使某次事件延迟或重放,系统仍然知道每个阶段允许做什么。
如果业务必须主动查询状态,也应该把查询结果写回同一套状态转换逻辑,而不是绕过事件处理器直接修改订单。Webhook 是主路径,轮询只是故障恢复和对账补偿手段。
Webhook 必须幂等
确认事件应按 at-least-once 投递设计。网络超时、服务重启和平台重放都会让同一事件多次到达。下游系统需要用事件 ID 去重,而不是假设同一事件只到达一次,更不能仅凭“订单当前看起来已经成功”跳过处理。
一个可靠的 handler 应按下面的顺序工作:
- 读取原始 request body,并先验证签名。
- 用
X-Event-Id建立带唯一约束的处理记录;重复插入表示该事件已经处理过,可以直接返回成功。 - 把耗时的履约、发邮件和第三方调用交给内部队列,尽快返回
2xx。 - 由后台任务按支付事件类型执行状态转换,并把业务结果和事件 ID 一起记录。
import { SIGNATURE_HEADER, verifySignature } from '@stableops/api-sdk/webhooks'
export async function POST(req: Request) {
const rawBody = await req.text()
const verification = verifySignature({
secrets: [process.env.STABLEOPS_WEBHOOK_SECRET!],
header: req.headers.get(SIGNATURE_HEADER) ?? undefined,
rawBody,
})
if (!verification.ok) return new Response('invalid signature', { status: 400 })
const eventId = req.headers.get('X-Event-Id')
if (!eventId || !(await recordEventOnce(eventId))) {
return new Response('ok')
}
await enqueuePaymentEvent(JSON.parse(rawBody))
return new Response('ok')
}recordEventOnce 的实现可以是数据库中 event_id 的唯一索引。关键不在于使用哪种队列或数据库,而在于“去重记录”和“产生副作用”要在同一条可靠的处理链路上。签名校验必须使用原始 body;先解析再序列化 JSON 会改变签名输入。完整的验签、重试和重放行为见 Webhook 文档。
回滚不是异常分支,而是状态机的一部分
payment.reverted 很少发生,但它不应该被当成“日志里看到了再处理”的异常分支。对于每个可能在 confirmed 前后产生乐观效果的动作,都应预先定义补偿:撤销站内余额、取消尚未执行的发货任务、冻结后续服务,或者转交人工审核。
补偿不等于立刻删除所有业务记录。更好的做法是保留原始支付事件、补偿事件和操作者信息,让财务与客服能回答三个问题:当时为什么认为付款有效、系统做过什么、后来为什么撤销。这样对账时不会把链上的回滚误判成普通的订单取消。
还应区分“尚未履约”和“已经履约”。前者可以直接终止任务;后者要根据权益类型选择冲正、补发付款链接或人工处理。即使产品承诺只在 finalized 后履约,也应订阅并记录 payment.reverted,因为它能暴露链数据、节点和订单状态之间的不一致。
上线前的检查清单
- 支付页能清楚展示“等待支付”“确认中”“支付完成”“支付失败或已过期”,而不是把所有状态都写成成功。
- 不可逆履约只由
payment.finalized触发,并且该动作本身可以按订单 ID 安全重试。 - Webhook 先验签、按
X-Event-Id去重,并在超时前返回2xx。 payment.reverted、payment.expired和重复投递都有自动化测试或演练记录。- 订单、支付事件、链上交易哈希和 Webhook 投递 ID 能相互关联,客服可以从订单直接定位问题。
- 失败和死信有告警;修复端点后可以通过重放恢复处理,而不需要人工伪造支付成功。
把这套检查放进发布流程,确认数就不再只是链基础设施团队的参数,而是一个可审计的产品承诺。
下一步
先为你的产品列出所有会因收款而触发的动作,按“可撤销”和“不可逆”分组,再把每一组映射到 confirmed 或 finalized。如果还没有支付事件的端到端测试,从一次 payment.finalized 的幂等履约和一次 payment.reverted 的补偿演练开始,通常能最快暴露状态设计中的缺口。
相关文章
对比托管网关、原始钱包转账和非托管支付基础设施,选择可靠的稳定币收款方案。
在 Next.js 中创建托管 USDC 收银台会话,安全跳转,并且只根据已验证的 Webhook 履约。