mcpField
C
crypto-payments

crypto-payments

github.com/ssheleg开发4224 次安装2 天前更新
代码仓库

关于 crypto-payments

这是一项面向支付集成开发者的通用加密货币支付技能,介绍发票与回调处理。内容侧重支付状态边界、安全且幂等的 Webhook 处理、对账,以及退款和审查冻结的防护措施。

主要功能

  1. 01映射支付服务商状态与终态
  2. 02安全验证 Webhook 签名
  3. 03幂等处理重复回调
  4. 04记录结算与对账数据
  5. 05谨慎处理退款和 AML 冻结

适用场景

  1. 01实现支持部分付款的加密货币结账
  2. 02避免 Webhook 重试造成重复入账
  3. 03依据结算记录调查付款情况
  4. 04将退款和 AML 冻结转交审核

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/ssheleg/sheleg-dev --skill crypto-payments

安装后在 Claude Code 中输入/crypto-payments确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
callback-route-hardening.md
heleket-provider.md
testing-and-local-dev.md

详细介绍

加密货币支付

银行卡扣款要么成功,要么失败,金额也固定不变。加密货币支付则是一项请求,对方可能只支付部分金额、超额支付、延迟支付,或在价格变动后支付。生产环境中几乎所有加密货币结账缺陷,都是因为代码按银行卡支付的方式编写,却遇到了上述情况。

此技能与支付服务提供商无关。某个网关的具体请求/响应格式见 references/heleket-provider.md;以下不变规则适用于 Coinbase Commerce、NOWPayments、BTCPay、Heleket,以及任何会开具发票并回调你的服务。只有一项与服务提供商有关,并会在对应位置标出: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)

影响每项设计决策的三个事实:

  1. 回调至少会投递一次。 服务提供商会不断重试,直到收到 200。每次投递都入账的处理程序会重复入账三次。
  2. 已支付金额不等于开票金额。 少付、多付,以及“支付了与报价不同的币种”都是常见状态,并非错误。
  3. 状态并非按你预期的方式单向变化。 支付可能经历 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链上的唯一关联信息;没有它,争议无法解决
networkTRC20、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_)。大多数加密货币支付网关不会这样做。请根据服务商文档回答以下三个问题,并记录答案:

  1. **是否有单独的测试凭据?**不是“测试模式”,而是不同的密钥。
  2. **能否从密钥中判断环境?**前缀、作用域,或其他任何标记。
  3. **密钥是否也用于签署 Webhook?**如果是,就无法缩小其权限范围:任何能验证回调的东西也能创建扣款。

答案决定设计,而最糟糕的组合很常见:一个密钥,没有标记,“测试模式”只是商户账户上的一个开关。这样,同一个密钥会先创建测试发票,之后有人切换仪表板开关,再创建真实发票——无需部署,也不会向任何持有该密钥的机器发出信号。

按以下顺序,建立服务商所允许的最强控制措施:

  1. 单独的沙盒账户,这样开发机器上的凭据就无法访问生产环境。这是唯一一种在所有人都忘了它的缘由后仍然有效的控制措施——这正是它优于在 README 中发出警告的全部理由。

  2. 声明环境并在启动时断言,适用于没有第二个凭据的情况。将声明保存在与密钥分开的变量中(<PROVIDER>_ENV=test| production):同时携带密钥并指明环境的同一个变量无法与自身进行校验,而两个变量可以。使用非机密信息固定生产环境的身份——商户 ID,或经过截断的生产密钥哈希——并在启动时拒绝以下两种不匹配情况:

    • 生产凭据被声明为测试凭据——显而易见的损失;
    • 测试凭据被声明为生产凭据——更隐蔽的情况:发票会结算到一个无人对账的商户,直到收入缺失才会出错。

    在模块加载时断言,而不是在支付处理程序中:仅仅持有密钥的运行也必须失败,而 Agent 和 CI 运行往往会持有密钥,却从不进行任何扣款。若检查无法判断——没有固定的身份标记——则在测试环境一侧拒绝。“无法证明安全”绝不能被理解成“安全”。

  3. **记录剩余风险。**如果服务商不提供测试凭据,开发者就会持有生产凭据,而断言只能缩小暴露窗口,无法将其关闭。被明确指出的不可避免风险,与未被发现的风险截然不同:前者会得到轮换、监控并重新评估,后者则会在事故发生后才被重新发现。

同一规则也适用于下方的模拟路径:在生产环境中设置 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”、“приём криптоплатежей”。

评论

支持 Markdown