接入实战 / OG BUSINESS INTEGRATION
以太坊与波场接入实战
从“手机展示充值地址”开始,完成充值入账、提现提交、Webhook 接收和历史记录更新。Ethereum 与 TRON 使用同一套 API,按当前环境选择网络和资产即可。本文按开发顺序讲做法,完整字段与错误码可随时打开对应 API 参考。
先从 AI 自包含接入包 下载公开规范、配置模板、Python + SQLite 参考实现与固定验收。把语言/框架、数据库、登录主体和既有余额接口填入适配输入后再生成代码;离线验收是主路径,流程模拟器是可选演示。未分别实测的 AI 产品或语言不承诺一次生成成功。
1. 一页看懂接入
你需要开发的是 OG 业务后端。 它调用支付平台 API、保存订单与余额、接收 Webhook,再把业务结果提供给手机页面。API 密钥只放在后端。
充值流程
把链上充值计入业务余额
- OG → 平台取址与二维码
按玩家和网络取址
- 玩家 → 充值地址钱包转账
选择同网络登记资产
- 平台 → OG接收充值结果
充值 Webhook + 查询
- OG 账本确认后入账
txRef 幂等入账一次
提现流程
从业务余额申请向客户提现
- OG 账本建单并预扣
保存原请求和订单号
- OG → 平台提交与出款
受理后等待人工流程
- 平台 → OG接收提现结果
提现 Webhook + 查询
- OG 账本终态结账
原预扣扣款或释放一次
共用 Webhook 入口,按 type 分开处理充值与提现
充值和提现是两条独立流程,各自查询、各自接收通知。提现只要求玩家已准备好并且 OG 业务余额充足,不要求先发生一笔充值。
先记住三个词:入账是增加玩家余额;预扣是提现时占用可用余额;释放预扣是解除这次占用,不是额外送一笔余额,也不是链上退款。
| 支付平台提供 | OG 需要实现 |
|---|---|
| 充值地址、链上状态、提现受理、查询与 Webhook | 玩家登录与授权、业务余额、订单、完整历史与页面更新 |
| 一笔交易的状态变化 | 同一笔业务只记一次账,重复消息只更新原记录 |
建议先完成第 2 节的公共准备,再分别实现充值、提现;Webhook 接收端要在测试转账前准备好。
2. 开始前:准备环境与签名
拿齐当前环境的配置
| 配置 | 用途 |
|---|---|
API baseUrl、API Access Key / secret | OG 后端主动调用平台 |
| Webhook Access Key / secret | 验证平台通知并回签,与 API 凭据独立 |
平台提供的 environment / accessNamespace | 固定环境与稳定接入方身份,用于拒绝事件绑定;不是 API Access Key,不自行命名或采信通知自报值 |
每条开放链的 chainId / chainName / tokenContract / tokenDecimals | 选网络、核对资产和换算金额 |
| 你们的完整 Webhook URL | 平台 POST 通知到此处;固定 HTTPS 路径,不带 query |
先用已鉴权的查询 Webhook 配置核对 environment/accessId 与平台材料一致,再将 environment 和对应 accessId 的 accessNamespace 固定保存。API Access Key 轮换不改变这项稳定身份;不一致先请平台确认。
文档地址、API 地址和模拟器地址分别使用,不能从文档域名推断 baseUrl。本地回环 HTTP 只适合本机联调,远程平台不能通过它访问你的电脑。
当前接入代码与模拟器按测试环境准入,联调用 Ethereum Sepolia 与 TRON Nile。主网接入需由平台提供支持主网的版本和开放材料,不能直接替换 chainId 切换。
主网资料可用于核对配置:
| 公开参考 | Ethereum Mainnet | TRON Mainnet |
|---|---|---|
| 网络 ID | 1 | 728126428 |
| 官方 USDT 合约 | 0xdac17f958d2ee523a2206206994597c13d831ec7 | TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t |
| 官方 USDT 精度 | 6 | 6 |
| 转账费用资产/资源 | ETH Gas | 带宽、能量或 TRX |
合约来源:Tether 官方清单;网络参考:Ethereum EIP-2228、TronLink 网络参数。代币合约不是玩家充值地址,测试网也不能使用主网合约代替登记资产。实际开放网络、合约、精度均以平台材料为准。
封装一次签名,所有 API 复用
POST 使用 UTF-8 JSON,设置 Content-Type: application/json;GET 不发送正文。每次请求带三个头,各发送一份:X-Access-Key、X-Timestamp、X-Signature。时间戳用 Unix 秒,与平台时间相差不超过 300 秒。
签名原文 = METHOD + "|" + PATH + "|" + TIMESTAMP + "|" + QUERY + "|" + BODY
X-Signature = HMAC-SHA256(secret, 签名原文) 的小写十六进制PATH 使用最终请求的转义路径;QUERY 不含 ?,保留原顺序;BODY 使用实际发送的原始字节。GET 的正文为空,空段及分隔符也保留。重试更新时间戳、重新签名,不改原订单内容。
下面的 JSON 都是业务模板:替换 REPLACE_ 后,使用同一份正文签名并发送。可直接复用 API 页的“完整签名 cURL”;算法与可运行例子见签名规则和第一个请求。
金额统一使用最小单位的正整数字符串。精度为 6 时,1 USDT = "1000000",不用浮点数、小数或科学计数。玩家使用稳定的 playerId,推荐简短 ASCII 标识,避免 /;提现订单号为恰好 30 位数字的字符串,保留前导零。
完成标志:正确签名能够调用取址接口。401 先查原文、时间戳和凭据;取址 422 先查两项字段和开放网络,再请平台检查地址服务。
3. 充值:自动取址到到账
OG → 平台
取址并显示二维码POST /v1/addresses
玩家 → 充值地址
玩家钱包转账核对网络与 USDT 合约
平台 → OG
获得充值状态Webhook 推送 + API 查询
OG → 业务余额
确认后入账一次核对资产,按 txRef 入账
打开充值页,自动获取地址
OG 后端从登录态确定玩家,调用获取充值地址:
POST /v1/addresses{
"playerId": "REPLACE_PLAYER_ID",
"chainId": "REPLACE_REGISTERED_CHAIN_ID"
}只传这两个字段。成功 HTTP 200 直接返回对象,没有 data 包装:
{
"address": "REPLACE_RETURNED_DEPOSIT_ADDRESS",
"chainName": "REPLACE_RETURNED_CHAIN_NAME",
"chainId": "REPLACE_RETURNED_CHAIN_ID",
"tokenContract": "REPLACE_RETURNED_TOKEN_CONTRACT"
}保存玩家、环境及这四个返回字段,再由同一个 address 生成二维码。进入充值页、切换玩家或网络时自动获取或复用地址,不用让玩家点击“取址”;平时刷新交易状态不反复取址。地址可能因登记资产或收款合约更换而变化,以最新成功响应为准。
手机二维码只需要包含充值地址。 Ethereum 和 TRON 都要让玩家在外部钱包另选相同网络、登记 USDT 和金额;二维码本身不保证选对网络。地址原样保存,TRON 地址严格区分大小写。
玩家从自己控制的钱包转入返回的地址。没有“提交充值”API;平台观察并同步交易后,才会出现充值记录。
查询或通知到达后,更新这一笔充值
| 什么时候用 | 调用哪个接口 | 如何处理 |
|---|---|---|
还不知道这笔的 txRef | 玩家充值列表:GET /v1/players/{playerId}/deposits?chainId=... | 从 items 发现记录,按 txRef 合并到 OG 账本 |
已知道 txRef | 单笔充值:GET /v1/deposits/{txRef} | 复核当前已同步状态 |
| 平台主动通知 | deposit_* Webhook | 验签后按同一个 txRef 更新,接收方法见第 5 节 |
玩家路径和 query 分别编码,再按最终 URL 签名。玩家充值列表只有 chainId 参数,没有分页、时间或地址筛选;单笔查询不带 query/body。
充值查询 status | 页面展示 | OG 账务动作 |
|---|---|---|
pending | 处理中 / 等待确认 | 不增加余额 |
confirmed | 已到账 | 核对玩家、环境、链、登记合约和金额,按 txRef 入账一次 |
invalidated | 已失效,核查中 | 已入账则冲正一次;未入账则不动余额 |
充值进度通知 deposit_detected、deposit_confirming 只更新展示;deposit_confirmed 的 status 是 confirmed,不是 deposit_confirmed。通知可能直接从已确认状态开始,不必等齐每个中间事件。
充值失效没有单独 Webhook,需后台查询复核。首次成功通知也可能已经过期,参考模式须显式选择查询复核后入账。充值已消费而可用余额不足冲正时,持久人工核查并阻止新增预扣,不自动造欠款或释放其他订单 held;其他策略须由接入方明确提供。 已失效或已冲正后收到旧成功通知,先在事务外查询同一 txRef;只有当前仍为 confirmed 且事实匹配,才锁定原记录并检查版本后恢复。查询失败、404 或列表缺失,都不能触发冲正或恢复。
完成标志:一笔转入显示一条记录,确认后只增加一次余额。没看到记录时,先查钱包网络、目标地址和合约,再请平台核对扫描与同步;不要再转一笔来“验证”。
4. 提现:提交申请到完成
OG → 业务账本
保存原单并预扣固定订单号与首次请求
OG → 平台
提交提现申请受理后等待人工出款
平台 → OG
获取原单结果Webhook 推送 + API 查询
OG → 业务账本
原预扣结账一次succeeded 扣款;failed/refunded 释放
先保存原单和预扣,再提交
- 从登录态取得玩家,校验可用业务余额、网络、收款地址和金额。玩家尚未准备时,先用同一
playerId成功取址;无需先充值。 - 客户端为一次主动申请保存稳定
clientRequestId。OG 以环境、接入方、可信登录主体和此键建立持久唯一约束;同键同内容返回原30位订单号,同键异内容冲突。账户级事务/条件更新同时检查余额、保存原单并预扣,真实并发的不同订单也不能超占。提交后丢响应重试仍复用此键,不再次预扣。 - 调用 POST /v1/withdrawals,正文恰好包含下面五个字段。
{
"withdrawOrderId": "REPLACE_30_DIGIT_WITHDRAW_ORDER_ID",
"playerId": "REPLACE_PLAYER_ID",
"chainId": "REPLACE_REGISTERED_CHAIN_ID",
"amount": "1000000",
"toAddress": "REPLACE_CUSTOMER_RECEIVING_ADDRESS"
}toAddress 是玩家希望收款的钱包地址,必须属于所选网络;不传 chainName/tokenContract/scope。金额示例仅在精度为 6 时表示 1 USDT。
明确受理后,允许继续下一笔
首次成功响应为 accepted=true、broadcast=false、status=manual_pending、route=manual,不含交易哈希。这表示受理,尚未提现成功。 当前人工流程还需要审批、链下出款和确认。
OG 后端核对响应中的原订单号、链、合约、金额、收款地址及受理字段,玩家归属由本地原单确定。持久保存结果后,再让手机清空金额/地址、自动准备下一笔。下方新增原订单的“处理中”记录,原预扣保持;下一笔使用新订单号,并按扣除已有预扣后的可用余额校验。
同单同内容再次 POST 返回保存的首次受理响应,不能用它覆盖后来的成功或失败。最新状态通过单笔提现查询或提现 Webhook 获取。
提现查询 status | OG 如何处理 |
|---|---|
manual_pending、held | 处理中或待核查,保留预扣 |
voucher_checking、voucher_failed | 凭证核验中或本次凭证未通过,仍保留预扣;不是提现失败终态 |
auto_sending | 兼容自动流程状态,仍保留预扣;当前人工主线不要求启用自动提现 |
succeeded | 将原预扣确认为已扣款,仅一次 |
failed、refunded | 释放原预扣,仅一次;不额外增加余额 |
提现进度通知的 accepted/sending/confirming 不是查询状态。有交易哈希、broadcast=false 或等待很久,都不能单独作为结账或释放预扣的依据。
超时、取消怎么处理
已确认部分付款时不得按失败退全额。 例如原单20已确认支付10,随后收到基础 failed/refunded,应视为与既有付款事实矛盾:保留已确认付款记录和整单 held,持久记录人工核查责任;不得进入通用释放分支、删除付款事实或把确认金额清零。预期重复通知与重启后仍不释放;失败先查原单持久付款记录、首次观察原文及核查任务。
提交超时、泛化 422,或查询 404/503 时,保留原单、原五字段和预扣,使用原订单查询或重新签名重试。不能换新订单号重试这笔未知结果,也不能因为历史列表缺单就退款。
如业务需要撤回,可单独接入取消未广播提现:POST /v1/withdrawals/{withdrawOrderId}/cancel,无 query、空 body。明确返回 refunded 才释放一次,并与 withdrawal_refunded 通知共用去重。409 的正文是 {"error":"conflict"};未终态时继续保持预扣。取消超时或 503 先查原单。退款状态表示业务预扣解除,不表示链上自动转币退款。
完成标志:明确受理后可连续填写下一笔,每笔原单始终独立更新、只结账一次。长期处理中时先查原单、人工审批和出款回执,再查 OG 原单与预扣记录。
接上“用户每日提现上限”
管理后台按链配置统一上限,每个用户分别累计,同一用户同一链的所有收款地址共用额度。例如同链上限相同,甲用户已提取的金额不会减少乙用户额度;甲更换收款地址仍使用甲的原额度。以太坊与波场分别计算。
当天按北京时间 00:00 重置,按钱包服务端记录的申请时间所属北京时间日累计,不按 OG 创建订单、手机点击或请求签名时间。首次保存上限之后、申请日期在当天的处理中和成功提现计入;昨天未完成的单不占今天。确定取消、失败或拒绝的申请不计入。再次保存只改金额,不清零当天已计入金额;未设置或已清空不限制,清空后重新保存才从新的保存时刻起算。
接入方保持原来的五字段提交与预扣流程,再增加下面的拒绝处理:
- 提交前保存原单和预扣。 平台可能先投递通知、后返回提交结果,接收端必须能找到原订单。
- 通用 422 先保留预扣并核实请求。 当前普通通道超限返回
{"error":"rejected"},其中没有原因、额度或重置时间;不能直接显示“确定超限”或退款。参数、链等预校验失败也会返回通用 422,但不会生成拒绝通知,不能只等待一个未生成的 Webhook。 - 收到完整 `withdrawal_rejected` 后确认拒绝。 先验签、校验全部字段并核对原单,再在一个持久事务中保存拒绝并释放原预扣一次。它不是
withdrawal_failed,不能只看code或final=true。 - 通知里展示原因和等待时间。 保存原通知快照,更新原订单的“已拒绝”记录;晚到的通用 422/503 不得把它改回“处理中”或再释放。
当前普通通道保存完整每日上限拒绝回执时,单笔 GET 返回 HTTP 200 与通知相同的原文,可补查漏通知。GET 只读,不重算额度、不重开原单或调用钱包付款。旧泛化拒单、损坏或身份不匹配回执仍为 503,保留预扣并交平台核查。玩家提现列表不包含未受理拒单,列表缺失不是退款证明。
手机如何提示超限
code | 页面提示 | 下一步 |
|---|---|---|
USER_DAILY_WITHDRAWAL_LIMIT_EXCEEDED | 已超过每日提现上限,请等待额度重置;显示重置时刻与剩余时间 | retryAt=quota.resetAt 提示本次快照的重置时间,不是提交冷却期;额度恢复后由玩家决定是否发起新申请 |
REQUEST_AMOUNT_EXCEEDS_DAILY_LIMIT | 本次金额超过每日上限,请调整金额 | retryAt/retryAfterSeconds=null;不能提示“等明天就可提这笔金额” |
从完整通知的 quota 读取 limitBase、spentBase、availableBase、requestedBase,先核对 quota.decimals 与可信资产配置的 tokenDecimals 一致,再按精度换算显示,不能只信通知自带精度。当前 spentBase 包含当天计入的处理中和成功申请金额,不只代表已付款;reservedBase="0"、blocked=false。policyVersion 是兼容字段,当前保存配置不会递增,不拿它判断快照新旧。
evaluatedAt 是判断时间,resetAt 是下一次北京时间 00:00,正文使用 UTC 时间字符串。倒数由 OG 后端按可信当前时间计算 max(0, ceil(resetAt - 当前时间)),手机使用校准后的时间;不要从收到通知开始重数 resetInSeconds。通知迟到且重置时间已过时,提示“额度重置时间已到,可重新申请”。快照不是实时额度,倒数到 0 不保证下一笔受理,也不自动发起付款。若已计入订单后来取消或明确失败,或平台提高上限,额度也可能在零点前恢复;retryAt 不禁止这时由玩家发起新申请,但旧单仍不能自动重发。
原拒绝订单在跨日、提额或清空配置后仍然是拒绝。玩家明确重新申请时,才用新的订单号重新检查余额与预扣;不要给旧单自动换号重发。
完整拒绝怎样只释放一次
拒绝通知的 schemaVersion=2 是正文格式版本,不表示普通通道的提交和查询也返回同一正文。完整字段、固定值和离线示例见Webhook API 参考:status=rejected,accepted/broadcast/paymentInitiated=false,final=true,retryMode=new_order,并包含 decisionId/eventId、玩家、原订单、链/合约、地址、金额、code/quota。用下载示例附带的解析器严格检查完整形状、nullable、重复键、UTF-8 和金额字符串,不能只解析自己需要的几个字段。
从自己的可信配置取得 environment/accessNamespace 身份范围(scope),与本地原单一起核对玩家、登记链与合约、按链规范比较的地址、金额、可信资产精度与决定标识;核验由该 scope 与订单派生的 eventId。同 ID 异原文、不同决定、身份不符或已有受理、凭证、付款证据时,保留首次证据和原账,持久记录冲突并核查,不能自动释放。
锁定原单后,只有无受理或付款证据的原预扣 held 才转为 released 一次,不额外增加余额。保存原文、摘要、decisionId/eventId、拒绝状态和账务后提交,再按第 5 节回签;保存失败不 ACK。rejected 不能加入通用 RELEASE;泛化错误、不完整拒绝、404/409/503、超时或额度查询都不能触发释放。重复通知与重启后重投仍验签,但已释放的原单不再改账。
完成标志:通知早于响应、晚于响应、重复、并发或重启后到达,都只释放原单一次;页面能正确提示原因与等待时间。失败先查完整原文、可信 scope 和原单绑定,再查事务、预扣与平台投递记录。
提现页展示当前每日上限
打开提现页或切换网络时,从登录态确定玩家,使用查询每日上限 GET /v1/withdrawal-quota?playerId=…&chainId=…,两个 query 各一次、空 body,按原始路径/query 签名。它读取管理后台保存的同一规则,不是提现前置条件,也不会提交订单。
quota 必返。对象中的 spentBase 是当前北京时间自然日已计入的申请额,含处理中和成功,排除取消、拒绝和失败;昨天未完成订单不占今天。首次保存前申请排除,修改保留起点,清空重存从新起点计入,包含等于起点的申请。reservedBase="0"、blocked=false,剩余钳到 0。decimals=6 时 1000000000 表示 1000 USDT,全程使用精确整数字符串。
quota:null 表示尚未设置或已清空,即不限额;此时不显示剩余额度和重置倒计时,不造一个 0 上限。有对象时 serverNow=quota.evaluatedAt,requestedBase/retryAt/retryAfterSeconds 均为 null,按 resetAt 与可信当前时间展示。当前快照不保证下一笔受理,不用于扣款/释放预扣,不重开已拒绝原单。
401 查签名,422 查登记链、两个 query 与空 body,404 表示玩家不存在,503/超时保持未知,稍后重查;先核对服务版本、目录与数据库只读权限,不把未知改成不限额。
历史特殊通道兼容
仅处理历史特殊通道原单时需要本节。已经保存完整拒绝回执的这类原单,其 POST 422、原单 GET 200 和 Webhook 使用同一份完整拒绝正文,三渠道共用上述一次释放事务;之后暂停能力,历史原单仍保留完整拒绝响应。处理时按原单已保存的通道判断,不能仅看当前开关。保存当前“用户每日提现上限”不会自动启用此能力。
历史特殊通道可能保留跨日未决占用,或返回 USER_WITHDRAWAL_QUOTA_BLOCKED;阻断时 retryAt=null,累计超限也不保证次日有足够额度。这些规则不能套用到当前普通通道。真实 PHP 或其他语言接入方启用该兼容模式前,要独立验证三渠道乱序/重复的一次释放与持久 ACK,不能以本地示例通过代替实际业务验收。
5. Webhook:接收、验签与回签
Webhook 是平台主动调用 OG 后端。先实现一个接收 URL,再由平台登记目标与独立通知凭据。两条链、充值和提现可共用这个 URL,按 type 分别处理;不向平台的 /notify 发请求“订阅”。
平台 → OG
推送通知原始 JSON + 三个签名头
OG 接收端
验证原文签名使用独立 Webhook 凭据
OG → 业务账本
校验并持久提交eventId 防重,更新原交易
OG → 平台
返回正确回签200 + 原请求 X-Signature
最小接收顺序
- 读取原始 path、query 和 body,检查三个签名头各只有一份,通知 Access Key 匹配且时间戳在 300 秒内。
- 用 Webhook secret 对原请求五段原文计算 HMAC,恒定时间比较。验签失败不改账,返回非 2xx。
- 验签后校验事件及字段,充值核对玩家和资产;提现关联本地原单并核对原金额。按
eventId防通知重复,按业务标识防重复改账。 - 在事务中保存通知、业务状态与账务结果,提交成功后才回签。保存失败不成功 ACK;未知订单、事实不足或冲突终态保持原账务,可靠记录异常并交给补查任务。
HTTP/1.1 200 OK
X-Signature: REPLACE_COMPUTED_REQUEST_HMAC
Content-Length: 0回签值是本次原请求 HMAC 的小写十六进制;不签应答 body,不改用当前时间。必须恰好一个 X-Signature,5 秒内返回 2xx,应答体不超过 4096 字节。重复通知仍要验签并正确回签。仅返回 200 不代表投递成功。
先用查询 Webhook 配置核对目标、启用状态和签名配置。它是只读接口,availability=ready 只说明具备发送前提,不证明送达。
事件怎么分流
每一行是一条独立通知,type/status/phase 按对应关系处理,不能交叉组合。进度只更新展示,充值与提现的账务各自独立。
type | status | phase | OG 处理 |
|---|---|---|---|
deposit_detected | deposit_detected | progress | 记录充值已发现,不入账 |
deposit_confirming | deposit_confirming | progress | 更新确认进度,不入账 |
deposit_confirmed | confirmed | 不出现 | 核对后按 txRef 入账一次 |
withdrawal_accepted | accepted | progress | 保留预扣 |
withdrawal_sending | sending | progress | 保留预扣,不证明已广播 |
withdrawal_confirming | confirming | progress | 保留预扣 |
withdrawal_held | held | progress | 等待核查,保留预扣 |
withdrawal_manual_pending | manual_pending | progress | 等待人工流程,保留预扣 |
withdrawal_auto_sending | auto_sending | progress | 兼容自动流程,保留预扣 |
withdrawal_voucher_checking | voucher_checking | progress | 凭证核验中,保留预扣 |
withdrawal_voucher_failed | voucher_failed | progress | 凭证未通过,仍保留预扣 |
withdrawal_succeeded | succeeded | 不出现 | 确认原预扣为扣款一次 |
withdrawal_failed | failed | 不出现 | 释放原预扣一次 |
withdrawal_refunded | refunded | 不出现 | 释放原预扣一次 |
withdrawal_rejected | rejected | 不出现 | 每日上限等受理前拒绝;完整正文严格校验及原单/scope 绑定后,专用事务一次释放 |
旧提现付款终态没有 playerId,由本地原单关联玩家;单笔成功另含 txHash,多笔成功可无顶层哈希,须有 verificationStage=payments_succeeded 与 paymentDetails。核对 paymentState=succeeded、完整事实总额等于原单、remaining/excess为零、没有待确认付款及冻结精度,部分付款不结清。有凭证阶段时可能带扩展字段,按本次通知校验,不能假定固定字段数。字段与配对见Webhook API 参考。确认数字段缺失表示未知,不补 0;充值使用 confirmationsRequired,提现使用 requiredConfirmations。
选择一个语言版本,接上真实事务
目的:复用原文验签、事件分流和提交后回签的接收边界。Go、Java、Python 任选一个,同一份可处理双链及充值/提现;新增拒绝先严格验证完整 wire,再由你们的 commit 完成业务绑定和账务。下载 ZIP 保持目录布局,内含 Go 共享 walletprotocol module、Python og_quota.py、Java QuotaRejection.java 及固定依赖脚本;单独复制接收器源码不包含这些依赖。
| 版本 | 接入方式 | 你们必须实现 |
|---|---|---|
| Go 1.24+,共享 walletprotocol/quota | 将 NewHandler(key, secret, commit) 注册到实际通知路径 | commit 提交完成返回 nil,失败返回 error |
| Java 17+,Jackson + QuotaRejection.java | 构造 OgWebhook,将原始请求交给 receive,原样写回 HttpResult | Commit.save 提交完成才返回,失败抛异常 |
| Python 3.10+,配套 og_quota.py(标准库) | 调用 WebhookReceiver.receive,保留原始 bytes 和完整头列表 | 同步 commit 提交完成才返回,失败抛异常 |
Java 示例需引入 com.fasterxml.jackson.core:jackson-databind:2.22.3。Maven 项目可添加以下依赖;已有项目应通过统一依赖管理保持 Jackson 各模块兼容。
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.22.3</version>
</dependency>Go · HTTP Handler + 共享 quota codec展开接收器源码
下载完整示例与校验依赖 ZIP ↗ · 下载 receiver.go ↗ · 单个源码需配套校验依赖;先接入真实 commit 事务,再用于通知接收。
// Package ogwebhook 提供 OG 通知接收边界:验签、事件分流、事务提交后回签。
// 用 NewHandler 将它接入你们的 HTTP 服务;数据库和账务由 commit 实现。
package ogwebhook
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"math/big"
"net/http"
"regexp"
"strconv"
"time"
"unicode/utf8"
"walletprotocol/quota"
)
const maxBody = 1 << 20 // 本示例的接收端上限:1 MiB。
// Event 保留原始 JSON 字段;金额、链 ID、订单号等不能转为浮点数。
// Family 是 deposit 或 withdrawal;提现所属链和玩家从本地原订单获取。
type Event struct {
Family string
Type string
ID string
Fields map[string]json.RawMessage
}
// String 只读取 JSON 字符串,数字/null/缺失字段均返回 false。
func (e Event) String(name string) (string, bool) {
var value string
err := json.Unmarshal(e.Fields[name], &value)
return value, err == nil && value != ""
}
// NewHandler 必须传入独立的通知凭据和真实的事务处理函数,不能使用空实现。
// commit 的职责(详见配套接入文档):
// - 按当前 OpenAPI 验证完整字段、玩家/链/合约及本地原订单金额。
// - v2 rejected 独立核对可信 environment/accessNamespace、原请求全部身份与 eventId,
// 三渠道共用事务;仅无受理/付款证据的原 held 可转 released 一次。保存原 bytes/摘要/决定,
// 同 ID 异文、不同决定、受理或付款冲突须持久隔离,不覆盖原证据;不能放进通用 RELEASE。
// - 同一事务持久化通知、按 eventId 去重、按 txRef/withdrawOrderId 幂等改账。
// - 进度不改余额;充值确认按 txRef 入账;提现成功确认预扣,失败/退款释放预扣。
// - 拒绝旧进度覆盖终态;异常和终态冲突持久化核查;失效充值恢复先查询复核。
// - 事务 COMMIT 成功后才返回 nil;合法重复也须核对并确认已持久化,再返回 nil。
// - 无法自动结账的异常可持久化到待核查表,保持余额不变,提交后返回 nil。
// 此时 ACK 仅代表可靠接收,不代表充值已入账或提现已结账。
//
// 不要启动 goroutine 后立即返回 nil,不要只写内存;失败返回 error,接收端不会回签。
// 建议服务端设置超时,并保证事务和响应在发送方的 5 秒超时内完成。
func NewHandler(accessKey, secret string, commit func(context.Context, Event, []byte) error) (http.Handler, error) {
if accessKey == "" || secret == "" || commit == nil {
return nil, errors.New("通知 access key、secret 和事务 commit 均不能为空")
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
key, keyOK := singleHeader(r, "X-Access-Key")
timestamp, timeOK := singleHeader(r, "X-Timestamp")
signature, sigOK := singleHeader(r, "X-Signature")
unix, err := strconv.ParseInt(timestamp, 10, 64)
now := time.Now().Unix()
if !keyOK || !timeOK || !sigOK || !hmac.Equal([]byte(key), []byte(accessKey)) ||
!digits.MatchString(timestamp) || err != nil || unix < now-300 || unix > now+300 ||
!lowerSignature.MatchString(signature) {
http.Error(w, "invalid authentication", http.StatusUnauthorized)
return
}
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
} else {
http.Error(w, "cannot read body", http.StatusBadRequest)
}
return
}
// 使用 HTTP 请求的原始路径、查询串与正文;反向代理须保留外部路径前缀。
// 登记的通知 URL 目前不允许 query,因此实际投递 RawQuery 通常为空。
// 仍保留 QUERY 段及分隔符。不要用 URL.Path、解码后的 query 或重序列化 JSON。
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(r.Method + "|" + r.URL.EscapedPath() + "|" + timestamp + "|" + r.URL.RawQuery + "|"))
mac.Write(raw)
expected := mac.Sum(nil)
provided, _ := hex.DecodeString(signature) // 上面的格式检查已限定 64 个小写 hex。
if !hmac.Equal(expected, provided) {
http.Error(w, "invalid authentication", http.StatusUnauthorized)
return
}
event, err := parseEvent(raw)
if err != nil {
http.Error(w, "invalid event", http.StatusBadRequest)
return
}
// 重复通知也执行 commit,由数据库唯一约束和事务完成幂等处理。
if err := commit(r.Context(), event, raw); err != nil {
http.Error(w, "transaction not committed", http.StatusServiceUnavailable)
return
}
// 签原通知请求,绝不签下面的响应正文;恰好返回一份签名。
w.Header().Set("X-Signature", hex.EncodeToString(expected))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "{}")
}), nil
}
func singleHeader(r *http.Request, name string) (string, bool) {
values := r.Header.Values(name)
if len(values) != 1 || values[0] == "" {
return "", false
}
return values[0], true
}
var (
digits = regexp.MustCompile(`^[0-9]+$`)
positiveAmount = regexp.MustCompile(`^[1-9][0-9]*$`)
orderID = regexp.MustCompile(`^[0-9]{30}$`)
lowerSignature = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
type eventRule struct {
family, status string
progress bool
}
// 充值和提现是独立流程,两类均由同一接收地址接收 Webhook。
var eventRules = map[string]eventRule{
"deposit_detected": {"deposit", "deposit_detected", true},
"deposit_confirming": {"deposit", "deposit_confirming", true},
"deposit_confirmed": {"deposit", "confirmed", false},
"withdrawal_accepted": {"withdrawal", "accepted", true},
"withdrawal_sending": {"withdrawal", "sending", true},
"withdrawal_confirming": {"withdrawal", "confirming", true},
"withdrawal_held": {"withdrawal", "held", true},
"withdrawal_manual_pending": {"withdrawal", "manual_pending", true},
"withdrawal_auto_sending": {"withdrawal", "auto_sending", true},
"withdrawal_voucher_checking": {"withdrawal", "voucher_checking", true},
"withdrawal_voucher_failed": {"withdrawal", "voucher_failed", true},
"withdrawal_succeeded": {"withdrawal", "succeeded", false},
"withdrawal_failed": {"withdrawal", "failed", false},
"withdrawal_refunded": {"withdrawal", "refunded", false},
}
// 这里校验分流所需最小字段,不替代完整的 OpenAPI 校验与业务核对。
func parseEvent(raw []byte) (Event, error) {
var event Event
bad := errors.New("invalid event")
// Unmarshal 保留原有的单对象、语法和 10000 层深度边界,再拒绝各层重复键。
if !utf8.Valid(raw) || json.Unmarshal(raw, &event.Fields) != nil || event.Fields == nil || !uniqueJSONKeys(raw) {
return event, bad
}
event.ID, _ = event.String("eventId")
event.Type, _ = event.String("type")
statusValue, _ := event.String("status")
v2 := false
for _, key := range []string{"schemaVersion", "decisionId", "quota", "final", "paymentInitiated", "retryMode", "accepted", "broadcast"} {
if _, present := event.Fields[key]; present {
v2 = true
}
}
if v2 || event.Type == "withdrawal_rejected" || statusValue == "rejected" {
// 共享 Go codec 是完整 v2 wire 的唯一权威;原单/scope 仍须 commit 从可信记录绑定。
rejection, err := quota.DecodeRejection(raw)
if err != nil {
return event, bad
}
event.Family, event.Type, event.ID = "withdrawal", rejection.Type, rejection.EventID
return event, nil
}
status, _ := event.String("status")
amount, _ := event.String("amount")
rule, known := eventRules[event.Type]
if !known || event.ID == "" || status != rule.status || len(amount) > 78 || !positiveAmount.MatchString(amount) {
return event, bad
}
n, validAmount := new(big.Int).SetString(amount, 10)
if !validAmount || n.BitLen() > 256 {
return event, bad
}
event.Family = rule.family
phase, _ := event.String("phase")
_, hasPhase := event.Fields["phase"]
if (rule.progress && phase != "progress") || (!rule.progress && hasPhase) {
return event, bad // 终态必须省略 phase;null 也不接受。
}
if event.Family == "deposit" {
for _, field := range []string{"playerId", "chainName", "chainId", "txHash", "logIndex", "tokenContract", "blockNumber", "txRef"} {
if _, ok := event.String(field); !ok {
return event, bad
}
}
if rule.progress && (!integerAtLeast(event.Fields["confirmations"], 0) || !integerAtLeast(event.Fields["confirmationsRequired"], 1)) {
return event, bad
}
} else {
id, _ := event.String("withdrawOrderId")
if !orderID.MatchString(id) {
return event, bad
}
if event.Type == "withdrawal_succeeded" {
if _, ok := event.String("txHash"); !ok {
stage, _ := event.String("verificationStage")
var details map[string]json.RawMessage
if stage != "payments_succeeded" || json.Unmarshal(event.Fields["paymentDetails"], &details) != nil || details == nil {
return event, bad
}
// Complete arithmetic and frozen-order binding belong to durable commit.
}
}
}
return event, nil
}
// 只检查键的唯一性,不改写参与验签和持久化的原始正文。
func uniqueJSONKeys(raw []byte) bool {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value func() error
value = func() error {
token, err := decoder.Token()
if err != nil {
return err
}
switch token {
case json.Delim('{'):
seen := map[string]bool{}
for decoder.More() {
key, err := decoder.Token()
if err != nil {
return err
}
name, ok := key.(string)
if !ok || seen[name] {
return errors.New("重复 JSON 键")
}
seen[name] = true
if err := value(); err != nil {
return err
}
}
_, err = decoder.Token()
case json.Delim('['):
for decoder.More() {
if err := value(); err != nil {
return err
}
}
_, err = decoder.Token()
}
return err
}
if value() != nil {
return false
}
_, err := decoder.Token()
return err == io.EOF
}
func integerAtLeast(raw json.RawMessage, minimum int64) bool {
var value int64
return string(raw) != "null" && json.Unmarshal(raw, &value) == nil && value >= minimum
}
Java · Java 17 + Jackson + QuotaRejection展开接收器源码
下载完整示例与校验依赖 ZIP ↗ · 下载 OgWebhook.java ↗ · 单个源码需配套校验依赖;先接入真实 commit 事务,再用于通知接收。
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
/**
* 目的:把 ETH / TRON 的充值、提现通知安全交给 OG 的业务事务,提交完成才回签。
* Java 17 + Jackson;框架无关。充值与提现是独立业务,提现不要求先发生充值。
* 旧14事件验证最小包络,v2 拒绝验证完整 wire;业务绑定和账务由必填 Commit 实现。
*/
public final class OgWebhook {
public record Header(String name, String value) {}
public record HttpResult(int status, Map<String, String> headers, byte[] body) {}
@FunctionalInterface
public interface Commit {
/**
* 返回前必须完成持久事务;抛错会得到 503 且不回签。
* 事务须绑定 OG/环境,验证玩家、链、资产、原订单金额及各事件必需字段;
* 按 eventId 保存原文摘要、检测同 ID 异文,并按 txRef / withdrawOrderId 幂等改账。
* 充值进度不入账;confirmed 入账一次,失效/冲正后恢复须先查询复核。
* 提现进度(含 voucher_failed)保留预扣;succeeded 确认扣款一次;
* failed / refunded 释放原预扣一次;乱序进度不得覆盖终态,冲突终态须核查。
* 重复已提交事件可直接返回;异常事实也可持久化隔离、安排核查后返回,不改余额。
* v2 rejected 必须另绑定可信 scope、原请求全部身份与 eventId;三渠道共用事务,
* 仅无 accepted/付款证据的原 held 可转 released 一次。原 bytes/摘要/决定同次保存,
* 同 ID 异文/不同决定/付款冲突持久隔离,不覆盖首次证据,不纳入通用 RELEASE。
* 禁止用空实现、内存集合或日志替代持久事务后就返回成功。
*/
void save(JsonNode event, byte[] originalBody) throws Exception;
}
private record Rule(String status, boolean progress) {}
private static final Map<String, Rule> RULES = Map.ofEntries(
Map.entry("deposit_detected", new Rule("deposit_detected", true)),
Map.entry("deposit_confirming", new Rule("deposit_confirming", true)),
Map.entry("deposit_confirmed", new Rule("confirmed", false)),
Map.entry("withdrawal_accepted", new Rule("accepted", true)),
Map.entry("withdrawal_sending", new Rule("sending", true)),
Map.entry("withdrawal_confirming", new Rule("confirming", true)),
Map.entry("withdrawal_held", new Rule("held", true)),
Map.entry("withdrawal_manual_pending", new Rule("manual_pending", true)),
Map.entry("withdrawal_auto_sending", new Rule("auto_sending", true)),
Map.entry("withdrawal_voucher_checking", new Rule("voucher_checking", true)),
Map.entry("withdrawal_voucher_failed", new Rule("voucher_failed", true)),
Map.entry("withdrawal_succeeded", new Rule("succeeded", false)),
Map.entry("withdrawal_failed", new Rule("failed", false)),
Map.entry("withdrawal_refunded", new Rule("refunded", false))
);
private static final ObjectMapper JSON = new ObjectMapper(JsonFactory.builder()
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build())
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private final byte[] accessKey, secret;
private final Clock clock;
/** 传入独立 Webhook 凭据;不能传 OG 调用平台 API 的密钥。配置不完整时拒绝启动。 */
public OgWebhook(String webhookAccessKey, String webhookSecret, Clock clock) {
if (webhookAccessKey == null || webhookAccessKey.isBlank()
|| webhookSecret == null || webhookSecret.isEmpty()
|| webhookAccessKey.startsWith("REPLACE_") || webhookSecret.startsWith("REPLACE_")
|| clock == null) throw new IllegalArgumentException("Webhook configuration required");
this.accessKey = bytes(webhookAccessKey);
this.secret = bytes(webhookSecret);
this.clock = clock;
}
/**
* rawTarget = 原始转义路径 + 可选 '?' + 原始 query;不能 URL 解码、排序或重新编码。
* Spring/Servlet 适配时用 getRequestURI() 和 getQueryString() 保留原样,
* 不要使用 getParameterMap();代理也必须保留签名时的外部路径与 query。
* headers 必须保留每个头的全部值(getHeaders),不能用单值 Map 隐藏重复头。
* rawBody 从输入流按最多 1 MiB + 1 字节读取;超限立即拒绝,不能先无限读取。
* 先传原字节进来验签,不能让框架解析 JSON 后再序列化。
*/
public HttpResult receive(String method, String rawTarget, List<Header> headers,
byte[] rawBody, Commit commit) {
if (!"POST".equals(method)) return reject(405);
if (rawBody == null || headers == null) return reject(400);
if (rawBody.length > 1_048_576) return reject(413);
if (commit == null) return reject(503);
if (rawTarget == null || !rawTarget.startsWith("/")
|| !rawTarget.matches("[\\x21-\\x7e]+") || rawTarget.contains("#")) return reject(400);
String key, timestamp, supplied;
try {
key = unique(headers, "X-Access-Key");
timestamp = unique(headers, "X-Timestamp");
supplied = unique(headers, "X-Signature");
} catch (IllegalArgumentException e) { return reject(401); }
if (!MessageDigest.isEqual(accessKey, bytes(key))
|| !timestamp.matches("[0-9]{1,20}") || !supplied.matches("[0-9a-f]{64}")) return reject(401);
try {
long ts = Long.parseLong(timestamp), now = clock.instant().getEpochSecond();
if (ts < now - 300 || ts > now + 300) return reject(401);
} catch (NumberFormatException e) { return reject(401); }
int mark = rawTarget.indexOf('?');
String path = mark < 0 ? rawTarget : rawTarget.substring(0, mark);
String query = mark < 0 ? "" : rawTarget.substring(mark + 1);
byte[] body = rawBody.clone();
byte[] expected;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
mac.update(bytes(method + "|" + path + "|" + timestamp + "|" + query + "|"));
expected = mac.doFinal(body);
} catch (java.security.GeneralSecurityException e) { return reject(503); }
if (!MessageDigest.isEqual(expected, HexFormat.of().parseHex(supplied))) return reject(401);
JsonNode event;
try {
// 协议正文为 UTF-8;先严格解码,避免 Jackson 自动识别并接受 UTF-16 等编码。
String utf8 = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(body)).toString();
event = JSON.readTree(utf8);
if (event != null && event.isObject() && (event.has("schemaVersion") || event.has("decisionId") || event.has("quota")
|| event.has("final") || event.has("paymentInitiated") || event.has("retryMode")
|| event.has("accepted") || event.has("broadcast")
|| "withdrawal_rejected".equals(event.path("type").textValue())
|| "rejected".equals(event.path("status").textValue()))) {
event = QuotaRejection.decode(body);
} else {
if (event == null || !event.isObject() || !text(event, "eventId")
|| !text(event, "type") || !text(event, "status") || !text(event, "amount")
|| !event.get("amount").textValue().matches("[1-9][0-9]{0,77}")
|| new BigInteger(event.get("amount").textValue()).bitLength() > 256) return reject(400);
String type = event.get("type").textValue();
Rule rule = RULES.get(type);
if (rule == null || !rule.status().equals(event.get("status").textValue())) return reject(400);
// 终态 phase 必须整个字段缺失;null 或空字符串也不接受。
if (rule.progress() ? !"progress".equals(event.path("phase").textValue()) : event.has("phase"))
return reject(400);
if (type.startsWith("deposit_")) {
for (String field : List.of("playerId", "chainName", "chainId", "txHash", "logIndex", "tokenContract", "blockNumber", "txRef")) {
if (!text(event, field)) return reject(400);
}
if (rule.progress() && (!event.path("confirmations").isIntegralNumber() || event.path("confirmations").bigIntegerValue().signum() < 0
|| !event.path("confirmationsRequired").isIntegralNumber() || event.path("confirmationsRequired").bigIntegerValue().signum() <= 0)) return reject(400);
} else if (!text(event, "withdrawOrderId")
|| !event.get("withdrawOrderId").textValue().matches("[0-9]{30}")) return reject(400);
if (type.equals("withdrawal_succeeded") && !text(event, "txHash")
&& !("payments_succeeded".equals(event.path("verificationStage").textValue()) && event.path("paymentDetails").isObject())) return reject(400);
}
} catch (java.io.IOException | IllegalArgumentException e) { return reject(400); }
try { commit.save(event, body); }
catch (Exception e) { return reject(503); } // 可记录固定错误分类;不要记录密钥或原始敏感正文。
// 回签覆盖「本次原请求」,不签 '{}';只发送一个 X-Signature,用 setHeader 而非 addHeader。
return new HttpResult(200, Map.of("Content-Type", "application/json",
"X-Signature", HexFormat.of().formatHex(expected)), bytes("{}"));
}
private static String unique(List<Header> headers, String name) {
String value = null;
for (Header h : headers) {
if (h == null || h.name() == null) throw new IllegalArgumentException("Invalid header");
if (name.equalsIgnoreCase(h.name())) {
if (value != null || h.value() == null || h.value().isEmpty())
throw new IllegalArgumentException("Missing or repeated signature header");
value = h.value();
}
}
if (value == null) throw new IllegalArgumentException("Missing signature header");
return value;
}
private static boolean text(JsonNode event, String name) {
return event.has(name) && event.get(name).isTextual() && !event.get(name).textValue().isEmpty();
}
private static byte[] bytes(String value) { return value.getBytes(StandardCharsets.UTF_8); }
private static HttpResult reject(int status) {
return new HttpResult(status, Map.of("Content-Type", "application/json"), bytes("{}"));
}
}
Python · Python 3.10+ + og_quota.py展开接收器源码
下载完整示例与校验依赖 ZIP ↗ · 下载 receiver.py ↗ · 单个源码需配套校验依赖;先接入真实 commit 事务,再用于通知接收。
"""OG Webhook 协议接收器:验签、校验事件包络、调用业务事务、提交后回签。
Python 3.10+,需配套 og_quota.py,两者只用标准库。它不启动 HTTP 服务,也不包含假账本。
从现有框架取得原始 body、未解码的 path/query、保留重复项的请求头后调用 receive。
commit(event, raw_body) 必须校验业务并持久提交后才正常返回;失败必须抛异常。
"""
from dataclasses import dataclass
import hashlib
import hmac
import inspect
import json
import re
import time
import sys
from pathlib import Path
# 完整下载包保留仓库相对布局,或将 og_quota.py 与本文件放在同一目录。
try:
from og_quota import decode_rejection
except ModuleNotFoundError as error:
if error.name != "og_quota":
raise
sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "scripts"))
from og_quota import decode_rejection
from typing import Callable
# type -> (status, phase)。None 表示字段必须缺失,不能为 null。
EVENTS = {
"deposit_detected": ("deposit_detected", "progress"),
"deposit_confirming": ("deposit_confirming", "progress"),
"deposit_confirmed": ("confirmed", None),
"withdrawal_accepted": ("accepted", "progress"),
"withdrawal_sending": ("sending", "progress"),
"withdrawal_confirming": ("confirming", "progress"),
"withdrawal_held": ("held", "progress"),
"withdrawal_manual_pending": ("manual_pending", "progress"),
"withdrawal_auto_sending": ("auto_sending", "progress"),
"withdrawal_voucher_checking": ("voucher_checking", "progress"),
"withdrawal_voucher_failed": ("voucher_failed", "progress"),
"withdrawal_succeeded": ("succeeded", None),
"withdrawal_failed": ("failed", None),
"withdrawal_refunded": ("refunded", None),
}
MAX_BODY = 1024 * 1024
@dataclass(frozen=True)
class Event:
family: str # deposit 或 withdrawal;与链无关,不硬编码 ETH/TRON。
event_id: str
type: str
fields: dict
@dataclass(frozen=True)
class HttpResult:
status: int
headers: dict[str, str]
body: bytes = b"{}"
def _object(pairs):
"""拒绝重复 JSON 键,避免不同解析器对同一正文产生不同解释。"""
result = {}
for key, value in pairs:
if key in result:
raise ValueError("重复 JSON 键")
result[key] = value
return result
class WebhookReceiver:
def __init__(self, access_key: str, secret: str, commit: Callable[[Event, bytes], None]):
if (not access_key or not secret or not callable(commit)
or inspect.iscoroutinefunction(commit)
or inspect.iscoroutinefunction(getattr(commit, "__call__", None))):
raise ValueError("必须提供独立 Webhook 凭据和已实现的同步 commit")
self.access_key = access_key
self.secret = secret.encode("utf-8")
self.commit = commit
def receive(self, method: str, raw_target: str,
headers: list[tuple[str, str]], raw_body: bytes) -> HttpResult:
"""raw_target 包含实际转义路径及原 query;禁止传框架已解码的 path。"""
reject = lambda status: HttpResult(status, {"Content-Type": "application/json"})
if method != "POST":
return reject(405)
if not isinstance(raw_body, bytes) or len(raw_body) > MAX_BODY:
return reject(413)
if not isinstance(raw_target, str) or not raw_target.startswith("/") or "#" in raw_target:
return reject(400)
try:
selected = {}
for name in ("x-access-key", "x-timestamp", "x-signature"):
values = [value for key, value in headers if key.lower() == name]
if len(values) != 1 or not isinstance(values[0], str):
return reject(401)
selected[name] = values[0]
stamp = selected["x-timestamp"]
signature = selected["x-signature"]
if (selected["x-access-key"] != self.access_key
or not re.fullmatch(r"[0-9]{1,20}", stamp)
or abs(int(time.time()) - int(stamp)) > 300
or not re.fullmatch(r"[0-9a-f]{64}", signature)):
return reject(401)
path, _, query = raw_target.partition("?")
prefix = f"POST|{path}|{stamp}|{query}|".encode("ascii")
expected = hmac.new(self.secret, prefix + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
return reject(401)
except (ValueError, TypeError, UnicodeError):
return reject(401)
try:
def invalid_constant(_value):
raise ValueError("不是合法 JSON 数值")
fields = json.loads(raw_body.decode("utf-8"), object_pairs_hook=_object,
parse_constant=invalid_constant)
if not isinstance(fields, dict):
return reject(400)
if (any(key in fields for key in ("schemaVersion", "decisionId", "quota", "final", "paymentInitiated", "retryMode", "accepted", "broadcast"))
or fields.get("type") == "withdrawal_rejected"
or fields.get("status") == "rejected"):
fields = decode_rejection(raw_body)
family = "withdrawal"
else:
for field in ("eventId", "type", "status", "amount"):
if not isinstance(fields.get(field), str) or not fields[field].strip():
return reject(400)
pair = EVENTS.get(fields["type"])
if pair is None or fields["status"] != pair[0]:
return reject(400)
if (pair[1] is None and "phase" in fields) or (pair[1] is not None and fields.get("phase") != pair[1]):
return reject(400)
if not re.fullmatch(r"[1-9][0-9]{0,77}", fields["amount"]) or int(fields["amount"]) >= 2**256:
return reject(400)
family = "deposit" if fields["type"].startswith("deposit_") else "withdrawal"
if family == "deposit":
if any(not isinstance(fields.get(k), str) or not fields[k].strip() for k in ("playerId", "chainName", "chainId", "txHash", "logIndex", "tokenContract", "blockNumber", "txRef")):
return reject(400)
if pair[1] == "progress" and any(type(fields.get(k)) is not int or fields[k] < floor for k, floor in (("confirmations", 0), ("confirmationsRequired", 1))):
return reject(400)
elif not isinstance(fields.get("withdrawOrderId"), str) or not re.fullmatch(r"[0-9]{30}", fields["withdrawOrderId"]):
return reject(400)
if fields["type"] == "withdrawal_succeeded" and not (isinstance(fields.get("txHash"), str) and fields["txHash"].strip()):
if fields.get("verificationStage") != "payments_succeeded" or not isinstance(fields.get("paymentDetails"), dict):
return reject(400)
except (ValueError, UnicodeError, RecursionError):
return reject(400)
event = Event(family, fields["eventId"], fields["type"], fields)
try:
# 必须接真实业务事务:完整字段/玩家/资产/原单核对,eventId 与业务键防重,
# 事件和账务一起提交。重复事件也进入此函数,由事务确认已处理。
# v2 拒绝须另绑定可信 scope、原请求及 eventId;无 accepted/付款证据的 held
# 才能转 released 一次。三渠道共用事务,同 ID 异文/不同决定/付款冲突持久隔离。
# 异常事实已持久隔离且未改账时可正常返回;ACK 不代表该笔已结账。
# 不要把此回调实现成 pass、后台提交或仅写日志。
result = self.commit(event, raw_body)
# 同步组件不能把未 await 的事务当成成功,装饰器返回 awaitable 也拒绝。
if inspect.isawaitable(result):
if inspect.iscoroutine(result):
result.close()
return reject(503)
except Exception:
return reject(503) # 不输出凭据或原文;无 X-Signature,平台可按策略重试。
return HttpResult(200, {"Content-Type": "application/json", "X-Signature": expected})
commit 不能是空函数、打印日志或启动后台任务后立即返回。三份示例都需要真实持久事务;框架须保留原路径/原始正文与重复头,不能解析 JSON 后再重组来验签。Python 示例不接受异步 commit 直接传入,ASGI 需在线程池调用同步组件,或实现真正等待提交的异步接收器。框架适配与固定签名演算见接收端指南。
通知可能重复、乱序或跳过进度,迟到进度不能覆盖终态。已持久记录的事件跨重启保留;提现终态(含 withdrawal_rejected)累计 3 次确定投递失败后停止自动重试,不能无限等待重投。DNS/发送前阻塞、进程取消等未知结果的区别及详细退避见通知重试规则。已受理订单继续查询补偿;当前普通通道每日上限拒单缺通知时,要请平台核查拒绝及投递记录。
完成标志:合法通知提交后得到正确回签,重复不重复改账;不符时先查原始字节、独立凭据、事务提交和应答签名,再查平台投递记录。
新建原单或确认充值归属时保存不可变的环境、接入方、玩家、链、合约与精度快照;新配置不覆盖历史资产。历史单查询/通知用旧快照核对,无已登记历史绑定的旧资产进入人工核查。
补查责任要落库:原单、预扣/收件箱和任务同事务创建;任务以 scope+原单/txRef 去重,worker 以租约领取、失败退避、过期重领,结束时核对租约版本。超过重试预算转持久人工核查且有负责人与处理证据;ACK 之前必须已保存任务。通知/查询任意先到与重启重投共用持久幂等账务事务。
6. 历史记录:每笔只保留一条
平台提供当前状态,OG 保存完整业务历史。Webhook 是更新消息,不是一笔新交易。 手机只读取 OG 的授权账本。
| 本地记录 | 唯一标识 | 收到查询或通知后 |
|---|---|---|
| 充值 | txRef | 更新原记录的状态、确认数及入账结果 |
| 提现 | withdrawOrderId | 更新原订单、预扣与结账结果 |
| 通知收件箱 | 当前接入方/环境及 eventId | 保存原始通知与处理结果,同 ID 内容冲突转核查 |
交易哈希只用于定位链上交易,不能代替 txRef 去重:同一交易可能包含多条转账日志。三个标识均按完整字符串保存。
平台 Webhook → OG 验签、幂等事务提交 → 正确回签
OG 提交后 → 通知该玩家页面刷新(SSE / WebSocket / 短轮询任选)
手机重读 OG 账本 → 找到同一 txRef / withdrawOrderId → 更新原记录页面断线或重新打开时,先重读当前玩家、当前网络的账本。迟到响应应核对玩家/网络与当前申请,不能覆盖正在填写的新提现;终态不被旧进度改回“处理中”。实时通道由 OG 自行实现,不是新增平台公共 API。
后台持续查询补偿
| 补偿任务 | 平台接口 | 注意什么 |
|---|---|---|
| 发现充值,复核已知充值 | 玩家充值列表 + 单笔充值 | 列表最多 1000 条、无下一页,只是当前同步视图;已入账充值仍要复核失效 |
| 补查已受理提现 | 玩家提现列表:GET /v1/players/{playerId}/withdrawals?chainId=...&page=1&pageSize=20 | 返回 {items,total,page,pageSize},按原订单合并,保留 OG 自己的未知提交 |
| 跟踪处理中或结果未知的提现 | 单笔提现:GET /v1/withdrawals/{withdrawOrderId} | 原单独立补查,空页、404/503 或超时均不释放预扣;每日上限完整拒绝可由 GET 200 原文补查,旧泛化或损坏回执交平台核查 |
提现列表不包含每日上限拒单,OG 应保留自己的拒绝记录和原通知。提现列表按首次受理时间 acceptedAt 倒序,同秒按订单号倒序;默认每页 20,可选 50,是 offset 分页,新单可能移动边界,须去重并刷新首页。其余页码与范围上限见 API 参考。充值列表按首次发现倒序;区块/日志序号是大整数字符串,不用 JavaScript Number 排序。
平台充值响应没有精确链上时间或逐笔收款地址。页面不要把通知接收时间当作充值时间,也不要把当前充值地址填进全部历史。查询失败或空列表不代表历史不存在,已有本地记录继续保留。
后台补偿独立于手机是否在线运行,与通知共用同一套账务方法。列表达到上限、同步长期中断或发现缺口时记录告警,向平台申请历史核查;不能声称这些列表能回补任意历史。旧近 24 小时充值对账接口仍保留兼容,手机主流程无需依赖它。
完成标志:重复通知和查询都只更新原记录,刷新后历史及余额仍一致。异常先查原业务标识、唯一约束、原单和事务,不按通知条数增加余额或记录。
7. 跟着 OG 模拟器验证
打开OG 流程模拟器,进入交易所 / 第三方接入。按当前服务实际开放网络操作;以下测试步骤分别在 Sepolia、Nile 执行。
链接打不开时,先查模拟器是否已启动以及 /og/ 代理路径。独立本机预览需将文档服务的 OG_DOCS_SIMULATOR_URL 设置为模拟器实际入口,再重启文档服务;预期链接指向实际模拟器,而不是文档服务的 404 页面。
| 步骤 | 你做什么 | 应看到什么 | 不符时先查 |
|---|---|---|---|
| 1. 玩家与网络 | 用右侧“OG 模拟 ID”切换玩家,点击充值/提现网络打开选择框 | 手机显示当前玩家、实际网络与资产 | 当前玩家和登记网络是否可用 |
| 2. 自动取址 | 打开充值标签 | 自动显示地址和纯地址二维码,无需手动取址 | 取址错误与网络/资产配置 |
| 3. 充值 | 客户外部钱包向该地址转入同网络的测试资产 | 同一笔记录从“处理中”变为“已到账”,只入账一次 | 钱包目标、网络、合约与平台同步 |
| 4. 提现 | 有业务余额的玩家填金额和同网络收款地址,提交 | 明确受理后表单自动清空,历史增加一条“处理中”;可直接填下一笔 | 原订单、受理字段、持久保存与可用余额 |
| 5. 等待结果 | 由授权出款人完成审批和实际出款,等待查询/通知 | 原提现记录更新成功或失败,预扣仅结算一次 | 人工队列、回执、确认状态和原单 |
| 6. 观察更新 | 同笔重复查询,等待后续 Webhook,再刷新页面 | 多次消息仍只有一条业务记录,历史与余额不重复变化 | 通知记录、验签、事务与原标识 |
| 7. 换另一条链 | 网络弹框切换 Sepolia/Nile,重新核对地址并重复验证 | 两条链各自的记录和状态正确,不能沿用旧网络参数 | 玩家、网络、合约和完整资产范围 |
模拟器收到 Webhook 并持久保存后,通过 SSE 提示手机重读账本;同时有查询补偿。取址不是实时接口,交易记录才需要持续更新。二维码不携带网络与金额;客户钱包仍须自行核对。
当前模拟器的提现历史来自自身持久订单,按原单查询补偿;没有取消按钮,也尚未调用公共玩家提现分页接口。第 4、6 节分别说明正式 OG 可以接入的取消与历史分页,不能据此认为模拟器已有这些操作。
遇到结果未知的提现,从历史“打开这笔原订单”,查询或按原内容重试;不要更换订单号。模拟器若收到事实不足的 TRON 基础终态,会可靠保存异常、保持预扣并回签,此时用原订单查询补齐事实,不能把“已回签”当成“已结账”。这是模拟器额外采用的保守核查策略,不改变公共契约:三语言适配器在签名、基础终态和本地冻结原单绑定均有效时,可按 succeeded/failed/refunded 结账;只有未知原单、身份/金额冲突、缺必需字段或付款事实矛盾才必须补查/隔离,不因 TRON 基础终态省略可选链字段一律拒绝。
模拟器是 OG 后端的参考实现,它的 /api/*、SSE、二维码和账务字段均是工具自己的接口;正式接入使用本文链接的 /v1/*,密钥与账务仍由你们后端管理。
完成标志:两条链分别验证充值、提现及各自 Webhook,保留真实 txRef、订单号、交易哈希、事件和账务结果。页面可打开或配置显示 ready,不能代替资金流程验收。
8. 验收与常见问题
每条链分别核对
- 自动取址与网络切换正确,二维码与复制地址一致。
- 一笔充值只入账一次,进度不入账;失效与恢复通过单笔查询核实。
- 一笔提现只预扣一次,明确受理后可继续新申请;未知结果始终查原单。
succeeded只确认扣款一次,failed/refunded只释放一次;凭证失败保持预扣。- 充值、提现都能独立收到 Webhook,持久提交后正确回签。
- 每日上限同用户同链跨地址累计,用户和链独立;通用 422/503 不释放,完整拒绝通知仅释放一次。
- 超限通知早到、迟到、重复和重启后重投均正确处理;单笔大于上限不提示“明天一定可提”。
- 北京时间跨日后昨日未完成单不占今日;再次保存不清零,清空后重存从新时刻起算,旧拒单始终不重开。
- 重复、乱序、漏通知及页面重开后,原记录与余额仍一致。
最常见的排错顺序
| 现象 | 先做什么 |
|---|---|
| API 401 | 对照原方法、路径、Unix 秒、query、body 和 API 凭据,重签原请求 |
| 取址 422 | 核对两个请求字段、玩家格式和开放网络;仍失败请平台查地址服务 |
| 钱包已转出,充值没出现 | 核对网络、资产、目标,再请平台查扫描/同步;不再补转一笔 |
| Webhook 重发 | 查原请求 HMAC、事务是否提交、唯一应答 X-Signature,不是只看 200 |
| 提现超时、泛化 422、查单 404/503 | 保留原单和预扣,查询或原内容重试;不换单号。疑似每日上限拒绝但无通知时,请平台核查拒绝与投递记录 |
| 已收到完整超限拒绝,但倒数明显过长 | 核对 resetAt 与 OG 服务端当前时间,不能从收件时间重数旧 resetInSeconds |
| 上限已调高或已跨日,旧单仍被拒绝 | 这是原单的固定拒绝结果;由玩家明确发起新申请,不自动换号重发 |
提现一直处理中或 held | 查人工审批、实际出款与链上确认,等待期间保持预扣 |
| 取消 409 | 当前不能取消;未终态时保持预扣,按原单查询,不当作退款成功 |
| 旧进度覆盖成功、余额重复 | 查事件/业务唯一键、锁或版本及共享账务事务,冲突时保留证据核查 |
需要平台协助时,提供环境、玩家、网络、txRef 或原订单号、交易哈希、eventId 及脱敏响应;不发送密钥。全部字段、限制与错误码以API 参考和OpenAPI为准。