编程 链上对账别只按 txHash 判重:ERC-20 Transfer 的 logIndex、removed 标记与三层幂等

2026-11-16 00:04:26

链上对账的粒度是 chainName+txHash+logIndex,而不是 txHash:Web3 支付自动结算的幂等与阻断边界

文档地址:

Web3 支付自动结算只解决一件事:当本地订单仍未支付,且链上交易与订单期望完全匹配时,在同一个事务内把订单和流水更新为已支付。它不发起链上交易、不托管私钥、不广播交易、不改写链上状态,也不处理退款和争议。

对账粒度必须落在 ERC-20 Transfer 事件上,唯一坐标为 chainName + transactionHash + logIndex。交易 Hash 只提供其中的一个维度,单靠它会漏账。

当前边界

已具备:Payment web3 Provider 创建链上收款上下文、查询交易确认状态;后台单笔与批量只读确认、已结算订单链上二次审计;ERC-20 Transfer 事件匹配、结算 dry-run 计划、计划检查项与 reviewFingerprint;人工复核日志;基于复核日志的显式结算执行器;超时自动通过、自动重试与失败通知;订单状态条件更新与 Transfer 事件唯一性指纹。

明确不做:不托管私钥、不签名、不广播;dry-run 和复核日志阶段不写成功订单;超时自动处理只有在 auto-approve 且 execute-approved=true 时才复用显式结算执行器写订单和流水;不自动退款;不处理跨链桥与非 EVM 链。

后续增强方向是更细的风控策略、链重组工单化处理和真实生产联调。

状态机:ready 只代表可以进入结算事务

结算过程在 Service 内部使用以下候选状态,不一定单独建表:

状态含义
pending本地订单未支付,等待链上交易
confirming交易已发现,但确认数不足
review_required链上确认满足基础条件,但缺少事件匹配上下文或需要人工复核
ready链上交易、金额、收款地址、Token 合约和本地订单状态都匹配
settling正在事务内写入订单和流水
settled已完成本地订单和流水更新
blocked存在阻断原因,不允许结算
failed查询或结算过程失败,需要人工处理

四条规则:不能只凭交易 Hash 标记订单成功;终态订单不能回退到未支付;本地已支付、已退款、已关闭的订单不能再次自动结算;ready 只代表可以进入结算事务,不代表已经结算成功。

第三条和第四条都指向同一个判据——事件级唯一性。一笔 tx 里可以有多条 Transfer,批量转账、合约批量 payout、聚合器路由都会让同一条 transactionHash 下出现多个 logIndex。只按 txHash 去重,同一笔交易里的第二笔收款会被当成“已处理”直接跳过;同一 txHash 内 logIndex 发生变化,之前算出的复核结果也随之失效。指纹里必须纳入 Transfer.blockNumber 和 Transfer.logIndex,原因就在这里。

结算前置检查

进入结算事务前,以下检查必须全部通过:

  • 订单存在:outTradeNo 能查到本地支付订单
  • 支付形态正确:订单 provider 是 web3
  • 本地未支付:本地状态仍为待支付或待确认
  • 链配置有效:chainName 存在且启用
  • 链 ID 匹配:RPC 返回的 chainId 与配置一致
  • 交易存在:交易 Hash 格式合法,RPC 能查到交易或回执
  • 交易成功:回执 status 成功
  • 确认数满足:当前区块高度达到 requiredConfirmations
  • 收款地址匹配:Transfer.to 与订单收款地址一致
  • Token 合约匹配:事件合约地址与订单期望的 Token 合约一致
  • 金额匹配:Transfer.value 与订单 amountRaw 精确相等
  • 事件唯一:chainName + transactionHash + logIndex 未被消费
  • 复核一致:提交的 reviewFingerprint 与服务端重新计算的结果一致

任意一项不通过,只能返回阻断原因,不能写订单状态。

三层幂等

层级推荐 key用途
链上事件chainName:transactionHash:logIndex防止同一笔转账事件重复结算
业务订单outTradeNo防止同一订单被重复变更状态
人工复核reviewFingerprint防止操作员提交过期页面结果;指纹包含 Transfer blockNumber、logIndex 和匹配原因码

