关于 crypto-payments
这是一项面向支付集成开发者的通用加密货币支付技能,介绍发票与回调处理。内容侧重支付状态边界、安全且幂等的 Webhook 处理、对账,以及退款和审查冻结的防护措施。
主要功能
- 01映射支付服务商状态与终态
- 02安全验证 Webhook 签名
- 03幂等处理重复回调
- 04记录结算与对账数据
- 05谨慎处理退款和 AML 冻结
适用场景
- 01实现支持部分付款的加密货币结账
- 02避免 Webhook 重试造成重复入账
- 03依据结算记录调查付款情况
- 04将退款和 AML 冻结转交审核
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ ssheleg/ sheleg-dev --skill crypto-payments安装后在 Claude Code 中输入/crypto-payments确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/callback-route-hardening.mdheleket-provider.mdtesting-and-local-dev.md详细介绍
加密货币支付
银行卡扣款要么成功,要么失败,金额也固定不变。加密货币支付则是一项请求,对方可能只支付部分金额、超额支付、延迟支付,或在价格变动后支付。生产环境中几乎所有加密货币结账缺陷,都是因为代码按银行卡支付的方式编写,却遇到了上述情况。
此技能与支付服务提供商无关。某个网关的具体请求/响应格式见 references/heleket-provider.md;以下不变规则适用于 Coinbase Commerce、NOWPayments、BTCPay、Heleket,以及任何会开具发票并回调你的服务。只有一项与服务提供商有关,并会在对应位置标出:Webhook 签名算法因网关而异。
选择服务提供商是业务和合规决策,而非技术决策。 加密货币支付处理商在监管状况方面差异显著——包括牌照、AML 计划和制裁风险——且其状况会发生变化。在通过任何处理商收取客户款项之前,请核实其当前状况,并定期重新核查。此技能会告诉你如何正确集成某个服务,但不会告诉你应该信任哪一个。
目录
- 生命周期,以及资金在哪些环节会丢失
- 状态映射与最终性
- Webhook 签名验证
- 幂等 Webhook 处理
- 回调路由加固
- 汇率缓冲
- 对账字段
- 入账:金额计算流程
- 退款与 AML 暂缓处理
- 测试/正式凭据边界
- 本地开发与测试矩阵
- 安全检查清单
- 常见陷阱
生命周期,以及资金在哪些环节会丢失
your app gateway blockchain
│ create invoice ─────►│
│◄──── invoice id, address, amount, expiry
│ │
│ (user pays) ────────┼──────────────────────►│
│ │◄── confirmations ─────│
│◄──── webhook: status change (MAY REPEAT)
│ credit the user │
│◄──── webhook: status change (again, later)
影响每项设计决策的三个事实:
- 回调至少会投递一次。 服务提供商会不断重试,直到收到 200。每次投递都入账的处理程序会重复入账三次。
- 已支付金额不等于开票金额。 少付、多付,以及“支付了与报价不同的币种”都是常见状态,并非错误。
- 状态并非按你预期的方式单向变化。 支付可能经历
pending → paid → refund_process → refund_paid,也可能在 AML 暂缓处理中停留数天。请明确建模终态集合。
切勿根据用户重定向回你的网站来确定其权益。 用户的浏览器返回 /success,只能证明用户有浏览器。请根据 Webhook 或服务器端状态轮询入账,绝不能根据客户端着陆页入账。
状态映射与最终性
每个网关都有自己的状态术语。请在一个函数中将其统一映射到你自己的状态,并明确定义终态集合:
const FINAL_STATUSES = ['PAID', 'FAILED', 'REFUNDED', 'EXPIRED'] as const;
function mapStatus(providerStatus: string): PaymentStatus {
switch (providerStatus) {
case 'paid':
case 'paid_over': return 'PAID'; // over-payment still pays
case 'wrong_amount': return 'UNDERPAID'; // partial — DO NOT credit in full
case 'confirm_check': return 'CONFIRMING'; // seen, not yet confirmed
case 'cancel':
case 'fail':
case 'system_fail': return 'FAILED';
case 'refund_process': return 'REFUNDING';
case 'refund_paid': return 'REFUNDED';
case 'locked': return 'AML_HOLD'; // compliance review, may take days
default: return 'PENDING';
}
}
paid_over 是最容易被双向误判的状态:将其视为失败会失去一位付费客户;将其视为恰好付清则会悄悄送出超额部分。请按实际收到的金额入账,而非开票金额——见下方的金额计算流程。
Webhook 签名验证
在证明回调端点安全之前,它就是一个未经身份验证的公共端点。
下面的 sign() 是某一家服务提供商的方案——Heleket 的方案:
md5(base64(json) + apiKey)。 模式(恒定时间比较、对原始请求体签名、解析前验证)具有通用性,但算法没有。经核实,截至 2026-08-30:Coinbase Commerce 使用原始请求体的 HMAC-SHA256 签名(X-CC-Webhook-Signature),NOWPayments 使用排序后的 JSON 的 HMAC-SHA512 签名(x-nowpayments-sig),BTCPay 使用 HMAC-SHA256 签名(BTCPay-Sig)。请以你所用服务提供商自己的参考资料为准,了解 Heleket 的完整格式,请参阅
references/heleket-provider.md。
import { createHash, timingSafeEqual } from 'node:crypto';
// Heleket's scheme. Swap the digest for your provider — see the note above.
function sign(payload: unknown, apiKey: string): string {
const json = JSON.stringify(payload);
return createHash('md5')
.update(Buffer.from(json).toString('base64') + apiKey)
.digest('hex');
}
function verify(body: Record<string, unknown>, apiKey: string): boolean {
const { sign: given, ...rest } = body; // signature is never part of the signed payload
if (typeof given !== 'string') return false;
const expected = sign(rest, apiKey);
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(given, 'utf8');
return a.length === b.length && timingSafeEqual(a, b); // length check FIRST — timingSafeEqual throws on mismatch
}
以下三条规则都曾让真实集成出过问题:
-
使用恒定时间比较。 对签名使用
===会逐字节泄露签名。还要先检查长度:缓冲区长度不等时,timingSafeEqual会抛出异常,因此直接调用会让伪造的签名返回 500,而不是 403。 -
只对服务提供商签过名的内容进行签名。 重新序列化解析后的请求体可能改变内容。如果框架允许,请保留原始请求体。
-
注意转义。 某些网关(尤其是基于 PHP 的网关)会对
json_encode生成的 JSON 签名,它会将正斜杠转义为\/,并将非 ASCII 字符转义为\uXXXX。JSON.stringify不会进行这两种转义。如果不含 URL 的负载签名正确,而包含 URL 的负载签名错误,原因就在这里。尝试两种编码并接受任一种——否则就会拒绝真实回调:const candidates = [json, json.replace(/\//g, '\\/')]; return candidates.some((c) => timingSafeCompare(md5(b64(c) + key), given));
在将任何内容解析到你的领域模型之前,先验证签名;验证失败时,不提供详细信息并返回 403。详尽的错误信息会成为探测工具。
幂等处理 Webhook
不要先读取再写入。让数据库拒绝第二次投递:
以下三项彼此独立——支付的生命周期状态、确认结算后获得的不可变授予,以及任何退款或冻结——混为一谈会导致为尚未结算的款项入账。CAS(使用 status notIn FINAL_STATUSES 条件的 updateMany)只推进生命周期:无论转为 FAILED 还是 PAID,它都会返回 count: 1,因此这并不代表可以入账。**只有确认结算后(mapped === 'PAID')才入账,并通过与入账操作原子执行的、每张发票唯一的授予账本记录确保只入账一次;退款和冻结应走各自的处理路径,绝不能当作重复事件忽略。**完整的处理程序见
references/callback-route-hardening.md。
重复事件一律返回 200。 返回 409 会让服务提供商不断重试。
回调路由加固
有两点与请求本身有关,而非其中的资金;这两点通常会导致“迁移到负载均衡器后 Webhook 就停止工作”:从你实际控制的代理跃点读取调用方 IP,而不是从 x-forwarded-for[0] 读取;并通过精确匹配而非前缀匹配,只豁免唯一一个回调路径,避免豁免 /api/payments 前缀时连结账端点也一并豁免,而资金就在这个端点上。
两点的具体代码见:
references/callback-route-hardening.md。
上文的签名验证才是真正的关卡;这些措施属于纵深防御。
转换缓冲
你以 USD 报价。用户使用的币种汇率会在你报价和对方转账之间波动。没有缓冲时,哪怕汇率只波动百分之几,也会让一笔全额付款变成 wrong_amount,结果你要无缘无故地处理一笔部分付款。
const BUFFER = 0.01; // 1%
const invoiceAmount = round(amountUsd * (1 + BUFFER), 2);
两条规则:
- 缓冲发票金额,按购买意向记账。 收取含缓冲的金额,按用户原本打算购买的内容为其记账。缓冲用于抵御汇率波动;它不是收入,也不应出现在你告知用户购买内容的描述中。
- 同时存储两个金额。
amountUsd(购买意向金额)和invoicedAmount(你要求支付的金额)并不相同;缺少这两个金额,就无法回答支持问题。
请根据你所接受币种的实际汇率波动情况来选择缓冲比例,而不是照搬本文档。
对账字段
六个月后,有人问:“这笔付款到底到账了没有,我们净收了多少?”在 webhook 时保存足够的信息来回答这个问题——支付网关的保留期限不等于你的保留期限:
| 字段 | 原因 |
|---|---|
merchantAmount | 扣除支付网关手续费后的净收金额——永远不等于发票金额 |
paidAmount + payerCurrency | 实际到账金额及其币种 |
paidAmountUsd | 支付网关在结算时给出的 USD 估值 |
commission | 这样净额与总额之间的计算一目了然,无需考古 |
txid | 链上的唯一关联信息;没有它,争议无法解决 |
network | TRC20、ERC20 与 BEP20——同一种币,不同的链,手续费也不同 |
from | 付款方地址,以便应对你迟早会遇到的 AML 问询 |
在结算 webhook 上写入这些字段,并在同一次更新中将状态改为已结算。
记账:金额层级
四个维度,绝不能隐式混用:Money(货币 + 最小单位——绝不使用浮点数)、Asset(网络 + 代币 + 小数金额)、Entitlement(套餐单位)以及带日期的 FX 报价(汇率 + 来源 + 时间)。paidAmountUsd 和 amountUsd 属于 Money;tokenAmount 是 Asset 金额(发票基础金额 + 缓冲——不是套餐单位)。跨维度使用 ?? 意味着隐式转换,因此应先将每个候选值转换为 USD 最小单位——Asset 金额只能通过来自已知来源的带日期报价进行转换;如果报价未知或缺失,必须阻止转换,而不是猜测:
const usd = [payment.paidAmountUsd // Money: gateway's valuation
, toUsdMinor(payment.tokenAmount, quote) // Asset -> Money, dated quote or BLOCKED
, payment.amountUsd]; // Money: the intent — last resort
const credit = usd.find(v => v != null);
顺序很重要:按购买意向金额估算超额付款,会悄悄留下多收的钱;按购买意向金额估算不足额付款,则等于白送产品。请写入一条审计记录,注明来源以及所用报价,否则一旦出现第一笔余额争议,你就无法证明金额。
退款与 AML 暂停处理
加密货币没有拒付,这常被误以为是“无法撤销”。
- 退款是多步骤状态,不是一个事件:
refund_process→refund_paid|refund_fail。你发起退款请求后,并不代表用户已经收到退款。 - AML 暂停处理没有明确期限。 一笔
locked付款可能几小时内解决,也可能永远没有结果。请如实告知用户(“支付服务提供商正在审核”),不要记账,不要自动取消,也不要重试发票——暂停期间开出第二张发票,可能导致收到两笔付款却只提供一份产品。 - 绝不要在 webhook 中自动退款。 应将暂停处理和退款转入人工可见的队列。
应将其设为前置条件,而不是写在段落里。 前四个版本中,上面三行都只是建议:一个从未阅读这些内容的 Agent,以及一个无人查看的 shell,都不会受到这三条建议的影响。现在,这个工具包随附一个 PreToolUse gate ——
plugins/sheleg-dev/hooks/money-gate.js ——
在授权人员为该类别完成本次会话的审批之前,会拒绝退款、付款、关闭争议、在声明为 test 的运行中导出 HELEKET_API_KEY,以及在生产环境中同时使用实时 sk_live_… 密钥和 SKIP_BILLING=true 的操作。工具包的 README.md 说明了各类别及其授权方式。在你的应用中,同样的规则由队列和人工执行;在 Agent 的 shell 中,则由 hook 执行。
测试凭据与生产凭据的边界
**在围绕服务商设计方案之前,先弄清楚它实际提供什么。**银行卡处理商会给你两个并行的世界——Stripe 会提供一个完整的第二个账户,并将环境标记在密钥本身中(sk_test_ / sk_live_)。大多数加密货币支付网关不会这样做。请根据服务商文档回答以下三个问题,并记录答案:
- **是否有单独的测试凭据?**不是“测试模式”,而是不同的密钥。
- **能否从密钥中判断环境?**前缀、作用域,或其他任何标记。
- **密钥是否也用于签署 Webhook?**如果是,就无法缩小其权限范围:任何能验证回调的东西也能创建扣款。
答案决定设计,而最糟糕的组合很常见:一个密钥,没有标记,“测试模式”只是商户账户上的一个开关。这样,同一个密钥会先创建测试发票,之后有人切换仪表板开关,再创建真实发票——无需部署,也不会向任何持有该密钥的机器发出信号。
按以下顺序,建立服务商所允许的最强控制措施:
-
单独的沙盒账户,这样开发机器上的凭据就无法访问生产环境。这是唯一一种在所有人都忘了它的缘由后仍然有效的控制措施——这正是它优于在 README 中发出警告的全部理由。
-
声明环境并在启动时断言,适用于没有第二个凭据的情况。将声明保存在与密钥分开的变量中(
<PROVIDER>_ENV=test| production):同时携带密钥并指明环境的同一个变量无法与自身进行校验,而两个变量可以。使用非机密信息固定生产环境的身份——商户 ID,或经过截断的生产密钥哈希——并在启动时拒绝以下两种不匹配情况:- 生产凭据被声明为测试凭据——显而易见的损失;
- 测试凭据被声明为生产凭据——更隐蔽的情况:发票会结算到一个无人对账的商户,直到收入缺失才会出错。
在模块加载时断言,而不是在支付处理程序中:仅仅持有密钥的运行也必须失败,而 Agent 和 CI 运行往往会持有密钥,却从不进行任何扣款。若检查无法判断——没有固定的身份标记——则在测试环境一侧拒绝。“无法证明安全”绝不能被理解成“安全”。
-
**记录剩余风险。**如果服务商不提供测试凭据,开发者就会持有生产凭据,而断言只能缩小暴露窗口,无法将其关闭。被明确指出的不可避免风险,与未被发现的风险截然不同:前者会得到轮换、监控并重新评估,后者则会在事故发生后才被重新发现。
同一规则也适用于下方的模拟路径:在生产环境中设置 SKIP_BILLING=true 并不是捷径,而是一条可免费获取资金的路径;断言的作用就是拒绝它。
针对完全不提供测试凭据的一个支付网关,提供了端到端的完整示例——
references/heleket-provider.md,测试与生产环境的边界和剩余风险。
本地开发与测试矩阵
你需要同时具备两条开发路径——一条是在生产环境中不可能启用的 SKIP_BILLING=true 分支,另一条是通过隧道连接真实支付网关,并使用带签名的模拟回调测试服务商无法按需触发的异常路径——此外,还要覆盖十种情况,才能相信测试套件全部通过;每种情况都注明了它能捕获的缺陷:
references/testing-and-local-dev.md。
安全检查清单
- 在解析任何域之前验证签名;使用恒定时间比较;先检查长度
- 接受两种 JSON 转义形式,或对原始正文签名
- Webhook 通过对非最终状态执行比较并交换来保证幂等
- 对重复请求返回 200,绝不返回 409
- IP 允许列表从正确位置计算代理跳数;缺少请求头时不得默认允许
- CSRF 仅对一个路径豁免,并进行精确匹配
- 绝不通过客户端重定向授予权益
- API 密钥和商户 ID 从环境变量读取,绝不存放在代码仓库中
- 如果服务提供商提供沙盒账户,则使用该账户;否则记录无法使用的原因
- 声明的环境必须是与密钥分开的变量,并且启动断言会拒绝这两种不匹配情况
- 断言在模块加载时运行,因此即使运行过程只持有密钥也会失败
- 除本地隧道外,所有环境中的回调 URL 都必须使用 HTTPS
- 断言
SKIP_BILLING在生产环境中不可能为真 - 在结算时写入对账字段
- 退款和 AML 暂停交由人工处理,绝不根据 webhook 自动处理
常见问题
| 症状 | 原因 |
|---|---|
| 某些负载的签名有效,另一些则无效 | 正斜杠 / Unicode 转义差异 |
| 伪造的回调导致 500 错误 | 对长度不相等的缓冲区调用 timingSafeEqual 会抛出异常 |
| 用户被入账两三次 | 使用先读取再写入,而不是比较并交换 |
| 服务提供商不断重试 | 对重复请求返回了 409 |
| 基础设施变更后 webhook 失效 | x-forwarded-for 跳数计算 |
| 用户显示“已付款”却没有产品 | 根据浏览器重定向入账 |
一半付款被标记为 wrong_amount | 没有转换缓冲区 |
| 无法回答“我们净赚了多少” | 从未存储佣金和 merchantAmount |
| 开发机、CI 任务或 Agent 运行持有生产密钥 | 没有单独的凭据,也没有将密钥绑定到声明环境的断言 |
| 商户控制面板中出现无人监控的真实发票 | 将暂存凭据声明为生产凭据——这种不匹配没有触发任何拒绝 |
降级处理
- 不是 Claude Code(Cursor、Codex、skills CLI、API 容器):上文所述的
PreToolUse门禁 —plugins/sheleg-dev/hooks/money-gate.js— 不会在这些环境中运行。没有任何机制会拒绝支付、退款、在test运行中导出的HELEKET_API_KEY,或生产环境中的SKIP_BILLING=true。这三行又变回了建议,而正是这种状态让它们连续四个版本都不起作用:明确说明你所在的环境,并明确要求由人工执行授权。 - 通过复制而非作为插件安装:规范会被带入,但钩子不会。README 会说明各类别,以及如何为会话授权。
- 没有 node,或无法运行测试样例:不变式测试样例是此技能中可执行的部分。如果检查未运行,请如实说明,而不要声称不变式已满足——无人查看退出代码时,未运行的检查和通过的检查无法区分。
触发词
其他会路由到此处的短语:“crypto checkout”、“pay with crypto”、“TRC20”、“IPN”、“Heleket”、“NOWPayments”、“недоплата”。
以下短语于 0.16.2 版从描述中移至此处:“USDT payment”、“приём криптоплатежей”。