# WalletShield USDT Webhook API：客户端完整接入文档

接口契约 **1.2.1**；文档修订 **2026-10-25 / public-12**；路径 `/v1`；事件正文 `schema_version="1.0"`。

**能力状态：BSC 主网、BSC 测试网、Sepolia、Nile 四网已启用按需采集及自动积压余额核对；无监控地址时空闲。有地址积压时先核对余额，持久化净差通知和缺口后从新边界继续。ETH／TRON 主网因黑名单持续逐块采集。净差不提供逐笔 Transfer 或付款证明，零差不生成通知；既有 detected／confirmed／reverted 语义保持。**

**最新文档固定地址：https://webhook.walletshield.ai/doc/api.md**

**API Base URL：https://webhook.walletshield.ai**（不包含 `/v1`）。

本文公开访问，不需要密钥。服务方另行提供一个 `WS_WEBHOOK_API_KEY` 后，客户端可按本文调用管理 API、创建回调并取得自己的签名密钥、完成模拟联调、在可用且获授权的网络创建钱包订阅及验证两阶段通知。所有请求与回调样例、验签向量和完整接收器代码都包含在本页；无需取得项目目录或其他文档。

**同一 API Key 可以同时用于主网和测试网。** 当前已授权客户端沿用原 Key 并增加三个主网权限，网络范围为全部六网；Ethereum Sepolia、BSC Testnet、TRON Nile 可用于联调，各网络采集与投递实时状态以接口为准。三主网真实客户端转账验收尚未完成。截至 2026-10-18，三个测试网的真实指定 USDT 转入、转出及 detected／finalized 两阶段通知闭环已通过核对，依据为客户端验签、持久化报告，以及服务端记录和独立公共 RPC 的链上核验。实际可用状态、权限和指定资产仍以 `/v1/networks` 为准；拥有网络权限不代表采集已就绪；按需网络 idle/degraded 可先创建订阅唤醒，转账前必须确认订阅 active、存在 start_block 并核对网络健康。

已核对 BSC 返回 503 后的重试与持久去重，以及客户端报告的原生币不误报、事件补拉和删除流程。真实链重组未在本次联调中发生。上述结果仅验收测试网链上通知，不代表真实订单、资金入账或商户回调已上线，也不构成延迟 SLA。

客户端需要提供自己的公网 HTTPS 接收地址。模拟测试无需链上资产；真实转账需自己的测试钱包、手续费币和指定测试 USDT。钱包私钥始终由客户端保管，管理 API 不接收私钥、助记词或交易签名。本文中的 `example`、`YOUR-CALLBACK-DOMAIN`、演示 Hash 和固定签名密钥都是占位数据，必须按流程替换。