结算事务的流程大致如下:

  1. 重新生成单笔结算计划,再校验规则不变的 reviewFingerprint
  2. 再次判断订单状态是否仍允许结算
  3. 用条件更新执行 pending -> success,不依赖 SELECT ... FOR UPDATE 之类的数据库方言锁,优先用 MyBatis-Plus 条件更新保证 MySQL、PostgreSQL、H2 语义一致
  4. 条件更新成功后,用事件唯一 key 写入或更新支付流水
  5. 条件更新失败则重新读取订单状态,返回已支付或并发冲突原因
  6. 写人工复核日志与操作日志
  7. 分发 CashierPaymentOrderSuccessHandler,余额充值、会员权益、业务订单等场景按 scene 做幂等后处理
  8. 发送通知或事件

事件唯一 key 已存在时,直接返回已处理结果,不重复写流水。余额充值这类场景不在 Web3 模块里直接改账,统一走 Payment 成功处理器。

链重组

  • 每条链配置独立的 requiredConfirmations
  • 结算前必须重新查询当前区块高度和交易回执,不能复用缓存
  • 结算前必须重新生成计划并校验 reviewFingerprint,指纹包含 blockNumber、logIndex 和不匹配原因码
  • 事件日志 removed=true 时,必须阻断结算
  • 当前确认数低于要求时,回到 confirming
  • 已结算后才发现链重组,不自动回滚订单,进入异常复核和补偿流程

生产环境上,BSC、Polygon 这类高吞吐链应设置更保守的确认数;大额订单强制人工复核;公共 RPC 只用于开发调试,生产使用稳定节点服务商或自建节点。

回执探针与两种对账口径

排查一笔 tx 到底对应几条 Transfer,直接看回执:

curl -s "$RPC_URL" -H 'Content-Type: application/json' -d '{
"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt",
"params":["0x"]
}' | jq '.result | {
status,
blockNumber,
logs: [.logs[] | {logIndex, address, to: .topics[2], removed}]
}'

status 为 0x1 表示链上执行成功;logs 中每条 Transfer 有独立的 logIndex;address 是 Token 合约;topics[2] 是收款地址,金额在 data 里需要与订单 amountRaw 直接比对;removed=true 说明该条日志已被重组移除。

两种 SQL 口径的差别:

-- 口径 A:只按 txHash 判重
SELECT 1 FROM cashier_payment_transaction
WHERE chain_name = ? AND transaction_hash = ? AND type = 'web3-settlement';
-- 同一 tx 内第二条 Transfer 会被误判为“已处理”,收款被吞

-- 口径 B:按事件唯一键判重
SELECT 1 FROM cashier_payment_transaction
WHERE chain_name = ? AND transaction_hash = ? AND log_index = ? AND type = 'web3-settlement';

口径 A 在单笔简单转账时表现正常,遇到批量 payout 就失效;口径 B 才能让“事件唯一”这条前置检查真正起作用。字段名以实际表结构为准。

人工、半自动与超时自动通过

三阶段推进:阶段 1 人工确认,只产出 dry-run 计划、复核指纹和复核日志,不改订单,当前已完成;阶段 2 半自动,小额、固定 Token、固定收款地址、事件完全匹配后自动结算,执行器雏形已具备;阶段 3 规则化,按链、Token、金额、商户、风险等级配置自动结算规则,仍在设计态。

超时自动处理相关配置及默认值:

spring.open.money.web3.settlement.review:
auto:
enabled: true
auto-startup: true
task-name: money.web3.payment-settlement-review.auto-handle
fixed-delay: 5m
timeout: 30m
action: auto-approve      # 可选 auto-approve / auto-reject
execute-approved: true
limit: 50
failure-notification:
enabled: true

自动重试使用 spring.open.money.web3.settlement.retry.*,周期性扫描可重试候选,写 auto-retry 审计日志后复用同一套显式结算执行器,max-attempts 限制同一订单与复核指纹的重试次数。

两条路径都复用 POST /api/v1/admin/web3/payment-settlement-review-logs/{id}/settle,不维护第二套结算规则。执行入口会重新生成单笔计划并校验 reviewFingerprint,通过后在同一事务内把 pending 订单条件更新为 success,写入类型 web3-settlement 的支付流水,并分发 CashierPaymentOrderSuccessHandler。订单已支付、状态不允许结算、并发更新冲突、指纹变化、计划不再是可结算候选,或同一 outTradeNo + transactionHash + web3-settlement 流水已存在时,返回跳过原因,不写资金状态。自动重试同样不能绕过复核指纹、当前链上计划、状态条件更新和流水幂等。

