稳定币支付如何安全退款:从申请到链上确认
稳定币退款必须作为新的链上转账单独处理。本文讲解 StableOps 如何校验最终订单和剩余额度,由商户从原收款地址签署退款交易,再核对目标地址、资产、金额与最终深度,并通过 Webhook、失败重试和对账避免重复退款与账务遗漏。
银行卡支付通常可以通过支付服务商发起退款。稳定币转账则不同。链上转账一旦确认,不能在原交易上执行撤销。商户必须从自己控制的钱包发起一笔新的转账,把约定金额发送到经过核验的退款地址。
这意味着退款不是支付订单的反向状态,也不是把订单从 finalized 改回未支付。它是一项独立的资金操作,需要自己的权限、状态、交易证据、Webhook 和对账记录。
StableOps 的非托管退款流程把这条边界固定下来:商户控制原收款地址的签名钱包,StableOps 记录退款请求,校验可退款额度,并根据商户提交的交易哈希核对链上结果。平台不持有商户私钥,也不会替商户发送退款交易。
退款是一笔新的链上转账
一笔稳定币退款至少包含两条记录:
原始支付
付款人钱包 -> 商户收款地址
payment.finalized
退款
原始收款地址 -> 客户提供的退款地址
refund.confirmed原始订单说明客户应付什么。退款记录说明商户决定退回什么,以及这笔新的链上交易是否真的完成。两者需要相互关联,但不能互相覆盖。
| 记录 | 作用 | 不应承担的职责 |
|---|---|---|
| 支付订单 | 保存原始金额、链、资产、收款地址和支付生命周期 | 不负责保存退款交易哈希 |
| 退款记录 | 保存退款金额、目标地址、状态和交易证据 | 不把原订单改写成未支付 |
| 商户账本 | 记录销售、退款、人工入账和手续费 | 不用钱包余额差额推断客户身份 |
| 链上记录 | 证明实际发生的转账与规范状态 | 不知道客户订单和退款政策 |
这也是为什么稳定币支付对账需要分别统计收款、退款、人工入账和资金调拨。钱包余额只是一项存量,不能代替事件记录。
只有最终完成的订单才能退款
退款前的第一道边界是订单状态。一个处于 detected 或 confirmed 的订单仍可能因为回执失败或链重组变成 reverted。这时不能把尚未稳定的付款当成可退款资金。
StableOps 只允许 finalized 的支付订单创建退款请求。这样可以避免以下错误:
- 付款后来回滚,商户却已经把同一笔金额退给客户。
- 退款处理与原始支付确认同时进行,形成重复资金流出。
- 客服根据钱包页面或客户提供的交易哈希,绕过最终性检查直接退款。
订单进入 finalized 只表示原始收款达到了平台的最终性边界。它不会自动发起退款,也不会替商户判断退款原因。退款原因、客户身份、客服审批和资金来源仍属于商户自己的业务流程。
这套退款接口不适用于少付、多付、发错网络或过期后到账,因为这些转账没有让原订单进入 finalized。商户应根据实际资金落地的链与地址,使用自己的资金工具处理,并把结果记入异常账本。具体流程见少付、多付或发错网络。
有关确认数与最终性的区别,请阅读稳定币支付确认数如何设计。
退款状态应该表示可观察的事实
退款状态不能只记录“客服点过按钮”。每个状态都应对应一项可以被系统或链上证据证明的事实。
requested -> submitted -> confirmed
| |
canceled failed -> submitted| 状态 | 含义 | 允许的下一步 |
|---|---|---|
requested | 退款金额与目标地址已经登记,尚未提交链上交易 | 签名并提交交易,或取消请求 |
submitted | 商户已提交交易哈希,系统正在核对回执和转账内容 | 等待确认,或在失败后重新提交 |
confirmed | 交易成功,且链、资产、来源、目标和金额全部匹配 | 进入完成与对账流程 |
failed | 交易回执失败,或交易内容不符合退款指令 | 修正问题后重新提交 |
canceled | 尚未提交的退款请求已取消 | 创建新的退款请求 |
submitted 不等于客户已经收到钱。交易哈希可能不存在,回执可能失败,交易也可能包含错误的代币转账。只有 confirmed 才能作为退款完成事件。
退款事件包括 refund.requested、refund.submitted、refund.confirmed 和 refund.failed。你的系统应对这些事件验签、去重,并把真正的客户权益或账务变化做成幂等操作。处理 Webhook 的通用模式见稳定币支付 Webhook:如何防止重复履约。
退款额度必须在并发下保持正确
一笔订单可能发生多次退款。例如,商户先退回一部分,再退回剩余部分。系统不能只检查每一笔退款是否小于原订单金额,还要确保所有未完成退款的合计不超过订单金额。
可退款余额可以这样表示:
可退款余额 = 原始订单金额 -
requested、submitted、confirmed 退款金额之和failed 不占用退款额度。失败退款重新提交时,StableOps 会再次锁定原支付订单并核算剩余额度。如果其他退款已经占用了额度,本次重新提交会被拒绝,避免旧退款和新退款合计超过原始收款。
两个客服窗口如果同时为同一订单发起退款,普通的“读取余额,再写入退款”会产生竞态:两次请求都读到相同的余额,最后合计超过原始收款。
安全的实现需要在同一数据库事务中完成以下操作:
- 锁定支付订单。
- 确认订单属于当前组织和环境,且状态为
finalized。 - 找到订单对应的已匹配支付事件。
- 按该事件的链和资产换算金额。
- 统计仍占用额度的退款。
- 确认本次退款不超过剩余金额。
- 写入退款记录和
refund.requested事件。
其中金额应使用代币的最小单位整数进行比较。不要用 JavaScript 浮点数计算稳定币金额,也不要因为显示格式相同就忽略不同链上的代币精度。
退款地址不能盲目使用付款来源地址
最危险的快捷做法是把原始交易的 from 地址直接当成退款地址。它在很多场景下并不代表真实客户:
- 交易所可能从热钱包统一提币。
- 智能合约钱包可能通过中继地址发起交易。
- 代理服务可能代替客户广播交易。
- 客户可能要求退到另一枚明确归属自己的地址。
退款时应让客户提供目标地址,并完成以下核验:
- 地址属于原始支付使用的同一条链。
- 地址格式与地址类型符合该链的规则。
- 目标地址不是商户误操作时容易混淆的收款地址。
- 客户、组织和资金风险检查满足商户政策。
- 退款金额、资产和手续费承担方式已经得到审批。
StableOps 创建退款请求时会按原始付款链校验目标地址格式。对于 EVM 链,地址随后按既定规则归一化。对于 TRON 和 Solana 等采用不同地址格式的网络,系统会保留原始大小写。格式校验不能证明地址属于客户,地址归属和风险审批仍由商户负责。
退款请求与签名交易要分开
非托管退款最重要的产品边界,是把“申请退款”和“签署交易”拆成两个动作。
第一步由后端创建退款请求。请求绑定已最终完成的支付订单、退款金额、原支付事件、链、资产、原收款地址和目标地址。创建请求不会移动资金。以下代码只能在商户服务端运行,不能把 API 密钥放进浏览器或移动端应用。
async function parseStableOpsResponse(response: Response) {
if (!response.ok) {
const detail = await response.text()
throw new Error(`StableOps API 请求失败(${response.status}):${detail}`)
}
return response.json()
}
const response = await fetch(`${API_URL}/v1/refunds`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
payment_order_id: 'po_123',
amount: '10.00',
destination_address: '0xCustomerRefundAddress',
}),
})
const refund = await parseStableOpsResponse(response)
// refund.status === 'requested'第二步由商户使用原收款地址签署并广播交易。交易成功提交后,商户把交易哈希登记到退款记录:
const submittedResponse = await fetch(`${API_URL}/v1/refunds/${refund.id}/submit`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ tx_hash: txHash }),
})
const submitted = await parseStableOpsResponse(submittedResponse)
// submitted.status === 'submitted'这两个接口的职责不同:
| 动作 | 谁控制 | 是否移动资金 |
|---|---|---|
| 创建退款请求 | 商户业务后端 | 否 |
| 签署并广播交易 | 控制原收款地址的商户钱包或资金服务 | 是,提交到区块链 |
| 登记交易哈希 | 商户后端 | 否,提供待核对的证据 |
| 确认退款 | StableOps 链上对账 | 否,更新退款事实 |
StableOps 的接口路径分别是 POST /v1/refunds、POST /v1/refunds/:id/submit 和 POST /v1/refunds/:id/cancel。字段与响应结构见创建非托管退款请求和登记商户签名的退款交易。生产环境应把签名权限放在资金审批边界之后,不要让面向客户的请求直接获得热钱包签名能力。
确认退款时要核对完整转账事实
只登记一个交易哈希不足以证明退款完成。退款对账至少应验证:
| 字段 | 核对内容 |
|---|---|
| 交易哈希 | 使用退款记录中的原始付款链查询该交易 |
| 回执 | 交易执行成功,不是失败回执 |
| 最终深度 | 当前回执所在区块达到该链配置的最终深度 |
| 来源地址 | 代币转账来自原始支付的收款地址 |
| 目标地址 | 代币转账目标等于退款请求中的地址 |
| 资产 | 代币合约或铸币地址等于原始支付资产 |
| 金额 | 最小单位金额等于退款请求金额 |
对于一个包含多条代币转账的交易,不能因为交易整体成功就把它认定为退款成功。系统必须找到与退款请求完全匹配的那条转账记录。
如果回执暂时不存在或状态未知,退款会保持 submitted 并等待下一次核对。回执失败,或者达到最终深度后仍找不到链、资产、来源、目标和金额完全匹配的转账时,退款才会进入 failed。不要把错误交易标记成已确认,也不要在查明实际资金去向前重复发起交易。
Webhook 处理仍然需要两层幂等
退款事件可能因为网络超时、接收方故障或人工重放而重复投递。接收端应保留原始请求体,先完成签名验证,再按 X-Event-Id 去重。
同时,事件去重不能替代业务幂等。比如,两个不同的 refund.confirmed 事件不应让同一张内部退款单产生两次账务冲销。建议同时建立两道保护:
- 事件收件箱按事件 ID 唯一保存。
- 内部退款记录按退款 ID 或业务退款编号幂等更新。
只有在数据库事务中完成事件记录和账务任务写入后,接收端才应返回成功。外部退款通知、订单状态更新和客户邮件可以放入可重试的发件箱,不要把这些副作用直接放在 HTTP 请求的最后一步。
下面是业务层的简化判断:
function applyRefundConfirmed(refundId: string, eventId: string) {
// 事务内完成:
// 1. eventId 不存在时写入事件收件箱
// 2. refundId 尚未完成时记入退款账务
// 3. 生成一次性的通知或对账任务
// 4. 重复事件不再次冲销
}不要使用浏览器回跳、客户提供的交易哈希或未验签事件直接完成退款账务。它们可以帮助客服排查,但不能替代服务端的链上核对。
常见异常应该如何处理
退款流程需要把失败原因和原始收款记录放在一起,方便客服、工程和财务共同调查。
| 异常 | 正确处理 |
|---|---|
原订单不是 finalized | 等待最终性,或根据业务政策取消退款申请 |
| 退款金额超过剩余额度 | 拒绝请求,检查已有的 requested、submitted 和 confirmed 退款 |
| 地址属于错误网络 | 取消未提交请求,重新收集同一网络上的目标地址 |
| 交易回执失败 | 标记 failed,保留失败原因,修正后重新提交 |
| 交易哈希没有预期转账 | 标记 failed,不要把交易当作成功退款 |
| 暂时查不到回执 | 保持 submitted,等待后续轮询,不要立即重复广播交易 |
| 客户要求退款到新地址 | 重新执行地址验证和审批,不直接覆盖原退款记录 |
| 退款已确认但客户未看到余额 | 提供交易哈希和区块浏览器链接,先确认客户查看的是正确网络和资产 |
付款回滚不是退款失败。payment.reverted 表示原始收款在规范链上不再成立,不能假设商户仍持有一笔需要退回的资金。有关这类边界,请阅读少付、多付或发错网络和付款回滚常见问题。
把退款纳入日常对账
每笔退款至少应能追溯到以下对象:
商户业务订单
-> StableOps 支付订单
-> 原始 payment.finalized 事件
-> 退款记录
-> 退款交易哈希
-> refund.confirmed 事件日常对账可以检查:
- 已最终完成的收款是否与商户账本中的收入一致。
- 每笔退款是否关联一个合法的原始支付事件。
confirmed退款是否都有成功的链上转账。requested或submitted退款是否长时间没有进展。- 退款金额合计是否超过订单金额。
- 退款手续费是否按照商户政策记账。
- 退款交易是否发生在正确的链和资产上。
月末关账时,不要只导出已确认退款。还应保留失败、取消和仍在处理中的退款,记录处理人、审批时间、失败原因以及相关交易引用。稳定币支付对账介绍了如何把订单、支付事件和链上转账连接起来。
上线前核对清单
- 只有
finalized支付订单可以创建退款请求。 - 退款额度按订单串行核算,未完成退款会占用额度。
- 金额按对应资产的最小单位比较,不使用浮点数。
- 退款目标地址由客户明确提供,并完成链、格式和风险核验。
- 创建退款请求与原收款地址的链上签名使用不同的权限边界。
- 提交交易哈希后,服务端核对回执、最终深度、来源、目标、资产和金额。
- 只有完整转账事实匹配时,退款才进入
confirmed。 -
refund.*Webhook 已完成原始请求体验签、事件去重和业务幂等。 - 失败和取消不会覆盖原始支付记录。
- 每笔退款都能关联商户订单、支付订单、原始支付事件和退款交易哈希。
- 沙盒环境至少测试部分退款、并发退款、错误交易和重复 Webhook。
稳定币退款的核心不是增加一个“退款”按钮,而是建立一条可审计的资金流。原始收款保持不可改写,退款作为独立交易经过审批、签名、核对和通知。这样即使链上交易失败、Webhook 重试或客服重复操作,商户仍能清楚回答三件事:原来收到了什么,决定退回什么,链上最终发生了什么。
相关文章
稳定币支付对账需要连接业务订单、支付事件与链上转账。本文讲解如何设计稳定外键、发现 Webhook 缺口,并执行日对账与月对账。
稳定币少付、多付、发错网络或过期后到账,都不应让订单被悄悄完成。本文说明如何发现、处理与预防各类支付不匹配。
稳定币转账并不等于支付完成。本文从支付订单状态机、链与资产精确匹配、确认和重组处理、接口幂等、Webhook 事件去重及可恢复履约出发,说明如何把链上转账转化为可安全进入生产环境、能够审计并支持异常恢复的支付事件。
本文讲解交易平台如何用唯一地址、链上最终确定、Webhook 幂等处理与每日对账构建加密货币充值监控,在支持客户选择多条网络的同时安全增加内部余额,妥善处理过期、迟到与异常转账,并降低重复记账、错误归属和链重组造成的真实资金损失。