开始接入：[第 11 节快速开始](#quickstart)。接口规则：[鉴权](#section-1)、[网络](#section-3)、[回调管理](#section-4)、[订阅](#section-6)、[补拉](#section-7)、[投递重试](#section-8)、[消息字段](#section-9)、[净余额核对](#balance-reconciliation)、[验签](#section-10)、[两阶段与重组](#section-12)。附录：[验收步骤](#acceptance)、[全部 JSON 样例](#samples)、[独立接收器](#receiver)。

## 接口总览

| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/v1/networks` | 查询获授权网络与测试币 |
| GET | `/v1/webhook-endpoints` | 查询本客户端回调地址 |
| POST | `/v1/webhook-endpoints` | 注册 HTTPS 回调地址 |
| PATCH | `/v1/webhook-endpoints/{id}` | 暂停、恢复或修改回调 |
| POST | `/v1/webhook-endpoints/{id}/rotate-secret` | 轮换回调签名密钥 |
| POST | `/v1/webhook-endpoints/{id}/test` | 排队发送模拟通知 |
| GET | `/v1/subscriptions` | 查询监控订阅及生效状态 |
| POST | `/v1/subscriptions` | 创建网络 USDT 地址订阅 |
| DELETE | `/v1/subscriptions/{id}` | 幂等停止订阅 |
| GET | `/v1/events` | 增量查询真实订阅事件，包括 BSC 主网／测试网、Sepolia、Nile 净余额审计事件 |
| GET | `/v1/balance-snapshots` | 查询已保存的余额快照 |
| GET | `/v1/deliveries` | 查询 Webhook 投递状态 |
| POST | `/v1/deliveries/{id}/retry` | 重试原投递任务 |
| GET | `/v1/collection-gaps` | 查询网络历史采集缺口 |

## 当前指定测试资产

| network | chainId / 网络 | USDT 合约 | decimals |
| --- | --- | --- | --- |
| ethereum-sepolia | 11155111 | `0xb464a579e9697694833f544d1b464cbd51405c84` | 6 |
| bsc-testnet | 97 | `0x436fc60cef0a8b4054420b50220c834a34bf7b90` | 18 |
| tron-nile | TRON 网络，无 EVM chainId | `TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf` | 6 |

此表是已登记资产清单；开始真实转账前用 `/v1/networks` 再核对。测试币不保证由 Tether 官方发行。不同合约的同名 USDT 不会被识别；手续费 ETH、BNB、TRX 不属于 USDT 动账。协议还包含 ethereum-mainnet（chainId 1）、bsc-mainnet（56）、tron-mainnet；主网权限和资产只能使用接口实际返回的配置。

领币入口由 `/v1/networks` 的 faucet.native_token_url、faucet.test_usdt_url、faucet.instructions 返回，链接可能需要登录或人机验证。当前两个 EVM 指定测试 USDT 没有登记公共领币链接，可使用已有持币测试钱包向收款钱包转账；Nile 领币入口以返回值为准。不要为测试自行换成另一合约，缺少测试币时可先完整进行模拟验签、重试与去重联调。

付款页面的链状态对应：detected → “已检测到付款，确认中”；confirmed 且 confirmation_status=finalized → 客户端独立查链、匹配有效订单、校验金额并完成幂等确认后，才显示“支付成功”和回调商户。转出不构成付款到账。wallet.balance.reconciled 仅用于审计，净增加也不得更新付款页面或确认订单。

<a id="section-1"></a>

## 1 地址与鉴权

外部交付的 WS_WEBHOOK_BASE_URL 是 HTTPS 根地址，不含末尾 /v1；下文路径均包含 /v1。当前交付地址为 `https://webhook.walletshield.ai`；内部环回地址只供服务运维使用。客户端调用本服务，回调则由本服务调用客户端，两种请求方向使用不同密钥。

| 凭据 | 用途 | 获取方式 |
| --- | --- | --- |
| API Key | 客户端调用管理 API | 服务部署并创建客户端后，由服务端单独交付 |
| signing_secret | 客户端验证收到的 Webhook | 创建回调或轮换密钥的成功响应 |
| key_id | 查找对应签名密钥 | 与 signing_secret 一起返回，回调请求头也携带 |

管理请求头：

```http
Authorization: Bearer YOUR_CLIENT_API_KEY
Content-Type: application/json
Accept: application/json
Idempotency-Key: 7f8b7e6e-3286-438e-b095-a43329579ac2
```

Idempotency-Key 对 POST 和 PATCH 必填，对 DELETE、GET 不需要。使用 1 至 128 个 ASCII 字符，建议每个逻辑操作生成新的 UUID；失败重试原操作时保持原键和请求体。幂等记录保存至少 24 小时，以客户端、HTTP 方法、路径和键隔离：同键同请求重放原结果，同键不同请求返回 409。不要用不同键盲目重试创建或轮换。

创建／轮换回调的幂等重放在有效窗口内可能返回同一签名密钥，服务端须限期加密保存此响应；普通 GET 从不返回密钥。窗口外不能依赖幂等键，须先查询资源状态。API Key 和签名密钥均不可放入 URL 查询参数。

GET 操作要求 read scope；POST、PATCH、DELETE 要求 write scope。缺少对应权限返回 403 insufficient_scope。当前交付 Key 同时有 read/write。

一把 API Key 可以同时授权 ethereum-mainnet、ethereum-sepolia、bsc-mainnet、bsc-testnet、tron-mainnet、tron-nile。各 Key 的实际授权以 `/v1/networks` 返回的网络集合为准；网络 `status` 单独表示可用性。受限的测试 Key 仍只能访问三个测试网，他人资源始终不可访问。Key 是不透明凭据，不能按名称或字符串前缀推断权限。本次权限调整沿用原 Key、client_id、endpoint、签名密钥及已有订阅；配额不因六网络而翻倍，按当前客户端统一计算。

<a id="section-2"></a>

## 2 通用格式

- 请求和响应为 UTF-8 JSON。对象可增加兼容字段，客户端忽略不认识的可选字段；不认识的事件类型应先保存、告警并停止该事件的资金处理。
- 时间采用 UTC RFC 3339，例如 2026-10-17T04:00:00Z。区块号是 JSON 整数，金额与原始金额是十进制字符串。
- amount_raw 禁止使用浮点数或 JavaScript Number；用 BigInt 或任意精度十进制处理。amount = amount_raw / 10^decimals，不能跨币种或网络直接相加。
- EVM 地址输入支持正确校验和或统一大小写格式，输出规范化为小写 0x；TRON 地址使用校验通过的 Base58Check，区分大小写。
- 普通成功响应为 {"data": ..., "request_id": "req_..."}。分页响应另含 next_cursor 和 has_more。
- 列表通用参数为 limit（1 至 1000，默认 100）和 cursor（不透明字符串）。过滤条件改变后必须重新开始分页。
- GET /v1/events 是按发布顺序追加的订阅事件流，含后续撤销，不按 block_number 充当分页游标。客户端持久化事件成功后保存 next_cursor，即使 has_more=false 也保留它供下一次增量查询。
- 未提供事件 cursor 时，从当前客户端可见且仍在保留期内的最早事件开始。默认在线保留 90 天；游标过期返回 410 及最早可用游标，不静默遗漏。

错误示例：

```json
{
  "error": {
    "code": "network_not_allowed",
    "message": "This client cannot access the requested network.",
    "details": {"network": "ethereum-mainnet"}
  },
  "request_id": "req_example_001"
}
```

| HTTP | 常见 code | 客户端处理 |
| --- | --- | --- |
| 400 | invalid_request、invalid_cursor | 修正请求或游标；不要循环重试坏请求 |
| 401 | invalid_api_key | 检查 API Key、失效时间及环境 |
| 403 | network_not_allowed、insufficient_scope | 使用获授权的网络或联系服务端 |
| 404 | resource_not_found | 确认资源 ID；无权访问的他人资源也可返回此码 |
| 409 | idempotency_conflict、subscription_conflict、endpoint_not_paused、delivery_not_retryable、cursor_filter_mismatch | 检查已有状态再操作 |
| 410 | cursor_expired | 明确处理缺失区间，再按 details.earliest_cursor 恢复 |
| 422 | invalid_address、unsupported_asset、unsupported_confirmation_policy、invalid_callback_url | 修正网络、地址、资产、策略或回调配置 |
| 429 | rate_limited、subscription_limit_exceeded | 尊重 Retry-After；订阅额度问题需减少监控或调整权限 |
| 503 | network_unavailable、service_unavailable | 退避重试，写请求保持同一幂等键 |

应用响应包含 X-Request-Id；429 包含以秒表示的 Retry-After。遇到连接超时或 5xx，写操作是否已成功可能未知，应使用原幂等键重试。日志记录 request_id 便于排查，避免记录 Authorization 和密钥响应。

当前公网边缘实测拦截默认 `Python-urllib/3.12`（403、Cloudflare error_code=1010，缺少应用X-Request-Id）。接入示例统一发送 `User-Agent: WalletShield-Webhook-Client/1.0`，已验证可达；客户端应使用明确的应用标识。边缘403请保留CF-Ray排查，与应用的403 network_not_allowed区分。本次没有更改Cloudflare安全规则。

<a id="section-3"></a>

## 3 查询网络和测试币

GET /v1/networks 无查询参数，返回当前客户端获授权的网络及其状态。

| 字段 | 类型 | 含义 |
| --- | --- | --- |
| network | string | ethereum-mainnet、ethereum-sepolia、bsc-mainnet、bsc-testnet、tron-mainnet、tron-nile |
| chain | string | ETH、BSC、TRON |
| chain_id | integer 或 null | EVM 链 ID；TRON 在本协议中返回 null，不据此判断网络 |
| is_testnet | boolean | 由服务端网络配置确定 |
| status | string | available、degraded、unavailable；按需模式 idle/degraded 可创建订阅启动采集，但不能立即转账 |
| collection_mode | string，可选 | continuous 或 on_demand；已部署兼容字段，缺省不推断模式 |
| collection_state | string，可选 | running、idle、starting；已部署兼容字段，采集状态不等于订阅已生效 |
| assets | array | asset、token_contract、decimals、is_test_asset |
| confirmation_policies | array | 支持的 mode、默认策略及 additional_blocks 允许范围 |
| faucet | object 或 null | native_token_url、test_usdt_url、instructions；未配置链接可为 null |
| explorer_tx_url_template | string 或 null | 交易浏览器模板，包含 {tx_hash} |

**按需采集（已部署）：** Ethereum 主网和 TRON 主网为 `continuous`，承担黑名单扫描，持续采集。BSC 主网、Ethereum Sepolia、BSC Testnet、TRON Nile 为 `on_demand`，无监控需求时为 `idle`，不发扫描 RPC／建立扫描 WebSocket；首个订阅唤醒采集，状态经过 `starting`，取得可靠链头和生效边界后才成为 `active`。`running` 只表示采集运行，仍须核对自己的订阅。

`on_demand + idle` 的网络可能返回 `status=degraded`；这是空闲状态，允许提交创建订阅以启动采集。不要等网络自动变 available 才创建首个订阅，否则没有启动需求。创建成功也不等于已生效：持续查询订阅，必须等 `status=active` 且 `start_block` 非 null，再发送 USDT。`unavailable` 或缺失指定合约仍按实际错误处理，不能把空闲例外扩展为所有降级情况。删除该网络最后一个监控需求后停止采集；这不会删除已保存事件和投递记录，也不能撤回已在途的 HTTP 请求。重新创建后以新订阅的生效边界为准，不承诺回补空闲期间的交易。采集需求是网络整体范围；删除本客户端最后一个订阅不一定意味着网络已无其他客户端或会员需求。


### 3.1 实际采集进度与历史缺口（1.2.1）

网络响应兼容新增 `collection_progress`：`head_block`、`finalized_block`、`collected_through`、`matched_through`、`observed_at`、`lag_blocks`、`processed_block_age_seconds`、`last_error_code`，以及可选 `scan_route`（primary/fallback_preferred）和 `scan_provider`（最近RPC的provider标识与paid标志，不含URL或密钥）。高度为整数或 null；observed_at 为 UTC 时间或 null；区块年龄为整数秒或 null。区块年龄包含链本身的出块时间，不是 Webhook 端到端延迟。按需 idle 时 lag_blocks 和区块年龄为 null，不把闲置旧高度当作当前故障。

`active` 表示订阅生效边界已建立，不代表当前网络健康或历史完整。应同时核对网络 available、订阅 active/start_block 及实际进度；有 active 订阅而长期无采集／匹配推进属于异常。链上最终确认规则保持不变。

`GET /v1/collection-gaps?network=tron-nile&limit=100` 返回获授权网络的已登记未采集区间，按 id 递增，不返回其他客户端地址或内部订单信息。必填 network，limit 为 1–200（默认100），cursor 为服务端不透明游标。沿用 Bearer API Key/read scope，无需幂等键。网络权限错误为403，坏游标为400，更换网络使用旧游标为409。

```json
{"data":[{"id":12,"network":"tron-nile","from_block":72068918,"through_block":72107644,"restart_block":72107645,"reason":"balance_recovery","transfer_details_available":false,"created_at":"2026-10-24T15:58:26Z"}],"has_more":false,"next_cursor":"OPAQUE_CURSOR","scope":"network_collection_history","payment_completeness_guaranteed":false,"request_id":"req_example_gap"}
```

该例为区间结构示意，不代替实时查询。保存 next_cursor 以增量查询；即使 has_more=false 仍保存返回游标。无新增记录时继续使用原游标。此列表为**网络历史范围**，不表示每个区间都影响本客户端；按订阅 start_block、停止边界及订单实际交易核对交集。暂无已登记区间也不是全历史完整性证明。

`last_collection_gap` 仅为最近一次区间摘要；`processing_coverage` 只统计未解决处理故障，两者不能互相替代。列表目前保留 compact 区间元数据，不提供未曾采集的交易。需要核验缺口内付款时，独立按交易 Hash 查成功回执、指定合约、Transfer 日志、金额和最终性，再按客户端订单规则人工处理。

按需网络积压恢复在短事务中核验并登记新边界，正常采集继续；余额核验独立处理，单个地址失败转人工记录及运营告警，不关闭同链其他订阅。`wallet.balance.reconciled` 始终不是 Transfer 或付款成功证明；余额不变也不能证明没有往返转账。超过60分钟的延迟通知不自动补推，查询与人工记录保留。既有 event_id、activity_id、签名和重试规则不变。

首期 asset 固定为 USDT。测试网 USDT 代表服务端指定的测试合约，不保证是 Tether 官方发行。只使用接口列出的合约；名字叫 USDT 的其他代币不会触发通知。

confirmation_policy 取以下结构之一：

```json
{"mode": "finalized"}
```

```json
{"mode": "confirmations", "additional_blocks": 5}
```

默认 finalized，分别对应已核验的 EVM 最终性与 TRON 固化区块规则。confirmations 仅在网络及客户端权限允许时使用；additional_blocks=5 表示事件所在块之后再有 5 个规范链区块，不包含事件所在块自身。具体范围由网络接口公布，选用不支持的策略返回 422，不自动降低标准。最终性模式等待可能更久。

<a id="section-4"></a>

## 4 回调地址管理

### 4.1 注册回调

POST /v1/webhook-endpoints，成功 201。请求示例见 [endpoint-create.json](#sample-endpoint-create)。

| 请求字段 | 必填 | 约束 |
| --- | --- | --- |
| url | 是 | 可达的 HTTPS URL，最长 2048 字符，不能含用户密码；不得指向受禁止的内网地址 |
| description | 否 | 客户端说明，最长 200 字符 |

成功示例：

```json
{
  "data": {
    "id": "wh_example_001",
    "url": "https://client.example.invalid/walletshield/webhook",
    "description": "测试环境回调",
    "status": "active",
    "signing_key_id": "key_example_001",
    "signing_secret": "whsec_example_NOT_A_REAL_SECRET",
    "created_at": "2026-10-17T04:00:00Z",
    "updated_at": "2026-10-17T04:00:00Z"
  },
  "request_id": "req_example_002"
}
```

示例密钥无效。客户端将真实响应中的密钥写入自己的安全配置，不能只记在控制台。GET /v1/webhook-endpoints 支持通用分页和 endpoint_id 过滤；返回相同资源字段但没有 signing_secret。

### 4.2 暂停和修改

PATCH /v1/webhook-endpoints/{id}，成功 200。允许字段 url、description、status，至少提供一个；status 为 active 或 paused。修改 url 前必须先将回调暂停，再修改，最后恢复；使用不同逻辑操作的幂等键。

暂停不停止链上采集，仅暂缓 HTTP 投递；恢复后继续积压任务。URL 变更后未开始的尝试使用新 URL，已发出的请求不可撤回。响应不返回签名密钥。

### 4.3 密钥轮换

POST /v1/webhook-endpoints/{id}/rotate-secret，请求 {"overlap_seconds": 3600}，成功 200。overlap_seconds 默认 3600，允许 300 至 86400。

响应 data 包含 endpoint_id、key_id、signing_secret、previous_key_id、previous_key_expires_at。新尝试立即使用新 key_id，客户端在重叠期保留旧 key_id 到旧密钥的映射，按请求头选择；到期删除旧映射。所有重试均以尝试发生时的新时间重新签名。

<a id="section-5"></a>

## 5 模拟通知

POST /v1/webhook-endpoints/{id}/test，请求：

```json
{"network": "ethereum-sepolia", "scenario": "incoming"}
```

network 必填且必须在客户端权限内；scenario 可选 incoming 或 outgoing，默认 incoming。测试接口仅允许测试网络。成功 202，data 返回 delivery_id、event_id、status=pending、simulated=true。

这是排队成功，不表示客户端已收到。通过 GET /v1/deliveries?delivery_id=... 查看最终状态。模拟回调的 event_type=webhook.test、simulated=true、is_testnet=true，链上定位字段和 subscription_id 为 null；不能把它当成真实转账。

<a id="section-6"></a>

## 6 地址订阅

### 6.1 创建

POST /v1/subscriptions，创建成功 201；已存在完全相同的有效订阅返回 200。

```json
{
  "network": "ethereum-sepolia",
  "asset": "USDT",
  "address": "0x2222222222222222222222222222222222222222",
  "webhook_endpoint_id": "wh_example_001",
  "confirmation_policy": {"mode": "finalized"},
  "notify_detected": true,
  "client_reference": "client-order-or-wallet-reference"
}
```

完整请求文件：[subscription-two-phase-create.json](#sample-subscription-two-phase-create)。示例地址仅演示格式。

| 请求字段 | 必填 | 类型与约束 |
| --- | --- | --- |
| network | 是 | string；第 3 节的网络标识，必须在客户端权限内 |
| asset | 是 | string；固定 `USDT` |
| address | 是 | string；监控钱包地址，按网络校验 |
| webhook_endpoint_id | 是 | string；本客户端已有的回调 ID |
| confirmation_policy | 否 | object；默认 `{"mode":"finalized"}` |
| notify_detected | 否 | boolean；默认 `false`，`true` 时额外发送检测阶段，仅支持 finalized 策略 |
| client_reference | 否 | string；最长 128 字符，供客户端关联，不表示订单已付款 |

`notify_detected` 必须是 JSON 布尔值；字符串 `"true"`、数字或 null 返回 `400 invalid_request`。`true` 与 confirmations 策略组合返回 `422 unsupported_confirmation_policy`。省略或传 false 保留原来的确认阶段通知；客户端不能任意指定 token_contract，服务端按 network 与 asset 选择允许合约。

同一客户端、回调、网络、资产和规范化地址只允许一个有效订阅。confirmation_policy、notify_detected 或 client_reference 与已有 active/initializing 订阅不同，返回 `409 subscription_conflict`，不会修改旧订阅。当前没有修改订阅的 PATCH 接口；开启或关闭两阶段须先 DELETE 停止旧订阅，再用新 Idempotency-Key 创建，并等待新的 active/start_block 后再转账。停止与重新激活之间没有覆盖的交易不自动补推。回调 endpoint 和签名密钥可继续使用。

响应 data 为订阅对象，包含 id、network、asset、address、token_contract、webhook_endpoint_id、confirmation_policy、notify_detected、client_reference、status、start_block、anchor_block、anchor_block_hash、activated_at、created_at。状态为 active、initializing 或 stopped。start_block 是首个覆盖的区块号；anchor_block 与 anchor_block_hash 表示激活时已观测的锚点，不是假定未来块哈希。

完整 `201` 响应见 [subscription-two-phase-created.json](#sample-subscription-two-phase-created)，OpenAPI 也提供同一响应样例。调用方应核对返回的 notify_detected 为 true，不能仅凭 HTTP 成功认定已开启两阶段。

创建时能读取可信链头 H，则 start_block=H+1、anchor_block=H、anchor_block_hash 为 H 的哈希。暂不能建立可靠边界时返回 initializing，相关边界字段为 null。客户端通过列表按 ID 查询，等 active 后再转账；initializing 期间不保证覆盖。初始余额核验未完成不影响已建立边界的订阅进入 active。

生效边界之前的转账不自动补推。激活锚点发生重组时服务端从共同祖先恢复规范链，保留约定的开始高度；客户端仍依据事件确认和撤销协议处理。

### 6.2 查询

GET /v1/subscriptions 支持 limit、cursor、subscription_id、network、status、address、client_reference。按 address 过滤时必须同时提供 network。查询自己的订阅可以用于轮询初始化状态或找回幂等创建结果。

### 6.3 删除

DELETE /v1/subscriptions/{id}，成功 200。重复删除同一资源返回已经停止的结果。

```json
{
  "data": {
    "id": "sub_example_001",
    "status": "stopped",
    "stopped_at": "2026-10-17T05:00:00Z",
    "stop_block": 12345680,
    "cancelled_deliveries": 2,
    "in_flight_deliveries": 1
  },
  "request_id": "req_example_003"
}
```

stop_block 是停止时该网络匹配消费者（match）已完成的高度，未知时为 null；不是承诺继续推送到这个高度。删除会停止新匹配并取消尚未开始的投递。已发出的 HTTP 请求可能仍到达；客户端可根据本地订阅停用状态决定只保存审计。共享链上事件、快照和其他客户端订阅不受影响。

<a id="section-7"></a>

## 7 事件和余额查询

GET /v1/events 支持 limit、cursor、subscription_id、network、event_type、activity_id。返回本客户端订阅已发布的 detected、confirmed、reverted，以及 BSC 主网／测试网、Sepolia、Nile 净余额核对记录；detected 只代表成功上链，不能用于确认订单成功。event_type 支持 wallet.activity.detected、wallet.activity.confirmed、wallet.activity.reverted、wallet.balance.reconciled。activity_id 精确匹配同一活动，不匹配净余额事件。筛选条件改变时重新开始分页，不能复用原游标。模拟通知只出现在投递查询中。每个对象与回调业务字段一致，但不含 delivery_id。

| 查询参数 | 含义 |
| --- | --- |
| network、subscription_id | 可选；限定网络或订阅，可组合 |
| activity_id | 可选；精确匹配共同活动 ID，补拉该活动的已发布阶段及撤销 |
| event_type | 可选；每次传一种类型，省略则包含全部事件；wallet.balance.reconciled 为上述四个按需网络的净余额核对事件。不接受逗号拼接多个类型 |
| limit、cursor | 通用分页参数；cursor 与同一组过滤条件绑定 |

按共同活动补拉，包含检测、最终确认及可能出现的撤销：

```bash
curl --fail-with-body --get "$WS_WEBHOOK_BASE_URL/v1/events" \
  -H "User-Agent: WalletShield-Webhook-Client/1.0" \
  -H "Authorization: Bearer $WS_WEBHOOK_API_KEY" \
  --data-urlencode "subscription_id=$WS_WEBHOOK_SUBSCRIPTION_ID" \
  --data-urlencode "activity_id=$WS_WEBHOOK_ACTIVITY_ID" \
  --data-urlencode "limit=100"
```

响应结构为 data 数组、next_cursor、has_more 和 request_id，完整两阶段页见 [events-two-phase.json](#sample-events-two-phase)。示例游标仅演示格式，实际应原样保存响应中的游标，下一次使用相同过滤条件附加 `cursor`。服务端按发布顺序分页，不按交易区块高度排序。Webhook 和补拉共用 `subscription_id + event_id` 去重；补拉不会创建新的投递任务。

持续恢复建议补拉全部事件类型，让同一游标覆盖阶段及撤销。若分别按 event_type 查询，每组条件必须保存独立游标；只查询 confirmed 会漏掉撤销。即使 has_more=false 也应在成功持久化该页后保存 next_cursor，后续继续增量拉取；更改条件却复用游标返回 `409 cursor_filter_mismatch`。

GET /v1/balance-snapshots 的 network、asset、address 必填，asset=USDT；仅允许查询客户端拥有过的有效或保留期内订阅地址。返回最新的已有快照，不因调用接口而发起付费 RPC。

快照字段为 network、asset、address、token_contract、balance_raw、decimals、balance、as_of_block、as_of_block_hash、queried_at、status、consistency。status 为 available、pending、stale、unavailable；consistency 为 block_pinned、latest、unknown。没有结果时金额和区块等字段为 null，不能当成余额零；仅 latest 且未核验高度时，区块字段为 null。

快照可能晚于本次转账，且可能属于整个区块结束状态。它不是每条日志执行后的逐笔余额，不可用两个快照相减推导所有中间转账。

`wallet.balance.reconciled` 的补拉契约见第 9.1 节；这是余额审计事件，没有 activity_id 或逐笔付款证明。

<a id="section-8"></a>

## 8 投递查询和重试

GET /v1/deliveries 支持 limit、cursor、delivery_id、subscription_id、webhook_endpoint_id、status。对象包含 id、event_id、subscription_id、webhook_endpoint_id、status、attempt_count、next_attempt_at、last_http_status、last_error_code、created_at、delivered_at。

status 取 pending、sending、retry、delivered、manual、cancelled。响应不泄露客户端响应正文或密钥。模拟通知的 subscription_id 为 null。

POST /v1/deliveries/{id}/retry 使用空对象 {} 及 Idempotency-Key，接受后返回 202 和原 delivery_id。retry／manual 可重新排队；pending／sending 返回现有状态，不能同时创建第二个任务。delivered／cancelled 返回 409，不主动重发已成功或已取消的通知。正常重试保持原业务负载、event_id 和 delivery_id。

回调成功是任何 **HTTP 2xx**，不解析响应正文判断业务成功；例如 `200 {"success":false}` 仍视为已送达。接收方应先验签并持久化，再返回 2xx，业务处理在本地异步完成。单次请求预算为 **10 秒**（包含连接及读取响应）。

**无应答、连接失败或超时：首次投递失败后最多自动重试 5 次，共最多 6 次尝试。** 以下是相对于上一次失败完成时刻的间隔，不是从首次发送算起的绝对时间；这组间隔不加随机抖动。

| 自动重试 | 等待时间 |
| --- | --- |
| 第 1 次 | 5 秒 |
| 第 2 次 | 60 秒 |
| 第 3 次 | 120 秒 |
| 第 4 次 | 240 秒 |
| 第 5 次 | 480 秒 |
| 第 5 次重试仍未取得有效应答 | 转为 manual，停止该任务的自动投递 |

DNS、TLS、连接中断、超时、无有效 HTTP 应答及超大响应按上述规则处理。投递进程中断且租约过期、结果未知的尝试也计入失败次数，从恢复时刻安排下一次等待；不因服务重启重置次数。队列等待、端点限速／熔断、暂停和服务中断可能推迟实际发送，不能把这些间隔当作到达时限。每个投递任务独立计数，detected、confirmed、reverted 各自适用；balance.reconciled 也使用同一规则。

已收到 HTTP 错误响应时：408、429、5xx 保留指数退避与抖动，单次等待最多 1 小时，当前无固定重试次数上限；秒数形式的 Retry-After 最多按 3600 秒采用。它们不消耗也不清零上述无有效应答的失败计数。其他 4xx 及 3xx 直接进入 manual，不跟随重定向；不符合回调安全要求的地址或配置也直接进入 manual。

**60 分钟自动通知时限：** 因采集／匹配积压、发送队列等待、端点暂停、限速、服务中断或回调失败而延迟超过 60 分钟，停止自动发送，任务转为 `manual`，`last_error_code="notification_expired"`。此限制同时适用于首次投递及所有自动重试；HTTP 408／429／5xx 也不能无限续期。其他尚未过期的事件继续独立处理。

- detected 从实际链上区块时间计算；历史交易迟到，不会因刚入库而获得新的发送时限。
- confirmed 从服务端持续观测并持久记录的链上确认就绪时刻计算，正常等待链上最终确认不计入 60 分钟。采集／匹配已积压超过一小时的历史事件，或确认观测中断超过一小时，不冒充正常最终性等待重新计时。
- reverted、balance.reconciled、模拟消息从产生该通知时计算；已开始的在途 HTTP 无法撤回，成功应答仍如实记录为 delivered。
- 对已经采集并持久化的事件，过期仅停止自动推送；这些事件继续保留在 `/v1/events` 中，投递失败记录可查询。人工核验后调用 retry 会开启新的 60 分钟发送窗口，重放相同幂等键不会刷新期限；原 event_id、delivery_id、正文和历史尝试保持。

**人工处理入口：** 当前没有专门的 Webhook 网页管理后台，也不会自动分派客服工单。`manual` 是可查询、可处理的持久状态；原事件、负载与失败历史保留，停止自动重试不等于已成功或删除订阅。其他通知继续按各自状态处理；同一端点的限速／熔断仍生效。

1. 使用 `GET /v1/deliveries?status=manual&limit=100` 查看本 API Key 所属客户端的待处理任务，按 next_cursor 分页。也可通过 delivery_id 查询单条；重点查看 attempt_count、last_http_status、last_error_code。
2. 人员核对客户端接收记录并修复回调服务。无应答时接收端可能已经保存消息，因此补发必须继续去重。
3. 使用 `POST /v1/deliveries/{id}/retry`，请求体 `{}`，为本次人工操作创建新的 Idempotency-Key。同一人工操作因网络失败重试时保留原键；不要定时循环调用此接口绕过次数上限。
4. 返回 202 表示重新排队，随后查询投递状态直到 delivered 或再次 manual。仍须满足订阅、网络、客户端权限及端点可用条件；已停止订阅不允许补发。重新排队启动一轮新的自动重试预算，累计 attempt_count 和历史尝试不清零。重放相同幂等键不会再开一轮。

`attempt_count` 是累计领取投递尝试的次数（包含首次、自动／人工重试及结果未知的尝试），不是本轮已用自动重试次数。`next_attempt_at` 仅在 pending／retry 时用作排期，manual 下的值不代表还会自动发送。每次重试保持 event_id、delivery_id 及原始正文不变，仅尝试 ID、签名时间及可能轮换后的签名密钥变化。客户端也可按第 7 节通过 /v1/events 补拉真实事件；模拟事件不进入该事件流。

<a id="section-9"></a>

## 9 Webhook 消息

请求方向为 WalletShield → 客户端注册的 HTTPS 地址。Content-Type 为 application/json，原始请求体是一个事件对象。下表描述既有逐笔动账和模拟通知；1.2.0 净余额核对事件的独立结构见第 9.1 节，适用于 BSC 主网／测试网、Sepolia、Nile 的自动积压恢复。

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| schema_version | string | 固定 1.0 |
| event_type | string | wallet.activity.detected、wallet.activity.confirmed、wallet.activity.reverted、webhook.test |
| event_id | string | 阶段事件 ID；detected、confirmed、各阶段撤销分别独立，重试保持不变 |
| activity_id | string | 同一规范链日志出现的关联 ID；两阶段及撤销共享。新真实事件提供，历史 1.0 事件可能缺省 |
| delivery_id | string | 稳定投递 ID，仅回调负载含此字段 |
| subscription_id | string 或 null | 真实事件的订阅 ID；模拟事件为 null |
| network、chain、is_testnet、simulated | 对应类型 | 网络与测试标记由服务端决定 |
| asset、token_contract | string，合约可为 null | USDT 与精确合约；模拟事件合约可为 null |
| address | string 或 null | 本条订阅监控地址；模拟事件可为 null |
| tx_hash、block_hash | string 或 null | EVM 为 0x 十六进制；TRON 为不含 0x 的十六进制；模拟事件为 null |
| block_number、transaction_index、event_index | integer 或 null | 真实链上定位，均从 0 起计索引；区块号不是日志序号 |
| event_index_scope | string 或 null | EVM 为 block，对应原生 logIndex；TRON 为 transaction，对应回执内日志序号 |
| block_time、occurred_at | string 或 null | block_time 为链上区块时间；occurred_at 为服务端生成该检测、确认、撤销或模拟记录的时间，不是客户端收到的时间 |
| from、to | string 或 null | 普通转账的双方；特殊管理事件无法适用时为 null |
| direction | string | in、out、self、none；相对于 address |
| event_kind | string | transfer、self_transfer、mint、burn、balance_adjustment、test |
| amount_raw、amount、decimals | string、string、integer | 数量非负，方向单独表达；不把销毁写成普通付款 |
| confirmation_policy | object | 实际使用的策略 |
| confirmation_status | string | detected、confirmed、finalized、reverted、simulated |
| balance_snapshot | object 或 null | 第 7 节定义，查询失败或未完成允许 null |
| reverts_event_id | string 或 null | 撤销所对应的原阶段 event_id，其余为 null |
| reverts_activity_id | string 或 null | 撤销所对应的 activity_id，其余为 null；历史事件可能缺省 |
| client_reference | string 或 null | 创建订阅时的关联信息 |

两阶段完整样本：[检测到转账](#sample-activity-detected)、[同一活动最终确认](#sample-two-phase-confirmed)、[检测阶段撤销](#sample-detected-reverted)。前两条共享 activity_id，event_id 和 delivery_id 分别不同。两阶段使用相同的 network、token_contract、tx_hash、block_hash、block_number、transaction_index、event_index、event_index_scope、from、to、direction、amount_raw 和 decimals；occurred_at、balance_snapshot 可不同，快照也可为 null。

原确认、撤销和模拟样本保留为兼容样例：[transfer-confirmed.json](#sample-transfer-confirmed)、[activity-reverted.json](#sample-activity-reverted)、[webhook-test.json](#sample-webhook-test)。另有 [BSC 18 位金额样本](#sample-bsc-transfer-confirmed) 和 [Nile 地址与日志序号样本](#sample-tron-transfer-confirmed)。历史 1.0 事件可能没有 activity_id，接收器仍须接受，并使用原事件去重与撤销关联；不能用交易 Hash 代替缺失的 activity_id 合并多条日志。样本地址、合约和哈希均为虚构，不能用于转账。

真实转账按照规范链收录实例生成 ID。重放同一实例保持 ID，重组后重新收录为新的事件；即使原区块哈希再次成为规范链，也使用新的规范链收录代次生成 ID，避免误用已撤销事件。撤销引用旧 ID。金额原值和 direction 在撤销消息中保留原事件含义，客户端不能把撤销当成新的反向普通转账。

同一事件命中多个订阅时分别投递，接收方用 subscription_id 与 event_id 去重；模拟事件用 delivery_id 去重。不同回调和重试可能乱序。若撤销先于原事件到达，先保存撤销标记，原事件后来到达也不能再次当成有效到账处理。HTTP 重复投递不能导致业务重复处理。

未知事件类型的兼容处理：先验证原始正文签名、来源及请求头；验签通过但 event_type 暂不支持时，将原始正文与事件／订阅身份持久化到独立待处理区并告警，不按 confirmed 或付款到账处理。只有可靠保存后才返回 2xx；保存失败不得应答成功。升级后按原身份幂等重放，不重复入账。补拉遇到未知类型也须先保存该页，再推进游标，不能静默跳过。本文接收器对已列举类型严格验证，未知类型会拒绝；接入方须补充上述隔离保存分支，不能假定样例已自动接受未来协议。

<a id="balance-reconciliation"></a>

### 9.1 按需网络净余额核对通知（1.2.0）

**BSC 主网、BSC 测试网、Sepolia、Nile 均已启用按需采集、自动积压余额核对及 `wallet.balance.reconciled` 净余额通知。** 有有效订阅且发生符合条件的采集积压时，服务端核对余额，持久化净差通知及扫描缺口后从新边界继续采集。没有监控需求时沿用按需空闲规则。ETH／TRON 主网持续采集，不使用此恢复通知。既有逐笔事件、订阅参数、两阶段和签名协议保持原语义；客户端无需增加触发接口或订阅开关。此能力不表示已经发生真实恢复，也不表示主网客户端转账或支付验收已完成。

该事件只报告两份可信余额证据之间的净差，不能还原交易数量、交易 Hash、付款人、逐笔金额或发生时间。**净增加不得显示为“已检测到付款”或“支付成功”，不得据此确认订单、入账或触发商户回调。** 缺口内收支可能相抵；零差不生成事件，不等于没有交易。缺少可信期初余额或查询失败时，不能写假零或宣称恢复成功。

完整回调与补拉样例均为演示数据，不证明真实恢复或投递。事件正文继续使用 `schema_version="1.0"`：

| 网络 | 回调 | 事件补拉 |
| --- | --- | --- |
| BSC 主网 | [balance-reconciled.json](#sample-balance-reconciled) | [events-balance-reconciled.json](#sample-events-balance-reconciled) |
| BSC 测试网 | [bsc-testnet-balance-reconciled.json](#sample-bsc-testnet-balance-reconciled) | [events-bsc-testnet-balance-reconciled.json](#sample-events-bsc-testnet-balance-reconciled) |
| Sepolia | [sepolia-balance-reconciled.json](#sample-sepolia-balance-reconciled) | [events-sepolia-balance-reconciled.json](#sample-events-sepolia-balance-reconciled) |
| Nile | [nile-balance-reconciled.json](#sample-nile-balance-reconciled) | [events-nile-balance-reconciled.json](#sample-events-nile-balance-reconciled) |

网络、chain、chainId、测试标记、合约及精度必须作为同一组验证，不能混用。四网都固定 `asset="USDT"`、`simulated=false`、`payment_eligible=false`；真实测试网观察不是模拟事件，也不能处理正式支付。Nile 的 chain_id 保持接口已登记的 null，不用其他接口的链 ID 别名代替。

| network | chain | chain_id | is_testnet | token_contract | decimals |
| --- | --- | --- | --- | --- | --- |
| `bsc-mainnet` | `BSC` | `56` | `false` | `0x55d398326f99059ff775485246999027b3197955` | `18` |
| `bsc-testnet` | `BSC` | `97` | `true` | `0x436fc60cef0a8b4054420b50220c834a34bf7b90` | `18` |
| `ethereum-sepolia` | `ETH` | `11155111` | `true` | `0xb464a579e9697694833f544d1b464cbd51405c84` | `6` |
| `tron-nile` | `TRON` | `null` | `true` | `TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf` | `6` |

| 字段 | 类型及语义 |
| --- | --- |
| event_type、event_kind、confirmation_status | 分别固定 `wallet.balance.reconciled`、`balance_reconciliation`、`reconciled` |
| event_id、reconciliation_id、recovery_run_id | 稳定事件 ID、余额核对 ID、服务端恢复运行 ID；重试及补拉保持不变 |
| network、chain、chain_id、is_testnet、asset、token_contract、decimals | 与上表完整匹配 |
| address、subscription_id、client_reference | 被监控的规范地址、原订阅及客户端关联信息；同地址可能属于多个订阅 |
| occurred_at | 首次生成记录的 UTC 时间，重试不变；不是转账时间 |
| confirmation_policy | 保留原订阅策略；本事件状态始终为 reconciled |
| change_direction | `net_increase` 或 `net_decrease`，只描述两份余额证据的净差 |
| delta_raw、delta | 非零有符号原始整数串及按本网络精度换算的十进制串；负数带 `-`，正数不带 `+`，十进制末尾零省略；禁止 float |
| observed_before、observed_after | 按下方 EVM 或 Nile 分支验证，不得混用 |
| boundary_finality | 固定 `finalized`；EVM 指两个 pinned 余额边界，Nile **仅指扫描跳过终点 H**，不声明 latest 余额已最终确认 |
| coverage_gap | 扫描缺口闭区间 `[L+1,H]`，transfer_details_available 固定 false；Nile 余额观察不保证恰好对应此区间 |
| payment_eligible | 固定 `false` |
| reason | BSC 两网固定 `authorized_bsc_backlog_recovery`；Sepolia／Nile 固定 `authorized_backlog_recovery` |
| delivery_id | 仅回调负载提供；补拉数据无此字段 |

**EVM 分支：BSC 主网、BSC 测试网、Sepolia。** 每个 observed 对象恰好包含 `block_number`、`block_hash`、`balance_raw`、`balance`、`consistency="block_pinned"`。余额为 uint256 原始整数串及按 decimals 换算的精确十进制串；两个指定且已最终确认的边界 L < H，哈希不同。coverage_gap 恰好为 `{ "from_block": L+1, "through_block": H, "transfer_details_available": false }`。不提供 balance_comparison_scope、snapshot_id 或 queried_at 字段。

EVM 稳定 ID：将 `[recovery_run_id, network, token_contract, address, observed_before.block_hash, observed_after.block_hash]` 按 UTF-8 JSON 紧凑编码（无空格、无 ASCII 转义；EVM 地址、合约、0x 区块 Hash 均小写），取 SHA-256 小写十六进制摘要；`event_id="evt_"+摘要`，`reconciliation_id="rec_"+摘要`。不同订阅生效边界不同可能产生不同 before，因此使用不同 ID，不将订阅生效前变化补给新订阅。

**Nile 分支：余额观察，不是指定历史块余额。** 顶层必须提供 `balance_comparison_scope="observed_snapshots"`。每个 observed 对象恰好包含：

| 字段 | Nile 余额观察语义 |
| --- | --- |
| block_number、block_hash | 均必须为 `null`；不能填入扫描 L／H |
| consistency | 固定 `latest` |
| balance_raw、balance | uint256 原始整数串及按 6 位换算的精确十进制串 |
| queried_at | 实际余额查询时间，UTC 且以 Z 结尾；before 时间不晚于 after |
| snapshot_id | 持久化快照 ID 的正十进制字符串，范围 1 至 2**63-1；两份观察 ID 不同 |

Nile 的 before 来自已有可信持久基线，after 来自本次查询。两次观察之间的余额净差**不保证恰好等于扫描缺口内的变化**。coverage_gap 除原三字段外还必须包含 `before_block_hash`（L）及 `through_block_hash`（H），均为不带 0x 的 64 位小写 TRON 原生块 Hash，首 16 位十六进制分别对应 L／H 高度。from_block=L+1，through_block=H，L < H；这些是扫描边界，与 observed 对象的查询时间、snapshot_id 分开。

Nile 稳定 ID：将 `[recovery_run_id, network, token_contract, address, "observed_snapshots", observed_before.snapshot_id, observed_after.snapshot_id]` 用相同的 UTF-8 JSON 紧凑编码后取 SHA-256，再加 evt_／rec_ 前缀。snapshot_id 保持字符串；TRON 地址和合约保留规范 Base58Check 大小写，不转小写。该七元组与 EVM 的六元组各自适用，不能用两个 null 块 Hash 代替两个快照身份。

以下逐笔交易字段全部必须显式为 `null`：`activity_id`、`tx_hash`、`block_hash`、`block_number`、`block_time`、`transaction_index`、`event_index`、`event_index_scope`、`from`、`to`、`direction`、`amount_raw`、`amount`、`balance_snapshot`、`reverts_event_id`、`reverts_activity_id`。

接收顺序：先按第 10 节验证原始字节签名，再验证网络身份、相应证据分支、全 null 字段、ID、缺口、`delta_raw = observed_after.balance_raw - observed_before.balance_raw`、精度及 change_direction。可靠持久化后返回 2xx，只作审计。失败重试和人工处理沿用第 8 节。同一地址的不同订阅可共享 ID，必须按 **subscription_id + event_id** 去重；回调与补拉共用此键。不能将 reconciliation_id 伪装为 activity_id。

`GET /v1/events?network=bsc-mainnet&event_type=wallet.balance.reconciled` 可单独补拉；三个测试网上线后，将 network 替换为 bsc-testnet、ethereum-sepolia 或 tron-nile 查询对应事件。省略 event_type 的完整流包含本客户端有权访问的该类型事件。activity_id 为 null，因此活动 ID 筛选不会返回本事件；更换过滤条件必须从无游标开始并单独保存游标。接收器将其保存为 `reconciled`，停用订阅的在途记录为 `audit_only`，不写 activities、不改变已有两阶段状态。仅接收测试网的客户端继续保留自己的权限校验，可将三个测试网身份一致的净余额事件保存审计，不更新订单、付款状态或商户回调。

<a id="section-10"></a>

## 10 签名验证

回调请求头：

```http
X-WS-Timestamp: 1792209600
X-WS-Key-Id: key_example_001
X-WS-Signature: v1=LOWERCASE_HEX_HMAC
X-WS-Event-Id: evt_example_confirmed
X-WS-Delivery-Id: dlv_example_confirmed
X-WS-Attempt-Id: att_example_001
```

HTTP 头名称不区分大小写。timestamp 是本次发送的 Unix 秒整数，不是区块时间；重试会产生新的 timestamp、attempt_id 和签名。签名步骤：

1. 取得原始请求体字节，不先 JSON 解析后重新序列化。
2. 按 key_id 选择该回调的 signing_secret，将密钥完整字符串按 UTF-8 编码，包含 whsec_ 前缀，不做 Base64 解码。
3. 组装 timestamp 的 ASCII 字节、一个英文句点字节和原始请求体，即 timestamp + "." + raw_body。
4. 计算 HMAC-SHA256，转为 64 位小写十六进制；与 v1= 后的值做常量时间比较。
5. 当前时间与 timestamp 相差不得超过 300 秒。通过后解析 JSON，并校验请求头 event_id／delivery_id 与签名体中的字段一致。

Python 验签示例，仅展示算法；密钥按 key_id 从客户端配置获取：

```python
import hashlib
import hmac
import time

def verify_signature(raw_body, timestamp, signature, secret, now=None):
    if len(timestamp) > 20 or not timestamp.isascii() or not timestamp.isdecimal():
        return False
    if len(signature) != 67 or not signature.isascii():
        return False
    current = int(time.time()) if now is None else int(now)
    if abs(current - int(timestamp)) > 300:
        return False
    message = timestamp.encode("ascii") + b"." + raw_body
    expected = "v1=" + hmac.new(
        secret.encode("utf-8"), message, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

未知 key_id 拒绝验签并检查轮换配置。生产保留时间校验，机器应同步时间。验签通过后先将原始通知和去重键持久化，再返回 200 或 204；下游业务失败由客户端本地任务重试，不应靠反复返回失败制造重复入账。

[signature-test-vector.json](#sample-signature-test-vector) 含公开演示 secret、timestamp、raw_body_utf8 和预期 signature。离线验证时使用向量中的 timestamp 作为 now；它不代表真实发送时间。修改一个请求体字节应得到不同签名。此固定演示 secret 不得用于实际回调。

<a id="section-11"></a>

## 11 仅使用本文与 API Key 开始测试

<a id="quickstart"></a>

服务方只需单独交付 `WS_WEBHOOK_API_KEY`。Base URL 已固定；endpoint ID、签名 Key ID、签名密钥、订阅 ID 均通过接口取得，不需要额外索取服务端文件。客户端自行准备公网 HTTPS 回调；真实链测试另需自己的测试钱包、手续费币和指定测试 USDT。模拟联调不需要钱包私钥或测试币。API Key 只放在客户端后端，不放在浏览器付款页面。

以下命令在客户端自己的空目录执行，使用 Bash、curl 和 Python 3；无需本项目仓库。所有 UUID 仅在开始一个新操作时生成一次；遇到超时或 5xx，保留相同键和请求体重试。

### 11.1 配置与网络查询

```bash
umask 077
mkdir -p ws-client-private
chmod 700 ws-client-private
export WS_WEBHOOK_BASE_URL='https://webhook.walletshield.ai'
read -r -s -p 'WS_WEBHOOK_API_KEY: ' WS_WEBHOOK_API_KEY
export WS_WEBHOOK_API_KEY
curl --fail-with-body --connect-timeout 10 --max-time 30 \
  "$WS_WEBHOOK_BASE_URL/v1/networks" \
  -H 'User-Agent: WalletShield-Webhook-Client/1.0' \
  -H "Authorization: Bearer $WS_WEBHOOK_API_KEY" \
  -o ws-client-private/networks.json
python3 -m json.tool ws-client-private/networks.json
```

返回范围由当前 API Key 权限决定；同一 Key 可同时授权主网和测试网。检查 network、asset=USDT、token_contract、decimals 及网络状态。已部署的 collection_mode/collection_state 字段区分连续与按需采集：ETH/TRON 主网 continuous，BSC 主网和三个测试网 on_demand。on_demand + idle 的 status=degraded 仍可继续创建首个订阅启动采集；不用在创建之前一直等待 available。其他 degraded/unavailable 不代表可用。不要因为代币名字相同就发送其他 USDT。真实交易前重新核对网络与订阅 active/start_block，不用文档中的状态作为实时健康保证。

### 11.2 注册自己的 HTTPS 回调

将下例 URL 改为自己的接收地址。使用本文附录接收器时，路径为 `/walletshield/webhook`；已有接收程序也可以使用自己的路径。回调须公网可达、使用可信 HTTPS 证书，并可接收 POST，不依赖浏览器登录或重定向。

```bash
cat > ws-client-private/endpoint-request.json <<'JSON'
{
  "url": "https://YOUR-CALLBACK-DOMAIN/walletshield/webhook",
  "description": "WalletShield test receiver"
}
JSON
python3 -c 'import uuid; print(uuid.uuid4())' > ws-client-private/endpoint-idempotency.txt
curl --fail-with-body --connect-timeout 10 --max-time 30 \
  "$WS_WEBHOOK_BASE_URL/v1/webhook-endpoints" \
  -H 'User-Agent: WalletShield-Webhook-Client/1.0' \
  -H "Authorization: Bearer $WS_WEBHOOK_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(cat ws-client-private/endpoint-idempotency.txt)" \
  --data-binary @ws-client-private/endpoint-request.json \
  -o ws-client-private/endpoint-response.json
```

预期 201。成功响应含一次需要保存的签名密钥，不要输出到公共日志。用下列命令提取到私有配置；GET 不返回签名密钥。若请求失败，先处理错误，不能继续当作已创建。

```bash
python3 - <<'PY'
import json, os
from pathlib import Path
folder = Path('ws-client-private')
data = json.loads((folder / 'endpoint-response.json').read_text())['data']
assert data['status'] == 'active'
(folder / 'endpoint-id.txt').write_text(data['id'])
keys = {data['signing_key_id']: {'secret': data['signing_secret'], 'expires_at': None}}
(folder / 'keys.json').write_text(json.dumps(keys))
for name in ('endpoint-response.json', 'endpoint-id.txt', 'keys.json'):
    os.chmod(folder / name, 0o600)
PY
export WS_WEBHOOK_ENDPOINT_ID="$(cat ws-client-private/endpoint-id.txt)"
```

已有 endpoint 的客户端沿用原 ID、Key ID 和签名密钥，跳过创建。密钥丢失时按第 4.3 节轮换，并在有效重叠期保留旧密钥。创建响应字段叫 `signing_key_id`，轮换响应字段叫 `key_id`。

### 11.3 部署接收器与模拟联调

可以按第 9、10、12 节实现自己的接收器，也可将[完整接收器附录](#receiver)的 Python 代码保存成客户端本地 `receiver.py`。它只验证、持久化并记录链状态，不连接订单或资金账本。按附录启动，并把自己的公网 HTTPS 回调接到该进程。

```bash
cat > ws-client-private/test-request.json <<'JSON'
{"network":"ethereum-sepolia","scenario":"incoming"}
JSON
python3 -c 'import uuid; print(uuid.uuid4())' > ws-client-private/test-idempotency.txt
curl --fail-with-body --connect-timeout 10 --max-time 30 \
  "$WS_WEBHOOK_BASE_URL/v1/webhook-endpoints/$WS_WEBHOOK_ENDPOINT_ID/test" \
  -H 'User-Agent: WalletShield-Webhook-Client/1.0' \
  -H "Authorization: Bearer $WS_WEBHOOK_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(cat ws-client-private/test-idempotency.txt)" \
  --data-binary @ws-client-private/test-request.json \
  -o ws-client-private/test-response.json
export WS_WEBHOOK_DELIVERY_ID="$(python3 -c 'import json; print(json.load(open("ws-client-private/test-response.json"))["data"]["delivery_id"])')"
curl --fail-with-body --get "$WS_WEBHOOK_BASE_URL/v1/deliveries" \
  -H 'User-Agent: WalletShield-Webhook-Client/1.0' \
  -H "Authorization: Bearer $WS_WEBHOOK_API_KEY" \
  --data-urlencode "delivery_id=$WS_WEBHOOK_DELIVERY_ID"
```

202 表示排队，查询到 delivered 才表示回调收到了成功 HTTP 响应；同时核对自己的验签及持久化记录。模拟类型始终是 `webhook.test`，不会生成真实 detected/finalized 或进入 `/v1/events`。分别用 `bsc-testnet`、`tron-nile`，以及 `scenario=outgoing` 重复测试，每个新操作使用新的幂等键。

### 11.4 创建两阶段钱包订阅

下面通过提示输入自己的收款钱包。示例选 Sepolia；BSC Testnet 或 Nile 分别改成 `bsc-testnet`、`tron-nile` 并输入对应钱包。客户端不能在请求中指定代币合约。

```bash
export WS_WEBHOOK_NETWORK='ethereum-sepolia'
read -r -p 'Test receiving wallet: ' WS_WEBHOOK_ADDRESS
export WS_WEBHOOK_ADDRESS
python3 - <<'PY'
import json, os
from pathlib import Path
request = {
    'network': os.environ['WS_WEBHOOK_NETWORK'], 'asset': 'USDT',
    'address': os.environ['WS_WEBHOOK_ADDRESS'],
    'webhook_endpoint_id': os.environ['WS_WEBHOOK_ENDPOINT_ID'],
    'confirmation_policy': {'mode': 'finalized'}, 'notify_detected': True,
    'client_reference': 'two-phase-test-' + os.environ['WS_WEBHOOK_NETWORK'],
}
Path('ws-client-private/subscription-request.json').write_text(json.dumps(request))
PY
python3 -c 'import uuid; print(uuid.uuid4())' > ws-client-private/subscription-idempotency.txt
curl --fail-with-body --connect-timeout 10 --max-time 30 \
  "$WS_WEBHOOK_BASE_URL/v1/subscriptions" \
  -H 'User-Agent: WalletShield-Webhook-Client/1.0' \
  -H "Authorization: Bearer $WS_WEBHOOK_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(cat ws-client-private/subscription-idempotency.txt)" \
  --data-binary @ws-client-private/subscription-request.json \
  -o ws-client-private/subscription-response.json
export WS_WEBHOOK_SUBSCRIPTION_ID="$(python3 -c 'import json; print(json.load(open("ws-client-private/subscription-response.json"))["data"]["id"])')"
curl --fail-with-body --get "$WS_WEBHOOK_BASE_URL/v1/subscriptions" \
  -H 'User-Agent: WalletShield-Webhook-Client/1.0' \
  -H "Authorization: Bearer $WS_WEBHOOK_API_KEY" \
  --data-urlencode "subscription_id=$WS_WEBHOOK_SUBSCRIPTION_ID"
```

确认 `notify_detected=true`、`status=active`、`start_block` 非 null，再发币。完整请求及 201 响应在[样例附录](#samples)。按需网络空闲时创建首个订阅会触发 starting；initializing 时先等待并查询，取得可靠起点后才 active，不能先付币后假定追补。空闲期间的历史交易不承诺补拉。原来未开启两阶段的订阅需要先停止后重建；同地址配置冲突为 409，不能通过不断更换幂等键修改旧订阅。

每网记录各自的 subscription_id、幂等键、生效高度和原始响应。上面的本地文件名用于一网演示，开始下一网前另存一份，避免覆盖需要重试的原请求。

### 11.5 真实转入、补拉和停止

按[验收步骤](#acceptance)每网先发一笔指定测试 USDT 转入；detected 只显示确认中，finalized 仍需客户端独立核验才确认订单。对于客户端自己的测试钱包，保存交易 Hash 和原始整数金额，不向服务方提供私钥。

补拉本订阅全部阶段：

```bash
curl --fail-with-body --get "$WS_WEBHOOK_BASE_URL/v1/events" \
  -H 'User-Agent: WalletShield-Webhook-Client/1.0' \
  -H "Authorization: Bearer $WS_WEBHOOK_API_KEY" \
  --data-urlencode "subscription_id=$WS_WEBHOOK_SUBSCRIPTION_ID" \
  --data-urlencode 'limit=100' \
  -o ws-client-private/events-page.json
```

按第 7 节先持久化事件和 next_cursor，再继续请求并附加 `--data-urlencode "cursor=$WS_WEBHOOK_CURSOR"`。不要只补 confirmed 而漏掉撤销。采用附录接收器时，可运行：

```bash
.venv/bin/python receiver.py --database ws-client-private/receiver.sqlite \
  --import-events ws-client-private/events-page.json \
  --cursor-name "$WS_WEBHOOK_SUBSCRIPTION_ID-all-stages"
```

结束或切换订阅时执行删除，注意这会停止本订阅，不是暂停客户端显示：

```bash
curl --fail-with-body --request DELETE \
  "$WS_WEBHOOK_BASE_URL/v1/subscriptions/$WS_WEBHOOK_SUBSCRIPTION_ID" \
  -H 'User-Agent: WalletShield-Webhook-Client/1.0' \
  -H "Authorization: Bearer $WS_WEBHOOK_API_KEY"
```

成功后保存 stopped 状态。采用附录接收器时再运行 `receiver.py --database ws-client-private/receiver.sqlite --stop-subscription "$WS_WEBHOOK_SUBSCRIPTION_ID"`，将可能迟到的在途事件只作审计。重新订阅使用新逻辑操作的幂等键，并重新等待 active/start_block。

按需网络删除最后一个整体监控需求后停止扫描；其他客户端或会员仍有需求时继续运行。已持久化事件与投递记录不因此删除。重新订阅须等待新的 active/start_block，不能复用旧生效边界。


<a id="section-12"></a>

## 12 同一 Transfer 的两阶段通知（1.1.0）

### 12.1 开启与阶段语义

创建订阅时传 `"notify_detected": true` 并使用 `"confirmation_policy": {"mode":"finalized"}`。未传或 false 保持既有行为；true 配 confirmations 策略返回 422，不降低最终确认标准。该参数必须为 JSON 布尔值，字符串或数字返回 400。

已存在 active/initializing 订阅且参数不同，POST 返回 409 subscription_conflict，不会偷偷更改原订阅。需要改变时先停止原订阅、再使用新 Idempotency-Key 和新参数重建，并等待新的 active/start_block 后再转账；停止到重新激活之间不覆盖的交易不能自动补推。已有参数相同则返回原订阅，幂等键重放仍遵循原请求正文。

| 阶段 | event_type | confirmation_status | 客户端用途 |
| --- | --- | --- | --- |
| 成功上链，未要求最终确认 | wallet.activity.detected | detected | 核验并匹配收款后显示“已检测到付款，确认中” |
| 达到最终确认 | wallet.activity.confirmed | finalized | 独立查链、匹配有效订单并幂等确认后显示“支付成功”并回调商户 |
| 发生重组撤销 | wallet.activity.reverted | reverted | 撤销对应活动的有效状态，进入重查／异常处理 |

检测阶段只来自已完整核验的规范块成功回执及登记 USDT 合约的 Transfer 日志，不使用广播或交易池消息。内部调用和 transferFrom 仍按实际执行日志识别。金额、地址、合约、网络、tx_hash、block_hash、block_number、transaction_index、event_index、event_index_scope 在两阶段保持一致；amount_raw 是整数十进制字符串，decimals 是精度。正数有效收付沿用现有规则；零金额、自转账不新增动账通知。管理类非 Transfer 事件仍沿用原确认规则。

### 12.2 关联与去重字段

activity_id 对应一次规范链上的具体日志出现，包含网络、区块哈希、交易哈希、日志序号范围／序号和规范链代次的身份；它不是交易哈希，客户端应视为不透明字符串。同一交易多条 Transfer 分别有不同 activity_id；同一活动命中多个订阅共享 activity_id，各订阅分别投递。重组后重新收录产生新的 activity_id，避免旧撤销标记误伤新出现。event_id 区分 detected 和 confirmed，各阶段的重试不变；delivery_id 对应每个订阅、每个阶段的投递任务。

| 客户端用途 | 使用字段 | 规则 |
| --- | --- | --- |
| 合并两阶段链上状态 | subscription_id + activity_id | 仅同一活动的不同阶段合并，不按 tx_hash 合并 |
| 持久化事件去重 | subscription_id + event_id | 回调与补拉统一去重；不同阶段各处理一次 |
| 查询投递／重试 | delivery_id | 阶段不同则不同；同一阶段的重试保持不变 |
| 撤销原阶段 | reverts_event_id | 引用原阶段 event_id，撤销本身有新的 event_id |
| 标记活动已撤销 | reverts_activity_id | 与撤销消息的 activity_id 一致 |

### 12.3 重组、重复和乱序

每个已发布阶段在重组后分别产生独立撤销事件，reverts_event_id 指向被撤销阶段，reverts_activity_id 与 activity_id 均指向共同活动。如果只有 detected 已发布则产生一条撤销；两阶段都已发布则分别撤销。未开始投递的原阶段会取消，已在途 HTTP 可能迟到；补拉流仍保留已发布阶段及撤销。

接收端用 subscription_id + event_id 去重，按 subscription_id + activity_id 聚合。保存已撤销活动标记；撤销先到时，后到的任何原阶段不能恢复该活动。finalized 先到时，后到 detected 只补充审计，不能将状态降回确认中。direction=out、self 或其他非收款活动不能触发付款成功；服务端始终只报告链上状态，不处理客户端订单。

| 当前活动状态 | 新收到的阶段 | 保存后的链状态 |
| --- | --- | --- |
| 尚无记录 | detected | detected；收款核验后可显示“已检测到付款，确认中” |
| 尚无记录或 detected | confirmed / finalized | finalized；客户端继续独立查链、匹配订单和幂等确认 |
| finalized | 迟到的 detected | 保持 finalized，只补充审计与首次接收时间 |
| 任意状态，包括尚无记录 | reverted | reverted；保留撤销标记，进入重查／异常处理 |
| reverted | 迟到的 detected 或 confirmed | 保持 reverted，不恢复为有效到账 |

重复 event_id 仅更新接收审计，不重复处理业务。对于相同 activity_id 却出现不一致的链上定位或金额字段，应保留证据并进入异常处理，不覆盖已核验记录。

签名、原始字节、时间窗口、密钥轮换和至少一次投递协议保持不变。两阶段均持久化并可通过 /v1/events 补拉。重试、故障追补和最终性等待会影响 HTTP 到达顺序；如果采集恢复时交易已经 finalized，两阶段可能紧接着发布，不能保证 detected 一定提前多久到达。

完整示例：[detected](#sample-activity-detected)、[同一活动 finalized](#sample-two-phase-confirmed)、[detected 撤销](#sample-detected-reverted)。接收器 activities 表保存单调的 chain_status 和两个阶段首次接收时间，只记录链状态，不自动确认支付。新客户端三网验证操作及计时模板见 [三网验收步骤与计时表](#acceptance)。

### 过期采集区间与历史完整性

ETH／TRON 主网持续采集发生积压时，超过 60 分钟且已最终确认的未采集区间可被跳过，从截止边界之后继续逐块采集。服务端核验边界并持久记录缺口；不会据此编造逐笔转账或付款成功通知。正常等待最终性不属于此跳过范围。四个按需网络仍采用已有的余额核对后恢复策略。

`GET /v1/networks` 的网络对象可包含 `last_collection_gap`：`from_block` 到 `through_block` 为最近一次跳过区间（两端包含），`restart_block` 为恢复起点，`anchor_hash`／`anchor_time` 为区间末尾区块标识与时间，`cutoff_time` 为此次一小时截止点，`reason=expired_backlog`，`transfer_details_available=false`。字段不存在不承诺所有历史完整；该字段只描述最近一次缺口。

已经保存的事件与投递仍可查询；**未采集的缺口内没有逐笔事件，不能通过 `/v1/events` 补回**。客户端应依据可靠链上查询核验涉及该区间的订单，不得把无通知或无事件当作未付款。新旧核对也不得把跳过区间计为有效覆盖。

### 处理缺口与故障隔离（2026-10-23）

服务端遇到单条日志、交易、区块或订阅处理错误时，会记录缺口并继续处理后续独立有效事件。跳过失败单元不代表该位置已完整核验，也不会生成猜测的到账金额。修复后可能补入有效事件，客户端仍须按事件标识去重，并允许乱序；最终确认不能被后到的 detected 降级。

启用故障隔离的网络在 `GET /v1/networks` 中增加可选字段：

```json
{
  "processing_coverage": {
    "status": "incomplete",
    "unresolved_count": 1,
    "first_block": 87337069,
    "last_block": 87337069
  }
}
```

`incomplete` 表示存在已知未解决的处理缺口，正常事件仍会投递。`no_known_gaps` 仅表示没有已登记的未解决缺口，不保证任何历史时间段均已扫描。计数是故障记录数，不是漏报交易或受影响用户数。字段为网络级汇总，不泄露其他客户端地址、交易或订阅信息。

`GET /v1/events` 保持查询实际生成的事件。超过 60 分钟积压时限的迟到通知不会因补读而重新自动发送，仍可查询已生成事件和进行人工处理；正常链上最终确认等待不计入该积压时限。API 签名、事件类型、金额格式和回调重试协议不变。


## 13 三个测试网的验收流程

<a id="acceptance"></a>

截至 2026-10-18，三个测试网真实 USDT 收发与两阶段通知已通过客户端报告、服务端记录和独立查链核对。以下是新客户端验证自己的接收器时使用的流程及空白记录模板，并非尚未完成的服务端验收记录。真实链重组未发生，撤销的本地处理测试与实链重组结果分别记录。

模拟联调通过后再做真实转账。原有客户端沿用自己的回调、签名密钥和收款钱包；已有 notify_detected=true、策略相同的有效订阅可继续使用。公开文档不列出任何客户的钱包或 endpoint ID。

1. 查询 `/v1/networks` 并核对合约和精度。按需模式 idle/degraded 可先创建订阅唤醒；转账前确认网络健康、三个订阅均 notify_detected=true、active，记录 start_block。
2. 每网先做 **1 笔普通测试 USDT 转入**，付款和收款地址不同，金额大于零；保存发送时间、tx_hash、amount_raw 和精度。
3. 独立查链核验成功回执、登记合约、Transfer 的 from/to、日志序号、区块 Hash、金额和最终性。通过本次交易的日志定位匹配活动，不只按 tx_hash 对账。
4. 无重组且每笔只有一条匹配日志时，共预期 **6 个阶段事件、3 个 activity_id**。重试可能产生更多 HTTP 请求；同笔多日志与重组也会产生额外事件，不按 HTTP 次数判断缺失或重复。
5. 保存 detected 与 finalized 的首次客户端接收时间，以及两条消息各自的 occurred_at。记录广播到首次检测、广播到最终确认、阶段间隔和服务端生成到接收的延迟；注明时钟同步方式，不把区块时间视作精确广播时间。finalized 等待按网络而异，未承诺固定秒数或 SLA。
6. 持久化后首次返回 503，核验服务方重试的 event_id/delivery_id/正文不变、签名有效、业务仅处理一次。把已验签的历史事件送入本地处理器，测试 finalized→detected 和 reverted→detected/confirmed 的乱序；无需伪造服务方签名。
   另在隔离接收器制造无应答／连接断开，按第 8 节检查 5、60、120、240、480 秒排期、第五次重试失败后 manual，再人工调用 retry 恢复。503 使用 HTTP 错误策略，不能代替无应答上限测试。
7. 将 `/v1/events` 补拉与回调合并，验证不重复处理、游标恢复和撤销关联。增加转出测试，确认 direction=out 不触发付款成功；单独转入手续费币不应产生 USDT 动账。
8. 删除订阅后再转账，核验停止边界；已在途 HTTP 只保存审计。真实链重组不保证在一次测试中发生，撤销协议用已验证的样例做本地处理测试，真实链重组证据单独记录。

| 网络 | Hash / 日志序号 | activity_id | detected ID / 首次接收 / 耗时 | finalized ID / 首次接收 / 耗时 | 验签 / 金额 / 去重 / 乱序 |
| --- | --- | --- | --- | --- | --- |
| ethereum-sepolia | 客户端填写 | 客户端填写 | 客户端填写 | 客户端填写 | 客户端填写 |
| bsc-testnet | 客户端填写 | 客户端填写 | 客户端填写 | 客户端填写 | 客户端填写 |
| tron-nile | 客户端填写 | 客户端填写 | 客户端填写 | 客户端填写 | 客户端填写 |

上述检查确认的是链上通知。接入真实订单仍由客户端实现有效订单匹配、独立查链、金额校验、资金入账幂等及商户回调；任何测试事件均不能计入正式资金账本。

## 14 常见问题与定位

| 现象 | 检查方式 |
| --- | --- |
| 只有 API Key，没有 signing_secret | 调用 POST /v1/webhook-endpoints；成功响应给出 signing_key_id 和 signing_secret，按第 11 节保存 |
| 文档能下载，API 401 | 文档公开；管理 API 仍须 Authorization: Bearer。检查 Key 完整性、有效期与权限 |
| 403 且没有 X-Request-Id | 可能由公网边缘拦截；使用文档的明确 User-Agent，保留 CF-Ray；不要与应用权限错误混淆 |
| 422 invalid_callback_url | 检查可信 HTTPS、公网解析、URL 是否有密码、是否跳转或指向内网 |
| 注册后 202，但没有收到模拟回调 | 202 仅排队，查询 /v1/deliveries 的状态、次数与 last_error_code；检查公网接收器及 endpoint 是否 paused |
| 回调验签失败 | 原始字节不能重新 JSON 序列化；完整 whsec_ 字符串作 UTF-8 密钥；核对 Key ID、±300 秒校时、头部与正文 ID 一致 |
| 503 network_unavailable 或 initializing | 暂停新转账，等待网络恢复和订阅 active/start_block；不能通过降确认标准绕过 |
| 有 USDT 余额，却没有动账通知 | 原余额不补造事件；核对网络、指定合约、start_block 及本次实际 Transfer，原生币或其他同名代币不在范围内 |
| 只有 confirmed，没有 detected | 查看订阅 notify_detected；原订阅默认 false，要按第 6 节停止并重建；历史事件不补造检测阶段 |
| confirmed 先于 detected 到达 | 正常允许乱序；按 activity_id 合并，保持 finalized，不退回确认中 |
| 同一活动多次出现或撤销先到 | subscription_id + event_id 去重，撤销标记优先；重组重新收录使用新的 activity_id |
| 409 subscription_conflict | 查询已有有效订阅；同地址的策略、notify_detected、client_reference 必须一致；不是换个幂等键就会修改 |
| 409 cursor_filter_mismatch | 过滤条件改变要从无游标开始；每组过滤条件单独保存 cursor |
| 410 cursor_expired | 已超保留期，按 details.earliest_cursor 处理缺失区间并恢复，不能假定无遗漏 |
| 429 | 遵守 Retry-After；区分请求速率与订阅数量。默认测试配置为 100 订阅、10 回调、120 管理请求/分钟，实际以签发账号限制为准 |
| test_usdt_url=null | 该合约无本服务登记的公共领币链接；使用已有持币测试钱包转出，或从该指定合约的资产管理方取得。模拟测试可继续，不使用其他 USDT 替代 |

向服务方排查时提供 request_id、网络、subscription_id、event_id/delivery_id、HTTP 状态和发生时间；API Key、signing_secret、私钥、助记词不要放入工单或日志。


## 15 完整 JSON 样例与固定验签向量

<a id="samples"></a>

以下均为演示数据，不能作为链上交易证明。检测与最终确认共享同一 activity_id；检测撤销样例展示另一种重组结果。游标和 ID 必须使用实际接口响应。

<a id="sample-endpoint-create"></a>

### 注册回调请求（endpoint-create.json）

```json
{
  "url": "https://client.example.invalid/walletshield/webhook",
  "description": "测试环境回调"
}
```

<a id="sample-subscription-create"></a>

### 默认单阶段订阅请求（subscription-create.json）

```json
{
  "network": "ethereum-sepolia",
  "asset": "USDT",
  "address": "0x2222222222222222222222222222222222222222",
  "webhook_endpoint_id": "wh_example_001",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "client_reference": "client-test-wallet-001"
}
```

<a id="sample-subscription-two-phase-create"></a>

### 两阶段订阅请求（subscription-two-phase-create.json）

```json
{
  "network": "ethereum-sepolia",
  "asset": "USDT",
  "address": "0x2222222222222222222222222222222222222222",
  "webhook_endpoint_id": "wh_example_001",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "client_reference": "client-test-wallet-001",
  "notify_detected": true
}
```

<a id="sample-subscription-two-phase-created"></a>

### 两阶段订阅 201 响应（subscription-two-phase-created.json）

```json
{
  "data": {
    "id": "sub_example_001",
    "network": "ethereum-sepolia",
    "asset": "USDT",
    "address": "0x2222222222222222222222222222222222222222",
    "webhook_endpoint_id": "wh_example_001",
    "confirmation_policy": {
      "mode": "finalized"
    },
    "client_reference": "client-test-wallet-001",
    "token_contract": "0x1111111111111111111111111111111111111111",
    "status": "active",
    "start_block": 12345678,
    "anchor_block": 12345677,
    "anchor_block_hash": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
    "activated_at": "2026-10-17T04:00:00Z",
    "created_at": "2026-10-17T04:00:00Z",
    "notify_detected": true
  },
  "request_id": "req_example_004"
}
```

<a id="sample-activity-detected"></a>

### 检测到成功上链 Transfer（activity-detected.json）

```json
{
  "schema_version": "1.0",
  "event_type": "wallet.activity.detected",
  "event_id": "evt_example_detected_log_7",
  "delivery_id": "dlv_example_detected_log_7",
  "subscription_id": "sub_example_001",
  "network": "ethereum-sepolia",
  "chain": "ETH",
  "is_testnet": true,
  "simulated": false,
  "asset": "USDT",
  "token_contract": "0x1111111111111111111111111111111111111111",
  "address": "0x2222222222222222222222222222222222222222",
  "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "block_number": 12345678,
  "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "block_time": "2026-10-17T10:00:00Z",
  "occurred_at": "2026-10-17T10:00:03Z",
  "transaction_index": 2,
  "event_index": 7,
  "event_index_scope": "block",
  "from": "0x3333333333333333333333333333333333333333",
  "to": "0x2222222222222222222222222222222222222222",
  "direction": "in",
  "event_kind": "transfer",
  "amount_raw": "12500000",
  "decimals": 6,
  "amount": "12.5",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "confirmation_status": "detected",
  "balance_snapshot": null,
  "reverts_event_id": null,
  "client_reference": "client-test-wallet-001",
  "activity_id": "act_example_sepolia_log_7_occurrence_1",
  "reverts_activity_id": null
}
```

<a id="sample-two-phase-confirmed"></a>

### 同一活动达到最终确认（two-phase-confirmed.json）

```json
{
  "schema_version": "1.0",
  "event_type": "wallet.activity.confirmed",
  "event_id": "evt_example_finalized_log_7",
  "delivery_id": "dlv_example_finalized_log_7",
  "subscription_id": "sub_example_001",
  "network": "ethereum-sepolia",
  "chain": "ETH",
  "is_testnet": true,
  "simulated": false,
  "asset": "USDT",
  "token_contract": "0x1111111111111111111111111111111111111111",
  "address": "0x2222222222222222222222222222222222222222",
  "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "block_number": 12345678,
  "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "block_time": "2026-10-17T10:00:00Z",
  "occurred_at": "2026-10-17T10:16:00Z",
  "transaction_index": 2,
  "event_index": 7,
  "event_index_scope": "block",
  "from": "0x3333333333333333333333333333333333333333",
  "to": "0x2222222222222222222222222222222222222222",
  "direction": "in",
  "event_kind": "transfer",
  "amount_raw": "12500000",
  "decimals": 6,
  "amount": "12.5",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "confirmation_status": "finalized",
  "balance_snapshot": null,
  "reverts_event_id": null,
  "client_reference": "client-test-wallet-001",
  "activity_id": "act_example_sepolia_log_7_occurrence_1",
  "reverts_activity_id": null
}
```

<a id="sample-detected-reverted"></a>

### 另一种结果：仅检测阶段发布后重组撤销（detected-reverted.json）

```json
{
  "schema_version": "1.0",
  "event_type": "wallet.activity.reverted",
  "event_id": "evt_example_reverted_detected_log_7",
  "delivery_id": "dlv_example_reverted_detected_log_7",
  "subscription_id": "sub_example_001",
  "network": "ethereum-sepolia",
  "chain": "ETH",
  "is_testnet": true,
  "simulated": false,
  "asset": "USDT",
  "token_contract": "0x1111111111111111111111111111111111111111",
  "address": "0x2222222222222222222222222222222222222222",
  "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "block_number": 12345678,
  "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "block_time": "2026-10-17T10:00:00Z",
  "occurred_at": "2026-10-17T10:00:10Z",
  "transaction_index": 2,
  "event_index": 7,
  "event_index_scope": "block",
  "from": "0x3333333333333333333333333333333333333333",
  "to": "0x2222222222222222222222222222222222222222",
  "direction": "in",
  "event_kind": "transfer",
  "amount_raw": "12500000",
  "decimals": 6,
  "amount": "12.5",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "confirmation_status": "reverted",
  "balance_snapshot": null,
  "reverts_event_id": "evt_example_detected_log_7",
  "client_reference": "client-test-wallet-001",
  "activity_id": "act_example_sepolia_log_7_occurrence_1",
  "reverts_activity_id": "act_example_sepolia_log_7_occurrence_1"
}
```

<a id="sample-events-two-phase"></a>

### 补拉两阶段的完整响应（events-two-phase.json）

```json
{
  "data": [
    {
      "schema_version": "1.0",
      "event_type": "wallet.activity.detected",
      "event_id": "evt_example_detected_log_7",
      "subscription_id": "sub_example_001",
      "network": "ethereum-sepolia",
      "chain": "ETH",
      "is_testnet": true,
      "simulated": false,
      "asset": "USDT",
      "token_contract": "0x1111111111111111111111111111111111111111",
      "address": "0x2222222222222222222222222222222222222222",
      "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      "block_number": 12345678,
      "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
      "block_time": "2026-10-17T10:00:00Z",
      "occurred_at": "2026-10-17T10:00:03Z",
      "transaction_index": 2,
      "event_index": 7,
      "event_index_scope": "block",
      "from": "0x3333333333333333333333333333333333333333",
      "to": "0x2222222222222222222222222222222222222222",
      "direction": "in",
      "event_kind": "transfer",
      "amount_raw": "12500000",
      "decimals": 6,
      "amount": "12.5",
      "confirmation_policy": {
        "mode": "finalized"
      },
      "confirmation_status": "detected",
      "balance_snapshot": null,
      "reverts_event_id": null,
      "client_reference": "client-test-wallet-001",
      "activity_id": "act_example_sepolia_log_7_occurrence_1",
      "reverts_activity_id": null
    },
    {
      "schema_version": "1.0",
      "event_type": "wallet.activity.confirmed",
      "event_id": "evt_example_finalized_log_7",
      "subscription_id": "sub_example_001",
      "network": "ethereum-sepolia",
      "chain": "ETH",
      "is_testnet": true,
      "simulated": false,
      "asset": "USDT",
      "token_contract": "0x1111111111111111111111111111111111111111",
      "address": "0x2222222222222222222222222222222222222222",
      "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      "block_number": 12345678,
      "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
      "block_time": "2026-10-17T10:00:00Z",
      "occurred_at": "2026-10-17T10:16:00Z",
      "transaction_index": 2,
      "event_index": 7,
      "event_index_scope": "block",
      "from": "0x3333333333333333333333333333333333333333",
      "to": "0x2222222222222222222222222222222222222222",
      "direction": "in",
      "event_kind": "transfer",
      "amount_raw": "12500000",
      "decimals": 6,
      "amount": "12.5",
      "confirmation_policy": {
        "mode": "finalized"
      },
      "confirmation_status": "finalized",
      "balance_snapshot": null,
      "reverts_event_id": null,
      "client_reference": "client-test-wallet-001",
      "activity_id": "act_example_sepolia_log_7_occurrence_1",
      "reverts_activity_id": null
    }
  ],
  "next_cursor": "cur_example_opaque_do_not_reuse",
  "has_more": false,
  "request_id": "req_example_events_two_phase"
}
```

<a id="sample-transfer-confirmed"></a>

### 兼容历史确认事件（transfer-confirmed.json）

```json
{
  "schema_version": "1.0",
  "event_type": "wallet.activity.confirmed",
  "event_id": "evt_example_confirmed",
  "delivery_id": "dlv_example_confirmed",
  "subscription_id": "sub_example_001",
  "network": "ethereum-sepolia",
  "chain": "ETH",
  "is_testnet": true,
  "simulated": false,
  "asset": "USDT",
  "token_contract": "0x1111111111111111111111111111111111111111",
  "address": "0x2222222222222222222222222222222222222222",
  "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "block_number": 12345678,
  "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "block_time": "2026-10-17T04:00:00Z",
  "occurred_at": "2026-10-17T04:00:00Z",
  "transaction_index": 2,
  "event_index": 7,
  "event_index_scope": "block",
  "from": "0x3333333333333333333333333333333333333333",
  "to": "0x2222222222222222222222222222222222222222",
  "direction": "in",
  "event_kind": "transfer",
  "amount_raw": "12500000",
  "decimals": 6,
  "amount": "12.5",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "confirmation_status": "finalized",
  "balance_snapshot": null,
  "reverts_event_id": null,
  "client_reference": "client-test-wallet-001"
}
```

<a id="sample-bsc-transfer-confirmed"></a>

### BSC 的 18 位原始整数金额（bsc-transfer-confirmed.json）

```json
{
  "schema_version": "1.0",
  "event_type": "wallet.activity.confirmed",
  "event_id": "evt_example_bsc",
  "delivery_id": "dlv_example_bsc",
  "subscription_id": "sub_example_bsc",
  "network": "bsc-testnet",
  "chain": "BSC",
  "is_testnet": true,
  "simulated": false,
  "asset": "USDT",
  "token_contract": "0x1111111111111111111111111111111111111111",
  "address": "0x2222222222222222222222222222222222222222",
  "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "block_number": 12345678,
  "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "block_time": "2026-10-17T04:00:00Z",
  "occurred_at": "2026-10-17T04:00:00Z",
  "transaction_index": 2,
  "event_index": 7,
  "event_index_scope": "block",
  "from": "0x3333333333333333333333333333333333333333",
  "to": "0x2222222222222222222222222222222222222222",
  "direction": "in",
  "event_kind": "transfer",
  "amount_raw": "12500000000000000000",
  "decimals": 18,
  "amount": "12.5",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "confirmation_status": "finalized",
  "balance_snapshot": null,
  "reverts_event_id": null,
  "client_reference": "client-test-wallet-001"
}
```

<a id="sample-tron-transfer-confirmed"></a>

### Nile 的地址和交易内日志序号（tron-transfer-confirmed.json）

```json
{
  "schema_version": "1.0",
  "event_type": "wallet.activity.confirmed",
  "event_id": "evt_example_tron",
  "delivery_id": "dlv_example_tron",
  "subscription_id": "sub_example_tron",
  "network": "tron-nile",
  "chain": "TRON",
  "is_testnet": true,
  "simulated": false,
  "asset": "USDT",
  "token_contract": "TBXSw8fM4jpQkGc6zZjsVABFpVN7UvXPdV",
  "address": "TD5gsCwxykWsLN9aPrq2TAfNjByuZKYp4E",
  "tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "block_number": 12345678,
  "block_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "block_time": "2026-10-17T04:00:00Z",
  "occurred_at": "2026-10-17T04:00:00Z",
  "transaction_index": 2,
  "event_index": 0,
  "event_index_scope": "transaction",
  "from": "TEdvoHEatmDKvTh3o9vBRB9Vdtbhn4QFhy",
  "to": "TD5gsCwxykWsLN9aPrq2TAfNjByuZKYp4E",
  "direction": "in",
  "event_kind": "transfer",
  "amount_raw": "12500000",
  "decimals": 6,
  "amount": "12.5",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "confirmation_status": "finalized",
  "balance_snapshot": null,
  "reverts_event_id": null,
  "client_reference": "client-test-wallet-001"
}
```

<a id="sample-activity-reverted"></a>

### 兼容历史撤销事件（activity-reverted.json）

```json
{
  "schema_version": "1.0",
  "event_type": "wallet.activity.reverted",
  "event_id": "evt_example_reverted",
  "delivery_id": "dlv_example_reverted",
  "subscription_id": "sub_example_001",
  "network": "ethereum-sepolia",
  "chain": "ETH",
  "is_testnet": true,
  "simulated": false,
  "asset": "USDT",
  "token_contract": "0x1111111111111111111111111111111111111111",
  "address": "0x2222222222222222222222222222222222222222",
  "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "block_number": 12345678,
  "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "block_time": "2026-10-17T04:00:00Z",
  "occurred_at": "2026-10-17T04:05:00Z",
  "transaction_index": 2,
  "event_index": 7,
  "event_index_scope": "block",
  "from": "0x3333333333333333333333333333333333333333",
  "to": "0x2222222222222222222222222222222222222222",
  "direction": "in",
  "event_kind": "transfer",
  "amount_raw": "12500000",
  "decimals": 6,
  "amount": "12.5",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "confirmation_status": "reverted",
  "balance_snapshot": null,
  "reverts_event_id": "evt_example_confirmed",
  "client_reference": "client-test-wallet-001"
}
```

<a id="sample-webhook-test"></a>

### 模拟回调，不进入真实事件流（webhook-test.json）

```json
{
  "schema_version": "1.0",
  "event_type": "webhook.test",
  "event_id": "evt_example_test",
  "delivery_id": "dlv_example_test",
  "subscription_id": null,
  "network": "ethereum-sepolia",
  "chain": "ETH",
  "is_testnet": true,
  "simulated": true,
  "asset": "USDT",
  "token_contract": null,
  "address": null,
  "tx_hash": null,
  "block_number": null,
  "block_hash": null,
  "block_time": null,
  "occurred_at": "2026-10-17T04:00:00Z",
  "transaction_index": null,
  "event_index": null,
  "event_index_scope": null,
  "from": null,
  "to": null,
  "direction": "in",
  "event_kind": "test",
  "amount_raw": "12500000",
  "decimals": 6,
  "amount": "12.5",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "confirmation_status": "simulated",
  "balance_snapshot": null,
  "reverts_event_id": null,
  "client_reference": null
}
```

<a id="sample-balance-reconciled"></a>

### BSC 主网净余额审计通知（balance-reconciled.json）

```json
{
  "schema_version": "1.0",
  "event_type": "wallet.balance.reconciled",
  "event_id": "evt_092f508e97fc19ee4fb3ba2a3d04fc0f5423a8e78593e8ab94f1793c42a5d57b",
  "reconciliation_id": "rec_092f508e97fc19ee4fb3ba2a3d04fc0f5423a8e78593e8ab94f1793c42a5d57b",
  "recovery_run_id": "recovery_example_bsc_001",
  "delivery_id": "dlv_example_bsc_reconciliation",
  "subscription_id": "sub_example_bsc_reconciliation",
  "network": "bsc-mainnet",
  "chain": "BSC",
  "chain_id": 56,
  "is_testnet": false,
  "simulated": false,
  "asset": "USDT",
  "token_contract": "0x55d398326f99059ff775485246999027b3197955",
  "address": "0x2222222222222222222222222222222222222222",
  "decimals": 18,
  "client_reference": "client-example-audit-only",
  "occurred_at": "2026-10-18T12:00:00Z",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "event_kind": "balance_reconciliation",
  "confirmation_status": "reconciled",
  "change_direction": "net_increase",
  "delta_raw": "1",
  "delta": "0.000000000000000001",
  "observed_before": {
    "block_number": 100,
    "block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "balance_raw": "12500000000000000000",
    "balance": "12.5",
    "consistency": "block_pinned"
  },
  "observed_after": {
    "block_number": 200,
    "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "balance_raw": "12500000000000000001",
    "balance": "12.500000000000000001",
    "consistency": "block_pinned"
  },
  "boundary_finality": "finalized",
  "coverage_gap": {
    "from_block": 101,
    "through_block": 200,
    "transfer_details_available": false
  },
  "payment_eligible": false,
  "reason": "authorized_bsc_backlog_recovery",
  "activity_id": null,
  "amount": null,
  "amount_raw": null,
  "balance_snapshot": null,
  "block_hash": null,
  "block_number": null,
  "block_time": null,
  "direction": null,
  "event_index": null,
  "event_index_scope": null,
  "from": null,
  "reverts_activity_id": null,
  "reverts_event_id": null,
  "to": null,
  "transaction_index": null,
  "tx_hash": null
}
```

<a id="sample-events-balance-reconciled"></a>

### BSC 主网净余额审计事件补拉（events-balance-reconciled.json）

```json
{
  "data": [
    {
      "schema_version": "1.0",
      "event_type": "wallet.balance.reconciled",
      "event_id": "evt_092f508e97fc19ee4fb3ba2a3d04fc0f5423a8e78593e8ab94f1793c42a5d57b",
      "reconciliation_id": "rec_092f508e97fc19ee4fb3ba2a3d04fc0f5423a8e78593e8ab94f1793c42a5d57b",
      "recovery_run_id": "recovery_example_bsc_001",
      "subscription_id": "sub_example_bsc_reconciliation",
      "network": "bsc-mainnet",
      "chain": "BSC",
      "chain_id": 56,
      "is_testnet": false,
      "simulated": false,
      "asset": "USDT",
      "token_contract": "0x55d398326f99059ff775485246999027b3197955",
      "address": "0x2222222222222222222222222222222222222222",
      "decimals": 18,
      "client_reference": "client-example-audit-only",
      "occurred_at": "2026-10-18T12:00:00Z",
      "confirmation_policy": {
        "mode": "finalized"
      },
      "event_kind": "balance_reconciliation",
      "confirmation_status": "reconciled",
      "change_direction": "net_increase",
      "delta_raw": "1",
      "delta": "0.000000000000000001",
      "observed_before": {
        "block_number": 100,
        "block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
        "balance_raw": "12500000000000000000",
        "balance": "12.5",
        "consistency": "block_pinned"
      },
      "observed_after": {
        "block_number": 200,
        "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
        "balance_raw": "12500000000000000001",
        "balance": "12.500000000000000001",
        "consistency": "block_pinned"
      },
      "boundary_finality": "finalized",
      "coverage_gap": {
        "from_block": 101,
        "through_block": 200,
        "transfer_details_available": false
      },
      "payment_eligible": false,
      "reason": "authorized_bsc_backlog_recovery",
      "activity_id": null,
      "amount": null,
      "amount_raw": null,
      "balance_snapshot": null,
      "block_hash": null,
      "block_number": null,
      "block_time": null,
      "direction": null,
      "event_index": null,
      "event_index_scope": null,
      "from": null,
      "reverts_activity_id": null,
      "reverts_event_id": null,
      "to": null,
      "transaction_index": null,
      "tx_hash": null
    }
  ],
  "next_cursor": "cur_example_reconciliation_do_not_reuse",
  "has_more": false,
  "request_id": "req_example_reconciliation"
}
```

<a id="sample-bsc-testnet-balance-reconciled"></a>

### BSC 测试网净余额审计通知示例（bsc-testnet-balance-reconciled.json）

```json
{
  "schema_version": "1.0",
  "event_type": "wallet.balance.reconciled",
  "event_id": "evt_f450385d7ce30974b72fe1dae01b2a16613baffef0ef3b6afbadce0008089081",
  "reconciliation_id": "rec_f450385d7ce30974b72fe1dae01b2a16613baffef0ef3b6afbadce0008089081",
  "recovery_run_id": "recovery_example_bsc_testnet_001",
  "delivery_id": "dlv_example_bsc_testnet_reconciliation",
  "subscription_id": "sub_example_bsc_testnet_reconciliation",
  "network": "bsc-testnet",
  "chain": "BSC",
  "chain_id": 97,
  "is_testnet": true,
  "simulated": false,
  "asset": "USDT",
  "token_contract": "0x436fc60cef0a8b4054420b50220c834a34bf7b90",
  "address": "0x2222222222222222222222222222222222222222",
  "decimals": 18,
  "client_reference": "client-example-audit-only",
  "occurred_at": "2026-10-18T12:00:00Z",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "event_kind": "balance_reconciliation",
  "confirmation_status": "reconciled",
  "change_direction": "net_increase",
  "delta_raw": "1",
  "delta": "0.000000000000000001",
  "observed_before": {
    "block_number": 100,
    "block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "balance_raw": "12500000000000000000",
    "balance": "12.5",
    "consistency": "block_pinned"
  },
  "observed_after": {
    "block_number": 200,
    "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "balance_raw": "12500000000000000001",
    "balance": "12.500000000000000001",
    "consistency": "block_pinned"
  },
  "boundary_finality": "finalized",
  "coverage_gap": {
    "from_block": 101,
    "through_block": 200,
    "transfer_details_available": false
  },
  "payment_eligible": false,
  "reason": "authorized_bsc_backlog_recovery",
  "activity_id": null,
  "amount": null,
  "amount_raw": null,
  "balance_snapshot": null,
  "block_hash": null,
  "block_number": null,
  "block_time": null,
  "direction": null,
  "event_index": null,
  "event_index_scope": null,
  "from": null,
  "reverts_activity_id": null,
  "reverts_event_id": null,
  "to": null,
  "transaction_index": null,
  "tx_hash": null
}
```

<a id="sample-events-bsc-testnet-balance-reconciled"></a>

### BSC 测试网净余额审计补拉示例（events-bsc-testnet-balance-reconciled.json）

```json
{
  "data": [
    {
      "schema_version": "1.0",
      "event_type": "wallet.balance.reconciled",
      "event_id": "evt_f450385d7ce30974b72fe1dae01b2a16613baffef0ef3b6afbadce0008089081",
      "reconciliation_id": "rec_f450385d7ce30974b72fe1dae01b2a16613baffef0ef3b6afbadce0008089081",
      "recovery_run_id": "recovery_example_bsc_testnet_001",
      "subscription_id": "sub_example_bsc_testnet_reconciliation",
      "network": "bsc-testnet",
      "chain": "BSC",
      "chain_id": 97,
      "is_testnet": true,
      "simulated": false,
      "asset": "USDT",
      "token_contract": "0x436fc60cef0a8b4054420b50220c834a34bf7b90",
      "address": "0x2222222222222222222222222222222222222222",
      "decimals": 18,
      "client_reference": "client-example-audit-only",
      "occurred_at": "2026-10-18T12:00:00Z",
      "confirmation_policy": {
        "mode": "finalized"
      },
      "event_kind": "balance_reconciliation",
      "confirmation_status": "reconciled",
      "change_direction": "net_increase",
      "delta_raw": "1",
      "delta": "0.000000000000000001",
      "observed_before": {
        "block_number": 100,
        "block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
        "balance_raw": "12500000000000000000",
        "balance": "12.5",
        "consistency": "block_pinned"
      },
      "observed_after": {
        "block_number": 200,
        "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
        "balance_raw": "12500000000000000001",
        "balance": "12.500000000000000001",
        "consistency": "block_pinned"
      },
      "boundary_finality": "finalized",
      "coverage_gap": {
        "from_block": 101,
        "through_block": 200,
        "transfer_details_available": false
      },
      "payment_eligible": false,
      "reason": "authorized_bsc_backlog_recovery",
      "activity_id": null,
      "amount": null,
      "amount_raw": null,
      "balance_snapshot": null,
      "block_hash": null,
      "block_number": null,
      "block_time": null,
      "direction": null,
      "event_index": null,
      "event_index_scope": null,
      "from": null,
      "reverts_activity_id": null,
      "reverts_event_id": null,
      "to": null,
      "transaction_index": null,
      "tx_hash": null
    }
  ],
  "next_cursor": "cur_example_reconciliation_do_not_reuse",
  "has_more": false,
  "request_id": "req_example_reconciliation"
}
```

<a id="sample-sepolia-balance-reconciled"></a>

### Sepolia 净余额审计通知示例（sepolia-balance-reconciled.json）

```json
{
  "schema_version": "1.0",
  "event_type": "wallet.balance.reconciled",
  "event_id": "evt_93ee0b1744f5f283527e0e03ff1081c31ee30e881efa814933ed1888d4eaa478",
  "reconciliation_id": "rec_93ee0b1744f5f283527e0e03ff1081c31ee30e881efa814933ed1888d4eaa478",
  "recovery_run_id": "recovery_example_sepolia_001",
  "delivery_id": "dlv_example_sepolia_reconciliation",
  "subscription_id": "sub_example_sepolia_reconciliation",
  "network": "ethereum-sepolia",
  "chain": "ETH",
  "chain_id": 11155111,
  "is_testnet": true,
  "simulated": false,
  "asset": "USDT",
  "token_contract": "0xb464a579e9697694833f544d1b464cbd51405c84",
  "address": "0x2222222222222222222222222222222222222222",
  "decimals": 6,
  "client_reference": "client-example-audit-only",
  "occurred_at": "2026-10-18T12:00:00Z",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "event_kind": "balance_reconciliation",
  "confirmation_status": "reconciled",
  "change_direction": "net_increase",
  "delta_raw": "1",
  "delta": "0.000001",
  "observed_before": {
    "block_number": 100,
    "block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "balance_raw": "12500000",
    "balance": "12.5",
    "consistency": "block_pinned"
  },
  "observed_after": {
    "block_number": 200,
    "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "balance_raw": "12500001",
    "balance": "12.500001",
    "consistency": "block_pinned"
  },
  "boundary_finality": "finalized",
  "coverage_gap": {
    "from_block": 101,
    "through_block": 200,
    "transfer_details_available": false
  },
  "payment_eligible": false,
  "reason": "authorized_backlog_recovery",
  "activity_id": null,
  "amount": null,
  "amount_raw": null,
  "balance_snapshot": null,
  "block_hash": null,
  "block_number": null,
  "block_time": null,
  "direction": null,
  "event_index": null,
  "event_index_scope": null,
  "from": null,
  "reverts_activity_id": null,
  "reverts_event_id": null,
  "to": null,
  "transaction_index": null,
  "tx_hash": null
}
```

<a id="sample-events-sepolia-balance-reconciled"></a>

### Sepolia 净余额审计补拉示例（events-sepolia-balance-reconciled.json）

```json
{
  "data": [
    {
      "schema_version": "1.0",
      "event_type": "wallet.balance.reconciled",
      "event_id": "evt_93ee0b1744f5f283527e0e03ff1081c31ee30e881efa814933ed1888d4eaa478",
      "reconciliation_id": "rec_93ee0b1744f5f283527e0e03ff1081c31ee30e881efa814933ed1888d4eaa478",
      "recovery_run_id": "recovery_example_sepolia_001",
      "subscription_id": "sub_example_sepolia_reconciliation",
      "network": "ethereum-sepolia",
      "chain": "ETH",
      "chain_id": 11155111,
      "is_testnet": true,
      "simulated": false,
      "asset": "USDT",
      "token_contract": "0xb464a579e9697694833f544d1b464cbd51405c84",
      "address": "0x2222222222222222222222222222222222222222",
      "decimals": 6,
      "client_reference": "client-example-audit-only",
      "occurred_at": "2026-10-18T12:00:00Z",
      "confirmation_policy": {
        "mode": "finalized"
      },
      "event_kind": "balance_reconciliation",
      "confirmation_status": "reconciled",
      "change_direction": "net_increase",
      "delta_raw": "1",
      "delta": "0.000001",
      "observed_before": {
        "block_number": 100,
        "block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
        "balance_raw": "12500000",
        "balance": "12.5",
        "consistency": "block_pinned"
      },
      "observed_after": {
        "block_number": 200,
        "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
        "balance_raw": "12500001",
        "balance": "12.500001",
        "consistency": "block_pinned"
      },
      "boundary_finality": "finalized",
      "coverage_gap": {
        "from_block": 101,
        "through_block": 200,
        "transfer_details_available": false
      },
      "payment_eligible": false,
      "reason": "authorized_backlog_recovery",
      "activity_id": null,
      "amount": null,
      "amount_raw": null,
      "balance_snapshot": null,
      "block_hash": null,
      "block_number": null,
      "block_time": null,
      "direction": null,
      "event_index": null,
      "event_index_scope": null,
      "from": null,
      "reverts_activity_id": null,
      "reverts_event_id": null,
      "to": null,
      "transaction_index": null,
      "tx_hash": null
    }
  ],
  "next_cursor": "cur_example_reconciliation_do_not_reuse",
  "has_more": false,
  "request_id": "req_example_reconciliation"
}
```

<a id="sample-nile-balance-reconciled"></a>

### Nile latest 余额观察审计通知示例（nile-balance-reconciled.json）

```json
{
  "schema_version": "1.0",
  "event_type": "wallet.balance.reconciled",
  "event_id": "evt_570c606bbd8e95e82d4a9c6e02be267c049f3e81423cc71f7909cb1c1bfd2339",
  "reconciliation_id": "rec_570c606bbd8e95e82d4a9c6e02be267c049f3e81423cc71f7909cb1c1bfd2339",
  "recovery_run_id": "recovery_example_nile_001",
  "delivery_id": "dlv_example_nile_reconciliation",
  "subscription_id": "sub_example_nile_reconciliation",
  "network": "tron-nile",
  "chain": "TRON",
  "chain_id": null,
  "is_testnet": true,
  "simulated": false,
  "asset": "USDT",
  "token_contract": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf",
  "address": "TD5gsCwxykWsLN9aPrq2TAfNjByuZKYp4E",
  "decimals": 6,
  "client_reference": "client-example-audit-only",
  "occurred_at": "2026-10-18T12:00:00Z",
  "confirmation_policy": {
    "mode": "finalized"
  },
  "event_kind": "balance_reconciliation",
  "confirmation_status": "reconciled",
  "change_direction": "net_increase",
  "delta_raw": "1",
  "delta": "0.000001",
  "observed_before": {
    "block_number": null,
    "block_hash": null,
    "balance_raw": "12500000",
    "balance": "12.5",
    "consistency": "latest",
    "snapshot_id": "101",
    "queried_at": "2026-10-18T09:00:00Z"
  },
  "observed_after": {
    "block_number": null,
    "block_hash": null,
    "balance_raw": "12500001",
    "balance": "12.500001",
    "consistency": "latest",
    "snapshot_id": "102",
    "queried_at": "2026-10-18T09:05:00Z"
  },
  "boundary_finality": "finalized",
  "coverage_gap": {
    "from_block": 101,
    "through_block": 200,
    "transfer_details_available": false,
    "before_block_hash": "0000000000000064aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "through_block_hash": "00000000000000c8bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
  },
  "payment_eligible": false,
  "reason": "authorized_backlog_recovery",
  "activity_id": null,
  "amount": null,
  "amount_raw": null,
  "balance_snapshot": null,
  "block_hash": null,
  "block_number": null,
  "block_time": null,
  "direction": null,
  "event_index": null,
  "event_index_scope": null,
  "from": null,
  "reverts_activity_id": null,
  "reverts_event_id": null,
  "to": null,
  "transaction_index": null,
  "tx_hash": null,
  "balance_comparison_scope": "observed_snapshots"
}
```

<a id="sample-events-nile-balance-reconciled"></a>

### Nile latest 余额观察审计补拉示例（events-nile-balance-reconciled.json）

```json
{
  "data": [
    {
      "schema_version": "1.0",
      "event_type": "wallet.balance.reconciled",
      "event_id": "evt_570c606bbd8e95e82d4a9c6e02be267c049f3e81423cc71f7909cb1c1bfd2339",
      "reconciliation_id": "rec_570c606bbd8e95e82d4a9c6e02be267c049f3e81423cc71f7909cb1c1bfd2339",
      "recovery_run_id": "recovery_example_nile_001",
      "subscription_id": "sub_example_nile_reconciliation",
      "network": "tron-nile",
      "chain": "TRON",
      "chain_id": null,
      "is_testnet": true,
      "simulated": false,
      "asset": "USDT",
      "token_contract": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf",
      "address": "TD5gsCwxykWsLN9aPrq2TAfNjByuZKYp4E",
      "decimals": 6,
      "client_reference": "client-example-audit-only",
      "occurred_at": "2026-10-18T12:00:00Z",
      "confirmation_policy": {
        "mode": "finalized"
      },
      "event_kind": "balance_reconciliation",
      "confirmation_status": "reconciled",
      "change_direction": "net_increase",
      "delta_raw": "1",
      "delta": "0.000001",
      "observed_before": {
        "block_number": null,
        "block_hash": null,
        "balance_raw": "12500000",
        "balance": "12.5",
        "consistency": "latest",
        "snapshot_id": "101",
        "queried_at": "2026-10-18T09:00:00Z"
      },
      "observed_after": {
        "block_number": null,
        "block_hash": null,
        "balance_raw": "12500001",
        "balance": "12.500001",
        "consistency": "latest",
        "snapshot_id": "102",
        "queried_at": "2026-10-18T09:05:00Z"
      },
      "boundary_finality": "finalized",
      "coverage_gap": {
        "from_block": 101,
        "through_block": 200,
        "transfer_details_available": false,
        "before_block_hash": "0000000000000064aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
        "through_block_hash": "00000000000000c8bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
      },
      "payment_eligible": false,
      "reason": "authorized_backlog_recovery",
      "activity_id": null,
      "amount": null,
      "amount_raw": null,
      "balance_snapshot": null,
      "block_hash": null,
      "block_number": null,
      "block_time": null,
      "direction": null,
      "event_index": null,
      "event_index_scope": null,
      "from": null,
      "reverts_activity_id": null,
      "reverts_event_id": null,
      "to": null,
      "transaction_index": null,
      "tx_hash": null,
      "balance_comparison_scope": "observed_snapshots"
    }
  ],
  "next_cursor": "cur_example_reconciliation_do_not_reuse",
  "has_more": false,
  "request_id": "req_example_reconciliation"
}
```

<a id="sample-signature-test-vector"></a>

### 固定离线 HMAC 验签向量（signature-test-vector.json）

```json
{
  "purpose": "公开的离线验签向量，不是实际密钥或链上交易",
  "algorithm": "HMAC-SHA256",
  "secret_encoding": "UTF-8 literal, including whsec_ prefix; no Base64 decoding",
  "signing_secret": "whsec_example_NOT_A_REAL_SECRET",
  "timestamp": "1792209600",
  "key_id": "key_example_001",
  "raw_body_utf8": "{\"schema_version\":\"1.0\",\"event_type\":\"webhook.test\",\"event_id\":\"evt_example_test\",\"delivery_id\":\"dlv_example_test\",\"subscription_id\":null,\"network\":\"ethereum-sepolia\",\"chain\":\"ETH\",\"is_testnet\":true,\"simulated\":true,\"asset\":\"USDT\",\"token_contract\":null,\"address\":null,\"tx_hash\":null,\"block_number\":null,\"block_hash\":null,\"block_time\":null,\"occurred_at\":\"2026-10-17T04:00:00Z\",\"transaction_index\":null,\"event_index\":null,\"event_index_scope\":null,\"from\":null,\"to\":null,\"direction\":\"in\",\"event_kind\":\"test\",\"amount_raw\":\"12500000\",\"decimals\":6,\"amount\":\"12.5\",\"confirmation_policy\":{\"mode\":\"finalized\"},\"confirmation_status\":\"simulated\",\"balance_snapshot\":null,\"reverts_event_id\":null,\"client_reference\":null}",
  "signature": "v1=1e0a79717274cd60295e38ed4ad614208c3f23078381d56cbd342e64589f3429",
  "verification_now_unix_seconds": 1792209600,
  "allowed_clock_skew_seconds": 300,
  "headers": {
    "Content-Type": "application/json",
    "X-WS-Timestamp": "1792209600",
    "X-WS-Key-Id": "key_example_001",
    "X-WS-Signature": "v1=1e0a79717274cd60295e38ed4ad614208c3f23078381d56cbd342e64589f3429",
    "X-WS-Event-Id": "evt_example_test",
    "X-WS-Delivery-Id": "dlv_example_test",
    "X-WS-Attempt-Id": "att_example_001"
  }
}
```

固定向量中的 signing_secret 公开且无效，仅验证算法。离线验证使用 verification_now_unix_seconds 作为 now；真实请求保持当前时间 ±300 秒校验。预期签名为 `v1=1e0a79717274cd60295e38ed4ad614208c3f23078381d56cbd342e64589f3429`，修改原始正文任何字节应失败。

## 16 独立持久化接收器完整代码

<a id="receiver"></a>

将下方完整 Python 代码保存为客户端本地 `receiver.py`，不需要下载项目仓库。Python 3.12 或兼容运行环境，外部依赖仅 aiohttp（代码回归环境使用 3.10.11；客户端按自身依赖政策选择并验证兼容版本）。第 11.2 节已生成私有 keys.json，API Key 不需要放进接收器配置。

```bash
python3 -m venv .venv
.venv/bin/python -m pip install aiohttp
chmod 600 ws-client-private/keys.json
.venv/bin/python receiver.py --database ws-client-private/receiver.sqlite \
  --keyring ws-client-private/keys.json --host 127.0.0.1 --port 18554 \
  --allow-local-http
```

上例的 HTTP 仅监听本机，须由客户端自己的公网 HTTPS 入口反向代理到 `127.0.0.1:18554/walletshield/webhook`；不能将本机地址注册给 WalletShield。也可给接收器传入 `--tls-cert /your/cert.pem --tls-key /your/key.pem`，去掉 `--allow-local-http`，由自己的入口安排公网可信证书和端口。程序启动后不会替客户端配置 DNS 或证书。

keys.json 格式：`{"实际key_id":{"secret":"完整signing_secret","expires_at":null}}`。轮换期间同时保存新旧 Key ID，旧密钥的 expires_at 为重叠期结束的 Unix 秒。示例进程每次请求重新读取密钥文件；用权限 600 的临时文件原子替换 keys.json，避免读到半写入内容，无需为轮换重启接收器。

接收器先持久化再返回 2xx，阶段去重和活动状态放在 SQLite；页面补拉导入与 next_cursor 同事务。`activity_state(subscription_id, activity_id)` 返回链状态及阶段首次接收时间。它不判断订单支付成功，客户端仍要校验自己的网络、指定合约、钱包、订单和金额。

`wallet.balance.reconciled` 由独立分支按 BSC 主网／测试网、Sepolia、Nile 的完整网络身份及证据分支严格验证并保存为 `reconciled`；停用订阅保存为 `audit_only`。该分支不写 activities，不返回付款活动状态，不确认订单。回调与补拉仍共用 subscription_id + event_id 去重。仅有余额净差无法证明逐笔付款。Nile 使用 latest 查询时间／持久快照 ID，不能当作指定历史块余额。客户端自己的测试网权限限制仍须保留；三个测试网记录可以保存审计，但不得接入正式支付。

用于测试重试：启动时追加 `--test-mode --test-fail-first 1` 可在首次请求持久化前返回 503；`--test-mode --test-drop-after-commit` 在持久化后丢失首个应答，用来验证已保存消息的重复投递。正式接收器不要启用故障开关。补拉、停止订阅命令见第 11.5 节。

```python
#!/usr/bin/env python3
from __future__ import annotations

import argparse
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import Decimal, localcontext
import hashlib
import hmac
import json
import os
from pathlib import Path
import re
import sqlite3
import ssl
import time

from aiohttp import web

MAX_WEBHOOK_BYTES = 262144
NETWORKS = {"ethereum-mainnet": ("ETH", False), "ethereum-sepolia": ("ETH", True),
            "bsc-mainnet": ("BSC", False), "bsc-testnet": ("BSC", True),
            "tron-mainnet": ("TRON", False), "tron-nile": ("TRON", True)}
EVENT_TYPES = {"wallet.activity.detected", "wallet.activity.confirmed", "wallet.activity.reverted", "webhook.test", "wallet.balance.reconciled"}
RECONCILIATION_NETWORKS = {
    "bsc-mainnet": (56, "0x55d398326f99059ff775485246999027b3197955", 18),
    "bsc-testnet": (97, "0x436fc60cef0a8b4054420b50220c834a34bf7b90", 18),
    "ethereum-sepolia": (11155111, "0xb464a579e9697694833f544d1b464cbd51405c84", 6),
    "tron-nile": (None, "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", 6),
}
RECONCILIATION_NULL_FIELDS = frozenset("""activity_id tx_hash block_hash block_number block_time
transaction_index event_index event_index_scope from to direction amount_raw amount balance_snapshot
reverts_event_id reverts_activity_id""".split())
_IDENTIFIER = re.compile(r"[\x21-\x7e]{1,128}\Z")
REQUIRED_EVENT_FIELDS = frozenset("""schema_version event_type event_id subscription_id network
chain is_testnet simulated asset token_contract address tx_hash block_number block_hash block_time
occurred_at transaction_index event_index event_index_scope from to direction event_kind amount_raw
decimals amount confirmation_policy confirmation_status balance_snapshot reverts_event_id client_reference""".split())


class SecurityError(ValueError):
    def __init__(self, code):
        self.code = code
        super().__init__(code)


@dataclass(frozen=True, repr=False)
class SigningKey:
    secret: str
    expires_at: int | None = None


def decode_json(raw_body):
    def pairs(items):
        result = {}
        for name, value in items:
            if name in result:
                raise SecurityError("invalid_json")
            result[name] = value
        return result
    def constant(_value):
        raise SecurityError("invalid_json")
    try:
        return json.loads(raw_body.decode("utf-8"), object_pairs_hook=pairs, parse_constant=constant)
    except (ValueError, UnicodeError, RecursionError):
        raise SecurityError("invalid_json") from None


def verify_signature(raw_body, timestamp, signature, secret, now=None):
    """Same fixed v1 protocol as API.md, independently usable by the client."""
    if not isinstance(raw_body, bytes) or not isinstance(timestamp, str):
        return False
    if not timestamp or len(timestamp) > 20 or not timestamp.isascii() or not timestamp.isdecimal():
        return False
    if not isinstance(signature, str) or not re.fullmatch(r"v1=[0-9a-f]{64}", signature):
        return False
    if not isinstance(secret, str) or not secret:
        return False
    current = int(time.time()) if now is None else int(now)
    if abs(current - int(timestamp)) > 300:
        return False
    expected = "v1=" + hmac.new(secret.encode("utf-8"), timestamp.encode("ascii") + b"." + raw_body,
                               hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


def verify_webhook(raw_body, headers, keyring, now=None):
    if not isinstance(raw_body, bytes) or len(raw_body) > MAX_WEBHOOK_BYTES:
        raise SecurityError("body_too_large")
    normalized = {}
    for name, value in headers.items():
        name = name.lower()
        if name.startswith("x-ws-") and name in normalized:
            raise SecurityError("duplicate_signature_header")
        normalized[name] = value
    current = int(time.time()) if now is None else int(now)
    key_id = normalized.get("x-ws-key-id")
    if not isinstance(key_id, str) or not _IDENTIFIER.fullmatch(key_id) or key_id not in keyring:
        raise SecurityError("unknown_signing_key")
    key = keyring[key_id]
    if key.expires_at is not None and current >= key.expires_at:
        raise SecurityError("expired_signing_key")
    if not verify_signature(raw_body, normalized.get("x-ws-timestamp"), normalized.get("x-ws-signature"), key.secret, current):
        raise SecurityError("invalid_signature")
    event = decode_json(raw_body)
    if not isinstance(event, dict):
        raise SecurityError("invalid_event")
    for field in ("event_id", "delivery_id"):
        value = event.get(field)
        if not isinstance(value, str) or not _IDENTIFIER.fullmatch(value) or value != normalized.get("x-ws-" + field.replace("_", "-")):
            raise SecurityError("header_body_id_mismatch")
    attempt_id = normalized.get("x-ws-attempt-id")
    if not isinstance(attempt_id, str) or not _IDENTIFIER.fullmatch(attempt_id):
        raise SecurityError("invalid_attempt_id")
    return event


def _tron_address(value):
    """Validate canonical TRON Base58Check without another client dependency."""
    alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
    if not isinstance(value, str) or not re.fullmatch(r"T[1-9A-HJ-NP-Za-km-z]{33}", value):
        return False
    number = 0
    for char in value:
        number = number * 58 + alphabet.index(char)
    if number >= 2**200:
        return False
    raw = number.to_bytes(25, "big")
    return raw[0] == 0x41 and hashlib.sha256(hashlib.sha256(raw[:-4]).digest()).digest()[:4] == raw[-4:]


def _snapshot_time(value):
    if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z", value):
        raise SecurityError("invalid_reconciliation_time")
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        raise SecurityError("invalid_reconciliation_time") from None


def _validate_reconciliation(event):
    """Boundary balance evidence only; never a Transfer or payment confirmation."""
    required = {"chain_id", "reconciliation_id", "recovery_run_id", "change_direction", "delta_raw", "delta",
                "observed_before", "observed_after", "boundary_finality", "coverage_gap", "payment_eligible", "reason"}
    if (required - event.keys() or RECONCILIATION_NULL_FIELDS - event.keys()
            or any(event[name] is not None for name in RECONCILIATION_NULL_FIELDS)):
        raise SecurityError("invalid_reconciliation_fields")
    identity = RECONCILIATION_NETWORKS.get(event["network"])
    latest = event["network"] == "tron-nile"
    if (identity is None or (event["chain_id"] is not None if latest else type(event["chain_id"]) is not int)
            or (event["chain_id"], event["token_contract"], event["decimals"]) != identity
            or event["simulated"] or type(event["decimals"]) is not int
            or event["event_kind"] != "balance_reconciliation" or event["confirmation_status"] != "reconciled"
            or event["payment_eligible"] is not False or event["boundary_finality"] != "finalized"
            or event["reason"] != ("authorized_bsc_backlog_recovery" if event["chain"] == "BSC"
                                   else "authorized_backlog_recovery")):
        raise SecurityError("invalid_reconciliation_identity")
    for field in ("subscription_id", "recovery_run_id"):
        if not isinstance(event[field], str) or not _IDENTIFIER.fullmatch(event[field]):
            raise SecurityError("invalid_reconciliation_identity")
    if ((not _tron_address(event["address"]) if latest else
             not isinstance(event["address"], str) or not re.fullmatch(r"0x[0-9a-f]{40}", event["address"]))
            or (event["client_reference"] is not None and not isinstance(event["client_reference"], str))):
        raise SecurityError("invalid_reconciliation_identity")
    occurred = event["occurred_at"]
    if not isinstance(occurred, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|\+00:00)", occurred):
        raise SecurityError("invalid_reconciliation_time")
    try:
        if datetime.fromisoformat(occurred.replace("Z", "+00:00")).utcoffset() != timedelta(0):
            raise ValueError()
    except ValueError:
        raise SecurityError("invalid_reconciliation_time") from None
    policy = event["confirmation_policy"]
    if not isinstance(policy, dict) or policy.get("mode") not in ("finalized", "confirmations"):
        raise SecurityError("invalid_confirmation_policy")
    if policy["mode"] == "confirmations":
        depth = policy.get("additional_blocks")
        if set(policy) != {"mode", "additional_blocks"} or type(depth) is not int or not 1 <= depth <= 10000:
            raise SecurityError("invalid_confirmation_policy")
    elif set(policy) != {"mode"}:
        raise SecurityError("invalid_confirmation_policy")
    if (latest and event.get("balance_comparison_scope") != "observed_snapshots") or (
            not latest and "balance_comparison_scope" in event):
        raise SecurityError("invalid_reconciliation_scope")
    decimals = event["decimals"]
    balances = []
    for name in ("observed_before", "observed_after"):
        observation = event[name]
        expected_fields = {"block_number", "block_hash", "balance_raw", "balance", "consistency"}
        if latest:
            expected_fields |= {"queried_at", "snapshot_id"}
        if not isinstance(observation, dict) or set(observation) != expected_fields:
            raise SecurityError("invalid_reconciliation_boundary")
        height, block_hash = observation["block_number"], observation["block_hash"]
        raw, balance = observation["balance_raw"], observation["balance"]
        if latest:
            snapshot_id = observation["snapshot_id"]
            if (height is not None or block_hash is not None or observation["consistency"] != "latest"
                    or not isinstance(snapshot_id, str) or not re.fullmatch(r"[1-9][0-9]{0,18}", snapshot_id)
                    or int(snapshot_id) >= 2**63):
                raise SecurityError("invalid_reconciliation_boundary")
            _snapshot_time(observation["queried_at"])
        elif (type(height) is not int or not 0 <= height < 2**63 or not isinstance(block_hash, str)
                or not re.fullmatch(r"0x[0-9a-f]{64}", block_hash) or observation["consistency"] != "block_pinned"):
            raise SecurityError("invalid_reconciliation_boundary")
        if (not isinstance(raw, str) or not re.fullmatch(r"0|[1-9][0-9]{0,77}", raw) or int(raw) >= 2**256
                or not isinstance(balance, str) or not re.fullmatch(rf"(0|[1-9][0-9]*)(\.[0-9]{{1,{decimals}}})?", balance)):
            raise SecurityError("invalid_reconciliation_amount")
        with localcontext() as context:
            context.prec = 400
            if Decimal(balance) != Decimal(raw).scaleb(-decimals):
                raise SecurityError("invalid_reconciliation_amount")
        balances.append(int(raw))
    before, after = event["observed_before"], event["observed_after"]
    identity = [event["recovery_run_id"], event["network"], event["token_contract"], event["address"]]
    identity += (["observed_snapshots", before["snapshot_id"], after["snapshot_id"]] if latest
                 else [before["block_hash"], after["block_hash"]])
    digest = hashlib.sha256(json.dumps(identity, ensure_ascii=False, separators=(",", ":")).encode("utf-8")).hexdigest()
    if event["event_id"] != "evt_" + digest or event["reconciliation_id"] != "rec_" + digest:
        raise SecurityError("invalid_reconciliation_identity")
    gap = event["coverage_gap"]
    expected_gap = {"from_block", "through_block", "transfer_details_available"}
    if latest:
        expected_gap |= {"before_block_hash", "through_block_hash"}
    if (not isinstance(gap, dict) or set(gap) != expected_gap
            or type(gap["from_block"]) is not int or type(gap["through_block"]) is not int
            or not 1 <= gap["from_block"] <= gap["through_block"] < 2**63
            or gap["transfer_details_available"] is not False):
        raise SecurityError("invalid_reconciliation_gap")
    if latest:
        for key, height in (("before_block_hash", gap["from_block"] - 1),
                            ("through_block_hash", gap["through_block"])):
            block_hash = gap[key]
            if (not isinstance(block_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", block_hash)
                    or int(block_hash[:16], 16) != height):
                raise SecurityError("invalid_reconciliation_gap")
        if (before["snapshot_id"] == after["snapshot_id"]
                or _snapshot_time(before["queried_at"]) > _snapshot_time(after["queried_at"])):
            raise SecurityError("invalid_reconciliation_boundary")
    elif (before["block_number"] >= after["block_number"] or before["block_hash"] == after["block_hash"]
            or gap["from_block"] != before["block_number"] + 1 or gap["through_block"] != after["block_number"]):
        raise SecurityError("invalid_reconciliation_gap")
    raw, amount = event["delta_raw"], event["delta"]
    if (not isinstance(raw, str) or not re.fullmatch(r"-?[1-9][0-9]{0,77}", raw)
            or int(raw) != balances[1] - balances[0] or not isinstance(amount, str)
            or not re.fullmatch(rf"-?(0|[1-9][0-9]*)(\.[0-9]{{1,{decimals}}})?", amount)):
        raise SecurityError("invalid_reconciliation_amount")
    with localcontext() as context:
        context.prec = 400
        if Decimal(amount) != Decimal(raw).scaleb(-decimals):
            raise SecurityError("invalid_reconciliation_amount")
    if event["change_direction"] != ("net_increase" if int(raw) > 0 else "net_decrease"):
        raise SecurityError("invalid_reconciliation_direction")


def validate_event(event: dict, *, callback=False) -> None:
    if not isinstance(event, dict) or event.get("schema_version") != "1.0":
        raise SecurityError("unsupported_schema_version")
    if event.get("event_type") not in EVENT_TYPES:
        raise SecurityError("unsupported_event_type")
    if REQUIRED_EVENT_FIELDS - event.keys():
        raise SecurityError("invalid_event")
    for name in ("event_id", "delivery_id") if callback else ("event_id",):
        if not isinstance(event.get(name), str) or not _IDENTIFIER.fullmatch(event[name]):
            raise SecurityError("invalid_event_id")
    network = event.get("network")
    if network not in NETWORKS or (event.get("chain"), event.get("is_testnet")) != NETWORKS[network]:
        raise SecurityError("invalid_network")
    if type(event.get("is_testnet")) is not bool or type(event.get("simulated")) is not bool:
        raise SecurityError("invalid_test_flags")
    if event.get("asset") != "USDT":
        raise SecurityError("unsupported_asset")
    if event["event_type"] == "wallet.balance.reconciled":
        _validate_reconciliation(event)
        return
    raw, amount, decimals = event.get("amount_raw"), event.get("amount"), event.get("decimals")
    if not isinstance(raw, str) or not re.fullmatch(r"0|[1-9][0-9]{0,77}", raw) or int(raw) >= 2**256:
        raise SecurityError("invalid_amount")
    if type(decimals) is not int or not 0 <= decimals <= 255 or not isinstance(amount, str) or not re.fullmatch(r"(0|[1-9][0-9]*)(\.[0-9]+)?", amount):
        raise SecurityError("invalid_amount")
    with localcontext() as context:
        context.prec = 400
        if Decimal(amount) != Decimal(raw).scaleb(-decimals):
            raise SecurityError("invalid_amount")
    if event.get("direction") not in ("in", "out", "self", "none"):
        raise SecurityError("invalid_direction")
    if event.get("event_kind") not in ("transfer", "self_transfer", "mint", "burn", "balance_adjustment", "test"):
        raise SecurityError("invalid_event_kind")
    policy = event.get("confirmation_policy")
    if not isinstance(policy, dict) or policy.get("mode") not in ("finalized", "confirmations"):
        raise SecurityError("invalid_confirmation_policy")
    if policy["mode"] == "confirmations":
        depth = policy.get("additional_blocks")
        if set(policy) != {"mode", "additional_blocks"} or type(depth) is not int or not 1 <= depth <= 10000:
            raise SecurityError("invalid_confirmation_policy")
    elif set(policy) != {"mode"}:
        raise SecurityError("invalid_confirmation_policy")
    if event.get("event_type") == "webhook.test":
        if not callback or not event["simulated"] or not event["is_testnet"] or event.get("subscription_id") is not None:
            raise SecurityError("invalid_simulation")
        if event.get("event_kind") != "test" or event.get("confirmation_status") != "simulated":
            raise SecurityError("invalid_simulation")
        if any(event.get(name) is not None for name in ("token_contract", "address", "tx_hash", "block_number",
               "block_hash", "block_time", "transaction_index", "event_index", "event_index_scope", "from", "to", "reverts_event_id")):
            raise SecurityError("invalid_simulation")
    else:
        subscription = event.get("subscription_id")
        if event["simulated"] or not isinstance(subscription, str) or not _IDENTIFIER.fullmatch(subscription):
            raise SecurityError("invalid_subscription")
        for name in ("block_number", "transaction_index", "event_index"):
            if type(event.get(name)) is not int or event[name] < 0:
                raise SecurityError("invalid_chain_position")
        if event.get("event_index_scope") != ("transaction" if event["chain"] == "TRON" else "block"):
            raise SecurityError("invalid_chain_position")
        if not all(isinstance(event.get(name), str) and event[name] for name in
                   ("token_contract", "address", "tx_hash", "block_hash", "block_time")):
            raise SecurityError("invalid_chain_position")
        aid = event.get("activity_id")
        if aid is not None and (not isinstance(aid, str) or not _IDENTIFIER.fullmatch(aid)):
            raise SecurityError("invalid_activity_id")
        if event["event_type"] == "wallet.activity.detected":
            if (aid is None or event.get("confirmation_status") != "detected" or policy["mode"] != "finalized"
                    or event.get("reverts_event_id") is not None or event.get("reverts_activity_id") is not None):
                raise SecurityError("invalid_detection")
        elif event["event_type"] == "wallet.activity.reverted":
            target = event.get("reverts_event_id")
            if not isinstance(target, str) or not _IDENTIFIER.fullmatch(target) or target == event["event_id"]:
                raise SecurityError("invalid_revocation")
            if (event.get("reverts_activity_id") is not None and event["reverts_activity_id"] != aid):
                raise SecurityError("invalid_revocation")
            if event.get("confirmation_status") != "reverted":
                raise SecurityError("invalid_revocation")
        elif event.get("reverts_event_id") is not None or event.get("confirmation_status") not in ("confirmed", "finalized"):
            raise SecurityError("invalid_confirmation")
        elif event["confirmation_status"] != ("finalized" if policy["mode"] == "finalized" else "confirmed"):
            raise SecurityError("invalid_confirmation")


class ReceiverStore:
    """Durable inbox; transactions finish before any HTTP acknowledgement."""

    def __init__(self, path: str):
        self.path = str(path)
        descriptor = os.open(self.path, os.O_CREAT | os.O_RDWR, 0o600)
        os.close(descriptor)
        os.chmod(self.path, 0o600)
        with self.connection() as db:
            db.executescript("""
                CREATE TABLE IF NOT EXISTS inbox(
                  dedupe_key TEXT PRIMARY KEY, subscription_id TEXT, event_id TEXT NOT NULL,
                  event_type TEXT NOT NULL, payload BLOB NOT NULL, semantic_sha256 TEXT NOT NULL,
                  state TEXT NOT NULL, received_at INTEGER NOT NULL);
                CREATE TABLE IF NOT EXISTS tombstones(
                  subscription_id TEXT NOT NULL, event_id TEXT NOT NULL,
                  PRIMARY KEY(subscription_id,event_id));
                CREATE TABLE IF NOT EXISTS stopped_subscriptions(
                  subscription_id TEXT PRIMARY KEY, stopped_at INTEGER NOT NULL);
                CREATE TABLE IF NOT EXISTS page_cursors(name TEXT PRIMARY KEY, cursor TEXT);
                CREATE TABLE IF NOT EXISTS activities(
                  subscription_id TEXT NOT NULL, activity_id TEXT NOT NULL,
                  chain_status TEXT NOT NULL, direction TEXT NOT NULL, identity_sha256 TEXT NOT NULL,
                  detected_received_at INTEGER, finalized_received_at INTEGER,
                  PRIMARY KEY(subscription_id,activity_id));
                CREATE TABLE IF NOT EXISTS receipts(
                  delivery_id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL, received_at INTEGER NOT NULL);
            """)

    @contextmanager
    def connection(self):
        db = sqlite3.connect(self.path, timeout=2)
        db.execute("PRAGMA synchronous=FULL")
        try:
            with db:
                yield db
        finally:
            db.close()

    def _persist(self, db, event, raw_body, *, callback):
        validate_event(event, callback=callback)
        simulated = event["event_type"] == "webhook.test"
        reconciled = event["event_type"] == "wallet.balance.reconciled"
        sub, event_id = event.get("subscription_id"), event["event_id"]
        dedupe = json.dumps(["test", event["delivery_id"]] if simulated else [sub, event_id], separators=(",", ":"))
        semantic = {k: v for k, v in event.items() if k != "delivery_id"}
        digest = hashlib.sha256(json.dumps(semantic, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
        existing = db.execute("SELECT semantic_sha256 FROM inbox WHERE dedupe_key=?", (dedupe,)).fetchone()
        if existing and existing[0] != digest:
            raise SecurityError("event_payload_conflict")
        if callback:
            previous = db.execute("SELECT dedupe_key FROM receipts WHERE delivery_id=?", (event["delivery_id"],)).fetchone()
            if previous and previous[0] != dedupe:
                raise SecurityError("delivery_payload_conflict")
            db.execute("INSERT OR IGNORE INTO receipts VALUES(?,?,?)", (event["delivery_id"], dedupe, int(time.time())))
        if existing:
            return False
        state = "reconciled" if reconciled else "test" if simulated else "detected" if event["event_type"] == "wallet.activity.detected" else "confirmed"
        if not simulated:
            if event["event_type"] == "wallet.activity.reverted":
                target = event["reverts_event_id"]
                db.execute("INSERT OR IGNORE INTO tombstones VALUES(?,?)", (sub, target))
                db.execute("UPDATE inbox SET state='reverted' WHERE subscription_id=? AND event_id=?", (sub, target))
                state = "revocation"
            elif not reconciled and db.execute("SELECT 1 FROM tombstones WHERE subscription_id=? AND event_id=?", (sub, event_id)).fetchone():
                state = "reverted"
            if db.execute("SELECT 1 FROM stopped_subscriptions WHERE subscription_id=?", (sub,)).fetchone():
                state = "audit_only"
        if not simulated and not reconciled and event.get("activity_id") is not None:
            aid = event["activity_id"]
            identity = {k: event[k] for k in ("network", "token_contract", "address", "tx_hash", "block_hash",
                "block_number", "transaction_index", "event_index", "event_index_scope", "from", "to",
                "direction", "amount_raw", "decimals", "event_kind")}
            fingerprint = hashlib.sha256(json.dumps(identity, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
            previous = db.execute("SELECT chain_status,identity_sha256 FROM activities WHERE subscription_id=? AND activity_id=?",
                                  (sub, aid)).fetchone()
            if previous and previous[1] != fingerprint:
                raise SecurityError("activity_payload_conflict")
            status = "reverted" if state in ("revocation", "reverted") else event["confirmation_status"]
            # A tombstone wins over both phases; finalized wins over late detection.
            rank = {"detected": 1, "confirmed": 2, "finalized": 3, "reverted": 4}
            if previous and rank[previous[0]] > rank[status]:
                status = previous[0]
            received = int(time.time())
            db.execute("""INSERT INTO activities VALUES(?,?,?,?,?,?,?)
                ON CONFLICT(subscription_id,activity_id) DO UPDATE SET chain_status=excluded.chain_status,
                detected_received_at=COALESCE(activities.detected_received_at,excluded.detected_received_at),
                finalized_received_at=COALESCE(activities.finalized_received_at,excluded.finalized_received_at)""",
                (sub, aid, status, event["direction"], fingerprint,
                 received if event["event_type"] == "wallet.activity.detected" else None,
                 received if event["confirmation_status"] == "finalized" else None))
        db.execute("INSERT INTO inbox VALUES(?,?,?,?,?,?,?,?)", (dedupe, sub, event_id, event["event_type"],
                   raw_body, digest, state, int(time.time())))
        return True

    def activity_state(self, subscription_id, activity_id):
        """Chain observation only. Order matching and merchant callbacks belong to the client."""
        with self.connection() as db:
            row = db.execute("""SELECT chain_status,direction,detected_received_at,finalized_received_at
                FROM activities WHERE subscription_id=? AND activity_id=?""", (subscription_id, activity_id)).fetchone()
            return dict(zip(("chain_status", "direction", "detected_received_at", "finalized_received_at"), row)) if row else None

    def persist(self, event: dict, raw_body: bytes) -> bool:
        with self.connection() as db:
            return self._persist(db, event, raw_body, callback=True)

    def import_page(self, page: dict, name="events") -> int:
        if not isinstance(page, dict) or not isinstance(page.get("data"), list) or type(page.get("has_more")) is not bool:
            raise SecurityError("invalid_event_page")
        if "next_cursor" not in page or (page["next_cursor"] is not None and not isinstance(page["next_cursor"], str)):
            raise SecurityError("invalid_event_page")
        with self.connection() as db:
            count = sum(self._persist(db, event, json.dumps(event, ensure_ascii=False).encode(), callback=False)
                        for event in page["data"])
            db.execute("INSERT INTO page_cursors VALUES(?,?) ON CONFLICT(name) DO UPDATE SET cursor=excluded.cursor",
                       (name, page["next_cursor"]))
            return count

    def stop_subscription(self, subscription_id: str):
        if not _IDENTIFIER.fullmatch(subscription_id):
            raise SecurityError("invalid_subscription")
        with self.connection() as db:
            db.execute("INSERT OR IGNORE INTO stopped_subscriptions VALUES(?,?)", (subscription_id, int(time.time())))


def load_keyring(path: str) -> dict[str, SigningKey]:
    source = Path(path)
    if source.stat().st_mode & 0o077:
        raise SecurityError("keyring_permissions_must_be_600")
    document = decode_json(source.read_bytes())
    if not isinstance(document, dict) or not document:
        raise SecurityError("invalid_keyring")
    result = {}
    for key_id, key in document.items():
        if not _IDENTIFIER.fullmatch(key_id) or not isinstance(key, dict) or not isinstance(key.get("secret"), str) or not key["secret"]:
            raise SecurityError("invalid_keyring")
        expiry = key.get("expires_at")
        if expiry is not None and (type(expiry) is not int or expiry < 0):
            raise SecurityError("invalid_keyring")
        result[key_id] = SigningKey(key["secret"], expiry)
    return result


def create_app(store: ReceiverStore, keyring, *, fail_first=0, drop_after_commit=False) -> web.Application:
    app = web.Application(client_max_size=MAX_WEBHOOK_BYTES)
    # Test fault switches are local constructor/CLI parameters, never HTTP inputs.
    faults = {"remaining": fail_first, "drop": drop_after_commit}

    async def receive(request):
        if request.content_type != "application/json":
            return web.json_response({"error": "unsupported_content_type"}, status=415)
        try:
            raw = await request.read()
            keys = keyring() if callable(keyring) else keyring
            event = verify_webhook(raw, request.headers, keys)
            validate_event(event, callback=True)
            if faults["remaining"] > 0:
                faults["remaining"] -= 1
                return web.json_response({"error": "test_temporary_failure"}, status=503)
            store.persist(event, raw)
            if faults["drop"]:
                faults["drop"] = False
                request.transport.abort()
            return web.Response(status=204)
        except SecurityError as error:
            status = 409 if error.code.endswith("payload_conflict") else 401 if error.code in (
                "invalid_signature", "unknown_signing_key", "expired_signing_key", "header_body_id_mismatch",
                "duplicate_signature_header", "invalid_attempt_id") else 400
            return web.json_response({"error": error.code}, status=status)
        except sqlite3.Error:
            return web.json_response({"error": "receiver_storage_unavailable"}, status=503)
        except OSError:
            return web.json_response({"error": "receiver_configuration_unavailable"}, status=503)

    app.router.add_post("/walletshield/webhook", receive)
    return app


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--database", required=True)
    parser.add_argument("--keyring")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=18554)
    parser.add_argument("--tls-cert")
    parser.add_argument("--tls-key")
    parser.add_argument("--allow-local-http", action="store_true")
    parser.add_argument("--import-events")
    parser.add_argument("--cursor-name", default="events")
    parser.add_argument("--stop-subscription")
    parser.add_argument("--test-mode", action="store_true")
    parser.add_argument("--test-fail-first", type=int, default=0)
    parser.add_argument("--test-drop-after-commit", action="store_true")
    args = parser.parse_args()
    store = ReceiverStore(args.database)
    if args.import_events:
        count = store.import_page(decode_json(Path(args.import_events).read_bytes()), args.cursor_name)
        print(json.dumps({"imported": count, "cursor_committed": True}))
        return
    if args.stop_subscription:
        store.stop_subscription(args.stop_subscription)
        print(json.dumps({"audit_only": True}))
        return
    if not args.keyring:
        parser.error("--keyring is required when serving")
    if (args.test_fail_first or args.test_drop_after_commit) and not args.test_mode:
        parser.error("Fault injection requires --test-mode")
    if args.test_fail_first < 0:
        parser.error("--test-fail-first must be nonnegative")
    context = None
    if args.tls_cert and args.tls_key:
        context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
        context.minimum_version = ssl.TLSVersion.TLSv1_2
        context.load_cert_chain(args.tls_cert, args.tls_key)
    elif not (args.allow_local_http and args.host in ("127.0.0.1", "::1")):
        parser.error("TLS certificate and key required; explicit HTTP is loopback-only")
    load_keyring(args.keyring)
    app = create_app(store, lambda: load_keyring(args.keyring), fail_first=args.test_fail_first,
                     drop_after_commit=args.test_drop_after_commit)
    web.run_app(app, host=args.host, port=args.port, ssl_context=context, access_log=None, print=None)


if __name__ == "__main__":
    try:
        main()
    except (SecurityError, sqlite3.Error, OSError):
        raise SystemExit("Receiver configuration or storage failed; check private configuration.") from None
```

---

本文由已核对的接口定义、公开样例与接收器生成。源内容指纹：`2b347b06e96594f8edb32eaa958c0bfb4d7b6327a74a7aabc036fa1ec64af1b6`。最新发布版本始终使用本页固定 URL；响应禁用缓存，更新时整份文件原子替换。