已结算二次审计:只读,且日志 resolved 不等于资金已修正

定时审计任务由 spring.open.money.web3.settlement.audit.* 驱动,关键项包括:enabled=true、auto-startup=true、task-name=money.web3.payment-settlement.audit、fixed-delay=30m、provider=web3、limit=50、older-than-minutes=60、chain-name=、required-confirmations=(为空时使用订单或链配置)、only-risky=true、risk-notification.enabled=true。

已结算后的链上复查走 POST /api/v1/admin/web3/payment-confirmations/settled-audits。该接口只读复查本地 success 订单,不回滚订单、不修改支付流水、不自动退款,用于识别链重组、RPC 查询失败、确认数不足、交易失败或 ERC-20 Transfer 不匹配等风险。

发现风险后写入 money_web3_payment_settlement_audit_log,后续操作都是运维闭环:

  • POST /api/v1/admin/web3/payment-settlement-audit-logs/{id}/recheck:重新核验单条风险日志,返回最新审计条目和 stillRisky,不改订单、流水或日志状态
  • GET .../compensation-candidates:返回 open + risky 风险日志的只读补偿候选队列
  • POST .../compensation-plan:输出 mark-resolved、wait-confirmations、recheck-later、manual-compensation、manual-review 等只读建议,响应的 riskLevel 与 chainReorgSuspected 用于后台排序和疑似重组提醒
  • PUT .../status-batch:批量把日志标记为 resolved 或 ignored

resolved / ignored 只表示运维处理状态,不代表资金已经修正。真正的链重组补偿或退款仍必须走独立规则和人工复核。

数据模型第一阶段复用已有表:cashier_payment_order、cashier_payment_transaction(含链上事件唯一 key)、cashier_payment_callback_log、money_web3_payment_settlement_review_log、money_web3_payment_settlement_audit_log。

通知内容可以带订单号、链名称、交易 Hash、区块浏览器链接和阻断原因;不能带 RPC URL、私钥、助记词、服务商密钥或任何可直接操作资金的凭据。

对账相关的稳定阻断原因码

原因码保持稳定,便于后台统计和前端展示。与事件级对账直接相关的部分:

原因码触发条件对账含义
chain-id-mismatchRPC 返回链 ID 与配置不一致查到了错误的链
chain-transaction-not-mined交易尚未出块还没有回执,无 logIndex
chain-transaction-failed回执 status 非成功交易存在但未执行成功
chain-confirmations-insufficient确认数低于要求回到 confirming
transfer-missing回执中找不到匹配的 Transfer可能对错了事件
receiver-address-mismatchTransfer.to 与订单收款地址不符事件坐标对上了但收款人不符
token-contract-mismatch事件合约地址与期望 Token 不符同 tx 内其他 Token 的日志
amount-mismatchTransfer.value 与 amountRaw 不等少付、超付或精度问题
transfer-removed事件已标记 removed=true链重组,必须阻断
transaction-already-exists同订单、txHash、结算类型的流水已存在历史口径可能只按 txHash 判重
review-fingerprint-mismatched提交指纹与服务端重算结果不一致blockNumber 或 logIndex 已变化
rpc-query-failedRPC 查询异常需人工介入,不能当作未支付

状态相关的 order-not-found、order-not-settleable、order-update-conflict、local-order-already-paid、provider-not-web3、chain-not-configured 仍然保留,但属于订单与配置层面的判据,与事件匹配无关。

开放问题

进入代码实现前仍需确认:

  • 原生币支付如何匹配金额和收款地址
  • 是否允许一个订单多笔转账合并支付
  • 少付、超付、重复支付如何处理
  • 不同链和 Token 的默认确认数策略
  • 多商户、多租户下的收款地址归属规则
  • 自动结算是否按金额阈值分级
  • 自动结算失败是否进入 Queue 或 Scheduler 重试,当前只做 Notification 提醒

这些问题明确之前,规则化全自动结算应保持设计态或人工确认态。

复制全文 生成海报 链上对账 Web3 支付 ERC-20 幂等 链重组

推荐文章

程序员茄子在线接单