Merge pull request #17 from sok-im/feature/redpacket_new1

Feature/redpacket new1
pull/3727/head
haoyunlt 3 weeks ago committed by GitHub
commit e8952672a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,92 @@
# RedPacket RPC Service
A Web3 Red Packet RPC service that has been migrated to the standard OpenIM
service layout: gRPC over `protocol/redpacket`, MongoDB via the `mgo` +
`controller` pattern, and command/discovery wiring through `pkg/common/cmd`
and `pkg/common/startrpc`.
For HTTP access, the service is exposed by the API gateway under `/redpacket/*`
(see `internal/api/redpacket.go`).
## Layout
```
.
├── main.go # cmd.NewRedPacketRpcCmd().Exec()
├── README.md
├── backend-api.md # Legacy API docs, kept for reference
├── client-integration-guide.md # Legacy integration docs, kept for reference
├── red-packet-go-backend-eth-tron.md # Architecture / chain integration design
└── redpacket-web3-integration-design.md # Web3 integration design
```
The actual implementation lives in:
- `protocol/redpacket/redpacket.proto` gRPC contract
- `pkg/common/storage/model/redpacket.go` Mongo BSON models
- `pkg/common/storage/database/redpacket.go` DAO interfaces
- `pkg/common/storage/database/mgo/redpacket.go` Mongo DAO impl
- `pkg/common/storage/controller/redpacket.go` Aggregated database façade
- `pkg/common/cmd/rpc_redpacket.go` Cobra entry, startrpc bootstrap
- `internal/rpc/redpacket/` gRPC service, chain client, indexers
- `internal/api/redpacket.go` Gin gateway handlers
- `config/openim-rpc-redpacket.yml` Service configuration
## Features
- ✅ Create red packet orders + on-chain `Created` callback reconciliation
- ✅ Red packet detail query (with full claim history)
- ✅ Claim signature issuance using the contract's `getSignMessage(...)`
- ✅ Claim result reporting + idempotent persistence by tx hash
- ✅ EVM event indexer (claim / refund)
- ✅ TRON full-node JSON-RPC integration scaffold
- ✅ EVM SIWE-style wallet binding (challenge / sign / confirm)
- ✅ Admin endpoints (signer / allowed token / expiry / allow-all-tokens / native-token)
## Configuration
See `config/openim-rpc-redpacket.yml` (alongside other OpenIM RPC configs).
```yaml
rpc:
registerIP: ""
listenIP: 0.0.0.0
autoSetPorts: false
ports: [10560]
prometheus:
enable: false
ports: [12560]
chain: # Optional — leave rpcURL empty to disable EVM
rpcURL: ""
contractAddress: ""
chainID: 0
signerPrivateKey: ""
configAdminPrivateKey: ""
tron: # Optional — leave fullNodeURL empty to disable TRON
fullNodeURL: ""
contractBase58: ""
ownerBase58: ""
privateKeyHex: ""
feeLimit: 100000000
indexer:
pollInterval: 5
```
`config/share.yml` registers the service name as `redPacket`.
## Limitations / TODO
- TRON `ConfirmWalletBind` signature verification is not yet implemented and
returns `not implemented`.
- TRON event decoding in `chain/tron_indexer.go` is still a scaffold and only
identifies events by topic-0; payload decoding will be added once the
contract event signatures are finalized.
- Admin endpoints (`/redpacket/admin/*`) currently mirror the legacy mock
behaviour for EVM and only forward live calls on TRON.
See `backend-api.md`, `client-integration-guide.md`, and the design docs for
detailed specifications.

@ -0,0 +1,703 @@
# RedPacket 后端接口说明
本文档按当前 `internal/api/redpacket.go``internal/rpc/redpacket/*` 实现整理。红包服务已经从独立 Gin 服务迁移为 OpenIM 标准 RPC 服务:
- HTTP 入口在 `internal/api` 网关,路由前缀为 `/redpacket`
- 网关通过 `pbredpacket.RedPacketClient` 调用 `internal/rpc/redpacket`
- RPC 服务使用 MongoDB 存储,通过 `pkg/common/storage/controller.RedPacketDatabase` 聚合 DAO
- 服务注册名为 `redPacket`,配置文件为 `config/openim-rpc-redpacket.yml`
## 1. 基础约定
### 1.1 Base URL
网关地址由 `openim-api` 部署决定,例如:
```text
http://127.0.0.1:10002
```
红包接口统一挂在:
```text
/redpacket
```
### 1.2 鉴权
当前 `internal/api/router.go``Whitelist` 未包含 `/redpacket/*`,因此所有红包 HTTP 接口默认都需要登录 token。
请求头:
```http
token: <OpenIM user token>
operationID: <request id>
```
RPC 层不信任请求体中的 `user_id`。当前登录用户统一从 `mcontext.GetOpUserID(ctx)` 读取。
### 1.3 请求字段命名
HTTP 请求建议使用 snake_case。网关使用 `a2r.ParseRequestNotCheck` 解析到 protobuf 请求对象。
示例:
- HTTP: `packet_id`
- protobuf Go 字段: `PacketID`
### 1.4 响应格式
网关使用 `apiresp.GinSuccess` / `apiresp.GinError` 包装响应。不同 OpenIM 版本的外层字段可能略有差异,下面示例重点展示 `data` 内容。
成功示意:
```json
{
"errCode": 0,
"errMsg": "",
"data": {}
}
```
失败示意:
```json
{
"errCode": 1001,
"errMsg": "packet_id is required"
}
```
## 2. 接口总览
用户侧接口:
- `POST /redpacket/create_order`
- `POST /redpacket/created_callback`
- `POST /redpacket/detail`
- `POST /redpacket/issue_claim_sign`
- `POST /redpacket/claim_result`
- `POST /redpacket/wallet_bind/challenge`
- `POST /redpacket/wallet_bind/confirm`
- `POST /redpacket/wallet_bind/detail`
管理员接口:
- `POST /redpacket/admin/set_signer`
- `POST /redpacket/admin/set_token`
- `POST /redpacket/admin/set_expiry`
- `POST /redpacket/admin/set_allow_all_tokens`
- `POST /redpacket/admin/set_native_token_enabled`
- `POST /redpacket/admin/parse_tx_events`
## 3. 用户侧接口
### 3.1 创建红包业务单
```text
POST /redpacket/create_order
gRPC: CreateOrder(CreateOrderReq) returns (CreateOrderResp)
```
链上创建红包前调用,服务端创建一条 `PENDING` 业务记录并返回 `biz_id`
请求示例:
```json
{
"chain_type": "EVM",
"chain_id": 1,
"contract_address": "0xA1f42567559aBA5Ff0aac84cdE1AaF1F9DbB888F",
"creator_wallet": "0x1111111111111111111111111111111111111111",
"group_id": "g001",
"scope_type": "GROUP",
"receiver_user_id": "",
"receiver_user_ids": [],
"packet_type": 1,
"token": "0x2222222222222222222222222222222222222222",
"total_amount": "1000000000000000000",
"total_shares": 10,
"expiry_at": 0,
"remark": "happy new year"
}
```
字段说明:
- `chain_type`: 必填,当前支持 `EVM`、`TRON`
- `chain_id`: 可选EVM client 可用时为空会使用配置的 chainID
- `contract_address`: 可选EVM/TRON client 可用时为空会使用配置地址
- `creator_wallet`: 必填,发红包钱包地址
- `scope_type`: `GROUP`、`DIRECT`、`PUBLIC`;空值默认 `PUBLIC`
- `group_id`: `scope_type=GROUP` 时必填
- `receiver_user_id` / `receiver_user_ids`: `scope_type=DIRECT` 时至少一个非空
- `packet_type`: `0` 固定红包,`1` 拼手气红包,`2` 转账
- `total_amount`: 链上最小单位十进制字符串
- `total_shares`: 总份数
- `expiry_at`: Unix 秒;`0` 表示使用合约默认过期
成功响应 `data`
```json
{
"biz_id": "f8a0f87e-d9cb-4d4a-8350-7bd43ab2e9a4"
}
```
服务端写入:
- collection: `red_packet`
- status: `PENDING`
- creatorUserID: 来自登录上下文,不来自请求体
### 3.2 创建交易回写
```text
POST /redpacket/created_callback
gRPC: CreatedCallback(CreatedCallbackReq) returns (CreatedCallbackResp)
```
链上创建交易确认后调用,用于把 `biz_id` 与链上 `packet_id` / `tx_hash` 绑定。
请求示例:
```json
{
"biz_id": "f8a0f87e-d9cb-4d4a-8350-7bd43ab2e9a4",
"tx_hash": "0xabc123...",
"packet_id": "10001",
"group_id": "g001",
"scope_type": "GROUP",
"receiver_user_id": "",
"receiver_user_ids": []
}
```
成功响应 `data`
```json
{}
```
服务端逻辑:
- `biz_id``tx_hash` 必填
- 如果链客户端可用,会解析交易 receipt 中的 `PacketCreated`
- 解析成功后校验 creator、packetType、token、amount、shares、expiry 是否与业务单一致
- 如果链客户端不可用或解析失败,但请求提供了 `packet_id`,会使用 fallback
- 成功后更新 `red_packet.status=ACTIVE`
### 3.3 查询红包详情
```text
POST /redpacket/detail
gRPC: GetDetail(GetDetailReq) returns (GetDetailResp)
```
请求示例:
```json
{
"packet_id": "10001"
}
```
成功响应 `data`
```json
{
"record": {
"biz_id": "f8a0f87e-d9cb-4d4a-8350-7bd43ab2e9a4",
"chain_type": "EVM",
"packet_id": "10001",
"chain_id": 1,
"contract_address": "0xA1f42567559aBA5Ff0aac84cdE1AaF1F9DbB888F",
"creator_user_id": "u1001",
"creator_wallet": "0x1111111111111111111111111111111111111111",
"group_id": "g001",
"scope_type": "GROUP",
"receiver_user_id": "",
"receiver_user_ids": [],
"packet_type": 1,
"token": "0x2222222222222222222222222222222222222222",
"total_amount": "1000000000000000000",
"total_shares": 10,
"claimed_amount": "123456789",
"claimed_shares": 1,
"expiry_at": 0,
"tx_hash": "0xabc123...",
"status": "ACTIVE",
"created_at": 1777000000,
"updated_at": 1777000060
},
"claims": [
{
"packet_id": "10001",
"user_id": "u2002",
"claimer_wallet": "0x3333333333333333333333333333333333333333",
"auth_nonce": "328840239847239847",
"claim_tx_hash": "0xdef456...",
"claimed_amount": "123456789",
"block_number": 1234567,
"status": "CONFIRMED",
"created_at": 1777000100,
"updated_at": 1777000100
}
]
}
```
说明:
- `created_at` / `updated_at` 为 Unix 秒
- `claims` 按 Mongo 查询返回DAO 层按 `created_at desc` 排序
### 3.4 申请领取签名
```text
POST /redpacket/issue_claim_sign
gRPC: IssueClaimSign(IssueClaimSignReq) returns (IssueClaimSignResp)
```
请求示例:
```json
{
"packet_id": "10001",
"claimer": "0x3333333333333333333333333333333333333333",
"random_seed": "0"
}
```
成功响应 `data`
```json
{
"auth_nonce": "328840239847239847",
"deadline": 1777012345,
"signature": "0x7b1e...a2",
"random_seed": "8888812345"
}
```
校验逻辑:
1. 当前用户必须存在:`mcontext.GetOpUserID(ctx) != ""`
2. `packet_id``claimer` 必填
3. 红包必须存在且 `status=ACTIVE`
4. 未过期、未退款
5. 当前用户与 `claimer` 钱包必须有 `ACTIVE` 绑定
6. 同一用户 / 同一钱包不能重复领取
7. 固定红包和拼手气红包要求 `group_id` 存在
8. 转账红包要求当前用户为 `receiver_user_id`
签名逻辑:
- EVM client 可用时调用 `getSignMessage(packetId, claimer, authNonce, randomSeed, deadline)` 获取 digest
- 使用 `chain.signerPrivateKey` 裸签 digest
- `v` 从 0/1 调整为 27/28
- 如果 signer 私钥未配置,当前代码会返回 placeholder 签名,仅适合本地调试
### 3.5 领取结果回写
```text
POST /redpacket/claim_result
gRPC: ClaimResult(ClaimResultReq) returns (ClaimResultResp)
```
请求示例:
```json
{
"packet_id": "10001",
"claimer": "0x3333333333333333333333333333333333333333",
"tx_hash": "0xdef456..."
}
```
成功响应 `data`
```json
{}
```
服务端逻辑:
- 先保存一条 `PENDING` claim
- 若能立即解析 `PacketClaimed` 事件,则更新为 `CONFIRMED`
- 成功解析后会累计 `claimed_amount` / `claimed_shares`
- 红包领完时状态变为 `COMPLETED`
- 如果 receipt 暂不可用,保持 `PENDING`,等待 indexer 补偿
### 3.6 发起钱包绑定挑战
```text
POST /redpacket/wallet_bind/challenge
gRPC: IssueWalletBindChallenge(IssueWalletBindChallengeReq)
```
请求示例:
```json
{
"chain_type": "EVM",
"chain_id": 1,
"wallet_address": "0x3333333333333333333333333333333333333333",
"domain": "redpacket.example.com",
"uri": "https://redpacket.example.com/wallet-bind"
}
```
成功响应 `data`
```json
{
"challenge_id": "1f7d9b0d-7b43-4d84-bb11-65f2ecf7e321",
"user_id": "u2002",
"chain_type": "EVM",
"chain_id": 1,
"wallet": "0x3333333333333333333333333333333333333333",
"protocol": "siwe-eip4361",
"sign_method": "personal_sign",
"nonce": "7b7d8d48-9db6-4e95-9daa-40e9517a2a85",
"message": "redpacket.example.com wants you to sign in with your Ethereum account:\n...",
"issued_at": "2026-04-30T03:00:00Z",
"expires_at": "2026-04-30T03:10:00Z"
}
```
说明:
- EVM 使用 `siwe-eip4361` + `personal_sign`
- TRON 使用 `tron-signmessagev2` + `signMessageV2`
- challenge 有效期为 10 分钟
### 3.7 确认钱包绑定
```text
POST /redpacket/wallet_bind/confirm
gRPC: ConfirmWalletBind(ConfirmWalletBindReq)
```
请求示例:
```json
{
"challenge_id": "1f7d9b0d-7b43-4d84-bb11-65f2ecf7e321",
"signature": "0x8f..."
}
```
成功响应 `data`
```json
{
"user_id": "u2002",
"chain_type": "EVM",
"chain_id": 1,
"wallet_address": "0x3333333333333333333333333333333333333333",
"status": "ACTIVE",
"verified_at": "2026-04-30T03:01:00Z"
}
```
当前限制:
- EVM 验签已实现
- TRON 验签当前返回 `TRON wallet binding verification is not implemented yet`
### 3.8 查询钱包绑定
```text
POST /redpacket/wallet_bind/detail
gRPC: GetWalletBinding(GetWalletBindingReq)
```
请求示例:
```json
{
"chain_type": "EVM",
"wallet_address": "0x3333333333333333333333333333333333333333"
}
```
成功响应 `data`
```json
{
"user_id": "u2002",
"chain_type": "EVM",
"chain_id": 1,
"wallet_address": "0x3333333333333333333333333333333333333333",
"status": "ACTIVE",
"challenge_id": "1f7d9b0d-7b43-4d84-bb11-65f2ecf7e321",
"verified_at": "2026-04-30T03:01:00Z"
}
```
## 4. 管理员接口
### 4.1 设置 signer
```text
POST /redpacket/admin/set_signer
gRPC: SetSigner(SetSignerReq)
```
请求:
```json
{
"signer_address": "0x4444444444444444444444444444444444444444"
}
```
响应:
```json
{
"message": "signer address updated successfully"
}
```
### 4.2 设置 token 白名单
```text
POST /redpacket/admin/set_token
gRPC: SetToken(SetTokenReq)
```
请求:
```json
{
"token_address": "0x2222222222222222222222222222222222222222",
"allowed": true,
"min_amount": "1000000"
}
```
响应:
```json
{
"message": "token configuration updated"
}
```
### 4.3 设置默认过期时间
```text
POST /redpacket/admin/set_expiry
gRPC: SetExpiry(SetExpiryReq)
```
请求:
```json
{
"expiry_seconds": 86400
}
```
### 4.4 设置是否允许所有 token
```text
POST /redpacket/admin/set_allow_all_tokens
gRPC: SetAllowAllTokens(SetAllowAllTokensReq)
```
请求:
```json
{
"allow_all": false
}
```
### 4.5 设置原生币开关
```text
POST /redpacket/admin/set_native_token_enabled
gRPC: SetNativeTokenEnabled(SetNativeTokenEnabledReq)
```
请求:
```json
{
"enabled": true
}
```
### 4.6 解析交易事件
```text
POST /redpacket/admin/parse_tx_events
gRPC: ParseTxEvents(ParseTxEventsReq)
```
请求:
```json
{
"tx_hash": "0xabc123...",
"chain": "eth"
}
```
EVM 响应:
```json
{
"chain": "eth",
"tx_hash": "0xabc123...",
"events": [
{
"name": "PacketCreated",
"data": {
"packetId": "10001",
"creator": "0x1111111111111111111111111111111111111111"
}
}
]
}
```
TRON 当前响应:
```json
{
"chain": "tron",
"tx_hash": "7d9e...txid",
"note": "TRON event parsing not fully implemented in this version"
}
```
### 4.7 管理接口当前行为边界
- EVM admin 接口当前为 mock仅记录日志并返回 message不发链上交易。
- TRON admin 接口会调用 `SendAdminTransaction(...)` 尝试发链上交易。
- 管理接口目前没有单独管理员校验,默认只依赖 API 网关 token。生产建议补管理员鉴权与审计。
## 5. 业务状态
红包状态:
- `PENDING`: 已创建业务单,尚未确认链上创建
- `ACTIVE`: 链上创建已确认,可领取
- `COMPLETED`: 已领取完成
- `REFUNDED`: 已退款
领取状态:
- `PENDING`: 已提交领取 txHashreceipt 尚未解析或未确认
- `CONFIRMED`: 已解析 `PacketClaimed`
- `FAILED`: 预留失败状态,当前逻辑仅用于重复领取判断时放行失败记录
钱包绑定 challenge 状态:
- `PENDING`
- `VERIFIED`
- `FAILED`
- `EXPIRED`
钱包绑定状态:
- `ACTIVE`
## 6. 常见错误
- `op user id is empty`: 缺少 token 或 token 未正确注入上下文
- `unsupported chain_type`: `chain_type` 不是 `EVM``TRON`
- `packet_id is required`: 缺少红包链上 ID
- `wallet is not bound to user`: 当前用户未绑定该领取钱包
- `user already claimed`: 当前用户已领取
- `already claimed`: 当前钱包已领取
- `packet is not active`: 红包尚未激活或已经完成/退款
- `packet is expired`: 红包已过期
- `TRON wallet binding verification is not implemented yet`: 当前未实现 TRON 绑定验签
## 7. 前端推荐调用顺序
创建红包:
1. `POST /redpacket/create_order`
2. 钱包发起 `createFixedPacket/createRandomPacket/createTransfer`
3. 从 `PacketCreated` 解析 `packetId`
4. `POST /redpacket/created_callback`
5. `POST /redpacket/detail` 刷新状态
绑定钱包:
1. `POST /redpacket/wallet_bind/challenge`
2. 钱包按 `sign_method` 签名 `message`
3. `POST /redpacket/wallet_bind/confirm`
4. `POST /redpacket/wallet_bind/detail`
领取红包:
1. `POST /redpacket/detail`
2. `POST /redpacket/issue_claim_sign`
3. 钱包调用链上 `claim(packetId, authNonce, randomSeed, deadline, signature)`
4. 可选:`POST /redpacket/claim_result`
5. `POST /redpacket/detail` 刷新状态
## 8. 存储与索引
Mongo collections
- `red_packet`
- `red_packet_claim`
- `red_packet_claim_auth`
- `red_packet_refund`
- `wallet_binding_challenge`
- `wallet_binding`
主要索引:
- `red_packet.biz_id` 唯一
- `red_packet.packet_id`
- `red_packet.group_id`
- `red_packet_claim.claim_tx_hash` 唯一
- `red_packet_claim.packet_id + user_id`
- `red_packet_claim.packet_id + claimer_wallet`
- `red_packet_claim_auth.auth_nonce` 唯一
- `wallet_binding_challenge.challenge_id` 唯一
- `wallet_binding.user_id + chain_type + wallet_address` 唯一
## 9. 配置文件
`config/openim-rpc-redpacket.yml`
```yaml
rpc:
registerIP: ""
listenIP: 0.0.0.0
autoSetPorts: false
ports: [10560]
prometheus:
enable: false
ports: [12560]
chain:
rpcURL: ""
contractAddress: ""
chainID: 0
signerPrivateKey: ""
configAdminPrivateKey: ""
tron:
fullNodeURL: ""
contractBase58: ""
ownerBase58: ""
privateKeyHex: ""
feeLimit: 100000000
indexer:
pollInterval: 5
```
`chain.rpcURL` 为空时 EVM client 初始化会失败并降级;`tron.fullNodeURL` 为空时 TRON client 不启用。服务会继续启动。

@ -0,0 +1,271 @@
# RedPacket 前端对接文档
本文档面向前端 / 网关 / App 对接方,说明红包领取和钱包绑定的真实接入方式,重点覆盖:
- 如何把当前登录用户传递给红包服务
- 如何绑定钱包
- 如何申请领取签名
- 前端何时发链、何时回写后端
## 1. 总体原则
红包服务已经切换为 RPC 上下文取当前用户 ID
- 前端不再把 `user_id` 当作可信业务参数传给红包服务
- 红包服务从请求上下文里的 `opUserID` 获取当前登录用户
- 上下文通常由网关或鉴权中间件根据 `token` 解析后注入
这意味着对接时必须满足一个前提:
- 请求进入红包服务前,网关已经完成 token 解析
- 并且把当前登录用户写入上下文中的 `opUserID`
如果没有这一层,红包服务会返回:
```json
{
"code": 403,
"message": "op user id missing in context"
}
```
## 2. 钱包绑定流程
### 2.1 流程图
```text
前端 -> 红包服务: POST /api/redpacket/wallet-bind/challenge
红包服务 -> 前端: challenge_id + message + sign_method
前端 -> 钱包: 对 message 签名
前端 -> 红包服务: POST /api/redpacket/wallet-bind/confirm
红包服务 -> 前端: 绑定成功
```
### 2.2 发起挑战
请求:
```http
POST /api/redpacket/wallet-bind/challenge
token: <user token>
Content-Type: application/json
```
```json
{
"chain_type": "EVM",
"chain_id": 1,
"wallet_address": "0x3333333333333333333333333333333333333333",
"domain": "redpacket.example.com",
"uri": "https://redpacket.example.com/wallet-bind"
}
```
返回里最关键的是:
- `challenge_id`
- `message`
- `sign_method`
前端要做的是:
- 按 `sign_method` 调钱包签名
- 当前 EVM 实现使用的是 `personal_sign`
### 2.3 确认绑定
请求:
```http
POST /api/redpacket/wallet-bind/confirm
token: <user token>
Content-Type: application/json
```
```json
{
"challenge_id": "1f7d9b0d-7b43-4d84-bb11-65f2ecf7e321",
"signature": "0x8f..."
}
```
成功后代表:
- 当前登录用户
- 当前链类型
- 当前钱包地址
已经在后端建立了有效绑定关系。
## 3. 领取签名流程
### 3.1 流程图
```text
前端 -> 红包服务: POST /api/redpacket/claim-sign
红包服务 -> 红包服务: 校验当前用户、钱包绑定、领取资格
红包服务 -> 合约: getSignMessage(packetId, claimer, authNonce, randomSeed, deadline)
红包服务 -> 前端: auth_nonce + random_seed + deadline + signature
前端 -> 钱包/链上: claim(packetId, authNonce, randomSeed, deadline, signature)
前端 -> 红包服务: POST /api/redpacket/claim-result (可选)
链监听器 -> 红包服务: 最终确认领取结果
```
### 3.2 申请领取签名
请求:
```http
POST /api/redpacket/claim-sign
token: <user token>
Content-Type: application/json
```
```json
{
"packet_id": "10001",
"claimer": "0x3333333333333333333333333333333333333333",
"random_seed": "0"
}
```
说明:
- `claimer` 必须是这次真正发链的地址
- `random_seed` 可省略或传 `0`
- 不需要传 `user_id`
后端会自动完成这些校验:
1. 当前登录用户存在
2. 红包存在且仍可领取
3. 当前登录用户与 `claimer` 已绑定
4. 当前用户在该红包下未领取过
5. 当前钱包在该红包下未领取过
6. 群红包 / 转账红包的附加业务限制通过
成功响应:
```json
{
"code": 0,
"message": "ok",
"data": {
"auth_nonce": "328840239847239847",
"deadline": 1777012345,
"signature": "0x7b1e...a2",
"random_seed": "8888812345"
}
}
```
### 3.3 前端拿到响应后要做什么
前端必须原样把这些参数传给链上:
```text
claim(packetId, authNonce, randomSeed, deadline, signature)
```
对应关系:
- `packetId` -> 前端当前红包 ID
- `authNonce` -> 响应里的 `auth_nonce`
- `randomSeed` -> 响应里的 `random_seed`
- `deadline` -> 响应里的 `deadline`
- `signature` -> 响应里的 `signature`
注意:
- 不要自己改 `auth_nonce`
- 不要重新算摘要
- 不要对摘要再次做 `signMessage`
- 后端返回的 `signature` 已经是最终可上链签名
## 4. 领取结果回写
`claim-result` 是可选的,主要作用是让业务侧尽快看到一条 `PENDING` 领取记录。
请求:
```http
POST /api/redpacket/claim-result
token: <user token>
Content-Type: application/json
```
```json
{
"packet_id": "10001",
"claimer": "0x3333333333333333333333333333333333333333",
"tx_hash": "0xdef456..."
}
```
说明:
- 不需要传 `user_id`
- 当前登录用户仍然从上下文中取
- 如果后端当前能立刻解析 receipt会把记录补成 `CONFIRMED`
- 如果不能,会先记成 `PENDING`
- 最终仍以链监听器为准
## 5. 前端推荐调用顺序
### 5.1 首次使用钱包领取
1. 用户登录业务系统
2. 前端请求 `/wallet-bind/challenge`
3. 钱包对 `message` 签名
4. 前端请求 `/wallet-bind/confirm`
5. 绑定成功后再进入领取流程
### 5.2 正常领取
1. 前端拿到红包 `packet_id`
2. 用户连接钱包,得到本次 `claimer` 地址
3. 前端请求 `/claim-sign`
4. 拿到 `auth_nonce + random_seed + deadline + signature`
5. 前端调用链上 `claim(...)`
6. 前端可选请求 `/claim-result`
7. 页面轮询详情页或等待业务侧状态同步
## 6. 常见错误和排查
### 6.1 `op user id missing in context`
原因:
- 网关没有解析 token
- 网关没有把 `opUserID` 注入上下文
- 直接绕过网关调用了红包服务
### 6.2 `wallet is not bound to user`
原因:
- 当前钱包还没绑定
- 当前钱包绑定的是别的业务用户
- 链类型不一致
### 6.3 `already claimed`
原因:
- 同一个钱包地址已经领过该红包
### 6.4 `user already claimed`
原因:
- 同一个业务用户已经领取过该红包
- 即使换钱包地址,也会被后端拦截
## 7. 后端接口与代码位置
- 接口契约文档:
[backend-api.md](/Users/panda/aiCode/red_packet/open-im-server-origin/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md)
- 领取签名核心逻辑:
[redpacket.go](/Users/panda/aiCode/red_packet/open-im-server-origin/cmd/openim-rpc/openim-rpc-redpacket/internal/service/redpacket.go)
- 用户上下文提取:
[user.go](/Users/panda/aiCode/red_packet/open-im-server-origin/cmd/openim-rpc/openim-rpc-redpacket/internal/authctx/user.go)

@ -0,0 +1,12 @@
package main
import (
"github.com/openimsdk/open-im-server/v3/pkg/common/cmd"
"github.com/openimsdk/tools/system/program"
)
func main() {
if err := cmd.NewRedPacketRpcCmd().Exec(); err != nil {
program.ExitWithError(err)
}
}

@ -0,0 +1,357 @@
# RedPacket Go 后端对接说明ETH + TRON
本文档基于当前 OpenIM 版红包服务实现整理,重点说明 Go 后端如何接入 EVM / TRON 链能力、如何签发 claim 授权、如何解析交易事件,以及当前实现中哪些能力是完整实现、哪些仍是 mock 或待补齐。
相关代码位置:
- RPC 入口:`cmd/openim-rpc/openim-rpc-redpacket/main.go`
- 服务启动:`pkg/common/cmd/rpc_redpacket.go`
- 业务逻辑:`internal/rpc/redpacket/service.go`
- 管理接口:`internal/rpc/redpacket/admin.go`
- 钱包绑定:`internal/rpc/redpacket/wallet.go`
- 链客户端:`internal/rpc/redpacket/chain`
- 合约 ABI`internal/rpc/redpacket/chain/abi/RedPacket.json`
- 配置文件:`config/openim-rpc-redpacket.yml`
## 1. 当前架构
`openim-rpc-redpacket` 已经不再是独立 Gin + GORM 服务,而是标准 OpenIM RPC 服务:
```text
openim-api
-> /redpacket/* HTTP API
-> pbredpacket.RedPacketClient
-> openim-rpc-redpacket
-> MongoDB + EVM/TRON clients
```
服务启动时会初始化:
- MongoDB DAO`controller.NewRedPacketDatabase(...)`
- EVM client`chain.rpcURL``chain.contractAddress` 配置完整时启用
- TRON client`tron.fullNodeURL``tron.contractBase58` 配置完整时启用
- signer 私钥:当 `chain.signerPrivateKey` 配置完整时用于 claim 裸签名
链客户端初始化失败不会阻止服务启动,但会导致链上确认、事件解析或签名 digest 获取降级。
## 2. 配置
`config/openim-rpc-redpacket.yml` 示例:
```yaml
rpc:
registerIP: ""
listenIP: 0.0.0.0
autoSetPorts: false
ports: [10560]
prometheus:
enable: false
ports: [12560]
chain:
rpcURL: "https://eth-mainnet.g.alchemy.com/v2/xxx"
contractAddress: "0x..."
chainID: 1
signerPrivateKey: "0x..."
configAdminPrivateKey: "0x..."
tron:
fullNodeURL: "https://api.trongrid.io"
contractBase58: "T..."
ownerBase58: "T..."
privateKeyHex: "..."
feeLimit: 100000000
indexer:
pollInterval: 5
```
配置含义:
- `chain.rpcURL`: EVM JSON-RPC 地址
- `chain.contractAddress`: EVM RedPacket 合约地址
- `chain.chainID`: EVM 链 ID用于记录业务单与构造交易
- `chain.signerPrivateKey`: claim 授权签名私钥,应对应合约 `signer`
- `chain.configAdminPrivateKey`: 管理写链私钥,当前 EVM admin 仍是 mock
- `tron.fullNodeURL`: TRON FullNode / TronGrid 地址
- `tron.contractBase58`: TRON 合约 Base58 地址
- `tron.ownerBase58`: TRON 管理交易发送地址
- `tron.privateKeyHex`: TRON 管理交易私钥
- `tron.feeLimit`: TRON 交易 fee limit
安全建议:
- `signerPrivateKey``configAdminPrivateKey` 必须分离
- 生产不要把管理私钥明文放在普通配置文件中,建议接入 KMS/HSM 或密钥托管服务
- `signerPrivateKey` 是高频签名密钥,权限只能用于 claim 授权,不应拥有合约配置权限
## 3. Claim 签名
### 3.1 合约签名事实
当前后端签名逻辑对应合约的:
```text
getSignMessage(packetId, claimer, authNonce, randomSeed, deadline)
claim(packetId, authNonce, randomSeed, deadline, signature)
```
后端流程:
1. 业务鉴权:登录用户、钱包绑定、红包状态、重复领取、群/转账资格
2. 生成 `authNonce`、`randomSeed`、`deadline`
3. EVM client 可用时调用链上 `getSignMessage(...)` 获取 digest
4. 用 `signerPrivateKey` 对 digest 做裸签名
5. 如果 `v` 是 0/1转换为 27/28
6. 保存 `red_packet_claim_auth`
7. 返回前端调用 `claim(...)` 所需参数
注意:不要使用 `personal_sign` 对 claim digest 签名。claim 授权使用的是裸 ECDSA 签名,不带 Ethereum Signed Message 前缀。
### 3.2 Go 裸签名示例
```go
func signClaimDigest(priv *ecdsa.PrivateKey, digest [32]byte) (string, error) {
sig, err := crypto.Sign(digest[:], priv)
if err != nil {
return "", err
}
if len(sig) == 65 && sig[64] < 27 {
sig[64] += 27
}
return "0x" + hex.EncodeToString(sig), nil
}
```
### 3.3 当前降级行为
当前代码有两个降级点:
- EVM client 不可用时,后端会用本地 `keccak256(packetID:claimer:nonce:randomSeed:deadline)` 生成 digest该 digest 不保证与合约一致,仅适合调试。
- signer 私钥未配置时,后端会返回 placeholder 签名;该签名不能通过链上验签。
生产环境必须配置可用的 EVM client 和 signer 私钥。
## 4. ETH 接入
### 4.1 创建红包
推荐调用顺序:
1. 后端 `CreateOrder` 生成 `biz_id`
2. 前端或托管钱包发起链上创建交易
3. 从 `PacketCreated` 事件解析 `packetId`
4. 调用 `CreatedCallback` 回写 `biz_id + tx_hash + packet_id`
5. 后端使用 EVM client 解析 receipt 并校验事件字段
6. 校验通过后业务单变为 `ACTIVE`
当前代码中的校验点:
- `tx_hash` 必填
- receipt 中必须有可识别的 `PacketCreated`
- event 解析出的 creator / packetType / token / amount / shares / expiry 要与业务单一致
- 如果链客户端不可用,允许请求体提供 `packet_id` fallback
### 4.2 领取红包
推荐调用顺序:
1. 前端确认用户已经绑定当前 EVM 钱包
2. 调用 `IssueClaimSign`
3. 前端使用返回参数调用合约 `claim(...)`
4. 交易提交后调用 `ClaimResult`
5. 后端解析 `PacketClaimed`,补全 amount、authNonce、blockNumber
`ClaimResult` 当前行为:
- 先落 `PENDING` 领取记录
- 能解析 receipt 时更新为 `CONFIRMED`
- 解析到 `PacketClaimed` 后更新红包领取进度
- 已领取份数达到 `total_shares` 时状态更新为 `COMPLETED`
### 4.3 事件解析
EVM 事件解析由 `internal/rpc/redpacket/chain/parser.go` 负责。管理接口也提供手动解析入口:
```http
POST /redpacket/admin/parse_tx_events
```
请求:
```json
{
"chain": "eth",
"tx_hash": "0xabc123..."
}
```
响应示例:
```json
{
"chain": "eth",
"tx_hash": "0xabc123...",
"events": [
{
"name": "PacketCreated",
"data": {
"packetId": "10001",
"creator": "0x1111111111111111111111111111111111111111",
"packetType": "1"
}
}
]
}
```
核心事件:
- `PacketCreated`: 创建成功,提供唯一可信 `packetId`
- `PacketClaimed`: 领取成功,提供实际领取金额
- `PacketRefunded`: 退款成功,提供退款金额与接收方
### 4.4 ETH 管理接口现状
当前 `internal/rpc/redpacket/admin.go` 中 EVM 管理接口是 mock
- `SetSigner`
- `SetToken`
- `SetExpiry`
- `SetAllowAllTokens`
- `SetNativeTokenEnabled`
这些接口在 EVM client 可用时只记录日志并返回成功 message不会真正发链上交易。上线前如需后端托管管理交易需要补充 EVM admin transaction 实现。
## 5. TRON 接入
### 5.1 TRON 创建与领取
TRON 合约兼容 EVM ABI 的 topic/data 事件模型,但地址、签名与交易广播流程和 EVM 不同。
当前后端支持:
- 创建业务单时 `chain_type=TRON`
- `contract_address` 可从 `tron.contractBase58` 自动填充
- TRON 钱包绑定 challenge 生成
- TRON admin 写交易通过 `SendAdminTransaction(...)` 尝试调用 FullNode
当前后端尚未完整支持:
- TRON 钱包绑定签名验签
- TRON claim digest 获取与 claim 签名链上闭环
- TRON receipt 事件完整解析与索引
### 5.2 TRON 管理交易
当前 TRON admin 使用 FullNode HTTP 流程:
```text
triggersmartcontract
-> gettransactionsign
-> broadcasttransaction
```
配置依赖:
- `tron.fullNodeURL`
- `tron.contractBase58`
- `tron.ownerBase58`
- `tron.privateKeyHex`
- `tron.feeLimit`
管理接口会把方法映射到合约调用:
- `SetSigner` -> `setSigner`
- `SetToken` -> `setAllowedToken`
- `SetExpiry` -> `setDefaultExpiryDuration`
- `SetAllowAllTokens` -> `setAllowAllTokens`
- `SetNativeTokenEnabled` -> `setNativeTokenEnabled`
### 5.3 TRON 事件解析现状
`ParseTxEvents(chain=tron)` 当前返回:
```json
{
"chain": "tron",
"tx_hash": "7d9e...txid",
"note": "TRON event parsing not fully implemented in this version"
}
```
后续如果要补齐,应实现:
1. 调用 `/wallet/gettransactioninfobyid`
2. 从 `log` 读取 topics/data
3. 将 TRON 地址字段规范化为 Base58 或 hex
4. 使用 `RedPacket.json` ABI 解码事件
5. 复用 EVM 的 `PacketCreated` / `PacketClaimed` / `PacketRefunded` 业务回写逻辑
## 6. 钱包绑定
### 6.1 EVM 绑定
EVM 绑定采用 SIWE 风格消息:
- protocol: `siwe-eip4361`
- sign method: `personal_sign`
- challenge 有效期: 10 分钟
确认绑定时,后端会:
1. 读取 `wallet_binding_challenge`
2. 检查状态为 `PENDING`
3. 检查未过期
4. 用 `personalSignMessage(message)` 计算 hash
5. `SigToPub` recover 地址
6. 比对 recover 地址与 challenge wallet
7. challenge 更新为 `VERIFIED`
8. upsert `wallet_binding`
### 6.2 TRON 绑定
TRON challenge 会生成:
- protocol: `tron-signmessagev2`
- sign method: `signMessageV2`
但确认绑定当前未实现,会返回:
```text
TRON wallet binding verification is not implemented yet
```
## 7. MongoDB 数据
当前使用 6 个 collection
- `red_packet`: 红包主记录
- `red_packet_claim`: 领取记录
- `red_packet_claim_auth`: claim 签名授权记录
- `red_packet_refund`: 退款记录
- `wallet_binding_challenge`: 钱包绑定 challenge
- `wallet_binding`: 钱包绑定关系
关键幂等约束:
- `red_packet.biz_id` 唯一
- `red_packet_claim.claim_tx_hash` 唯一
- `red_packet_claim_auth.auth_nonce` 唯一
- `wallet_binding_challenge.challenge_id` 唯一
- `wallet_binding.user_id + chain_type + wallet_address` 唯一
## 8. 部署检查清单
上线前至少确认:
- `share.yml` 中存在 `rpcRegisterName.redPacket: redPacket`
- `openim-rpc-redpacket.yml` 已加入配置目录
- `openim-api` watch service list 包含 `redPacket`
- MongoDB 可用且服务启动时能创建索引
- EVM 环境配置了有效 `rpcURL`、`contractAddress`、`signerPrivateKey`
- 生产关闭 placeholder signer 降级路径
- 管理接口补充管理员鉴权与操作审计
- 如需 ETH admin 写链,补齐当前 mock 实现
- 如需 TRON 完整闭环补齐绑定验签、事件解析、claim 签名链路

@ -0,0 +1,430 @@
# RedPacket Web3 接入设计文档
本文档描述红包系统在当前 OpenIM 架构中的 Web3 接入设计。内容以当前代码为准覆盖前端、钱包、API 网关、RPC 服务、MongoDB、EVM/TRON 合约交互与事件回写。
## 1. 设计目标
业务目标:
- 支持固定红包、拼手气红包、待领取转账
- 支持 EVM 链红包创建、领取签名、事件解析
- 支持 TRON 链配置预留与部分管理交易能力
- 支持用户钱包绑定领取前强制校验“OpenIM 用户 ID + 钱包地址”的绑定关系
- 通过 API 网关对外提供 HTTP 接口,内部保持标准 OpenIM gRPC 服务形态
安全目标:
- 钱包归属必须先绑定后领取
- claim 授权必须绑定 `packetId + claimer + authNonce + randomSeed + deadline`
- 同一用户、同一钱包对同一红包都不能重复领取
- signer 私钥只用于 claim 授权,不用于合约配置
- 管理类交易与高频签名职责分离
工程目标:
- RPC 服务接入 OpenIM 的配置、服务发现、日志和 MongoDB 体系
- HTTP API 只做参数解析和 RPC 转发
- MongoDB 保存业务状态、签名授权、领取记录和钱包绑定关系
- 链上事件作为最终一致性的依据
## 2. 当前系统边界
已经实现:
- `openim-rpc-redpacket` 标准 RPC 入口
- `/redpacket/*` API 网关路由
- MongoDB 存储模型和 DAO
- EVM claim digest 获取、裸签名、事件解析
- EVM 钱包绑定验签
- TRON 钱包绑定 challenge 生成
- TRON admin transaction 调用框架
- 创建回写、领取回写、详情查询
仍需补齐:
- EVM admin 写链当前是 mock
- TRON 钱包绑定签名验签未实现
- TRON 交易事件解析未完整实现
- refund API 当前未对外暴露,仅有退款模型与事件预留
- 管理接口未做独立管理员鉴权
- 自动 indexer loop 当前只是配置和代码结构预留,主要回写仍依赖 callback / parse
## 3. 总体架构
```mermaid
flowchart LR
User[OpenIM 用户] --> App[App / H5 / Web]
App --> Wallet[钱包]
App --> API[openim-api /redpacket]
API --> RPC[openim-rpc-redpacket]
RPC --> DB[(MongoDB)]
RPC --> EVM[EVM RPC Client]
RPC --> TRON[TRON FullNode Client]
Wallet --> Contract[RedPacket Contract]
EVM --> Contract
TRON --> Contract
```
模块职责:
- App/H5/Web连接钱包、发起链上交易、调用后端接口、展示红包状态
- 钱包:签名交易、签名绑定 challenge、广播交易
- openim-api解析 HTTP 请求、注入登录用户上下文、调用 gRPC client
- openim-rpc-redpacket业务鉴权、签名、存储、链上 receipt 解析
- MongoDB保存业务状态和审计数据
- RedPacket 合约:维护链上红包状态、验签、防重放、转账结算
- EVM/TRON client链上读写、事件解析、管理交易预留
## 4. 核心数据模型
### 4.1 红包主记录
collection: `red_packet`
保存内容:
- `biz_id`: 后端业务单号,唯一
- `chain_type`: `EVM``TRON`
- `packet_id`: 链上红包 ID
- `chain_id`: 链 ID
- `contract_address`: 合约地址
- `creator_user_id`: OpenIM 发红包用户 ID
- `creator_wallet`: 发红包钱包地址
- `group_id`: 群红包所属群
- `scope_type`: `GROUP`、`DIRECT`、`PUBLIC`
- `receiver_user_id` / `receiver_user_ids`: 转账红包目标用户
- `packet_type`: `0` 固定、`1` 拼手气、`2` 转账
- `token`: token 地址
- `total_amount` / `total_shares`: 总金额与总份数
- `claimed_amount` / `claimed_shares`: 已领取进度
- `expiry_at`: 过期时间
- `tx_hash`: 创建交易 hash
- `status`: `PENDING`、`ACTIVE`、`COMPLETED`、`REFUNDED`
### 4.2 领取授权
collection: `red_packet_claim_auth`
保存每次签名发放:
- `packet_id`
- `claimer`
- `auth_nonce`
- `random_seed`
- `deadline`
- `signature`
- `used`
- `created_at`
`auth_nonce` 建唯一索引,用于防止重复授权 nonce。
### 4.3 领取记录
collection: `red_packet_claim`
保存链上领取回写结果:
- `packet_id`
- `user_id`
- `claimer_wallet`
- `auth_nonce`
- `claim_tx_hash`
- `claimed_amount`
- `block_number`
- `status`
重复领取判断同时检查:
- `packet_id + user_id`
- `packet_id + claimer_wallet`
### 4.4 钱包绑定
collection:
- `wallet_binding_challenge`
- `wallet_binding`
绑定流程先保存 challenge验签通过后 upsert active binding。领取签名前必须存在 `ACTIVE` 绑定。
## 5. 创建红包流程
```mermaid
sequenceDiagram
autonumber
participant App as App/H5
participant API as openim-api
participant RPC as redpacket RPC
participant DB as MongoDB
participant Wallet as Wallet
participant C as Contract
App->>API: POST /redpacket/create_order
API->>RPC: CreateOrder
RPC->>RPC: 校验登录用户和 scope
RPC->>DB: insert red_packet(PENDING)
RPC-->>App: biz_id
App->>Wallet: 发起 create transaction
Wallet->>C: createFixed/createRandom/createTransfer
C-->>Wallet: tx hash + receipt
App->>App: 解析 PacketCreated.packetId
App->>API: POST /redpacket/created_callback
API->>RPC: CreatedCallback
RPC->>C: 可选解析 receipt 并校验事件
RPC->>DB: update red_packet(ACTIVE)
RPC-->>App: ok
```
关键规则:
- `biz_id` 由后端生成,链上没有该字段
- `packet_id` 的可信来源是 `PacketCreated`
- 业务单先落 `PENDING`,链上确认后更新为 `ACTIVE`
- 如果 EVM client 可用,后端会校验 receipt 事件与业务单参数是否一致
- `creator_user_id` 从 token 上下文获取,不能由前端传入
scope 规则:
- `PUBLIC`: 公开红包,不要求 `group_id`
- `GROUP`: 群红包,必须传 `group_id`
- `DIRECT`: 指定用户红包,必须传 `receiver_user_id``receiver_user_ids`
## 6. 钱包绑定流程
```mermaid
sequenceDiagram
autonumber
participant App as App/H5
participant Wallet as Wallet
participant API as openim-api
participant RPC as redpacket RPC
participant DB as MongoDB
App->>API: POST /redpacket/wallet_bind/challenge
API->>RPC: IssueWalletBindChallenge
RPC->>DB: insert challenge(PENDING)
RPC-->>App: message + sign_method
App->>Wallet: 对 message 签名
Wallet-->>App: signature
App->>API: POST /redpacket/wallet_bind/confirm
API->>RPC: ConfirmWalletBind
RPC->>RPC: 验签并 recover 钱包地址
RPC->>DB: challenge=VERIFIED, upsert binding(ACTIVE)
RPC-->>App: binding detail
```
EVM 绑定:
- 使用 SIWE 风格 message
- `sign_method=personal_sign`
- 后端使用 Ethereum Signed Message 前缀 recover 地址
- recover 地址必须等于 challenge 的 `wallet_address`
TRON 绑定:
- 当前仅生成 challenge
- `sign_method=signMessageV2`
- confirm 阶段尚未实现验签
安全边界:
- challenge 默认 10 分钟过期
- challenge 只能从 `PENDING` 确认一次
- 领取签名前必须查询到 active binding
## 7. 领取红包流程
```mermaid
sequenceDiagram
autonumber
participant App as App/H5
participant API as openim-api
participant RPC as redpacket RPC
participant DB as MongoDB
participant C as Contract
participant Wallet as Wallet
App->>API: POST /redpacket/detail
API->>RPC: GetDetail
RPC->>DB: 查询红包和领取记录
RPC-->>App: detail
App->>API: POST /redpacket/issue_claim_sign
API->>RPC: IssueClaimSign
RPC->>DB: 校验红包、绑定、重复领取、scope
RPC->>C: getSignMessage(...)
RPC->>RPC: signer 私钥裸签名
RPC->>DB: insert claim_auth
RPC-->>App: authNonce + randomSeed + deadline + signature
App->>Wallet: claim(...)
Wallet->>C: claim transaction
C-->>Wallet: tx hash + receipt
App->>API: POST /redpacket/claim_result
API->>RPC: ClaimResult
RPC->>C: 可选解析 PacketClaimed
RPC->>DB: 保存 claim更新领取进度
RPC-->>App: ok
```
领取前后端校验:
- 登录用户存在
- 红包存在
- 红包状态为 `ACTIVE`
- 红包未过期
- 当前用户绑定了 `claimer` 钱包
- 当前用户未领取
- 当前钱包未领取
- 群红包必须有关联群
- 转账红包必须匹配指定接收用户
claim 签名字段:
- `packetId`
- `claimer`
- `authNonce`
- `randomSeed`
- `deadline`
前端必须原样把后端返回的参数传给合约 `claim(...)`。任一字段变化都会导致验签失败。
## 8. 管理配置流程
管理员接口位于:
```text
/redpacket/admin/*
```
当前对外接口:
- `set_signer`
- `set_token`
- `set_expiry`
- `set_allow_all_tokens`
- `set_native_token_enabled`
- `parse_tx_events`
设计分权:
- owner / 多签:最高权限
- config admin低频参数配置
- signer高频 claim 授权签名
当前实现边界:
- EVM 管理接口是 mock只返回成功 message
- TRON 管理接口会尝试通过 FullNode 发交易
- API 层未做独立管理员角色校验,生产必须补齐
## 9. 事件与最终一致性
核心事件:
- `PacketCreated`: 创建成功,获得链上 `packetId`
- `PacketClaimed`: 领取成功,获得真实领取金额
- `PacketRefunded`: 退款成功,获得退款目标与金额
当前一致性策略:
- 创建阶段由 `created_callback` 回写,并在 EVM client 可用时解析 receipt 校验
- 领取阶段由 `claim_result` 先保存 `PENDING`,能解析 receipt 时立即确认
- 后续 indexer 可基于 `indexer.pollInterval` 扩展为后台轮询与补偿
幂等建议:
- 以 `tx_hash` 做领取回写幂等
- 以 `biz_id` 做创建业务单幂等
- 以 `packet_id + user_id``packet_id + claimer_wallet` 做重复领取判断
- 事件重复消费时,只允许状态向前推进,不回退已确认状态
## 10. API 设计摘要
用户侧:
- `POST /redpacket/create_order`: 创建业务单
- `POST /redpacket/created_callback`: 创建交易回写
- `POST /redpacket/detail`: 查询红包详情
- `POST /redpacket/issue_claim_sign`: 领取签名发放
- `POST /redpacket/claim_result`: 领取交易回写
- `POST /redpacket/wallet_bind/challenge`: 钱包绑定 challenge
- `POST /redpacket/wallet_bind/confirm`: 钱包绑定确认
- `POST /redpacket/wallet_bind/detail`: 查询当前用户的钱包绑定
管理员侧:
- `POST /redpacket/admin/set_signer`
- `POST /redpacket/admin/set_token`
- `POST /redpacket/admin/set_expiry`
- `POST /redpacket/admin/set_allow_all_tokens`
- `POST /redpacket/admin/set_native_token_enabled`
- `POST /redpacket/admin/parse_tx_events`
## 11. 前端接入建议
创建页:
1. 用户选择红包类型、金额、份数、过期时间和链
2. 调用 `create_order`
3. 钱包发起链上创建交易
4. 从 receipt 解析 `PacketCreated.packetId`
5. 调用 `created_callback`
6. 展示分享页或详情页
领取页:
1. 查询 `detail`
2. 检查当前钱包是否已绑定
3. 未绑定则先走 wallet bind
4. 调用 `issue_claim_sign`
5. 钱包发起 `claim(...)`
6. 调用 `claim_result`
7. 刷新 `detail`
钱包绑定页:
1. 获取当前钱包地址和 chain type
2. 调用 `wallet_bind/challenge`
3. 按 `sign_method` 调钱包签名
4. 调用 `wallet_bind/confirm`
5. 调用 `wallet_bind/detail` 验证绑定状态
## 12. 风险与待办
必须尽快处理:
- 修复 `protocol/redpacket/redpacket.proto` 与当前 `internal/api` / `internal/rpc` 使用的 protobuf 类型不一致问题
- 补充管理员接口的 OpenIM 管理员权限校验
- 移除或保护 placeholder signature 降级路径
- EVM admin 从 mock 改为真实交易或明确只允许前端钱包管理
按业务优先级处理:
- 补齐 TRON 绑定验签
- 补齐 TRON 事件解析
- 增加 refund HTTP/RPC 接口
- 增加后台 indexer loop 与事件补偿
- 增加管理员操作审计 collection
上线检查:
- API 网关能发现 `redPacket` RPC 服务
- MongoDB 索引创建成功
- signer 地址与合约 signer 一致
- EVM RPC 能稳定获取 receipt
- claim 签名在测试链可通过合约验签
- 钱包绑定 recover 地址与实际钱包一致
## 13. 总结
当前 RedPacket 的核心链路是:
```text
OpenIM 登录身份
-> 钱包绑定
-> 业务鉴权
-> 后端 signer 裸签 claim digest
-> 前端钱包发 claim 交易
-> 链上事件回写 MongoDB
```
这条链路把“谁是 OpenIM 用户”“谁控制钱包”“谁有资格领取”“链上是否最终成功”分成四层校验,后端只签发授权,不直接替用户领取,从而保持用户资产操作仍由钱包确认。

@ -0,0 +1,31 @@
rpc:
registerIP: ""
listenIP: 0.0.0.0
autoSetPorts: false
ports: [10560]
prometheus:
enable: false
ports: [12560]
# EVM (Ethereum / Polygon / BSC / ...) chain configuration.
# Leave rpcURL empty to disable the EVM client; the RPC service will then
# only expose TRON-related functionality (or the offchain code paths).
chain:
rpcURL: ""
contractAddress: ""
chainID: 0
signerPrivateKey: ""
configAdminPrivateKey: ""
# TRON full-node configuration. Leave fullNodeURL empty to disable TRON.
tron:
fullNodeURL: ""
contractBase58: ""
ownerBase58: ""
privateKeyHex: ""
feeLimit: 100000000
# Indexer polling interval (in seconds). Used by both EVM and TRON event indexers.
indexer:
pollInterval: 5

@ -12,6 +12,7 @@ rpcRegisterName:
captcha: captcha
rtc: rtc
crypto: crypto
redPacket: redPacket
imAdminUserID: [ imAdmin ]

@ -30,7 +30,9 @@ require github.com/google/uuid v1.6.0
require (
github.com/IBM/sarama v1.43.0
github.com/fatih/color v1.14.1
github.com/VirgilSecurity/virgil-sdk-go v5.2.1+incompatible
github.com/ethereum/go-ethereum v1.14.12
github.com/fatih/color v1.16.0
github.com/gin-contrib/gzip v1.0.1
github.com/go-redis/redis v6.15.9+incompatible
github.com/go-redis/redismock/v9 v9.2.0
@ -62,8 +64,7 @@ require (
cloud.google.com/go/longrunning v0.5.5 // indirect
cloud.google.com/go/storage v1.40.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/VirgilSecurity/virgil-crypto-go v0.0.0-20180221191626-33caf95f9a5d // indirect
github.com/VirgilSecurity/virgil-sdk-go v5.2.1+incompatible // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.32.5 // indirect
@ -87,6 +88,7 @@ require (
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/bits-and-blooms/bitset v1.13.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
@ -94,9 +96,15 @@ require (
github.com/clbanning/mxj v1.8.4 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/consensys/bavard v0.1.13 // indirect
github.com/consensys/gnark-crypto v0.12.1 // indirect
github.com/coreos/go-semver v0.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c // indirect
github.com/crate-crypto/go-kzg-4844 v1.0.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/dennwc/iters v1.2.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@ -105,6 +113,8 @@ require (
github.com/eapache/queue v1.1.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/ethereum/c-kzg-4844 v1.0.0 // indirect
github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
@ -124,7 +134,7 @@ require (
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect
github.com/google/cel-go v0.27.0 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
@ -137,6 +147,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/holiman/uint256 v1.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
@ -166,6 +177,7 @@ require (
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.69 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mmcloughlin/addchain v0.4.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@ -212,6 +224,7 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/supranational/blst v0.3.13 // indirect
github.com/tencentyun/cos-go-sdk-v5 v0.7.47 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
@ -262,6 +275,7 @@ require (
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
rsc.io/tmplfunc v0.0.3 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect

@ -26,6 +26,8 @@ firebase.google.com/go/v4 v4.14.1/go.mod h1:fgk2XshgNDEKaioKco+AouiegSI9oTWVqRaB
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/IBM/sarama v1.43.0 h1:YFFDn8mMI2QL0wOrG0J2sFoVIAFl7hS9JQi2YZsXtJc=
github.com/IBM/sarama v1.43.0/go.mod h1:zlE6HEbC/SMQ9mhEYaF7nNLYOUyrs0obySKCckWP9BM=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
@ -35,8 +37,8 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
github.com/VirgilSecurity/virgil-crypto-go v0.0.0-20180221191626-33caf95f9a5d h1:ElVLTQRuo+LvdhsvybRwBTXvDCjMyB0Dv4mhOPnjQUQ=
github.com/VirgilSecurity/virgil-crypto-go v0.0.0-20180221191626-33caf95f9a5d/go.mod h1:zyDDPi7Ihhd5JdTYQCcdmzACnF824PYV6E6UELQiZ1w=
github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI=
github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI=
github.com/VirgilSecurity/virgil-sdk-go v5.2.1+incompatible h1:icWPcnsM0eqDs3pNxglM/3FbuF0Y9WUygpRM4PdBbec=
github.com/VirgilSecurity/virgil-sdk-go v5.2.1+incompatible/go.mod h1:8kxwYsqg97YNwiVCrte1fqbP6H9VJ2vjSuyj1p1CP/8=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
@ -85,6 +87,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
@ -112,6 +116,22 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/pebble v1.1.2 h1:CUh2IPtR4swHlEj48Rhfzw6l/d0qA31fItcIszQVIsA=
github.com/cockroachdb/pebble v1.1.2/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/consensys/bavard v0.1.13 h1:oLhMLOFGTLdlda/kma4VOJazblc7IM5y5QPd2A/YjhQ=
github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI=
github.com/consensys/gnark-crypto v0.12.1 h1:lHH39WuuFgVHONRl3J0LRBtuYdQTumFSDtJF7HpyG8M=
github.com/consensys/gnark-crypto v0.12.1/go.mod h1:v2Gy7L/4ZRosZ7Ivs+9SfUDr0f5UlG+EM5t7MPHiLuY=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@ -122,12 +142,23 @@ github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmf
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c h1:uQYC5Z1mdLRPrZhHjHxufI8+2UG/i25QG92j0Er9p6I=
github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c/go.mod h1:geZJZH3SzKCqnz5VT0q/DyIG/tvu/dZk+VIfXicupJs=
github.com/crate-crypto/go-kzg-4844 v1.0.0 h1:TsSgHwrkTKecKJ4kadtHi4b3xHW5dCFUDFnUp1TsawI=
github.com/crate-crypto/go-kzg-4844 v1.0.0/go.mod h1:1kMhvPgI0Ky3yIa+9lFySEBUBXkYxeOi8ZF1sYioxhc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo=
github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
@ -164,8 +195,14 @@ github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9O
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/ethereum/c-kzg-4844 v1.0.0 h1:0X1LBXxaEtYD9xsyj9B9ctQEZIpnvVDeoBx8aHEwTNA=
github.com/ethereum/c-kzg-4844 v1.0.0/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0=
github.com/ethereum/go-ethereum v1.14.12 h1:8hl57x77HSUo+cXExrURjU/w1VhL+ShCTJrTwcCQSe4=
github.com/ethereum/go-ethereum v1.14.12/go.mod h1:RAC2gVMWJ6FkxSPESfbshrcKpIokgQKsVKmAuqdekDY=
github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 h1:8NfxH2iXvJ60YRB8ChToFTUzl8awsc3cJ8CbLjGIl/A=
github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
@ -182,6 +219,8 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
github.com/gin-contrib/gzip v1.0.1/go.mod h1:njt428fdUNRvjuJf16tZMYZ2Yl+WQB53X5wmhDwXvC4=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@ -232,6 +271,8 @@ github.com/go-zookeeper/zk v1.0.3/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@ -256,8 +297,8 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk=
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
@ -286,6 +327,7 @@ github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -303,6 +345,8 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgf
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
@ -312,8 +356,18 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4 h1:X4egAf/gcS1zATw6wn4Ej8vjuVGxeHdan+bRb2ebyv4=
github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4/go.mod h1:5GuXa7vkL8u9FkFuWdVvfR5ix8hRB7DbOAaYULamFpc=
github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
github.com/holiman/uint256 v1.3.1 h1:JfTzmih28bittyHM8z360dCjIA9dbPIBlcTI6lmctQs=
github.com/holiman/uint256 v1.3.1/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@ -364,6 +418,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leanovate/gopter v0.2.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7c=
github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
@ -402,6 +458,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.69 h1:l8AnsQFyY1xiwa/DaQskY4NXSLA2yrGsW5iD9nRPVS0=
@ -411,6 +469,11 @@ github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY=
github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU=
github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg=
@ -441,6 +504,8 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
@ -528,6 +593,8 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5X
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
@ -536,8 +603,11 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
@ -583,6 +653,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/supranational/blst v0.3.13 h1:AYeSxdOMacwu7FBmpfloBz5pbFXDmJL33RuwnKtmTjk=
github.com/supranational/blst v0.3.13/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
github.com/tencentyun/cos-go-sdk-v5 v0.7.47 h1:uoS4Sob16qEYoapkqJq1D1Vnsy9ira9BfNUMtoFYTI4=
@ -597,6 +671,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/wenlng/go-captcha/v2 v2.0.5 h1:+1FpVwJZmLCqEHxOt+HvpUArFGo107nRxOeRVHkZhTc=
github.com/wenlng/go-captcha/v2 v2.0.5/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQvYAQdr0cx/h34=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
@ -615,6 +691,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@ -827,6 +905,8 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/virgil.v5 v5.2.1 h1:8NnvRXg66qC6C4uqVhuMEfm8wInUGC+QG2vdbMaCbUI=
@ -857,6 +937,8 @@ k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU=
rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=

@ -48,6 +48,7 @@ func Start(ctx context.Context, index int, cfg *Config) error {
client, err = kdisc.NewDiscoveryRegister(&cfg.Discovery, &cfg.Share, []string{
cfg.Share.RpcRegisterName.MessageGateway,
cfg.Share.RpcRegisterName.Captcha,
cfg.Share.RpcRegisterName.RedPacket,
})
if err != nil {
return errs.WrapMsg(err, "failed to register discovery service")

@ -0,0 +1,245 @@
package api
import (
"github.com/gin-gonic/gin"
pbredpacket "github.com/openimsdk/protocol/redpacket"
"github.com/openimsdk/tools/a2r"
"github.com/openimsdk/tools/apiresp"
"github.com/openimsdk/tools/log"
)
type RedPacketApi struct {
Client pbredpacket.RedPacketClient
}
func NewRedPacketApi(client pbredpacket.RedPacketClient) *RedPacketApi {
return &RedPacketApi{Client: client}
}
func (h *RedPacketApi) CreateOrder(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.CreateOrderReq](ctx)
if err != nil {
log.ZError(ctx, "redpacket create order parse failed", err)
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.CreateOrder(ctx, req)
if err != nil {
log.ZError(ctx, "redpacket create order rpc failed", err)
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) CreatedCallback(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.CreatedCallbackReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.CreatedCallback(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) GetDetail(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.GetDetailReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.GetDetail(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) IssueClaimSign(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.IssueClaimSignReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.IssueClaimSign(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) ClaimResult(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.ClaimResultReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.ClaimResult(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) RequestRefund(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.RequestRefundReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.RequestRefund(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) GetRefund(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.GetRefundReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.GetRefund(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) IssueWalletBindChallenge(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.IssueWalletBindChallengeReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.IssueWalletBindChallenge(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) ConfirmWalletBind(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.ConfirmWalletBindReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.ConfirmWalletBind(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) GetWalletBinding(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.GetWalletBindingReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.GetWalletBinding(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
// Admin endpoints
func (h *RedPacketApi) AdminSetSigner(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.SetSignerReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.SetSigner(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) AdminSetToken(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.SetTokenReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.SetToken(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) AdminSetExpiry(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.SetExpiryReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.SetExpiry(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) AdminSetAllowAllTokens(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.SetAllowAllTokensReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.SetAllowAllTokens(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) AdminSetNativeTokenEnabled(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.SetNativeTokenEnabledReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.SetNativeTokenEnabled(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (h *RedPacketApi) AdminParseTxEvents(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.ParseTxEventsReq](ctx)
if err != nil {
apiresp.GinError(ctx, err)
return
}
resp, err := h.Client.ParseTxEvents(ctx, req)
if err != nil {
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}

@ -12,6 +12,7 @@ import (
pbcrypto "github.com/openimsdk/protocol/crypto"
"github.com/openimsdk/protocol/group"
"github.com/openimsdk/protocol/msg"
pbredpacket "github.com/openimsdk/protocol/redpacket"
"github.com/openimsdk/protocol/relation"
"github.com/openimsdk/protocol/rtc"
"github.com/openimsdk/protocol/third"
@ -117,7 +118,10 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
if err != nil {
return nil, err
}
redpacketConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.RedPacket)
if err != nil {
return nil, err
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
@ -378,6 +382,30 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
cryptoGroup.POST("/integrity_report", cr.IntegrityReport)
}
// RedPacket
{
rp := NewRedPacketApi(pbredpacket.NewRedPacketClient(redpacketConn))
redpacketGroup := r.Group("/redpacket")
redpacketGroup.POST("/create_order", rp.CreateOrder)
redpacketGroup.POST("/created_callback", rp.CreatedCallback)
redpacketGroup.POST("/detail", rp.GetDetail)
redpacketGroup.POST("/issue_claim_sign", rp.IssueClaimSign)
redpacketGroup.POST("/claim_result", rp.ClaimResult)
redpacketGroup.POST("/request_refund", rp.RequestRefund)
redpacketGroup.POST("/get_refund", rp.GetRefund)
redpacketGroup.POST("/wallet_bind/challenge", rp.IssueWalletBindChallenge)
redpacketGroup.POST("/wallet_bind/confirm", rp.ConfirmWalletBind)
redpacketGroup.POST("/wallet_bind/detail", rp.GetWalletBinding)
adminGroup := redpacketGroup.Group("/admin")
adminGroup.POST("/set_signer", rp.AdminSetSigner)
adminGroup.POST("/set_token", rp.AdminSetToken)
adminGroup.POST("/set_expiry", rp.AdminSetExpiry)
adminGroup.POST("/set_allow_all_tokens", rp.AdminSetAllowAllTokens)
adminGroup.POST("/set_native_token_enabled", rp.AdminSetNativeTokenEnabled)
adminGroup.POST("/parse_tx_events", rp.AdminParseTxEvents)
}
{
statisticsGroup := r.Group("/statistics")
statisticsGroup.POST("/user/register", u.UserRegisterCount)

@ -124,8 +124,8 @@ func (s *server) GenerateCaptcha(ctx context.Context, _ *pbcaptcha.GenerateCaptc
CaptchaID: id,
MasterImage: masterImage,
TileImage: tileImage,
TileY: int32(block.DY),
ExpireAt: expiredAt.Unix(),
TileY: int32(block.Y),
}, nil
}
@ -159,9 +159,10 @@ func (s *server) VerifyCaptcha(ctx context.Context, req *pbcaptcha.VerifyCaptcha
log.ZWarn(ctx, "captcha expired", nil, "captchaID", req.CaptchaID, "expiredAt", doc.ExpiredAt.Unix())
return nil, servererrs.ErrFileUploadedExpired.WrapMsg("captcha expired", "captchaID", req.CaptchaID)
}
success := slide.Validate(int(req.X), int(req.Y), doc.X, doc.Y, s.conf.VerifyPadding)
x, y := req.GetX(), req.GetY()
success := slide.Validate(int(x), int(y), doc.X, doc.Y, s.conf.VerifyPadding)
if !success {
log.ZError(ctx, "captcha validate failed", nil, "captchaID", req.CaptchaID, "x", req.X, "y", req.Y, "docX", doc.X, "docY", doc.Y)
log.ZError(ctx, "captcha validate failed", nil, "captchaID", req.CaptchaID, "x", x, "y", y, "docX", doc.X, "docY", doc.Y)
}
return &pbcaptcha.VerifyCaptchaResp{Success: success}, nil
}

@ -0,0 +1,217 @@
package redpacket
import (
"context"
"encoding/json"
"fmt"
"math/big"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/openimsdk/open-im-server/v3/pkg/authverify"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
pbredpacket "github.com/openimsdk/protocol/redpacket"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
"github.com/openimsdk/tools/mcontext"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// checkAdminPermission is a convenience wrapper used by every admin handler.
func (s *redPacketServer) checkAdminPermission(ctx context.Context) error {
return authverify.CheckAdmin(ctx, s.config.Share.IMAdminUserID)
}
// recordAudit persists an admin audit entry asynchronously; errors are only
// logged so they never block the primary operation.
func (s *redPacketServer) recordAudit(ctx context.Context, action string, req interface{}, opErr error) {
params := ""
if b, err := json.Marshal(req); err == nil {
params = string(b)
}
result := "success"
errMsg := ""
if opErr != nil {
result = "failed"
errMsg = opErr.Error()
}
entry := &model.AdminAuditLog{
ID: primitive.NewObjectID(),
OperatorID: mcontext.GetOpUserID(ctx),
Action: action,
Params: params,
Result: result,
ErrMsg: errMsg,
CreatedAt: time.Now().UTC(),
}
if err := s.db.CreateAdminAuditLog(ctx, entry); err != nil {
log.ZWarn(ctx, "redpacket admin audit log write failed", err, "action", action)
}
}
func (s *redPacketServer) SetSigner(ctx context.Context, req *pbredpacket.SetSignerReq) (resp *pbredpacket.SetSignerResp, retErr error) {
defer func() { s.recordAudit(ctx, "SetSigner", req, retErr) }()
if err := s.checkAdminPermission(ctx); err != nil {
return nil, err
}
if req.SignerAddress == "" {
return nil, errs.ErrArgs.WrapMsg("signer_address is required")
}
if s.chainClient != nil {
log.ZInfo(ctx, "redpacket admin setSigner (eth mock)", "signerAddress", req.SignerAddress)
return &pbredpacket.SetSignerResp{Message: "signer address updated successfully"}, nil
}
if s.tronClient != nil {
if _, err := s.tronClient.SendAdminTransaction(ctx, "setSigner", req.SignerAddress); err != nil {
return nil, errs.ErrInternalServer.WrapMsg("setSigner failed: " + err.Error())
}
return &pbredpacket.SetSignerResp{Message: "signer address updated successfully"}, nil
}
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
}
func (s *redPacketServer) SetToken(ctx context.Context, req *pbredpacket.SetTokenReq) (resp *pbredpacket.SetTokenResp, retErr error) {
defer func() { s.recordAudit(ctx, "SetToken", req, retErr) }()
if err := s.checkAdminPermission(ctx); err != nil {
return nil, err
}
if req.TokenAddress == "" {
return nil, errs.ErrArgs.WrapMsg("token_address is required")
}
minAmountBig := new(big.Int)
if req.MinAmount != "" {
if _, ok := minAmountBig.SetString(req.MinAmount, 10); !ok {
return nil, errs.ErrArgs.WrapMsg("invalid min_amount", "minAmount", req.MinAmount)
}
}
if s.chainClient != nil {
log.ZInfo(ctx, "redpacket admin setToken (eth mock)",
"tokenAddress", req.TokenAddress,
"allowed", req.Allowed,
"minAmount", req.MinAmount,
)
return &pbredpacket.SetTokenResp{Message: "token configuration updated"}, nil
}
if s.tronClient != nil {
if _, err := s.tronClient.SendAdminTransaction(ctx, "setAllowedToken", req.TokenAddress, req.Allowed, minAmountBig); err != nil {
return nil, errs.ErrInternalServer.WrapMsg("setAllowedToken failed: " + err.Error())
}
return &pbredpacket.SetTokenResp{Message: "token configuration updated"}, nil
}
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
}
func (s *redPacketServer) SetExpiry(ctx context.Context, req *pbredpacket.SetExpiryReq) (resp *pbredpacket.SetExpiryResp, retErr error) {
defer func() { s.recordAudit(ctx, "SetExpiry", req, retErr) }()
if err := s.checkAdminPermission(ctx); err != nil {
return nil, err
}
if req.ExpirySeconds <= 0 {
return nil, errs.ErrArgs.WrapMsg("expiry_seconds must be positive")
}
if s.chainClient != nil {
log.ZInfo(ctx, "redpacket admin setExpiry (eth mock)", "expirySeconds", req.ExpirySeconds)
return &pbredpacket.SetExpiryResp{Message: "expiry duration updated"}, nil
}
if s.tronClient != nil {
if _, err := s.tronClient.SendAdminTransaction(ctx, "setDefaultExpiryDuration", req.ExpirySeconds); err != nil {
return nil, errs.ErrInternalServer.WrapMsg("setDefaultExpiryDuration failed: " + err.Error())
}
return &pbredpacket.SetExpiryResp{Message: "expiry duration updated"}, nil
}
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
}
func (s *redPacketServer) SetAllowAllTokens(ctx context.Context, req *pbredpacket.SetAllowAllTokensReq) (resp *pbredpacket.SetAllowAllTokensResp, retErr error) {
defer func() { s.recordAudit(ctx, "SetAllowAllTokens", req, retErr) }()
if err := s.checkAdminPermission(ctx); err != nil {
return nil, err
}
if s.chainClient != nil {
log.ZInfo(ctx, "redpacket admin setAllowAllTokens (eth mock)", "allowAll", req.AllowAll)
return &pbredpacket.SetAllowAllTokensResp{Message: "allow all tokens setting updated"}, nil
}
if s.tronClient != nil {
if _, err := s.tronClient.SendAdminTransaction(ctx, "setAllowAllTokens", req.AllowAll); err != nil {
return nil, errs.ErrInternalServer.WrapMsg("setAllowAllTokens failed: " + err.Error())
}
return &pbredpacket.SetAllowAllTokensResp{Message: "allow all tokens setting updated"}, nil
}
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
}
func (s *redPacketServer) SetNativeTokenEnabled(ctx context.Context, req *pbredpacket.SetNativeTokenEnabledReq) (resp *pbredpacket.SetNativeTokenEnabledResp, retErr error) {
defer func() { s.recordAudit(ctx, "SetNativeTokenEnabled", req, retErr) }()
if err := s.checkAdminPermission(ctx); err != nil {
return nil, err
}
if s.chainClient != nil {
log.ZInfo(ctx, "redpacket admin setNativeTokenEnabled (eth mock)", "enabled", req.Enabled)
return &pbredpacket.SetNativeTokenEnabledResp{Message: "native token setting updated"}, nil
}
if s.tronClient != nil {
if _, err := s.tronClient.SendAdminTransaction(ctx, "setNativeTokenEnabled", req.Enabled); err != nil {
return nil, errs.ErrInternalServer.WrapMsg("setNativeTokenEnabled failed: " + err.Error())
}
return &pbredpacket.SetNativeTokenEnabledResp{Message: "native token setting updated"}, nil
}
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
}
func (s *redPacketServer) ParseTxEvents(ctx context.Context, req *pbredpacket.ParseTxEventsReq) (resp *pbredpacket.ParseTxEventsResp, retErr error) {
defer func() { s.recordAudit(ctx, "ParseTxEvents", req, retErr) }()
if err := s.checkAdminPermission(ctx); err != nil {
return nil, err
}
if req.TxHash == "" {
return nil, errs.ErrArgs.WrapMsg("tx_hash is required")
}
if req.Chain == "tron" {
if s.tronClient == nil {
return nil, errs.ErrInternalServer.WrapMsg("TRON client not configured")
}
events, err := s.tronClient.ParseTransactionReceipt(ctx, req.TxHash)
if err != nil {
return nil, errs.ErrInternalServer.WrapMsg("parse TRON tx receipt failed: " + err.Error())
}
out := make([]*pbredpacket.ParsedEvent, 0, len(events))
for _, e := range events {
data := make(map[string]string, len(e.Data))
for k, v := range e.Data {
data[k] = fmt.Sprintf("%v", v)
}
out = append(out, &pbredpacket.ParsedEvent{Name: e.Name, Data: data})
}
return &pbredpacket.ParseTxEventsResp{Chain: "tron", TxHash: req.TxHash, Events: out}, nil
}
if s.chainClient != nil {
txHashBytes := common.HexToHash(req.TxHash)
events, err := s.chainClient.ParseTransactionReceipt(ctx, txHashBytes)
if err != nil {
return nil, errs.ErrInternalServer.WrapMsg("parse tx receipt failed: " + err.Error())
}
out := make([]*pbredpacket.ParsedEvent, 0, len(events))
for _, e := range events {
data := make(map[string]string, len(e.Data))
for k, v := range e.Data {
data[k] = fmt.Sprintf("%v", v)
}
out = append(out, &pbredpacket.ParsedEvent{
Name: e.Name,
Data: data,
})
}
return &pbredpacket.ParseTxEventsResp{
Chain: "eth",
TxHash: req.TxHash,
Events: out,
}, nil
}
return nil, errs.ErrInternalServer.WrapMsg("no client available for chain: " + req.Chain)
}

@ -0,0 +1,66 @@
[
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "packetId", "type": "uint256" },
{ "indexed": true, "name": "creator", "type": "address" },
{ "indexed": true, "name": "packetType", "type": "uint8" },
{ "indexed": false, "name": "token", "type": "address" },
{ "indexed": false, "name": "totalAmount", "type": "uint256" },
{ "indexed": false, "name": "totalShares", "type": "uint256" },
{ "indexed": false, "name": "expiryAt", "type": "uint256" }
],
"name": "PacketCreated",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "packetId", "type": "uint256" },
{ "indexed": true, "name": "claimer", "type": "address" },
{ "indexed": false, "name": "amount", "type": "uint256" },
{ "indexed": false, "name": "remainingAmount", "type": "uint256" },
{ "indexed": false, "name": "remainingShares", "type": "uint256" },
{ "indexed": false, "name": "authNonce", "type": "uint256" }
],
"name": "PacketClaimed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "packetId", "type": "uint256" },
{ "indexed": true, "name": "operator", "type": "address" },
{ "indexed": true, "name": "refundTo", "type": "address" },
{ "indexed": false, "name": "amount", "type": "uint256" }
],
"name": "PacketRefunded",
"type": "event"
},
{
"inputs": [
{ "name": "packetId", "type": "uint256" },
{ "name": "claimer", "type": "address" },
{ "name": "authNonce", "type": "uint256" },
{ "name": "randomSeed", "type": "uint256" },
{ "name": "deadline", "type": "uint256" }
],
"name": "getSignMessage",
"outputs": [{ "name": "", "type": "bytes32" }],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{ "name": "packetId", "type": "uint256" },
{ "name": "authNonce", "type": "uint256" },
{ "name": "randomSeed", "type": "uint256" },
{ "name": "deadline", "type": "uint256" },
{ "name": "signature", "type": "bytes" }
],
"name": "claim",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
]

@ -0,0 +1,207 @@
package chain
import (
"context"
"crypto/ecdsa"
_ "embed"
"fmt"
"math/big"
"strings"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
)
//go:embed abi/RedPacket.json
var embeddedABI []byte
// ChainClient handles blockchain interactions for RedPacket.
type ChainClient struct {
client *ethclient.Client
contractABI abi.ABI
contractAddr common.Address
signerKey *ecdsa.PrivateKey
configAdminKey *ecdsa.PrivateKey
chainID *big.Int
}
func NewClient(rpcURL, contractAddress string, chainID int64, signerPrivateKey, configAdminPrivateKey string) (*ChainClient, error) {
client, err := ethclient.Dial(rpcURL)
if err != nil {
return nil, fmt.Errorf("failed to connect to ethereum: %w", err)
}
abiJSON, err := ExtractABIFromEmbeddedArtifact()
if err != nil {
return nil, fmt.Errorf("failed to load ABI: %w", err)
}
parsedABI, err := abi.JSON(strings.NewReader(string(abiJSON)))
if err != nil {
return nil, fmt.Errorf("failed to parse ABI: %w", err)
}
contractAddr := common.HexToAddress(contractAddress)
var signerKey *ecdsa.PrivateKey
if signerPrivateKey != "" {
signerKey, err = crypto.HexToECDSA(strings.TrimPrefix(signerPrivateKey, "0x"))
if err != nil {
return nil, fmt.Errorf("invalid signer private key: %w", err)
}
}
var adminKey *ecdsa.PrivateKey
if configAdminPrivateKey != "" {
adminKey, err = crypto.HexToECDSA(strings.TrimPrefix(configAdminPrivateKey, "0x"))
if err != nil {
return nil, fmt.Errorf("invalid config admin private key: %w", err)
}
}
return &ChainClient{
client: client,
contractABI: parsedABI,
contractAddr: contractAddr,
signerKey: signerKey,
configAdminKey: adminKey,
chainID: big.NewInt(chainID),
}, nil
}
func (c *ChainClient) GetSignMessage(ctx context.Context, packetID *big.Int, claimer common.Address, authNonce, randomSeed, deadline *big.Int) ([32]byte, error) {
var digest [32]byte
data, err := c.contractABI.Pack("getSignMessage", packetID, claimer, authNonce, randomSeed, deadline)
if err != nil {
return digest, fmt.Errorf("failed to pack getSignMessage: %w", err)
}
msg := ethereum.CallMsg{
To: &c.contractAddr,
Data: data,
}
result, err := c.client.CallContract(ctx, msg, nil)
if err != nil {
return digest, fmt.Errorf("call getSignMessage failed: %w", err)
}
copy(digest[:], result)
return digest, nil
}
func (c *ChainClient) SignClaim(digest [32]byte) ([]byte, error) {
if c.signerKey == nil {
return nil, fmt.Errorf("signer key not configured")
}
sig, err := crypto.Sign(digest[:], c.signerKey)
if err != nil {
return nil, fmt.Errorf("sign failed: %w", err)
}
if len(sig) == 65 && sig[64] < 27 {
sig[64] += 27
}
return sig, nil
}
func (c *ChainClient) ParseTransactionReceipt(ctx context.Context, txHash common.Hash) ([]*ParsedEvent, error) {
receipt, err := c.client.TransactionReceipt(ctx, txHash)
if err != nil {
return nil, fmt.Errorf("get receipt failed: %w", err)
}
return ParseEventsFromLogs(receipt.Logs, c.contractABI)
}
func (c *ChainClient) ContractAddress() common.Address {
return c.contractAddr
}
func (c *ChainClient) ChainID() *big.Int {
if c.chainID == nil {
return nil
}
return new(big.Int).Set(c.chainID)
}
// EthClient exposes the underlying ethclient for indexers.
func (c *ChainClient) EthClient() *ethclient.Client {
return c.client
}
// ContractABI exposes the parsed ABI for indexers.
func (c *ChainClient) ContractABI() abi.ABI {
return c.contractABI
}
// RefundPacket submits an on-chain refund transaction for an expired red
// packet. It uses the configAdminKey to sign and broadcast the transaction.
// Returns the transaction hash on success.
func (c *ChainClient) RefundPacket(ctx context.Context, packetIDStr string) (string, error) {
if c.configAdminKey == nil {
return "", fmt.Errorf("config admin key not configured")
}
packetID, ok := new(big.Int).SetString(packetIDStr, 10)
if !ok {
return "", fmt.Errorf("invalid packetID: %s", packetIDStr)
}
data, err := c.contractABI.Pack("refundPacket", packetID)
if err != nil {
return "", fmt.Errorf("pack refundPacket failed: %w", err)
}
fromAddr := crypto.PubkeyToAddress(c.configAdminKey.PublicKey)
nonce, err := c.client.PendingNonceAt(ctx, fromAddr)
if err != nil {
return "", fmt.Errorf("get nonce failed: %w", err)
}
gasPrice, err := c.client.SuggestGasPrice(ctx)
if err != nil {
return "", fmt.Errorf("suggest gas price failed: %w", err)
}
gasLimit, err := c.client.EstimateGas(ctx, ethereum.CallMsg{
From: fromAddr,
To: &c.contractAddr,
Data: data,
})
if err != nil {
gasLimit = 200000 // fallback
}
tx := types.NewTransaction(nonce, c.contractAddr, big.NewInt(0), gasLimit, gasPrice, data)
signedTx, err := types.SignTx(tx, types.NewEIP155Signer(c.chainID), c.configAdminKey)
if err != nil {
return "", fmt.Errorf("sign refund tx failed: %w", err)
}
if err := c.client.SendTransaction(ctx, signedTx); err != nil {
return "", fmt.Errorf("send refund tx failed: %w", err)
}
return signedTx.Hash().Hex(), nil
}
func (c *ChainClient) Close() {
if c.client != nil {
c.client.Close()
}
}
func ExtractABIFromEmbeddedArtifact() ([]byte, error) {
if len(embeddedABI) == 0 {
return nil, fmt.Errorf("embedded ABI is empty")
}
return embeddedABI, nil
}

@ -0,0 +1,215 @@
package chain
import (
"context"
"fmt"
"math/big"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
"github.com/openimsdk/tools/log"
)
type Indexer struct {
client *ChainClient
db controller.RedPacketDatabase
pollInterval time.Duration
lastBlock uint64
contractAddr common.Address
}
func NewIndexer(client *ChainClient, db controller.RedPacketDatabase, pollInterval int, startBlock uint64) *Indexer {
if pollInterval <= 0 {
pollInterval = 5
}
return &Indexer{
client: client,
db: db,
pollInterval: time.Duration(pollInterval) * time.Second,
lastBlock: startBlock,
contractAddr: client.contractAddr,
}
}
func (i *Indexer) Start(ctx context.Context) {
log.ZInfo(ctx, "starting RedPacket ETH event indexer")
go func() {
defer func() {
if r := recover(); r != nil {
log.ZError(ctx, "redpacket eth indexer panic recovered", fmt.Errorf("%v", r))
}
}()
ticker := time.NewTicker(i.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.ZInfo(ctx, "redpacket eth indexer stopped")
return
case <-ticker.C:
if err := i.poll(ctx); err != nil {
log.ZWarn(ctx, "redpacket eth indexer poll error", err)
}
}
}
}()
// Compensation loop: periodically scan DB for expired-but-unclosed packets
// and mark them EXPIRED so the UI reflects the correct state even if the
// on-chain refund event was missed.
go func() {
defer func() {
if r := recover(); r != nil {
log.ZError(ctx, "redpacket eth compensation panic recovered", fmt.Errorf("%v", r))
}
}()
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := i.compensate(ctx); err != nil {
log.ZWarn(ctx, "redpacket eth compensation error", err)
}
}
}
}()
}
func (i *Indexer) compensate(ctx context.Context) error {
now := time.Now().Unix()
packets, err := i.db.GetExpiredPendingPackets(ctx, now)
if err != nil {
return fmt.Errorf("get expired packets failed: %w", err)
}
for _, rp := range packets {
if err := i.db.UpdateRedPacketStatus(ctx, rp.PacketID, "EXPIRED"); err != nil {
log.ZWarn(ctx, "redpacket eth compensation mark expired failed", err, "packetID", rp.PacketID)
continue
}
log.ZInfo(ctx, "redpacket eth compensation: marked packet EXPIRED", "packetID", rp.PacketID)
}
return nil
}
func (i *Indexer) poll(ctx context.Context) error {
header, err := i.client.client.HeaderByNumber(ctx, nil)
if err != nil {
return fmt.Errorf("get header failed: %w", err)
}
currentBlock := header.Number.Uint64()
if currentBlock <= i.lastBlock {
return nil
}
query := ethereum.FilterQuery{
FromBlock: big.NewInt(int64(i.lastBlock + 1)),
ToBlock: big.NewInt(int64(currentBlock)),
Addresses: []common.Address{i.contractAddr},
}
logs, err := i.client.client.FilterLogs(ctx, query)
if err != nil {
return fmt.Errorf("filter logs failed: %w", err)
}
logPtrs := make([]*types.Log, len(logs))
for idx := range logs {
logPtrs[idx] = &logs[idx]
}
events, err := ParseEventsFromLogs(logPtrs, i.client.contractABI)
if err != nil {
return err
}
for _, event := range events {
if err := i.processEvent(ctx, event); err != nil {
log.ZWarn(ctx, "process redpacket eth event failed", err, "event", event.Name)
}
}
i.lastBlock = currentBlock
log.ZInfo(ctx, "redpacket eth indexed", "block", currentBlock, "events", len(events))
return nil
}
func (i *Indexer) processEvent(ctx context.Context, event *ParsedEvent) error {
switch event.Name {
case "PacketCreated":
return i.handlePacketCreated(ctx, event)
case "PacketClaimed":
return i.handlePacketClaimed(ctx, event)
case "PacketRefunded":
return i.handlePacketRefunded(ctx, event)
default:
return nil
}
}
func (i *Indexer) handlePacketCreated(ctx context.Context, event *ParsedEvent) error {
packetID := GetPacketIDFromEvent(event)
creator := GetAddressFromEvent(event, "creator")
log.ZInfo(ctx, "PacketCreated event", "packetID", packetID.String(), "creator", creator.Hex())
return nil
}
func (i *Indexer) handlePacketClaimed(ctx context.Context, event *ParsedEvent) error {
packetID := GetPacketIDFromEvent(event)
claimer := GetAddressFromEvent(event, "claimer")
amount := GetAmountFromEvent(event)
authNonce := GetUintFromEvent(event, "authNonce")
log.ZInfo(ctx, "PacketClaimed event", "packetID", packetID.String(), "claimer", claimer.Hex(), "amount", amount.String())
claim := &model.RedPacketClaim{
PacketID: packetID.String(),
ClaimerWallet: claimer.Hex(),
AuthNonce: authNonce.String(),
ClaimTxHash: event.TxHash.Hex(),
ClaimedAmount: amount.String(),
BlockNumber: event.BlockNumber,
Status: "CONFIRMED",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := i.db.SaveClaim(ctx, claim); err != nil {
return err
}
if err := i.db.MarkClaimAuthUsed(ctx, authNonce.String()); err != nil {
return err
}
// Pass "" for forced status; DB layer auto-derives COMPLETED/ACTIVE.
// TxHash is the idempotency key: prevents double-counting if ClaimResult RPC
// already processed this same transaction.
return i.db.UpdateRedPacketClaimProgress(ctx, packetID.String(), amount.String(), "", event.TxHash.Hex())
}
func (i *Indexer) handlePacketRefunded(ctx context.Context, event *ParsedEvent) error {
packetID := GetPacketIDFromEvent(event)
refundTo := GetAddressFromEvent(event, "refundTo")
amount := GetAmountFromEvent(event)
log.ZInfo(ctx, "PacketRefunded event", "packetID", packetID.String(), "refundTo", refundTo.Hex(), "amount", amount.String())
if err := i.db.SaveRefund(ctx, &model.RedPacketRefund{
PacketID: packetID.String(),
RefundTo: refundTo.Hex(),
TxHash: event.TxHash.Hex(),
Amount: amount.String(),
CreatedAt: time.Now(),
}); err != nil {
return err
}
return i.db.UpdateRedPacketStatus(ctx, packetID.String(), "REFUNDED")
}

@ -0,0 +1,117 @@
package chain
import (
"fmt"
"math/big"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
type ParsedEvent struct {
Name string
Data map[string]interface{}
TxHash common.Hash
BlockNumber uint64
}
func ParseEventsFromLogs(logs []*types.Log, contractABI abi.ABI) ([]*ParsedEvent, error) {
var events []*ParsedEvent
for _, log := range logs {
if len(log.Topics) == 0 {
continue
}
event, err := parseEvent(log, contractABI)
if err == nil && event != nil {
events = append(events, event)
}
}
return events, nil
}
func parseEvent(log *types.Log, contractABI abi.ABI) (*ParsedEvent, error) {
for name, event := range contractABI.Events {
if event.ID != log.Topics[0] {
continue
}
data := make(map[string]interface{})
indexedIdx := 1
for _, arg := range event.Inputs {
if arg.Indexed {
if indexedIdx < len(log.Topics) {
if arg.Type.T == abi.AddressTy {
data[arg.Name] = common.BytesToAddress(log.Topics[indexedIdx].Bytes())
} else if arg.Type.T == abi.UintTy || arg.Type.T == abi.IntTy {
data[arg.Name] = new(big.Int).SetBytes(log.Topics[indexedIdx].Bytes())
} else {
data[arg.Name] = log.Topics[indexedIdx].Hex()
}
indexedIdx++
}
}
}
if len(log.Data) > 0 {
unpacked, err := event.Inputs.Unpack(log.Data)
if err == nil {
nonIndexedIdx := 0
for _, arg := range event.Inputs {
if !arg.Indexed {
if nonIndexedIdx < len(unpacked) {
data[arg.Name] = unpacked[nonIndexedIdx]
nonIndexedIdx++
}
}
}
}
}
return &ParsedEvent{
Name: name,
Data: data,
TxHash: log.TxHash,
BlockNumber: log.BlockNumber,
}, nil
}
return nil, fmt.Errorf("unknown event: %s", log.Topics[0].Hex())
}
func GetPacketIDFromEvent(event *ParsedEvent) *big.Int {
if id, ok := event.Data["packetId"]; ok {
if b, ok := id.(*big.Int); ok {
return b
}
}
return big.NewInt(0)
}
func GetAddressFromEvent(event *ParsedEvent, key string) common.Address {
value, ok := event.Data[key]
if !ok {
return common.Address{}
}
addr, _ := value.(common.Address)
return addr
}
func GetAmountFromEvent(event *ParsedEvent) *big.Int {
return GetUintFromEvent(event, "amount")
}
func GetUintFromEvent(event *ParsedEvent, key string) *big.Int {
value, ok := event.Data[key]
if !ok {
return big.NewInt(0)
}
if b, ok := value.(*big.Int); ok {
return b
}
return big.NewInt(0)
}

@ -0,0 +1,78 @@
package chain
import (
"math/big"
"strings"
"testing"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
func TestParseEventsFromLogs_ParsesRefundEvent(t *testing.T) {
abiJSON, err := ExtractABIFromEmbeddedArtifact()
if err != nil {
t.Fatalf("ExtractABIFromEmbeddedArtifact() error = %v", err)
}
parsedABI, err := abi.JSON(strings.NewReader(string(abiJSON)))
if err != nil {
t.Fatalf("abi.JSON() error = %v", err)
}
eventDef := parsedABI.Events["PacketRefunded"]
packetID := big.NewInt(101)
operator := common.HexToAddress("0x1111111111111111111111111111111111111111")
refundTo := common.HexToAddress("0x2222222222222222222222222222222222222222")
amount := big.NewInt(8888)
data, err := eventDef.Inputs.NonIndexed().Pack(amount)
if err != nil {
t.Fatalf("Pack() error = %v", err)
}
log := &types.Log{
Address: common.HexToAddress("0x3333333333333333333333333333333333333333"),
Topics: []common.Hash{
eventDef.ID,
common.BigToHash(packetID),
common.BytesToHash(common.LeftPadBytes(operator.Bytes(), 32)),
common.BytesToHash(common.LeftPadBytes(refundTo.Bytes(), 32)),
},
Data: data,
BlockNumber: 77,
TxHash: common.HexToHash("0xabc"),
}
events, err := ParseEventsFromLogs([]*types.Log{log}, parsedABI)
if err != nil {
t.Fatalf("ParseEventsFromLogs() error = %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
event := events[0]
if event.Name != "PacketRefunded" {
t.Fatalf("unexpected event name: %s", event.Name)
}
if got := GetPacketIDFromEvent(event).String(); got != "101" {
t.Fatalf("packet id mismatch: got %s", got)
}
if got := GetAddressFromEvent(event, "operator").Hex(); got != operator.Hex() {
t.Fatalf("operator mismatch: got %s want %s", got, operator.Hex())
}
if got := GetAddressFromEvent(event, "refundTo").Hex(); got != refundTo.Hex() {
t.Fatalf("refundTo mismatch: got %s want %s", got, refundTo.Hex())
}
if got := GetAmountFromEvent(event).String(); got != "8888" {
t.Fatalf("amount mismatch: got %s", got)
}
if event.BlockNumber != 77 {
t.Fatalf("block number mismatch: got %d", event.BlockNumber)
}
if event.TxHash != common.HexToHash("0xabc") {
t.Fatalf("tx hash mismatch: got %s", event.TxHash.Hex())
}
}

@ -0,0 +1,298 @@
package chain
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"time"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
type TronClient struct {
fullNodeURL string
contractBase58 string
ownerBase58 string
privateKeyHex string
feeLimit int64
abiJSON string
parsedABI abi.ABI
}
func NewTronClient(fullNodeURL, contractBase58, ownerBase58, privateKeyHex string, abiJSON []byte, feeLimit int64) (*TronClient, error) {
if fullNodeURL == "" {
return nil, fmt.Errorf("fullNodeURL is required for TRON")
}
parsedABI, err := abi.JSON(bytes.NewReader(abiJSON))
if err != nil {
return nil, fmt.Errorf("parse TRON ABI failed: %w", err)
}
return &TronClient{
fullNodeURL: fullNodeURL,
contractBase58: contractBase58,
ownerBase58: ownerBase58,
privateKeyHex: privateKeyHex,
feeLimit: feeLimit,
abiJSON: string(abiJSON),
parsedABI: parsedABI,
}, nil
}
func (t *TronClient) ContractAddress() string {
return t.contractBase58
}
// ContractBase58 exposes the contract base58 address for indexers.
func (t *TronClient) ContractBase58() string {
return t.contractBase58
}
// FullNodeURL exposes the full node URL for indexers.
func (t *TronClient) FullNodeURL() string {
return t.fullNodeURL
}
func (t *TronClient) ParseTransactionReceipt(ctx context.Context, txID string) ([]*ParsedEvent, error) {
info, err := t.getTransactionInfo(ctx, txID)
if err != nil {
return nil, err
}
logs, err := tronLogsToEVMLogs(info, txID)
if err != nil {
return nil, err
}
return ParseEventsFromLogs(logs, t.parsedABI)
}
func (t *TronClient) SendAdminTransaction(ctx context.Context, methodName string, args ...interface{}) (string, error) {
if t.privateKeyHex == "" || t.ownerBase58 == "" {
return "", fmt.Errorf("TRON admin credentials not configured")
}
selector := methodName
if len(args) > 0 {
selector = fmt.Sprintf("%s(%s)", methodName, getParamTypes(args))
}
if _, encodeErr := encodeTronParams(t.abiJSON, methodName, args...); encodeErr != nil {
return "", fmt.Errorf("encode params failed: %w", encodeErr)
}
return SendTronAdminTx(
ctx,
t.fullNodeURL,
t.ownerBase58,
t.contractBase58,
selector,
methodName,
t.feeLimit,
t.privateKeyHex,
t.abiJSON,
args...,
)
}
func (t *TronClient) GetSignMessageForTron(ctx context.Context, packetID *big.Int, claimer, authNonce, randomSeed, deadline string) (string, error) {
return "", fmt.Errorf("TRON getSignMessage not fully implemented yet - use ETH path for signing")
}
type tronTxInfoResp struct {
ID string `json:"id"`
BlockNumber uint64 `json:"blockNumber"`
Log []struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
} `json:"log"`
}
func getParamTypes(args []interface{}) string {
types := make([]string, len(args))
for i, arg := range args {
switch arg.(type) {
case string, common.Address:
types[i] = "address"
case bool:
types[i] = "bool"
case int, int64, *big.Int:
types[i] = "uint256"
default:
types[i] = "unknown"
}
}
return strings.Join(types, ",")
}
func SendTronAdminTx(
ctx context.Context,
fullNodeURL, ownerBase58, contractBase58, selector, methodName string,
feeLimit int64,
privateKeyHex string,
abiJSON string,
args ...interface{},
) (string, error) {
paramHex, err := encodeTronParams(abiJSON, methodName, args...)
if err != nil {
return "", err
}
var triggerResp map[string]interface{}
err = postJSON(ctx, fullNodeURL+"/wallet/triggersmartcontract", map[string]interface{}{
"owner_address": ownerBase58,
"contract_address": contractBase58,
"function_selector": selector,
"parameter": paramHex,
"fee_limit": feeLimit,
"call_value": 0,
"visible": true,
}, &triggerResp)
if err != nil {
return "", fmt.Errorf("trigger contract failed: %w", err)
}
txObj, ok := triggerResp["transaction"].(map[string]interface{})
if !ok {
return "", fmt.Errorf("transaction not found in trigger response")
}
var signedResp map[string]interface{}
err = postJSON(ctx, fullNodeURL+"/wallet/gettransactionsign", map[string]interface{}{
"transaction": txObj,
"privateKey": privateKeyHex,
}, &signedResp)
if err != nil {
return "", fmt.Errorf("sign transaction failed: %w", err)
}
var broadcastResp map[string]interface{}
err = postJSON(ctx, fullNodeURL+"/wallet/broadcasttransaction", signedResp, &broadcastResp)
if err != nil {
return "", fmt.Errorf("broadcast failed: %w", err)
}
if result, _ := broadcastResp["result"].(bool); !result {
return "", fmt.Errorf("broadcast failed: %v", broadcastResp)
}
txid, _ := broadcastResp["txid"].(string)
return txid, nil
}
func (t *TronClient) getTransactionInfo(ctx context.Context, txID string) (*tronTxInfoResp, error) {
var info tronTxInfoResp
if err := postJSON(ctx, t.fullNodeURL+"/wallet/gettransactioninfobyid", map[string]interface{}{
"value": txID,
}, &info); err != nil {
return nil, err
}
return &info, nil
}
func tronLogsToEVMLogs(info *tronTxInfoResp, txID string) ([]*types.Log, error) {
if info == nil {
return nil, fmt.Errorf("tron tx info is nil")
}
txHash := common.HexToHash(addHexPrefix(txID))
logs := make([]*types.Log, 0, len(info.Log))
for _, entry := range info.Log {
topics := make([]common.Hash, 0, len(entry.Topics))
for _, topic := range entry.Topics {
topics = append(topics, common.HexToHash(addHexPrefix(topic)))
}
data, err := hex.DecodeString(strings.TrimPrefix(entry.Data, "0x"))
if err != nil {
return nil, fmt.Errorf("decode tron log data failed: %w", err)
}
logs = append(logs, &types.Log{
Address: tronLogAddressToCommonAddress(entry.Address),
Topics: topics,
Data: data,
BlockNumber: info.BlockNumber,
TxHash: txHash,
})
}
return logs, nil
}
func tronLogAddressToCommonAddress(raw string) common.Address {
raw = strings.TrimPrefix(raw, "0x")
raw = strings.TrimPrefix(raw, "41")
if len(raw) > 40 {
raw = raw[len(raw)-40:]
}
return common.HexToAddress(addHexPrefix(raw))
}
func addHexPrefix(value string) string {
if strings.HasPrefix(value, "0x") || strings.HasPrefix(value, "0X") {
return value
}
return "0x" + value
}
func encodeTronParams(abiJSON, method string, args ...interface{}) (string, error) {
parsed, err := abi.JSON(strings.NewReader(abiJSON))
if err != nil {
return "", err
}
m, ok := parsed.Methods[method]
if !ok {
return "", fmt.Errorf("method not found: %s", method)
}
packed, err := m.Inputs.Pack(args...)
if err != nil {
return "", err
}
return hex.EncodeToString(packed), nil
}
func postJSON(ctx context.Context, url string, body interface{}, out interface{}) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
httpClient := &http.Client{Timeout: 10 * time.Second}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 300 {
return fmt.Errorf("http %d: %s", resp.StatusCode, string(raw))
}
if err := json.Unmarshal(raw, out); err != nil {
return err
}
return nil
}

@ -0,0 +1,261 @@
package chain
import (
"context"
"fmt"
"time"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
"github.com/openimsdk/tools/log"
)
type TronIndexer struct {
client *TronClient
db controller.RedPacketDatabase
pollInterval time.Duration
lastBlockNum int64
contractAddress string
}
func NewTronIndexer(client *TronClient, db controller.RedPacketDatabase, pollInterval int, startBlock int64) *TronIndexer {
if pollInterval <= 0 {
pollInterval = 3
}
return &TronIndexer{
client: client,
db: db,
pollInterval: time.Duration(pollInterval) * time.Second,
lastBlockNum: startBlock,
contractAddress: client.contractBase58,
}
}
func (t *TronIndexer) Start(ctx context.Context) {
log.ZInfo(ctx, "starting RedPacket TRON event indexer")
go func() {
defer func() {
if r := recover(); r != nil {
log.ZError(ctx, "redpacket tron indexer panic recovered", fmt.Errorf("%v", r))
}
}()
ticker := time.NewTicker(t.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.ZInfo(ctx, "redpacket tron indexer stopped")
return
case <-ticker.C:
if err := t.poll(ctx); err != nil {
log.ZWarn(ctx, "redpacket tron indexer poll error", err)
time.Sleep(2 * time.Second)
}
}
}
}()
go func() {
defer func() {
if r := recover(); r != nil {
log.ZError(ctx, "redpacket tron compensation panic recovered", fmt.Errorf("%v", r))
}
}()
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := t.compensate(ctx); err != nil {
log.ZWarn(ctx, "redpacket tron compensation error", err)
}
}
}
}()
}
func (t *TronIndexer) compensate(ctx context.Context) error {
now := time.Now().Unix()
packets, err := t.db.GetExpiredPendingPackets(ctx, now)
if err != nil {
return fmt.Errorf("get expired packets failed: %w", err)
}
for _, rp := range packets {
if err := t.db.UpdateRedPacketStatus(ctx, rp.PacketID, "EXPIRED"); err != nil {
log.ZWarn(ctx, "redpacket tron compensation mark expired failed", err, "packetID", rp.PacketID)
continue
}
log.ZInfo(ctx, "redpacket tron compensation: marked packet EXPIRED", "packetID", rp.PacketID)
}
return nil
}
func (t *TronIndexer) poll(ctx context.Context) error {
currentBlock, err := t.getNowBlock(ctx)
if err != nil {
return fmt.Errorf("get now block failed: %w", err)
}
if currentBlock <= t.lastBlockNum {
return nil
}
log.ZDebug(ctx, "redpacket tron scanning blocks", "from", t.lastBlockNum+1, "to", currentBlock)
// Advance the cursor only up to the last successfully processed block so
// that a transient RPC failure does not cause blocks to be silently skipped.
lastOK := t.lastBlockNum
for blockNum := t.lastBlockNum + 1; blockNum <= currentBlock; blockNum++ {
if err := t.scanBlock(ctx, blockNum); err != nil {
log.ZWarn(ctx, "redpacket tron scan block failed", err, "block", blockNum)
break
}
lastOK = blockNum
}
t.lastBlockNum = lastOK
return nil
}
func (t *TronIndexer) getNowBlock(ctx context.Context) (int64, error) {
var resp map[string]interface{}
err := postJSON(ctx, t.client.fullNodeURL+"/wallet/getnowblock", map[string]interface{}{}, &resp)
if err != nil {
return 0, err
}
if blockHeader, ok := resp["block_header"].(map[string]interface{}); ok {
if rawData, ok := blockHeader["raw_data"].(map[string]interface{}); ok {
if number, ok := rawData["number"].(float64); ok {
return int64(number), nil
}
}
}
return 0, fmt.Errorf("could not parse block number")
}
func (t *TronIndexer) scanBlock(ctx context.Context, blockNum int64) error {
var blockResp map[string]interface{}
err := postJSON(ctx, t.client.fullNodeURL+"/wallet/getblockbynum", map[string]interface{}{
"num": blockNum,
}, &blockResp)
if err != nil {
return err
}
transactions, ok := blockResp["transactions"].([]interface{})
if !ok {
return nil
}
for _, txInterface := range transactions {
tx, ok := txInterface.(map[string]interface{})
if !ok {
continue
}
txID, _ := tx["txID"].(string)
if txID == "" {
continue
}
if err := t.processTransaction(ctx, txID); err != nil {
log.ZWarn(ctx, "redpacket tron process tx failed", err, "txID", txID)
}
}
return nil
}
// processTransaction parses the on-chain receipt through the ABI (same path as
// the ETH indexer) and dispatches each decoded event to the appropriate handler.
func (t *TronIndexer) processTransaction(ctx context.Context, txID string) error {
events, err := t.client.ParseTransactionReceipt(ctx, txID)
if err != nil {
return fmt.Errorf("parse tron tx receipt failed: %w", err)
}
for _, event := range events {
log.ZDebug(ctx, "redpacket tron event detected", "event", event.Name, "txID", txID)
switch event.Name {
case "PacketCreated":
if err := t.handleTronPacketCreated(ctx, event, txID); err != nil {
log.ZWarn(ctx, "redpacket tron handlePacketCreated failed", err, "txID", txID)
}
case "PacketClaimed":
if err := t.handleTronPacketClaimed(ctx, event, txID); err != nil {
log.ZWarn(ctx, "redpacket tron handlePacketClaimed failed", err, "txID", txID)
}
case "PacketRefunded":
if err := t.handleTronPacketRefunded(ctx, event, txID); err != nil {
log.ZWarn(ctx, "redpacket tron handlePacketRefunded failed", err, "txID", txID)
}
}
}
return nil
}
func (t *TronIndexer) handleTronPacketCreated(ctx context.Context, event *ParsedEvent, txID string) error {
packetID := GetPacketIDFromEvent(event)
creator := GetAddressFromEvent(event, "creator")
log.ZInfo(ctx, "tron PacketCreated event", "packetID", packetID.String(), "creator", creator.Hex(), "txID", txID)
return nil
}
func (t *TronIndexer) handleTronPacketClaimed(ctx context.Context, event *ParsedEvent, txID string) error {
packetID := GetPacketIDFromEvent(event)
claimer := GetAddressFromEvent(event, "claimer")
amount := GetAmountFromEvent(event)
authNonce := GetUintFromEvent(event, "authNonce")
log.ZInfo(ctx, "tron PacketClaimed event", "packetID", packetID.String(), "claimer", claimer.Hex(), "amount", amount.String(), "txID", txID)
claim := &model.RedPacketClaim{
PacketID: packetID.String(),
ClaimerWallet: claimer.Hex(),
AuthNonce: authNonce.String(),
ClaimTxHash: txID,
ClaimedAmount: amount.String(),
BlockNumber: event.BlockNumber,
Status: "CONFIRMED",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := t.db.SaveClaim(ctx, claim); err != nil {
return err
}
if err := t.db.MarkClaimAuthUsed(ctx, authNonce.String()); err != nil {
return err
}
// Pass "" for forced status; DB layer auto-derives COMPLETED/ACTIVE.
// txID is the idempotency key: prevents double-counting if ClaimResult RPC
// already processed this same transaction.
return t.db.UpdateRedPacketClaimProgress(ctx, packetID.String(), amount.String(), "", txID)
}
func (t *TronIndexer) handleTronPacketRefunded(ctx context.Context, event *ParsedEvent, txID string) error {
packetID := GetPacketIDFromEvent(event)
refundTo := GetAddressFromEvent(event, "refundTo")
amount := GetAmountFromEvent(event)
log.ZInfo(ctx, "tron PacketRefunded event", "packetID", packetID.String(), "refundTo", refundTo.Hex(), "amount", amount.String(), "txID", txID)
if err := t.db.SaveRefund(ctx, &model.RedPacketRefund{
PacketID: packetID.String(),
RefundTo: refundTo.Hex(),
TxHash: txID,
Amount: amount.String(),
CreatedAt: time.Now(),
}); err != nil {
return err
}
return t.db.UpdateRedPacketStatus(ctx, packetID.String(), "REFUNDED")
}
func (t *TronIndexer) GetLastProcessedBlock() int64 {
return t.lastBlockNum
}

@ -0,0 +1,90 @@
package chain
import (
"math/big"
"strings"
"testing"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
)
func TestTronLogsToEVMLogsAndParsePacketCreated(t *testing.T) {
abiJSON, err := ExtractABIFromEmbeddedArtifact()
if err != nil {
t.Fatalf("ExtractABIFromEmbeddedArtifact() error = %v", err)
}
parsedABI, err := abi.JSON(strings.NewReader(string(abiJSON)))
if err != nil {
t.Fatalf("abi.JSON() error = %v", err)
}
eventDef := parsedABI.Events["PacketCreated"]
packetID := big.NewInt(12)
creator := common.HexToAddress("0x1111111111111111111111111111111111111111")
packetType := big.NewInt(1)
token := common.HexToAddress("0x2222222222222222222222222222222222222222")
totalAmount := big.NewInt(1000)
totalShares := big.NewInt(10)
expiryAt := big.NewInt(1234567890)
data, err := eventDef.Inputs.NonIndexed().Pack(token, totalAmount, totalShares, expiryAt)
if err != nil {
t.Fatalf("Pack() error = %v", err)
}
info := &tronTxInfoResp{
ID: "abc123",
BlockNumber: 88,
Log: []struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
}{
{
Address: "41aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Topics: []string{
strings.TrimPrefix(eventDef.ID.Hex(), "0x"),
strings.TrimPrefix(common.BigToHash(packetID).Hex(), "0x"),
strings.TrimPrefix(common.BytesToHash(common.LeftPadBytes(creator.Bytes(), 32)).Hex(), "0x"),
strings.TrimPrefix(common.BigToHash(packetType).Hex(), "0x"),
},
Data: common.Bytes2Hex(data),
},
},
}
logs, err := tronLogsToEVMLogs(info, info.ID)
if err != nil {
t.Fatalf("tronLogsToEVMLogs() error = %v", err)
}
events, err := ParseEventsFromLogs(logs, parsedABI)
if err != nil {
t.Fatalf("ParseEventsFromLogs() error = %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
event := events[0]
if event.Name != "PacketCreated" {
t.Fatalf("unexpected event name: %s", event.Name)
}
if got := GetPacketIDFromEvent(event).String(); got != "12" {
t.Fatalf("packet id mismatch: got %s", got)
}
if got := GetAddressFromEvent(event, "creator").Hex(); got != creator.Hex() {
t.Fatalf("creator mismatch: got %s want %s", got, creator.Hex())
}
if got := GetUintFromEvent(event, "packetType").String(); got != "1" {
t.Fatalf("packetType mismatch: got %s", got)
}
if got := GetAddressFromEvent(event, "token").Hex(); got != token.Hex() {
t.Fatalf("token mismatch: got %s want %s", got, token.Hex())
}
if event.BlockNumber != 88 {
t.Fatalf("block number mismatch: got %d", event.BlockNumber)
}
}

@ -0,0 +1,150 @@
package redpacket
import (
"context"
"crypto/ecdsa"
"github.com/ethereum/go-ethereum/crypto"
"github.com/openimsdk/open-im-server/v3/internal/rpc/redpacket/chain"
"github.com/openimsdk/open-im-server/v3/pkg/common/config"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/database/mgo"
"github.com/openimsdk/open-im-server/v3/pkg/rpcli"
pbredpacket "github.com/openimsdk/protocol/redpacket"
"github.com/openimsdk/tools/db/mongoutil"
"github.com/openimsdk/tools/discovery"
"github.com/openimsdk/tools/log"
"google.golang.org/grpc"
)
type Config struct {
RpcConfig config.RedPacket
MongodbConfig config.Mongo
Share config.Share
Discovery config.Discovery
}
type redPacketServer struct {
pbredpacket.UnimplementedRedPacketServer
config *Config
db controller.RedPacketDatabase
chainClient *chain.ChainClient
tronClient *chain.TronClient
signerKey *ecdsa.PrivateKey
groupClient *rpcli.GroupClient
relationClient *rpcli.RelationClient
}
func Start(ctx context.Context, conf *Config, registry discovery.SvcDiscoveryRegistry, server *grpc.Server) error {
mgoClient, err := mongoutil.NewMongoDB(ctx, conf.MongodbConfig.Build())
if err != nil {
return err
}
db := mgoClient.GetDB()
rpDB, err := mgo.NewRedPacketMongo(db)
if err != nil {
return err
}
claimDB, err := mgo.NewRedPacketClaimMongo(db)
if err != nil {
return err
}
claimAuthDB, err := mgo.NewRedPacketClaimAuthMongo(db)
if err != nil {
return err
}
refundDB, err := mgo.NewRedPacketRefundMongo(db)
if err != nil {
return err
}
challengeDB, err := mgo.NewWalletBindingChallengeMongo(db)
if err != nil {
return err
}
bindingDB, err := mgo.NewWalletBindingMongo(db)
if err != nil {
return err
}
auditLogDB, err := mgo.NewAdminAuditLogMongo(db)
if err != nil {
return err
}
repo := controller.NewRedPacketDatabase(rpDB, claimDB, claimAuthDB, refundDB, challengeDB, bindingDB, auditLogDB)
chainClient, err := chain.NewClient(
conf.RpcConfig.Chain.RPCURL,
conf.RpcConfig.Chain.ContractAddress,
conf.RpcConfig.Chain.ChainID,
conf.RpcConfig.Chain.SignerPrivateKey,
conf.RpcConfig.Chain.ConfigAdminPrivateKey,
)
if err != nil {
log.ZWarn(ctx, "redpacket eth client init failed, continuing without it", err)
chainClient = nil
}
var tronClient *chain.TronClient
if conf.RpcConfig.Tron.FullNodeURL != "" {
abiJSON, abiErr := chain.ExtractABIFromEmbeddedArtifact()
if abiErr != nil {
log.ZWarn(ctx, "redpacket tron load abi failed", abiErr)
} else {
tronClient, err = chain.NewTronClient(
conf.RpcConfig.Tron.FullNodeURL,
conf.RpcConfig.Tron.ContractBase58,
conf.RpcConfig.Tron.OwnerBase58,
conf.RpcConfig.Tron.PrivateKeyHex,
abiJSON,
conf.RpcConfig.Tron.FeeLimit,
)
if err != nil {
log.ZWarn(ctx, "redpacket tron client init failed", err)
tronClient = nil
}
}
}
var signerKey *ecdsa.PrivateKey
if k := conf.RpcConfig.Chain.SignerPrivateKey; k != "" {
sk, parseErr := crypto.HexToECDSA(k)
if parseErr != nil {
log.ZWarn(ctx, "redpacket signer private key parse failed", parseErr)
} else {
signerKey = sk
}
}
groupConn, err := registry.GetConn(ctx, conf.Share.RpcRegisterName.Group)
if err != nil {
return err
}
friendConn, err := registry.GetConn(ctx, conf.Share.RpcRegisterName.Friend)
if err != nil {
return err
}
srv := &redPacketServer{
config: conf,
db: repo,
chainClient: chainClient,
tronClient: tronClient,
signerKey: signerKey,
groupClient: rpcli.NewGroupClient(groupConn),
relationClient: rpcli.NewRelationClient(friendConn),
}
pbredpacket.RegisterRedPacketServer(server, srv)
if chainClient != nil {
ethIndexer := chain.NewIndexer(chainClient, repo, conf.RpcConfig.Indexer.PollInterval, 0)
ethIndexer.Start(ctx)
}
if tronClient != nil {
tronIndexer := chain.NewTronIndexer(tronClient, repo, conf.RpcConfig.Indexer.PollInterval, 0)
tronIndexer.Start(ctx)
}
return nil
}

@ -0,0 +1,989 @@
package redpacket
import (
"context"
"encoding/hex"
"fmt"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/google/uuid"
"github.com/openimsdk/open-im-server/v3/internal/rpc/redpacket/chain"
"github.com/openimsdk/open-im-server/v3/pkg/common/servererrs"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
pbredpacket "github.com/openimsdk/protocol/redpacket"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
"github.com/openimsdk/tools/mcontext"
)
func (s *redPacketServer) CreateOrder(ctx context.Context, req *pbredpacket.CreateOrderReq) (*pbredpacket.CreateOrderResp, error) {
currentUserID := mcontext.GetOpUserID(ctx)
if currentUserID == "" {
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
}
bizID := uuid.NewString()
chainType, err := normalizeChainType(req.ChainType)
if err != nil {
return nil, err
}
scopeType := normalizeScopeType(req.ScopeType)
if err := validateCreateScope(scopeType, req.GroupID, req.ReceiverUserID, req.ReceiverUserIDs); err != nil {
return nil, err
}
if err := s.validateCreateHook(ctx, req); err != nil {
return nil, err
}
chainID := req.ChainID
contractAddress := strings.TrimSpace(req.ContractAddress)
if chainType == "EVM" && s.chainClient != nil {
if chainID == 0 {
if chainValue := s.chainClient.ChainID(); chainValue != nil {
chainID = chainValue.Int64()
}
}
if contractAddress == "" {
contractAddress = s.chainClient.ContractAddress().Hex()
}
}
if chainType == "TRON" && s.tronClient != nil && contractAddress == "" {
contractAddress = s.tronClient.ContractAddress()
}
rp := &model.RedPacket{
BizID: bizID,
ChainType: chainType,
ChainID: chainID,
ContractAddress: contractAddress,
CreatorUserID: currentUserID,
CreatorWallet: req.CreatorWallet,
GroupID: req.GroupID,
ScopeType: scopeType,
ReceiverUserID: req.ReceiverUserID,
ReceiverUserIDs: append([]string(nil), req.ReceiverUserIDs...),
PacketType: req.PacketType,
Token: req.Token,
TotalAmount: req.TotalAmount,
TotalShares: req.TotalShares,
ExpiryAt: req.ExpiryAt,
Status: "PENDING",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.db.CreateRedPacket(ctx, rp); err != nil {
log.ZError(ctx, "create redpacket failed", err, "bizID", bizID)
return nil, servererrs.ErrDatabase.WrapMsg("failed to create red packet")
}
return &pbredpacket.CreateOrderResp{BizID: bizID}, nil
}
func (s *redPacketServer) CreatedCallback(ctx context.Context, req *pbredpacket.CreatedCallbackReq) (*pbredpacket.CreatedCallbackResp, error) {
opUserID := mcontext.GetOpUserID(ctx)
if opUserID == "" {
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
}
if strings.TrimSpace(req.BizID) == "" || strings.TrimSpace(req.TxHash) == "" {
return nil, errs.ErrArgs.WrapMsg("biz_id and tx_hash are required")
}
rp, err := s.db.GetRedPacketByBizID(ctx, req.BizID)
if err != nil {
return nil, err
}
if rp.CreatorUserID != opUserID {
return nil, servererrs.ErrNoPermission.WrapMsg("only the creator can submit the creation callback")
}
groupID := firstNonEmpty(req.GroupID, rp.GroupID)
scopeType := normalizeScopeType(firstNonEmpty(req.ScopeType, rp.ScopeType))
receiverUserID := firstNonEmpty(req.ReceiverUserID, rp.ReceiverUserID)
receiverUserIDs := rp.ReceiverUserIDs
if len(req.ReceiverUserIDs) > 0 {
receiverUserIDs = append([]string(nil), req.ReceiverUserIDs...)
}
if err := validateCreateScope(scopeType, groupID, receiverUserID, receiverUserIDs); err != nil {
return nil, err
}
createdPacket, err := s.resolveCreatedPacket(ctx, rp, req.TxHash, req.PacketID)
if err != nil {
return nil, err
}
if err := s.db.UpdateRedPacketCreated(ctx, &model.RedPacket{
BizID: req.BizID,
ChainType: rp.ChainType,
PacketID: createdPacket.PacketID,
ChainID: createdPacket.ChainID,
ContractAddress: createdPacket.ContractAddress,
TxHash: req.TxHash,
GroupID: groupID,
ScopeType: scopeType,
ReceiverUserID: receiverUserID,
ReceiverUserIDs: receiverUserIDs,
Status: "ACTIVE",
}); err != nil {
return nil, err
}
return &pbredpacket.CreatedCallbackResp{}, nil
}
func (s *redPacketServer) GetDetail(ctx context.Context, req *pbredpacket.GetDetailReq) (*pbredpacket.GetDetailResp, error) {
if strings.TrimSpace(req.PacketID) == "" {
return nil, errs.ErrArgs.WrapMsg("packet_id is required")
}
rp, err := s.db.GetRedPacketByPacketID(ctx, req.PacketID)
if err != nil {
return nil, err
}
claims, err := s.db.GetClaimsByPacketID(ctx, req.PacketID)
if err != nil {
claims = nil
}
return &pbredpacket.GetDetailResp{
Record: redPacketModelToProto(rp),
Claims: claimsModelToProto(claims),
}, nil
}
func (s *redPacketServer) IssueClaimSign(ctx context.Context, req *pbredpacket.IssueClaimSignReq) (*pbredpacket.IssueClaimSignResp, error) {
currentUserID := mcontext.GetOpUserID(ctx)
if currentUserID == "" {
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
}
if strings.TrimSpace(req.PacketID) == "" || strings.TrimSpace(req.Claimer) == "" {
return nil, errs.ErrArgs.WrapMsg("packet_id and claimer are required")
}
if err := s.canClaim(ctx, req.PacketID, req.Claimer, currentUserID); err != nil {
return nil, err
}
packetIDBig := new(big.Int)
if _, ok := packetIDBig.SetString(req.PacketID, 10); !ok {
return nil, errs.ErrArgs.WrapMsg("invalid packet_id", "packetID", req.PacketID)
}
claimerAddr := common.HexToAddress(req.Claimer)
nonce := fmt.Sprintf("%d", time.Now().UnixNano())
authNonceBig := new(big.Int)
authNonceBig.SetString(nonce, 10)
deadline := time.Now().Add(5 * time.Minute).Unix()
randomSeedBig := new(big.Int)
if req.RandomSeed != "" && req.RandomSeed != "0" {
if _, ok := randomSeedBig.SetString(req.RandomSeed, 10); !ok {
return nil, errs.ErrArgs.WrapMsg("invalid random_seed", "randomSeed", req.RandomSeed)
}
} else {
randomSeedBig.SetInt64(time.Now().UnixNano())
}
deadlineBig := big.NewInt(deadline)
var digest [32]byte
var err error
if s.chainClient != nil {
digest, err = s.chainClient.GetSignMessage(ctx, packetIDBig, claimerAddr, authNonceBig, randomSeedBig, deadlineBig)
if err != nil {
return nil, errs.ErrInternalServer.WrapMsg("getSignMessage failed: " + err.Error())
}
} else {
digest = crypto.Keccak256Hash([]byte(fmt.Sprintf("%s:%s:%s:%s:%d", req.PacketID, req.Claimer, nonce, randomSeedBig.String(), deadline)))
}
var signature []byte
if s.signerKey != nil {
signature, err = crypto.Sign(digest[:], s.signerKey)
if err != nil {
return nil, errs.ErrInternalServer.WrapMsg("sign failed: " + err.Error())
}
if len(signature) == 65 && signature[64] < 27 {
signature[64] += 27
}
} else {
return nil, errs.ErrInternalServer.WrapMsg("signer key not configured; cannot issue claim signature")
}
sigHex := "0x" + hex.EncodeToString(signature)
auth := &model.RedPacketClaimAuth{
PacketID: req.PacketID,
Claimer: req.Claimer,
AuthNonce: nonce,
RandomSeed: randomSeedBig.String(),
Deadline: deadline,
Signature: sigHex,
CreatedAt: time.Now(),
}
if err := s.db.CreateClaimAuth(ctx, auth); err != nil {
return nil, servererrs.ErrDatabase.WrapMsg("save claim auth failed: " + err.Error())
}
return &pbredpacket.IssueClaimSignResp{
AuthNonce: nonce,
Deadline: deadline,
Signature: sigHex,
RandomSeed: randomSeedBig.String(),
}, nil
}
func (s *redPacketServer) ClaimResult(ctx context.Context, req *pbredpacket.ClaimResultReq) (*pbredpacket.ClaimResultResp, error) {
currentUserID := mcontext.GetOpUserID(ctx)
if currentUserID == "" {
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
}
if strings.TrimSpace(req.PacketID) == "" || strings.TrimSpace(req.Claimer) == "" || strings.TrimSpace(req.TxHash) == "" {
return nil, errs.ErrArgs.WrapMsg("packet_id, claimer and tx_hash are required")
}
rp, err := s.db.GetRedPacketByPacketID(ctx, req.PacketID)
if err != nil {
return nil, err
}
if err := validateClaimBase(rp, currentUserID, req.Claimer); err != nil {
return nil, err
}
claim := &model.RedPacketClaim{
PacketID: req.PacketID,
UserID: currentUserID,
ClaimerWallet: req.Claimer,
ClaimTxHash: req.TxHash,
Status: "PENDING",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.db.SaveClaim(ctx, claim); err != nil {
return nil, err
}
claimedEvent, err := s.resolveClaimedEvent(ctx, rp, req.TxHash)
if err != nil {
log.ZWarn(ctx, "resolve claim event failed", err, "txHash", req.TxHash)
return &pbredpacket.ClaimResultResp{}, nil
}
if claimedEvent == nil {
return &pbredpacket.ClaimResultResp{}, nil
}
if !strings.EqualFold(claimedEvent.ClaimerWallet, req.Claimer) {
return nil, errs.ErrArgs.WrapMsg(fmt.Sprintf("claim event claimer mismatch: got %s want %s", claimedEvent.ClaimerWallet, req.Claimer))
}
confirmed := &model.RedPacketClaim{
PacketID: req.PacketID,
UserID: currentUserID,
ClaimerWallet: claimedEvent.ClaimerWallet,
AuthNonce: claimedEvent.AuthNonce,
ClaimTxHash: req.TxHash,
ClaimedAmount: claimedEvent.Amount,
BlockNumber: claimedEvent.BlockNumber,
Status: "CONFIRMED",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.db.SaveClaim(ctx, confirmed); err != nil {
return nil, err
}
if claimedEvent.AuthNonce != "" {
if err := s.db.MarkClaimAuthUsed(ctx, claimedEvent.AuthNonce); err != nil {
log.ZWarn(ctx, "mark claim auth used failed", err, "authNonce", claimedEvent.AuthNonce)
}
}
// Pass "" for status so the DB layer auto-derives COMPLETED/ACTIVE.
// Pass req.TxHash as the idempotency key so concurrent indexer processing
// of the same transaction cannot double-count the claim.
if err := s.db.UpdateRedPacketClaimProgress(ctx, req.PacketID, claimedEvent.Amount, "", req.TxHash); err != nil {
return nil, err
}
return &pbredpacket.ClaimResultResp{}, nil
}
// canClaim runs the claim-eligibility check (formerly RedPacketService.CanClaim).
func (s *redPacketServer) canClaim(ctx context.Context, packetID, claimer, userID string) error {
rp, err := s.db.GetRedPacketByPacketID(ctx, packetID)
if err != nil {
return err
}
if err := validateClaimBase(rp, userID, claimer); err != nil {
return err
}
if err := s.ensureWalletBinding(ctx, userID, claimer, rp.ChainType); err != nil {
return err
}
switch rp.PacketType {
case 0:
return s.validateFixedPacketClaim(ctx, rp, userID, claimer)
case 1:
return s.validateRandomPacketClaim(ctx, rp, userID, claimer)
case 2:
return s.validateTransferPacketClaim(ctx, rp, userID, claimer)
default:
return errs.ErrArgs.WrapMsg(fmt.Sprintf("unsupported packet_type: %d", rp.PacketType))
}
}
type claimedEventSnapshot struct {
ClaimerWallet string
AuthNonce string
Amount string
BlockNumber uint64
}
type createdPacketSnapshot struct {
PacketID string
ChainID int64
ContractAddress string
CreatorWallet string
PacketType int32
Token string
TotalAmount string
TotalShares int32
ExpiryAt int64
}
func (s *redPacketServer) resolveCreatedPacket(ctx context.Context, rp *model.RedPacket, txHashHex, fallbackPacketID string) (*createdPacketSnapshot, error) {
switch rp.ChainType {
case "EVM":
// Offline mode: no chain client configured; caller must supply packet_id directly.
if s.chainClient == nil {
if fallbackPacketID == "" {
return nil, errs.ErrArgs.WrapMsg("packet_id is required when EVM client is unavailable")
}
return buildFallbackCreatedPacket(rp, fallbackPacketID), nil
}
events, err := s.chainClient.ParseTransactionReceipt(ctx, common.HexToHash(txHashHex))
if err != nil {
return nil, errs.ErrInternalServer.WrapMsg("parse created tx failed: " + err.Error())
}
for _, event := range events {
if event.Name != "PacketCreated" {
continue
}
createdPacket := buildCreatedPacketSnapshot(rp, event)
if chainValue := s.chainClient.ChainID(); chainValue != nil {
createdPacket.ChainID = chainValue.Int64()
}
createdPacket.ContractAddress = s.chainClient.ContractAddress().Hex()
if err := validateCreatedPacket(rp, createdPacket); err != nil {
return nil, err
}
return createdPacket, nil
}
return nil, errs.ErrInternalServer.WrapMsg("PacketCreated event not found in tx: " + txHashHex)
case "TRON":
// Offline mode: no chain client configured; caller must supply packet_id directly.
if s.tronClient == nil {
if fallbackPacketID == "" {
return nil, errs.ErrArgs.WrapMsg("packet_id is required when TRON client is unavailable")
}
return buildFallbackCreatedPacket(rp, fallbackPacketID), nil
}
events, err := s.tronClient.ParseTransactionReceipt(ctx, txHashHex)
if err != nil {
return nil, errs.ErrInternalServer.WrapMsg("parse tron created tx failed: " + err.Error())
}
for _, event := range events {
if event.Name != "PacketCreated" {
continue
}
createdPacket := buildCreatedPacketSnapshot(rp, event)
createdPacket.ContractAddress = firstNonEmpty(s.tronClient.ContractAddress(), rp.ContractAddress)
if err := validateCreatedPacket(rp, createdPacket); err != nil {
return nil, err
}
return createdPacket, nil
}
return nil, errs.ErrInternalServer.WrapMsg("PacketCreated event not found in TRON tx: " + txHashHex)
default:
return nil, errs.ErrArgs.WrapMsg("unsupported chain_type: " + rp.ChainType)
}
}
// validateCreateHook reserves a centralized validation extension point split by packet type.
func (s *redPacketServer) validateCreateHook(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
switch req.PacketType {
case 0:
return s.validateFixedPacketCreate(ctx, req)
case 1:
return s.validateRandomPacketCreate(ctx, req)
case 2:
return s.validateTransferPacketCreate(ctx, req)
default:
return errs.ErrArgs.WrapMsg(fmt.Sprintf("unsupported packet_type: %d", req.PacketType))
}
}
// validateCreateBaseFields validates the fields shared by every red packet type.
// It does not look up creator identity or scope; those are handled by the per-type hooks.
func validateCreateBaseFields(req *pbredpacket.CreateOrderReq) (*big.Int, error) {
if strings.TrimSpace(req.CreatorWallet) == "" {
return nil, errs.ErrArgs.WrapMsg("creator_wallet is required")
}
if strings.TrimSpace(req.TotalAmount) == "" {
return nil, errs.ErrArgs.WrapMsg("total_amount is required")
}
total, ok := new(big.Int).SetString(req.TotalAmount, 10)
if !ok || total.Sign() <= 0 {
return nil, errs.ErrArgs.WrapMsg("total_amount must be a positive integer string", "totalAmount", req.TotalAmount)
}
if req.ExpiryAt != 0 && req.ExpiryAt <= time.Now().Unix() {
return nil, errs.ErrArgs.WrapMsg("expiry_at must be 0 or a future unix timestamp", "expiryAt", req.ExpiryAt)
}
return total, nil
}
// validateCreatorScope verifies group membership / friend relationship for the creator
// based on the requested scope. PUBLIC scope skips relationship checks.
func (s *redPacketServer) validateCreatorScope(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
creatorUserID := mcontext.GetOpUserID(ctx)
if creatorUserID == "" {
return servererrs.ErrNoPermission.WrapMsg("op user id is empty")
}
switch normalizeScopeType(req.ScopeType) {
case "GROUP":
return s.ensureGroupEligibility(ctx, req.GroupID, creatorUserID)
case "DIRECT":
if strings.TrimSpace(req.ReceiverUserID) != "" {
if err := s.ensureFriendRelationship(ctx, creatorUserID, req.ReceiverUserID); err != nil {
return err
}
}
for _, receiverID := range req.ReceiverUserIDs {
if strings.TrimSpace(receiverID) == "" {
continue
}
if err := s.ensureFriendRelationship(ctx, creatorUserID, receiverID); err != nil {
return err
}
}
return nil
default:
return nil
}
}
// validateFixedPacketCreate validates fixed red packets:
// - shared base fields
// - scope_type must be GROUP (fixed packets are group-only; claim validators require group_id)
// - 0 < total_shares <= maxTotalShares
// - total_amount must be divisible by total_shares (each share is an integer in min units)
// - creator must be an active member of the group
func (s *redPacketServer) validateFixedPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
total, err := validateCreateBaseFields(req)
if err != nil {
return err
}
if normalizeScopeType(req.ScopeType) != "GROUP" {
return errs.ErrArgs.WrapMsg("fixed packet must use scope_type=GROUP")
}
if req.TotalShares <= 0 {
return errs.ErrArgs.WrapMsg("total_shares must be positive for fixed packet", "totalShares", req.TotalShares)
}
if req.TotalShares > maxTotalShares {
return errs.ErrArgs.WrapMsg(fmt.Sprintf("total_shares must not exceed %d for fixed packet", maxTotalShares), "totalShares", req.TotalShares)
}
shares := big.NewInt(int64(req.TotalShares))
if new(big.Int).Mod(total, shares).Sign() != 0 {
return errs.ErrArgs.WrapMsg("total_amount must be divisible by total_shares for fixed packet",
"totalAmount", req.TotalAmount, "totalShares", req.TotalShares)
}
return s.validateCreatorScope(ctx, req)
}
// validateRandomPacketCreate validates random (lucky) red packets:
// - shared base fields
// - scope_type must be GROUP (random packets are group-only; claim validators require group_id)
// - 0 < total_shares <= maxTotalShares
// - total_amount >= total_shares (at least 1 min unit per share)
// - creator must be an active member of the group
func (s *redPacketServer) validateRandomPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
total, err := validateCreateBaseFields(req)
if err != nil {
return err
}
if normalizeScopeType(req.ScopeType) != "GROUP" {
return errs.ErrArgs.WrapMsg("random packet must use scope_type=GROUP")
}
if req.TotalShares <= 0 {
return errs.ErrArgs.WrapMsg("total_shares must be positive for random packet", "totalShares", req.TotalShares)
}
if req.TotalShares > maxTotalShares {
return errs.ErrArgs.WrapMsg(fmt.Sprintf("total_shares must not exceed %d for random packet", maxTotalShares), "totalShares", req.TotalShares)
}
shares := big.NewInt(int64(req.TotalShares))
if total.Cmp(shares) < 0 {
return errs.ErrArgs.WrapMsg("total_amount must be >= total_shares for random packet",
"totalAmount", req.TotalAmount, "totalShares", req.TotalShares)
}
return s.validateCreatorScope(ctx, req)
}
// validateTransferPacketCreate validates transfer red packets:
// - shared base fields
// - scope_type must be DIRECT (transfer is a 1-to-1 direct send)
// - total_shares == 1
// - exactly one receiver_user_id (receiver_user_ids must be empty)
// - receiver must not be the creator (no self-transfer)
// - creator and receiver must be friends
func (s *redPacketServer) validateTransferPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
if _, err := validateCreateBaseFields(req); err != nil {
return err
}
if normalizeScopeType(req.ScopeType) != "DIRECT" {
return errs.ErrArgs.WrapMsg("transfer packet must use scope_type=DIRECT")
}
if req.TotalShares != 1 {
return errs.ErrArgs.WrapMsg("transfer packet must have total_shares == 1", "totalShares", req.TotalShares)
}
// Reject ambiguous input: receiver_user_ids is not applicable for transfer.
if len(req.ReceiverUserIDs) > 0 {
return errs.ErrArgs.WrapMsg("transfer packet uses receiver_user_id (singular), not receiver_user_ids")
}
receiverUserID := strings.TrimSpace(req.ReceiverUserID)
if receiverUserID == "" {
return errs.ErrArgs.WrapMsg("receiver_user_id is required for transfer packet")
}
creatorUserID := mcontext.GetOpUserID(ctx)
if creatorUserID == "" {
return servererrs.ErrNoPermission.WrapMsg("op user id is empty")
}
if creatorUserID == receiverUserID {
return errs.ErrArgs.WrapMsg("transfer packet cannot be sent to yourself")
}
return s.ensureFriendRelationship(ctx, creatorUserID, receiverUserID)
}
func buildFallbackCreatedPacket(rp *model.RedPacket, packetID string) *createdPacketSnapshot {
return &createdPacketSnapshot{
PacketID: packetID,
ChainID: rp.ChainID,
ContractAddress: rp.ContractAddress,
CreatorWallet: strings.ToLower(rp.CreatorWallet),
PacketType: rp.PacketType,
Token: normalizeTokenAddress(rp.Token),
TotalAmount: rp.TotalAmount,
TotalShares: rp.TotalShares,
ExpiryAt: rp.ExpiryAt,
}
}
func buildCreatedPacketSnapshot(rp *model.RedPacket, event *chain.ParsedEvent) *createdPacketSnapshot {
return &createdPacketSnapshot{
PacketID: chain.GetPacketIDFromEvent(event).String(),
ChainID: rp.ChainID,
ContractAddress: rp.ContractAddress,
CreatorWallet: strings.ToLower(chain.GetAddressFromEvent(event, "creator").Hex()),
PacketType: int32(chain.GetUintFromEvent(event, "packetType").Int64()),
Token: strings.ToLower(chain.GetAddressFromEvent(event, "token").Hex()),
TotalAmount: chain.GetUintFromEvent(event, "totalAmount").String(),
TotalShares: int32(chain.GetUintFromEvent(event, "totalShares").Int64()),
ExpiryAt: chain.GetUintFromEvent(event, "expiryAt").Int64(),
}
}
func validateCreatedPacket(rp *model.RedPacket, createdPacket *createdPacketSnapshot) error {
if createdPacket == nil {
return errs.ErrInternalServer.WrapMsg("created packet is nil")
}
if createdPacket.CreatorWallet != "" && strings.ToLower(rp.CreatorWallet) != createdPacket.CreatorWallet {
return errs.ErrArgs.WrapMsg(fmt.Sprintf("creator mismatch: got %s want %s", createdPacket.CreatorWallet, rp.CreatorWallet))
}
if createdPacket.PacketType != rp.PacketType {
return errs.ErrArgs.WrapMsg(fmt.Sprintf("packet type mismatch: got %d want %d", createdPacket.PacketType, rp.PacketType))
}
if createdPacket.TotalAmount != rp.TotalAmount {
return errs.ErrArgs.WrapMsg(fmt.Sprintf("total amount mismatch: got %s want %s", createdPacket.TotalAmount, rp.TotalAmount))
}
if createdPacket.TotalShares != rp.TotalShares {
return errs.ErrArgs.WrapMsg(fmt.Sprintf("total shares mismatch: got %d want %d", createdPacket.TotalShares, rp.TotalShares))
}
expectedToken := normalizeTokenAddress(rp.Token)
if createdPacket.Token != expectedToken {
return errs.ErrArgs.WrapMsg(fmt.Sprintf("token mismatch: got %s want %s", createdPacket.Token, expectedToken))
}
if rp.ExpiryAt > 0 && createdPacket.ExpiryAt != rp.ExpiryAt {
return errs.ErrArgs.WrapMsg(fmt.Sprintf("expiry mismatch: got %d want %d", createdPacket.ExpiryAt, rp.ExpiryAt))
}
return nil
}
func validateClaimBase(rp *model.RedPacket, userID, claimer string) error {
if rp == nil {
return servererrs.ErrRecordNotFound.WrapMsg("packet not found")
}
if strings.TrimSpace(userID) == "" {
return errs.ErrArgs.WrapMsg("user_id is required")
}
if strings.TrimSpace(claimer) == "" {
return errs.ErrArgs.WrapMsg("claimer is required")
}
// Check status first to give precise error messages for each terminal state.
switch rp.Status {
case "ACTIVE":
// ok, continue to expiry check
case "REFUNDED":
return errs.ErrArgs.WrapMsg("packet has been refunded")
case "EXPIRED":
return errs.ErrArgs.WrapMsg("packet has expired")
default:
return errs.ErrArgs.WrapMsg("packet is not claimable, current status: " + rp.Status)
}
// Guard against the race where status is still ACTIVE but expiry has passed.
if rp.ExpiryAt > 0 && rp.ExpiryAt <= time.Now().Unix() {
return errs.ErrArgs.WrapMsg("packet has expired")
}
return nil
}
func (s *redPacketServer) validateFixedPacketClaim(ctx context.Context, rp *model.RedPacket, userID, claimer string) error {
if strings.TrimSpace(rp.GroupID) == "" {
return errs.ErrArgs.WrapMsg("group_id is required for fixed packet claim")
}
if err := s.ensureNotClaimed(ctx, rp.PacketID, userID, claimer); err != nil {
return err
}
return s.ensureGroupEligibility(ctx, rp.GroupID, userID)
}
func (s *redPacketServer) validateRandomPacketClaim(ctx context.Context, rp *model.RedPacket, userID, claimer string) error {
if strings.TrimSpace(rp.GroupID) == "" {
return errs.ErrArgs.WrapMsg("group_id is required for random packet claim")
}
if err := s.ensureNotClaimed(ctx, rp.PacketID, userID, claimer); err != nil {
return err
}
return s.ensureGroupEligibility(ctx, rp.GroupID, userID)
}
func (s *redPacketServer) validateTransferPacketClaim(ctx context.Context, rp *model.RedPacket, userID, claimer string) error {
if err := s.ensureNotClaimed(ctx, rp.PacketID, userID, claimer); err != nil {
return err
}
if strings.TrimSpace(rp.ReceiverUserID) == "" {
return errs.ErrArgs.WrapMsg("receiver_user_id is required for transfer claim")
}
if rp.ReceiverUserID != userID {
return errs.ErrNoPermission.WrapMsg("user is not the designated receiver")
}
return s.ensureFriendRelationship(ctx, rp.CreatorUserID, userID)
}
func (s *redPacketServer) ensureNotClaimed(ctx context.Context, packetID, userID, claimer string) error {
if strings.TrimSpace(userID) != "" {
claim, err := s.db.GetClaimByPacketIDAndUserID(ctx, packetID, userID)
if err == nil && claim != nil && claim.Status != "FAILED" {
return errs.ErrArgs.WrapMsg("user already claimed")
}
if err != nil && !errs.ErrRecordNotFound.Is(err) {
return err
}
}
claim, err := s.db.GetClaimByPacketIDAndClaimer(ctx, packetID, claimer)
if err == nil && claim != nil && claim.Status != "FAILED" {
return errs.ErrArgs.WrapMsg("already claimed")
}
if err != nil && !errs.ErrRecordNotFound.Is(err) {
return err
}
return nil
}
func (s *redPacketServer) ensureWalletBinding(ctx context.Context, userID, claimer, chainType string) error {
if _, err := s.db.GetActiveWalletBinding(ctx, userID, chainType, claimer); err != nil {
if errs.ErrRecordNotFound.Is(err) {
return errs.ErrNoPermission.WrapMsg("wallet is not bound to user")
}
return err
}
return nil
}
// ensureGroupEligibility verifies that userID is an active member of groupID.
func (s *redPacketServer) ensureGroupEligibility(ctx context.Context, groupID, userID string) error {
groupID = strings.TrimSpace(groupID)
userID = strings.TrimSpace(userID)
if groupID == "" {
return errs.ErrArgs.WrapMsg("group_id is required for group claim")
}
if userID == "" {
return errs.ErrArgs.WrapMsg("user_id is required for group claim")
}
if s.groupClient == nil {
return servererrs.ErrInternalServer.WrapMsg("group client is not initialized")
}
if _, err := s.groupClient.GetGroupMemberInfo(ctx, groupID, userID); err != nil {
if errs.ErrRecordNotFound.Is(err) {
return errs.ErrNoPermission.WrapMsg("user is not a member of the group", "groupID", groupID, "userID", userID)
}
return err
}
return nil
}
// ensureFriendRelationship verifies that userA and userB are mutual friends.
// It is used in two contexts:
// - validateCreatorScope (DIRECT scope): checking that each listed receiver is
// a friend of the creator. In that path userA == userB is theoretically possible
// (creator adding themselves to a list), which is allowed here; the transfer
// validator has its own explicit self-transfer prohibition.
// - validateTransferPacketClaim: re-confirming the friendship at claim time.
//
// Self-transfer is intentionally allowed at this level; call sites that need to
// prohibit it (e.g. validateTransferPacketCreate) must do so before calling here.
func (s *redPacketServer) ensureFriendRelationship(ctx context.Context, userA, userB string) error {
userA = strings.TrimSpace(userA)
userB = strings.TrimSpace(userB)
if userA == "" || userB == "" {
return errs.ErrArgs.WrapMsg("both user IDs are required for friend relationship check")
}
if userA == userB {
return nil
}
if s.relationClient == nil {
return servererrs.ErrInternalServer.WrapMsg("relation client is not initialized")
}
ok, err := s.relationClient.IsFriend(ctx, userA, userB)
if err != nil {
return err
}
if !ok {
return errs.ErrNoPermission.WrapMsg("users are not friends", "userA", userA, "userB", userB)
}
return nil
}
func (s *redPacketServer) resolveClaimedEvent(ctx context.Context, rp *model.RedPacket, txHash string) (*claimedEventSnapshot, error) {
var (
events []*chain.ParsedEvent
err error
)
switch rp.ChainType {
case "EVM":
if s.chainClient == nil {
return nil, nil
}
events, err = s.chainClient.ParseTransactionReceipt(ctx, common.HexToHash(txHash))
case "TRON":
if s.tronClient == nil {
return nil, nil
}
events, err = s.tronClient.ParseTransactionReceipt(ctx, txHash)
default:
return nil, errs.ErrArgs.WrapMsg("unsupported chain_type: " + rp.ChainType)
}
if err != nil {
return nil, err
}
for _, event := range events {
if event.Name != "PacketClaimed" {
continue
}
packetID := chain.GetPacketIDFromEvent(event).String()
claimerWallet := strings.ToLower(chain.GetAddressFromEvent(event, "claimer").Hex())
if packetID != rp.PacketID {
return nil, errs.ErrArgs.WrapMsg(fmt.Sprintf("claim event packet mismatch: got %s want %s", packetID, rp.PacketID))
}
return &claimedEventSnapshot{
ClaimerWallet: claimerWallet,
AuthNonce: chain.GetUintFromEvent(event, "authNonce").String(),
Amount: chain.GetAmountFromEvent(event).String(),
BlockNumber: event.BlockNumber,
}, nil
}
return nil, nil
}
// maxTotalShares caps the number of shares to prevent abuse.
const maxTotalShares = 10_000
func normalizeScopeType(scopeType string) string {
switch strings.ToUpper(strings.TrimSpace(scopeType)) {
case "GROUP", "DIRECT", "PUBLIC":
return strings.ToUpper(strings.TrimSpace(scopeType))
default:
return "PUBLIC"
}
}
func normalizeChainType(chainType string) (string, error) {
switch strings.ToUpper(strings.TrimSpace(chainType)) {
case "EVM":
return "EVM", nil
case "TRON":
return "TRON", nil
default:
return "", errs.ErrArgs.WrapMsg("unsupported chain_type: " + chainType)
}
}
func validateCreateScope(scopeType, groupID, receiverUserID string, receiverUserIDs []string) error {
switch scopeType {
case "GROUP":
if strings.TrimSpace(groupID) == "" {
return errs.ErrArgs.WrapMsg("group_id is required when scope_type=GROUP")
}
case "DIRECT":
if strings.TrimSpace(receiverUserID) == "" && len(receiverUserIDs) == 0 {
return errs.ErrArgs.WrapMsg("receiver_user_id or receiver_user_ids is required when scope_type=DIRECT")
}
}
return nil
}
func normalizeTokenAddress(token string) string {
if strings.TrimSpace(token) == "" {
return strings.ToLower(common.Address{}.Hex())
}
return strings.ToLower(common.HexToAddress(token).Hex())
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func redPacketModelToProto(rp *model.RedPacket) *pbredpacket.RedPacketRecord {
if rp == nil {
return nil
}
return &pbredpacket.RedPacketRecord{
BizID: rp.BizID,
ChainType: rp.ChainType,
PacketID: rp.PacketID,
ChainID: rp.ChainID,
ContractAddress: rp.ContractAddress,
CreatorUserID: rp.CreatorUserID,
CreatorWallet: rp.CreatorWallet,
GroupID: rp.GroupID,
ScopeType: rp.ScopeType,
ReceiverUserID: rp.ReceiverUserID,
ReceiverUserIDs: append([]string(nil), rp.ReceiverUserIDs...),
PacketType: rp.PacketType,
Token: rp.Token,
TotalAmount: rp.TotalAmount,
TotalShares: rp.TotalShares,
ClaimedAmount: rp.ClaimedAmount,
ClaimedShares: rp.ClaimedShares,
ExpiryAt: rp.ExpiryAt,
TxHash: rp.TxHash,
Status: rp.Status,
CreatedAt: rp.CreatedAt.Unix(),
UpdatedAt: rp.UpdatedAt.Unix(),
}
}
// RequestRefund allows the red-packet creator to submit an on-chain refund
// transaction for an expired packet. The indexer will asynchronously pick up
// the on-chain RefundPacket event and mark the packet as REFUNDED in the DB.
func (s *redPacketServer) RequestRefund(ctx context.Context, req *pbredpacket.RequestRefundReq) (*pbredpacket.RequestRefundResp, error) {
currentUserID := mcontext.GetOpUserID(ctx)
if currentUserID == "" {
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
}
if req.GetPacketID() == "" {
return nil, errs.ErrArgs.WrapMsg("packet_id is required")
}
rp, err := s.db.GetRedPacketByPacketID(ctx, req.GetPacketID())
if err != nil {
return nil, err
}
if rp.CreatorUserID != currentUserID {
return nil, errs.ErrNoPermission.WrapMsg("only the creator can request a refund")
}
if rp.Status == "REFUNDED" {
return &pbredpacket.RequestRefundResp{TxHash: "", Status: "REFUNDED"}, nil
}
if rp.ExpiryAt > 0 && time.Now().Unix() < rp.ExpiryAt {
return nil, errs.ErrArgs.WrapMsg("red packet has not expired yet")
}
// Submit the on-chain refund transaction.
var txHash string
if s.chainClient != nil {
txHash, err = s.chainClient.RefundPacket(ctx, rp.PacketID)
if err != nil {
return nil, errs.ErrInternalServer.WrapMsg("submit refund tx failed: " + err.Error())
}
} else if s.tronClient != nil {
packetIDBig, ok := new(big.Int).SetString(rp.PacketID, 10)
if !ok {
return nil, errs.ErrInternalServer.WrapMsg("invalid packet id format")
}
txHash, err = s.tronClient.SendAdminTransaction(ctx, "refundPacket", packetIDBig)
if err != nil {
return nil, errs.ErrInternalServer.WrapMsg("submit tron refund tx failed: " + err.Error())
}
} else {
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
}
log.ZInfo(ctx, "redpacket refund submitted", "packetID", rp.PacketID, "txHash", txHash)
return &pbredpacket.RequestRefundResp{TxHash: txHash, Status: "PENDING"}, nil
}
func (s *redPacketServer) GetRefund(ctx context.Context, req *pbredpacket.GetRefundReq) (*pbredpacket.GetRefundResp, error) {
if req.GetPacketID() == "" {
return nil, errs.ErrArgs.WrapMsg("packet_id is required")
}
refund, err := s.db.GetRefundByPacketID(ctx, req.GetPacketID())
if err != nil {
return nil, err
}
return &pbredpacket.GetRefundResp{
PacketID: refund.PacketID,
RefundTo: refund.RefundTo,
TxHash: refund.TxHash,
Amount: refund.Amount,
CreatedAt: refund.CreatedAt.Unix(),
}, nil
}
func claimsModelToProto(claims []*model.RedPacketClaim) []*pbredpacket.RedPacketClaimRecord {
out := make([]*pbredpacket.RedPacketClaimRecord, 0, len(claims))
for _, c := range claims {
if c == nil {
continue
}
out = append(out, &pbredpacket.RedPacketClaimRecord{
PacketID: c.PacketID,
UserID: c.UserID,
ClaimerWallet: c.ClaimerWallet,
AuthNonce: c.AuthNonce,
ClaimTxHash: c.ClaimTxHash,
ClaimedAmount: c.ClaimedAmount,
BlockNumber: c.BlockNumber,
Status: c.Status,
CreatedAt: c.CreatedAt.Unix(),
UpdatedAt: c.UpdatedAt.Unix(),
})
}
return out
}

@ -0,0 +1,349 @@
package redpacket
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/google/uuid"
"github.com/openimsdk/open-im-server/v3/pkg/common/servererrs"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
pbredpacket "github.com/openimsdk/protocol/redpacket"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/mcontext"
)
func (s *redPacketServer) IssueWalletBindChallenge(ctx context.Context, req *pbredpacket.IssueWalletBindChallengeReq) (*pbredpacket.IssueWalletBindChallengeResp, error) {
currentUserID := mcontext.GetOpUserID(ctx)
if currentUserID == "" {
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
}
chainType, err := normalizeChainType(req.ChainType)
if err != nil {
return nil, err
}
walletAddress := strings.TrimSpace(req.WalletAddress)
if walletAddress == "" {
return nil, errs.ErrArgs.WrapMsg("wallet_address is required")
}
challengeID := uuid.NewString()
nonce := uuid.NewString()
issuedAt := time.Now().UTC()
expiresAt := issuedAt.Add(10 * time.Minute)
protocol := "siwe-eip4361"
signMethod := "personal_sign"
message := buildEVMBindMessage(currentUserID, walletAddress, req.Domain, req.Uri, req.ChainID, challengeID, nonce, issuedAt, expiresAt)
if chainType == "TRON" {
protocol = "tron-signmessagev2"
signMethod = "signMessageV2"
message = buildTRONBindMessage(currentUserID, walletAddress, req.ChainID, challengeID, nonce, issuedAt, expiresAt)
}
challenge := &model.WalletBindingChallenge{
ChallengeID: challengeID,
UserID: currentUserID,
ChainType: chainType,
ChainID: req.ChainID,
WalletAddress: walletAddress,
Nonce: nonce,
Message: message,
Protocol: protocol,
SignMethod: signMethod,
Status: "PENDING",
ExpiresAt: expiresAt,
CreatedAt: issuedAt,
UpdatedAt: issuedAt,
}
if err := s.db.CreateWalletBindingChallenge(ctx, challenge); err != nil {
return nil, err
}
return &pbredpacket.IssueWalletBindChallengeResp{
ChallengeID: challengeID,
UserID: currentUserID,
ChainType: chainType,
ChainID: req.ChainID,
Wallet: walletAddress,
Protocol: protocol,
SignMethod: signMethod,
Nonce: nonce,
Message: message,
IssuedAt: issuedAt.Format(time.RFC3339),
ExpiresAt: expiresAt.Format(time.RFC3339),
}, nil
}
func (s *redPacketServer) ConfirmWalletBind(ctx context.Context, req *pbredpacket.ConfirmWalletBindReq) (*pbredpacket.ConfirmWalletBindResp, error) {
if strings.TrimSpace(req.ChallengeID) == "" || strings.TrimSpace(req.Signature) == "" {
return nil, errs.ErrArgs.WrapMsg("challenge_id and signature are required")
}
challenge, err := s.db.GetWalletBindingChallenge(ctx, req.ChallengeID)
if err != nil {
return nil, err
}
if challenge.Status != "PENDING" {
return nil, errs.ErrArgs.WrapMsg("challenge is not pending")
}
if time.Now().UTC().After(challenge.ExpiresAt) {
challenge.Status = "EXPIRED"
challenge.UpdatedAt = time.Now()
_ = s.db.UpdateWalletBindingChallenge(ctx, challenge)
return nil, errs.ErrArgs.WrapMsg("challenge is expired")
}
var verifyErr error
switch challenge.ChainType {
case "EVM":
verifyErr = verifyEVMBindSignature(challenge.Message, challenge.WalletAddress, req.Signature)
case "TRON":
verifyErr = verifyTRONBindSignature(challenge.Message, challenge.WalletAddress, req.Signature)
default:
return nil, errs.ErrArgs.WrapMsg("unsupported chain_type: " + challenge.ChainType)
}
if verifyErr != nil {
challenge.Status = "FAILED"
challenge.Signature = req.Signature
challenge.UpdatedAt = time.Now()
_ = s.db.UpdateWalletBindingChallenge(ctx, challenge)
return nil, verifyErr
}
now := time.Now().UTC()
challenge.Status = "VERIFIED"
challenge.Signature = req.Signature
challenge.VerifiedAt = &now
challenge.UpdatedAt = now
if err := s.db.UpdateWalletBindingChallenge(ctx, challenge); err != nil {
return nil, err
}
binding := &model.WalletBinding{
UserID: challenge.UserID,
ChainType: challenge.ChainType,
ChainID: challenge.ChainID,
WalletAddress: challenge.WalletAddress,
Status: "ACTIVE",
ChallengeID: challenge.ChallengeID,
VerifiedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.db.UpsertWalletBinding(ctx, binding); err != nil {
return nil, err
}
return &pbredpacket.ConfirmWalletBindResp{
UserID: binding.UserID,
ChainType: binding.ChainType,
ChainID: binding.ChainID,
WalletAddress: binding.WalletAddress,
Status: binding.Status,
VerifiedAt: binding.VerifiedAt.Format(time.RFC3339),
}, nil
}
func (s *redPacketServer) GetWalletBinding(ctx context.Context, req *pbredpacket.GetWalletBindingReq) (*pbredpacket.GetWalletBindingResp, error) {
currentUserID := mcontext.GetOpUserID(ctx)
if currentUserID == "" {
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
}
normalizedChainType, err := normalizeChainType(req.ChainType)
if err != nil {
return nil, err
}
binding, err := s.db.GetActiveWalletBinding(ctx, currentUserID, normalizedChainType, req.WalletAddress)
if err != nil {
return nil, err
}
return &pbredpacket.GetWalletBindingResp{
UserID: binding.UserID,
ChainType: binding.ChainType,
ChainID: binding.ChainID,
WalletAddress: binding.WalletAddress,
Status: binding.Status,
ChallengeID: binding.ChallengeID,
VerifiedAt: binding.VerifiedAt.Format(time.RFC3339),
}, nil
}
func buildEVMBindMessage(userID, walletAddress, domainIn, uriIn string, chainID int64, challengeID, nonce string, issuedAt, expiresAt time.Time) string {
domain := strings.TrimSpace(domainIn)
if domain == "" {
domain = "redpacket"
}
uri := strings.TrimSpace(uriIn)
if uri == "" {
uri = "https://redpacket.local/wallet-bind"
}
var b strings.Builder
fmt.Fprintf(&b, "%s wants you to sign in with your Ethereum account:\n", domain)
b.WriteString(strings.TrimSpace(walletAddress))
b.WriteString("\n\n")
fmt.Fprintf(&b, "Bind wallet %s to user %s.\n", strings.TrimSpace(walletAddress), strings.TrimSpace(userID))
fmt.Fprintf(&b, "URI: %s\n", uri)
fmt.Fprintf(&b, "Version: 1\n")
fmt.Fprintf(&b, "Chain ID: %d\n", chainID)
fmt.Fprintf(&b, "Nonce: %s\n", nonce)
fmt.Fprintf(&b, "Issued At: %s\n", issuedAt.Format(time.RFC3339))
fmt.Fprintf(&b, "Expiration Time: %s\n", expiresAt.Format(time.RFC3339))
fmt.Fprintf(&b, "Request ID: %s", challengeID)
return b.String()
}
func buildTRONBindMessage(userID, walletAddress string, chainID int64, challengeID, nonce string, issuedAt, expiresAt time.Time) string {
return fmt.Sprintf(
"Bind TRON wallet %s to user %s\nchallenge_id: %s\nnonce: %s\nchain_id: %d\nissued_at: %s\nexpires_at: %s",
strings.TrimSpace(walletAddress),
strings.TrimSpace(userID),
challengeID,
nonce,
chainID,
issuedAt.Format(time.RFC3339),
expiresAt.Format(time.RFC3339),
)
}
func verifyEVMBindSignature(message, walletAddress, signature string) error {
if strings.TrimSpace(message) == "" {
return errs.ErrArgs.WrapMsg("bind message is empty")
}
if !common.IsHexAddress(walletAddress) {
return errs.ErrArgs.WrapMsg("invalid evm wallet address")
}
sig, err := hex.DecodeString(strings.TrimPrefix(signature, "0x"))
if err != nil {
return errs.ErrArgs.WrapMsg("decode signature failed: " + err.Error())
}
if len(sig) != 65 {
return errs.ErrArgs.WrapMsg(fmt.Sprintf("invalid signature length: %d", len(sig)))
}
if sig[64] >= 27 {
sig[64] -= 27
}
if sig[64] > 1 {
return errs.ErrArgs.WrapMsg("invalid signature recovery id")
}
hash := crypto.Keccak256Hash([]byte(personalSignMessage(message)))
pubKey, err := crypto.SigToPub(hash.Bytes(), sig)
if err != nil {
return errs.ErrInternalServer.WrapMsg("recover signer failed: " + err.Error())
}
recovered := crypto.PubkeyToAddress(*pubKey)
if !strings.EqualFold(recovered.Hex(), walletAddress) {
return errs.ErrNoPermission.WrapMsg("signature does not match wallet address")
}
return nil
}
func personalSignMessage(message string) string {
return fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message)
}
// verifyTRONBindSignature verifies a TRON signMessageV2 (TronLink) signature.
// TRON uses the same secp256k1 curve as Ethereum; the only differences are:
// - message prefix: "\x19TRON Signed Message:\n<decimal_len>"
// - wallet address: base58check-encoded with a leading 0x41 byte
func verifyTRONBindSignature(message, walletAddress, signature string) error {
if strings.TrimSpace(message) == "" {
return errs.ErrArgs.WrapMsg("bind message is empty")
}
sig, err := hex.DecodeString(strings.TrimPrefix(signature, "0x"))
if err != nil {
return errs.ErrArgs.WrapMsg("decode tron signature failed: " + err.Error())
}
if len(sig) != 65 {
return errs.ErrArgs.WrapMsg(fmt.Sprintf("invalid tron signature length: %d", len(sig)))
}
// Some TRON wallets encode v as 27/28; normalise to 0/1.
if sig[64] >= 27 {
sig[64] -= 27
}
prefix := fmt.Sprintf("\x19TRON Signed Message:\n%d", len(message))
hash := crypto.Keccak256Hash([]byte(prefix + message))
pubKey, err := crypto.SigToPub(hash.Bytes(), sig)
if err != nil {
return errs.ErrInternalServer.WrapMsg("recover tron signer failed: " + err.Error())
}
// Derive the raw 20-byte address (identical derivation to Ethereum).
recoveredAddr := crypto.PubkeyToAddress(*pubKey)
// Decode the TRON base58check address to its 20 raw bytes.
addrBytes, err := decodeTRONAddress(walletAddress)
if err != nil {
return errs.ErrArgs.WrapMsg("invalid tron address: " + err.Error())
}
if !bytes.Equal(recoveredAddr.Bytes(), addrBytes) {
return errs.ErrNoPermission.WrapMsg("tron signature does not match wallet address")
}
return nil
}
// decodeTRONAddress decodes a TRON base58check address and returns the 20
// raw address bytes (i.e., without the leading 0x41 network prefix byte).
func decodeTRONAddress(addr string) ([]byte, error) {
decoded := tronBase58Decode(addr)
if len(decoded) != 25 {
return nil, fmt.Errorf("invalid length %d", len(decoded))
}
payload := decoded[:21]
checksum := decoded[21:25]
h1 := sha256.Sum256(payload)
h2 := sha256.Sum256(h1[:])
if !bytes.Equal(h2[:4], checksum) {
return nil, fmt.Errorf("invalid base58check checksum")
}
if payload[0] != 0x41 {
return nil, fmt.Errorf("invalid tron address prefix byte: 0x%02x", payload[0])
}
return payload[1:], nil
}
const tronBase58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
func tronBase58Decode(s string) []byte {
n := new(big.Int)
base := big.NewInt(58)
for _, c := range s {
idx := strings.IndexRune(tronBase58Alphabet, c)
if idx < 0 {
return nil
}
n.Mul(n, base)
n.Add(n, big.NewInt(int64(idx)))
}
decoded := n.Bytes()
leadingOnes := 0
for _, c := range s {
if c == '1' {
leadingOnes++
} else {
break
}
}
out := make([]byte, leadingOnes+len(decoded))
copy(out[leadingOnes:], decoded)
return out
}

@ -45,6 +45,7 @@ var (
OpenIMRPCUserCfgFileName string
OpenIMRPCRtcCfgFileName string
OpenIMRPCCryptoCfgFileName string
OpenIMRPCRedPacketCfgFileName string
DiscoveryConfigFilename string
)
@ -77,6 +78,7 @@ func init() {
OpenIMRPCUserCfgFileName = "openim-rpc-user.yml"
OpenIMRPCRtcCfgFileName = "openim-rpc-rtc.yml"
OpenIMRPCCryptoCfgFileName = "openim-rpc-crypto.yml"
OpenIMRPCRedPacketCfgFileName = "openim-rpc-redpacket.yml"
DiscoveryConfigFilename = "discovery.yml"
ConfigEnvPrefixMap = make(map[string]string)
@ -87,7 +89,8 @@ func init() {
OpenIMAPICfgFileName, OpenIMCronTaskCfgFileName, OpenIMMsgGatewayCfgFileName,
OpenIMMsgTransferCfgFileName, OpenIMPushCfgFileName, OpenIMCaptchaCfgFileName, OpenIMRPCAuthCfgFileName, OpenIMRPCCaptchaCfgFileName,
OpenIMRPCConversationCfgFileName, OpenIMRPCFriendCfgFileName, OpenIMRPCGroupCfgFileName,
OpenIMRPCMsgCfgFileName, OpenIMRPCThirdCfgFileName, OpenIMRPCUserCfgFileName, OpenIMRPCRtcCfgFileName, OpenIMRPCCryptoCfgFileName, DiscoveryConfigFilename,
OpenIMRPCMsgCfgFileName, OpenIMRPCThirdCfgFileName, OpenIMRPCUserCfgFileName, OpenIMRPCRtcCfgFileName, OpenIMRPCCryptoCfgFileName,
OpenIMRPCRedPacketCfgFileName, DiscoveryConfigFilename,
}
for _, fileName := range fileNames {

@ -0,0 +1,47 @@
package cmd
import (
"context"
"github.com/openimsdk/open-im-server/v3/internal/rpc/redpacket"
"github.com/openimsdk/open-im-server/v3/pkg/common/startrpc"
"github.com/openimsdk/open-im-server/v3/version"
"github.com/openimsdk/tools/system/program"
"github.com/spf13/cobra"
)
type RedPacketRpcCmd struct {
*RootCmd
ctx context.Context
configMap map[string]any
redPacketConfig *redpacket.Config
}
func NewRedPacketRpcCmd() *RedPacketRpcCmd {
var redPacketConfig redpacket.Config
ret := &RedPacketRpcCmd{redPacketConfig: &redPacketConfig}
ret.configMap = map[string]any{
OpenIMRPCRedPacketCfgFileName: &redPacketConfig.RpcConfig,
MongodbConfigFileName: &redPacketConfig.MongodbConfig,
ShareFileName: &redPacketConfig.Share,
DiscoveryConfigFilename: &redPacketConfig.Discovery,
}
ret.RootCmd = NewRootCmd(program.GetProcessName(), WithConfigMap(ret.configMap))
ret.ctx = context.WithValue(context.Background(), "version", version.Version)
ret.Command.RunE = func(cmd *cobra.Command, args []string) error {
return ret.runE()
}
return ret
}
func (c *RedPacketRpcCmd) Exec() error {
return c.Execute()
}
func (c *RedPacketRpcCmd) runE() error {
return startrpc.Start(c.ctx, &c.redPacketConfig.Discovery, &c.redPacketConfig.RpcConfig.Prometheus, c.redPacketConfig.RpcConfig.RPC.ListenIP,
c.redPacketConfig.RpcConfig.RPC.RegisterIP, c.redPacketConfig.RpcConfig.RPC.AutoSetPorts, c.redPacketConfig.RpcConfig.RPC.Ports,
c.Index(), c.redPacketConfig.Share.RpcRegisterName.RedPacket, &c.redPacketConfig.Share, c.redPacketConfig,
nil,
redpacket.Start)
}

@ -436,6 +436,7 @@ type RpcRegisterName struct {
Captcha string `mapstructure:"captcha"`
Rtc string `mapstructure:"rtc"`
Crypto string `mapstructure:"crypto"`
RedPacket string `mapstructure:"redPacket"`
}
func (r *RpcRegisterName) GetServiceNames() []string {
@ -452,6 +453,7 @@ func (r *RpcRegisterName) GetServiceNames() []string {
r.Captcha,
r.Rtc,
r.Crypto,
r.RedPacket,
}
}
@ -491,6 +493,39 @@ type VirgilConfig struct {
AppKeyID string `mapstructure:"appKeyID"`
}
type RedPacket struct {
RPC struct {
RegisterIP string `mapstructure:"registerIP"`
ListenIP string `mapstructure:"listenIP"`
AutoSetPorts bool `mapstructure:"autoSetPorts"`
Ports []int `mapstructure:"ports"`
} `mapstructure:"rpc"`
Prometheus Prometheus `mapstructure:"prometheus"`
Chain RedPacketChain `mapstructure:"chain"`
Tron RedPacketTron `mapstructure:"tron"`
Indexer RedPacketIndexer `mapstructure:"indexer"`
}
type RedPacketChain struct {
RPCURL string `mapstructure:"rpcURL"`
ContractAddress string `mapstructure:"contractAddress"`
ChainID int64 `mapstructure:"chainID"`
SignerPrivateKey string `mapstructure:"signerPrivateKey"`
ConfigAdminPrivateKey string `mapstructure:"configAdminPrivateKey"`
}
type RedPacketTron struct {
FullNodeURL string `mapstructure:"fullNodeURL"`
ContractBase58 string `mapstructure:"contractBase58"`
OwnerBase58 string `mapstructure:"ownerBase58"`
PrivateKeyHex string `mapstructure:"privateKeyHex"`
FeeLimit int64 `mapstructure:"feeLimit"`
}
type RedPacketIndexer struct {
PollInterval int `mapstructure:"pollInterval"`
}
// FullConfig stores all configurations for before and after events
type Webhooks struct {
@ -703,6 +738,7 @@ var (
OpenIMRPCUserCfgFileName = "openim-rpc-user.yml"
OpenIMRPCRtcCfgFileName = "openim-rpc-rtc.yml"
OpenIMRPCCryptoCfgFileName = "openim-rpc-crypto.yml"
OpenIMRPCRedPacketCfgFileName = "openim-rpc-redpacket.yml"
RedisConfigFileName = "redis.yml"
ShareFileName = "share.yml"
WebhooksConfigFileName = "webhooks.yml"
@ -796,6 +832,10 @@ func (c *Crypto) GetConfigFileName() string {
return OpenIMRPCCryptoCfgFileName
}
func (rp *RedPacket) GetConfigFileName() string {
return OpenIMRPCRedPacketCfgFileName
}
func (r *Redis) GetConfigFileName() string {
return RedisConfigFileName
}

@ -0,0 +1,160 @@
package controller
import (
"context"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/database"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
)
// RedPacketDatabase is a façade aggregating all redpacket-related collections.
// It mirrors the legacy Repository interface so the rpc service layer stays
// unaware of the underlying storage.
type RedPacketDatabase interface {
CreateRedPacket(ctx context.Context, rp *model.RedPacket) error
GetRedPacketByBizID(ctx context.Context, bizID string) (*model.RedPacket, error)
GetRedPacketByPacketID(ctx context.Context, packetID string) (*model.RedPacket, error)
UpdateRedPacketCreated(ctx context.Context, rp *model.RedPacket) error
UpdateRedPacketStatus(ctx context.Context, packetID, status string) error
UpdateRedPacketClaimProgress(ctx context.Context, packetID, claimedAmount, status, claimTxHash string) error
GetExpiredPendingPackets(ctx context.Context, nowUnix int64) ([]*model.RedPacket, error)
CreateClaimAuth(ctx context.Context, auth *model.RedPacketClaimAuth) error
GetClaimAuth(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error)
MarkClaimAuthUsed(ctx context.Context, authNonce string) error
SaveClaim(ctx context.Context, claim *model.RedPacketClaim) error
GetClaimByPacketIDAndClaimer(ctx context.Context, packetID, claimer string) (*model.RedPacketClaim, error)
GetClaimByPacketIDAndUserID(ctx context.Context, packetID, userID string) (*model.RedPacketClaim, error)
GetClaimsByPacketID(ctx context.Context, packetID string) ([]*model.RedPacketClaim, error)
SaveRefund(ctx context.Context, refund *model.RedPacketRefund) error
GetRefundByPacketID(ctx context.Context, packetID string) (*model.RedPacketRefund, error)
CreateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error
GetWalletBindingChallenge(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error)
UpdateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error
UpsertWalletBinding(ctx context.Context, binding *model.WalletBinding) error
GetActiveWalletBinding(ctx context.Context, userID, chainType, walletAddress string) (*model.WalletBinding, error)
CreateAdminAuditLog(ctx context.Context, entry *model.AdminAuditLog) error
}
type redPacketDatabase struct {
rp database.RedPacket
claim database.RedPacketClaim
claimAuth database.RedPacketClaimAuth
refund database.RedPacketRefund
challenge database.WalletBindingChallenge
binding database.WalletBinding
auditLog database.AdminAuditLog
}
func NewRedPacketDatabase(
rp database.RedPacket,
claim database.RedPacketClaim,
claimAuth database.RedPacketClaimAuth,
refund database.RedPacketRefund,
challenge database.WalletBindingChallenge,
binding database.WalletBinding,
auditLog database.AdminAuditLog,
) RedPacketDatabase {
return &redPacketDatabase{
rp: rp,
claim: claim,
claimAuth: claimAuth,
refund: refund,
challenge: challenge,
binding: binding,
auditLog: auditLog,
}
}
func (d *redPacketDatabase) CreateRedPacket(ctx context.Context, rp *model.RedPacket) error {
return d.rp.Create(ctx, rp)
}
func (d *redPacketDatabase) GetRedPacketByBizID(ctx context.Context, bizID string) (*model.RedPacket, error) {
return d.rp.GetByBizID(ctx, bizID)
}
func (d *redPacketDatabase) GetRedPacketByPacketID(ctx context.Context, packetID string) (*model.RedPacket, error) {
return d.rp.GetByPacketID(ctx, packetID)
}
func (d *redPacketDatabase) UpdateRedPacketCreated(ctx context.Context, rp *model.RedPacket) error {
return d.rp.UpdateCreated(ctx, rp)
}
func (d *redPacketDatabase) UpdateRedPacketStatus(ctx context.Context, packetID, status string) error {
return d.rp.UpdateStatus(ctx, packetID, status)
}
func (d *redPacketDatabase) UpdateRedPacketClaimProgress(ctx context.Context, packetID, claimedAmount, status, claimTxHash string) error {
return d.rp.UpdateClaimProgress(ctx, packetID, claimedAmount, status, claimTxHash)
}
func (d *redPacketDatabase) CreateClaimAuth(ctx context.Context, auth *model.RedPacketClaimAuth) error {
return d.claimAuth.Create(ctx, auth)
}
func (d *redPacketDatabase) GetClaimAuth(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error) {
return d.claimAuth.Get(ctx, packetID, claimer)
}
func (d *redPacketDatabase) MarkClaimAuthUsed(ctx context.Context, authNonce string) error {
return d.claimAuth.MarkUsed(ctx, authNonce)
}
func (d *redPacketDatabase) SaveClaim(ctx context.Context, claim *model.RedPacketClaim) error {
return d.claim.Save(ctx, claim)
}
func (d *redPacketDatabase) GetClaimByPacketIDAndClaimer(ctx context.Context, packetID, claimer string) (*model.RedPacketClaim, error) {
return d.claim.GetByPacketIDAndClaimer(ctx, packetID, claimer)
}
func (d *redPacketDatabase) GetClaimByPacketIDAndUserID(ctx context.Context, packetID, userID string) (*model.RedPacketClaim, error) {
return d.claim.GetByPacketIDAndUserID(ctx, packetID, userID)
}
func (d *redPacketDatabase) GetClaimsByPacketID(ctx context.Context, packetID string) ([]*model.RedPacketClaim, error) {
return d.claim.ListByPacketID(ctx, packetID)
}
func (d *redPacketDatabase) SaveRefund(ctx context.Context, refund *model.RedPacketRefund) error {
return d.refund.Save(ctx, refund)
}
func (d *redPacketDatabase) GetRefundByPacketID(ctx context.Context, packetID string) (*model.RedPacketRefund, error) {
return d.refund.GetByPacketID(ctx, packetID)
}
func (d *redPacketDatabase) GetExpiredPendingPackets(ctx context.Context, nowUnix int64) ([]*model.RedPacket, error) {
return d.rp.GetExpiredPending(ctx, nowUnix)
}
func (d *redPacketDatabase) CreateAdminAuditLog(ctx context.Context, entry *model.AdminAuditLog) error {
return d.auditLog.Create(ctx, entry)
}
func (d *redPacketDatabase) CreateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error {
return d.challenge.Create(ctx, challenge)
}
func (d *redPacketDatabase) GetWalletBindingChallenge(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error) {
return d.challenge.Get(ctx, challengeID)
}
func (d *redPacketDatabase) UpdateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error {
return d.challenge.Update(ctx, challenge)
}
func (d *redPacketDatabase) UpsertWalletBinding(ctx context.Context, binding *model.WalletBinding) error {
return d.binding.Upsert(ctx, binding)
}
func (d *redPacketDatabase) GetActiveWalletBinding(ctx context.Context, userID, chainType, walletAddress string) (*model.WalletBinding, error) {
return d.binding.GetActive(ctx, userID, chainType, walletAddress)
}

@ -0,0 +1,539 @@
package mgo
import (
"context"
"math/big"
"time"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/database"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
"github.com/openimsdk/tools/errs"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// ---- RedPacket ----
type RedPacketMgo struct {
coll *mongo.Collection
}
func NewRedPacketMongo(db *mongo.Database) (database.RedPacket, error) {
coll := db.Collection("red_packet")
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
{
Keys: bson.D{{Key: "biz_id", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{Key: "packet_id", Value: 1}},
},
{
Keys: bson.D{{Key: "group_id", Value: 1}},
},
})
if err != nil {
return nil, err
}
return &RedPacketMgo{coll: coll}, nil
}
func (m *RedPacketMgo) Create(ctx context.Context, rp *model.RedPacket) error {
_, err := m.coll.InsertOne(ctx, rp)
return err
}
func (m *RedPacketMgo) GetByBizID(ctx context.Context, bizID string) (*model.RedPacket, error) {
var rp model.RedPacket
err := m.coll.FindOne(ctx, bson.M{"biz_id": bizID}).Decode(&rp)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errs.ErrRecordNotFound.WrapMsg("red packet not found", "bizID", bizID)
}
return nil, err
}
return &rp, nil
}
func (m *RedPacketMgo) GetByPacketID(ctx context.Context, packetID string) (*model.RedPacket, error) {
var rp model.RedPacket
err := m.coll.FindOne(ctx, bson.M{"packet_id": packetID}).Decode(&rp)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errs.ErrRecordNotFound.WrapMsg("red packet not found", "packetID", packetID)
}
return nil, err
}
return &rp, nil
}
func (m *RedPacketMgo) UpdateCreated(ctx context.Context, rp *model.RedPacket) error {
updates := bson.M{
"chain_type": rp.ChainType,
"packet_id": rp.PacketID,
"tx_hash": rp.TxHash,
"chain_id": rp.ChainID,
"contract_address": rp.ContractAddress,
"group_id": rp.GroupID,
"scope_type": rp.ScopeType,
"receiver_user_id": rp.ReceiverUserID,
"receiver_user_ids": rp.ReceiverUserIDs,
"status": rp.Status,
"updated_at": time.Now(),
}
res, err := m.coll.UpdateOne(ctx, bson.M{"biz_id": rp.BizID}, bson.M{"$set": updates})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return errs.ErrRecordNotFound.WrapMsg("red packet not found", "bizID", rp.BizID)
}
return nil
}
func (m *RedPacketMgo) UpdateStatus(ctx context.Context, packetID, status string) error {
res, err := m.coll.UpdateOne(ctx, bson.M{"packet_id": packetID},
bson.M{"$set": bson.M{"status": status, "updated_at": time.Now()}})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return errs.ErrRecordNotFound.WrapMsg("red packet not found", "packetID", packetID)
}
return nil
}
func (m *RedPacketMgo) UpdateClaimProgress(ctx context.Context, packetID, claimedAmount, status, claimTxHash string) error {
var rp model.RedPacket
err := m.coll.FindOne(ctx, bson.M{"packet_id": packetID}).Decode(&rp)
if err != nil {
if err == mongo.ErrNoDocuments {
return errs.ErrRecordNotFound.WrapMsg("red packet not found", "packetID", packetID)
}
return err
}
totalClaimed := addNumericStrings(rp.ClaimedAmount, claimedAmount)
nextShares := rp.ClaimedShares + 1
// Auto-derive status when the caller does not force one.
nextStatus := status
if nextStatus == "" {
if rp.PacketType == 2 {
nextStatus = "COMPLETED"
} else if rp.TotalShares > 0 && nextShares >= rp.TotalShares {
nextStatus = "COMPLETED"
} else {
tcBig, tok := new(big.Int).SetString(totalClaimed, 10)
taBig, taok := new(big.Int).SetString(rp.TotalAmount, 10)
if tok && taok && tcBig.Cmp(taBig) >= 0 {
nextStatus = "COMPLETED"
}
}
}
setFields := bson.M{
"claimed_amount": totalClaimed,
"claimed_shares": nextShares,
"updated_at": time.Now(),
}
if nextStatus != "" {
setFields["status"] = nextStatus
}
// The $addToSet + $ne filter makes the whole update idempotent per claimTxHash:
// if two code paths (RPC handler and indexer) both attempt to process the same
// transaction, only the first UpdateOne will match and the second is a no-op.
filter := bson.M{"packet_id": packetID}
if claimTxHash != "" {
filter["processed_claim_hashes"] = bson.M{"$ne": claimTxHash}
}
update := bson.M{"$set": setFields}
if claimTxHash != "" {
update["$addToSet"] = bson.M{"processed_claim_hashes": claimTxHash}
}
_, err = m.coll.UpdateOne(ctx, filter, update)
return err
}
func addNumericStrings(current, delta string) string {
left := new(big.Int)
if current != "" {
left.SetString(current, 10)
}
right := new(big.Int)
if delta != "" {
right.SetString(delta, 10)
}
return new(big.Int).Add(left, right).String()
}
// ---- RedPacketClaim ----
type RedPacketClaimMgo struct {
coll *mongo.Collection
}
func NewRedPacketClaimMongo(db *mongo.Database) (database.RedPacketClaim, error) {
coll := db.Collection("red_packet_claim")
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
{
Keys: bson.D{{Key: "claim_tx_hash", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{Key: "packet_id", Value: 1}, {Key: "user_id", Value: 1}},
},
{
Keys: bson.D{{Key: "packet_id", Value: 1}, {Key: "claimer_wallet", Value: 1}},
},
})
if err != nil {
return nil, err
}
return &RedPacketClaimMgo{coll: coll}, nil
}
func (m *RedPacketClaimMgo) Save(ctx context.Context, claim *model.RedPacketClaim) error {
if claim.UserID != "" {
var existing model.RedPacketClaim
err := m.coll.FindOne(ctx, bson.M{
"packet_id": claim.PacketID,
"user_id": claim.UserID,
}).Decode(&existing)
if err == nil {
updates := bson.M{
"claimer_wallet": claim.ClaimerWallet,
"auth_nonce": claim.AuthNonce,
"claim_tx_hash": claim.ClaimTxHash,
"claimed_amount": claim.ClaimedAmount,
"block_number": claim.BlockNumber,
"status": claim.Status,
"updated_at": claim.UpdatedAt,
}
_, err := m.coll.UpdateOne(ctx,
bson.M{"packet_id": claim.PacketID, "user_id": claim.UserID},
bson.M{"$set": updates})
return err
}
if err != mongo.ErrNoDocuments {
return err
}
}
_, err := m.coll.UpdateOne(ctx,
bson.M{"claim_tx_hash": claim.ClaimTxHash},
bson.M{"$set": claim},
options.Update().SetUpsert(true),
)
return err
}
func (m *RedPacketClaimMgo) GetByPacketIDAndClaimer(ctx context.Context, packetID, claimer string) (*model.RedPacketClaim, error) {
var claim model.RedPacketClaim
err := m.coll.FindOne(ctx,
bson.M{"packet_id": packetID, "claimer_wallet": claimer},
options.FindOne().SetSort(bson.D{{Key: "created_at", Value: -1}}),
).Decode(&claim)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errs.ErrRecordNotFound.WrapMsg("claim not found", "packetID", packetID, "claimer", claimer)
}
return nil, err
}
return &claim, nil
}
func (m *RedPacketClaimMgo) GetByPacketIDAndUserID(ctx context.Context, packetID, userID string) (*model.RedPacketClaim, error) {
var claim model.RedPacketClaim
err := m.coll.FindOne(ctx,
bson.M{"packet_id": packetID, "user_id": userID},
options.FindOne().SetSort(bson.D{{Key: "created_at", Value: -1}}),
).Decode(&claim)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errs.ErrRecordNotFound.WrapMsg("claim not found", "packetID", packetID, "userID", userID)
}
return nil, err
}
return &claim, nil
}
func (m *RedPacketClaimMgo) ListByPacketID(ctx context.Context, packetID string) ([]*model.RedPacketClaim, error) {
cursor, err := m.coll.Find(ctx,
bson.M{"packet_id": packetID},
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}),
)
if err != nil {
return nil, err
}
var claims []*model.RedPacketClaim
if err := cursor.All(ctx, &claims); err != nil {
return nil, err
}
return claims, nil
}
// ---- RedPacketClaimAuth ----
type RedPacketClaimAuthMgo struct {
coll *mongo.Collection
}
func NewRedPacketClaimAuthMongo(db *mongo.Database) (database.RedPacketClaimAuth, error) {
coll := db.Collection("red_packet_claim_auth")
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
{
Keys: bson.D{{Key: "auth_nonce", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{Key: "packet_id", Value: 1}, {Key: "claimer", Value: 1}},
},
})
if err != nil {
return nil, err
}
return &RedPacketClaimAuthMgo{coll: coll}, nil
}
func (m *RedPacketClaimAuthMgo) Create(ctx context.Context, auth *model.RedPacketClaimAuth) error {
_, err := m.coll.InsertOne(ctx, auth)
return err
}
func (m *RedPacketClaimAuthMgo) Get(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error) {
var auth model.RedPacketClaimAuth
err := m.coll.FindOne(ctx, bson.M{
"packet_id": packetID,
"claimer": claimer,
"used": false,
}).Decode(&auth)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errs.ErrRecordNotFound.WrapMsg("claim auth not found", "packetID", packetID, "claimer", claimer)
}
return nil, err
}
return &auth, nil
}
func (m *RedPacketClaimAuthMgo) MarkUsed(ctx context.Context, authNonce string) error {
res, err := m.coll.UpdateOne(ctx,
bson.M{"auth_nonce": authNonce},
bson.M{"$set": bson.M{"used": true}},
)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return errs.ErrRecordNotFound.WrapMsg("claim auth not found", "authNonce", authNonce)
}
return nil
}
// ---- RedPacketRefund ----
type RedPacketRefundMgo struct {
coll *mongo.Collection
}
func NewRedPacketRefundMongo(db *mongo.Database) (database.RedPacketRefund, error) {
coll := db.Collection("red_packet_refund")
_, err := coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{
Keys: bson.D{{Key: "tx_hash", Value: 1}},
Options: options.Index().SetUnique(true),
})
if err != nil {
return nil, err
}
return &RedPacketRefundMgo{coll: coll}, nil
}
func (m *RedPacketRefundMgo) Save(ctx context.Context, refund *model.RedPacketRefund) error {
_, err := m.coll.UpdateOne(ctx,
bson.M{"tx_hash": refund.TxHash},
bson.M{"$setOnInsert": refund},
options.Update().SetUpsert(true),
)
return err
}
func (m *RedPacketRefundMgo) GetByPacketID(ctx context.Context, packetID string) (*model.RedPacketRefund, error) {
var r model.RedPacketRefund
err := m.coll.FindOne(ctx, bson.M{"packet_id": packetID}).Decode(&r)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errs.ErrRecordNotFound.WrapMsg("refund not found", "packetID", packetID)
}
return nil, err
}
return &r, nil
}
// ---- WalletBindingChallenge ----
type WalletBindingChallengeMgo struct {
coll *mongo.Collection
}
func NewWalletBindingChallengeMongo(db *mongo.Database) (database.WalletBindingChallenge, error) {
coll := db.Collection("wallet_binding_challenge")
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
{
Keys: bson.D{{Key: "challenge_id", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{Key: "user_id", Value: 1}},
},
{
Keys: bson.D{{Key: "wallet_address", Value: 1}},
},
})
if err != nil {
return nil, err
}
return &WalletBindingChallengeMgo{coll: coll}, nil
}
func (m *WalletBindingChallengeMgo) Create(ctx context.Context, challenge *model.WalletBindingChallenge) error {
_, err := m.coll.InsertOne(ctx, challenge)
return err
}
func (m *WalletBindingChallengeMgo) Get(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error) {
var c model.WalletBindingChallenge
err := m.coll.FindOne(ctx, bson.M{"challenge_id": challengeID}).Decode(&c)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errs.ErrRecordNotFound.WrapMsg("wallet binding challenge not found", "challengeID", challengeID)
}
return nil, err
}
return &c, nil
}
func (m *WalletBindingChallengeMgo) Update(ctx context.Context, c *model.WalletBindingChallenge) error {
updates := bson.M{
"status": c.Status,
"signature": c.Signature,
"verified_at": c.VerifiedAt,
"updated_at": c.UpdatedAt,
}
res, err := m.coll.UpdateOne(ctx, bson.M{"challenge_id": c.ChallengeID}, bson.M{"$set": updates})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return errs.ErrRecordNotFound.WrapMsg("wallet binding challenge not found", "challengeID", c.ChallengeID)
}
return nil
}
// ---- WalletBinding ----
type WalletBindingMgo struct {
coll *mongo.Collection
}
func NewWalletBindingMongo(db *mongo.Database) (database.WalletBinding, error) {
coll := db.Collection("wallet_binding")
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
{
Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "chain_type", Value: 1}, {Key: "wallet_address", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{Key: "user_id", Value: 1}},
},
})
if err != nil {
return nil, err
}
return &WalletBindingMgo{coll: coll}, nil
}
// GetExpiredPending returns red packets that have expired but are still in
// "ACTIVE" status (i.e., on-chain creation confirmed, not yet fully claimed or refunded).
func (m *RedPacketMgo) GetExpiredPending(ctx context.Context, now int64) ([]*model.RedPacket, error) {
cur, err := m.coll.Find(ctx, bson.M{
"status": "ACTIVE",
"expiry_at": bson.M{"$lt": now, "$gt": 0},
})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var out []*model.RedPacket
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
func (m *WalletBindingMgo) Upsert(ctx context.Context, b *model.WalletBinding) error {
filter := bson.M{
"user_id": b.UserID,
"chain_type": b.ChainType,
"wallet_address": b.WalletAddress,
}
updates := bson.M{
"chain_id": b.ChainID,
"status": b.Status,
"challenge_id": b.ChallengeID,
"verified_at": b.VerifiedAt,
"revoked_at": b.RevokedAt,
"updated_at": b.UpdatedAt,
}
setOnInsert := bson.M{
"created_at": b.CreatedAt,
}
_, err := m.coll.UpdateOne(ctx, filter,
bson.M{"$set": updates, "$setOnInsert": setOnInsert},
options.Update().SetUpsert(true),
)
return err
}
func (m *WalletBindingMgo) GetActive(ctx context.Context, userID, chainType, walletAddress string) (*model.WalletBinding, error) {
var b model.WalletBinding
err := m.coll.FindOne(ctx, bson.M{
"user_id": userID,
"chain_type": chainType,
"wallet_address": walletAddress,
"status": "ACTIVE",
}).Decode(&b)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errs.ErrRecordNotFound.WrapMsg("active wallet binding not found", "userID", userID, "chainType", chainType, "walletAddress", walletAddress)
}
return nil, err
}
return &b, nil
}
// ---- AdminAuditLog ----
type AdminAuditLogMgo struct {
coll *mongo.Collection
}
func NewAdminAuditLogMongo(db *mongo.Database) (database.AdminAuditLog, error) {
coll := db.Collection("admin_audit_log")
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
{Keys: bson.D{{Key: "operator_id", Value: 1}}},
{Keys: bson.D{{Key: "created_at", Value: -1}}},
})
if err != nil {
return nil, err
}
return &AdminAuditLogMgo{coll: coll}, nil
}
func (m *AdminAuditLogMgo) Create(ctx context.Context, entry *model.AdminAuditLog) error {
_, err := m.coll.InsertOne(ctx, entry)
return err
}

@ -0,0 +1,55 @@
package database
import (
"context"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
)
type RedPacket interface {
Create(ctx context.Context, rp *model.RedPacket) error
GetByBizID(ctx context.Context, bizID string) (*model.RedPacket, error)
GetByPacketID(ctx context.Context, packetID string) (*model.RedPacket, error)
UpdateCreated(ctx context.Context, rp *model.RedPacket) error
UpdateStatus(ctx context.Context, packetID, status string) error
// UpdateClaimProgress atomically increments the claim counter for packetID.
// claimTxHash is used as an idempotency key so that re-processing the same
// on-chain transaction never double-counts. When status is empty the method
// auto-derives the correct status (COMPLETED or ACTIVE).
UpdateClaimProgress(ctx context.Context, packetID, claimedAmount, status, claimTxHash string) error
// GetExpiredPending returns ACTIVE packets whose expiry_at < now (unix seconds).
GetExpiredPending(ctx context.Context, now int64) ([]*model.RedPacket, error)
}
type RedPacketClaim interface {
Save(ctx context.Context, claim *model.RedPacketClaim) error
GetByPacketIDAndClaimer(ctx context.Context, packetID, claimer string) (*model.RedPacketClaim, error)
GetByPacketIDAndUserID(ctx context.Context, packetID, userID string) (*model.RedPacketClaim, error)
ListByPacketID(ctx context.Context, packetID string) ([]*model.RedPacketClaim, error)
}
type RedPacketClaimAuth interface {
Create(ctx context.Context, auth *model.RedPacketClaimAuth) error
Get(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error)
MarkUsed(ctx context.Context, authNonce string) error
}
type RedPacketRefund interface {
Save(ctx context.Context, refund *model.RedPacketRefund) error
GetByPacketID(ctx context.Context, packetID string) (*model.RedPacketRefund, error)
}
type AdminAuditLog interface {
Create(ctx context.Context, log *model.AdminAuditLog) error
}
type WalletBindingChallenge interface {
Create(ctx context.Context, challenge *model.WalletBindingChallenge) error
Get(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error)
Update(ctx context.Context, challenge *model.WalletBindingChallenge) error
}
type WalletBinding interface {
Upsert(ctx context.Context, binding *model.WalletBinding) error
GetActive(ctx context.Context, userID, chainType, walletAddress string) (*model.WalletBinding, error)
}

@ -0,0 +1,107 @@
package model
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type RedPacket struct {
BizID string `bson:"biz_id"`
ChainType string `bson:"chain_type"`
PacketID string `bson:"packet_id"`
ChainID int64 `bson:"chain_id"`
ContractAddress string `bson:"contract_address"`
CreatorUserID string `bson:"creator_user_id"`
CreatorWallet string `bson:"creator_wallet"`
GroupID string `bson:"group_id"`
ScopeType string `bson:"scope_type"`
ReceiverUserID string `bson:"receiver_user_id"`
ReceiverUserIDs []string `bson:"receiver_user_ids"`
PacketType int32 `bson:"packet_type"`
Token string `bson:"token"`
TotalAmount string `bson:"total_amount"`
TotalShares int32 `bson:"total_shares"`
ClaimedAmount string `bson:"claimed_amount"`
ClaimedShares int32 `bson:"claimed_shares"`
ProcessedClaimHashes []string `bson:"processed_claim_hashes"`
ExpiryAt int64 `bson:"expiry_at"`
TxHash string `bson:"tx_hash"`
Status string `bson:"status"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
type RedPacketClaim struct {
PacketID string `bson:"packet_id"`
UserID string `bson:"user_id"`
ClaimerWallet string `bson:"claimer_wallet"`
AuthNonce string `bson:"auth_nonce"`
ClaimTxHash string `bson:"claim_tx_hash"`
ClaimedAmount string `bson:"claimed_amount"`
BlockNumber uint64 `bson:"block_number"`
Status string `bson:"status"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
type RedPacketClaimAuth struct {
PacketID string `bson:"packet_id"`
Claimer string `bson:"claimer"`
AuthNonce string `bson:"auth_nonce"`
RandomSeed string `bson:"random_seed"`
Deadline int64 `bson:"deadline"`
Signature string `bson:"signature"`
Used bool `bson:"used"`
CreatedAt time.Time `bson:"created_at"`
}
type RedPacketRefund struct {
PacketID string `bson:"packet_id"`
RefundTo string `bson:"refund_to"`
TxHash string `bson:"tx_hash"`
Amount string `bson:"amount"`
CreatedAt time.Time `bson:"created_at"`
}
type WalletBindingChallenge struct {
ChallengeID string `bson:"challenge_id"`
UserID string `bson:"user_id"`
ChainType string `bson:"chain_type"`
ChainID int64 `bson:"chain_id"`
WalletAddress string `bson:"wallet_address"`
Nonce string `bson:"nonce"`
Message string `bson:"message"`
Protocol string `bson:"protocol"`
SignMethod string `bson:"sign_method"`
Status string `bson:"status"`
Signature string `bson:"signature"`
ExpiresAt time.Time `bson:"expires_at"`
VerifiedAt *time.Time `bson:"verified_at,omitempty"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
type WalletBinding struct {
UserID string `bson:"user_id"`
ChainType string `bson:"chain_type"`
ChainID int64 `bson:"chain_id"`
WalletAddress string `bson:"wallet_address"`
Status string `bson:"status"`
ChallengeID string `bson:"challenge_id"`
VerifiedAt time.Time `bson:"verified_at"`
RevokedAt *time.Time `bson:"revoked_at,omitempty"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
// AdminAuditLog records each admin operation for accountability.
type AdminAuditLog struct {
ID primitive.ObjectID `bson:"_id"`
OperatorID string `bson:"operator_id"`
Action string `bson:"action"`
Params string `bson:"params"` // JSON-encoded request
Result string `bson:"result"` // "success" | "failed"
ErrMsg string `bson:"err_msg"`
CreatedAt time.Time `bson:"created_at"`
}

@ -0,0 +1,14 @@
package rpcli
import (
pbredpacket "github.com/openimsdk/protocol/redpacket"
"google.golang.org/grpc"
)
func NewRedPacketClient(cc grpc.ClientConnInterface) *RedPacketClient {
return &RedPacketClient{pbredpacket.NewRedPacketClient(cc)}
}
type RedPacketClient struct {
pbredpacket.RedPacketClient
}

@ -0,0 +1,261 @@
#!/usr/bin/env bash
# ============================================================
# 红包 HTTP 接口测试create_order / created_callback
#
# 路由(与 internal/api/router.go 一致):
# POST ${HOST}/redpacket/create_order
# POST ${HOST}/redpacket/created_callback
#
# 鉴权:两接口均不在白名单,需在 Header 携带 token见 protocol/constant constant.Token = "token")。
# 追踪Header 需携带 operationID。
#
# 依赖curl、jq自动拉管理员 token 时另需 python3。
#
# 用法示例:
# chmod +x scripts/test/redpacket_api_test.sh
# GROUP_ID=你的群ID USER_ID=你的用户ID ./scripts/test/redpacket_api_test.sh
# ./scripts/test/redpacket_api_test.sh --host http://127.0.0.1:10002 --group-id xxx --try-callback
#
# 说明:
# - create_order 在 packetType=0拼手气固定份时要求 scopeType=GROUP 且当前用户在该群内。
# - 若 RPC 侧未配置 EVM chain clientcreated_callback 可走「离线」路径:传任意非空 txHash
# 并在 body 中提供与订单一致的 packetID见 internal/rpc/redpacket resolveCreatedPacket EVM 分支)。
# - 生产环境若已接链created_callback 需真实上链交易哈希,此时请自行设置 TX_HASH / PACKET_ID。
# ============================================================
set -euo pipefail
HOST="${HOST:-http://127.0.0.1:10002}"
USER_ID="${USER_ID:-5694418935}"
PLATFORM_ID="${PLATFORM_ID:-2}"
ADMIN_TOKEN="${ADMIN_TOKEN:-}"
OPENIM_SECRET="${OPENIM_SECRET:-openIM123}"
ADMIN_USER_ID="${ADMIN_USER_ID:-imAdmin}"
TOKEN="${TOKEN:-}"
GROUP_ID="${GROUP_ID:-}"
CHAIN_TYPE="${CHAIN_TYPE:-EVM}"
CHAIN_ID="${CHAIN_ID:-0}"
SCOPE_TYPE="${SCOPE_TYPE:-GROUP}"
PACKET_TYPE="${PACKET_TYPE:-0}"
CREATOR_WALLET="${CREATOR_WALLET:-0x0000000000000000000000000000000000000001}"
TOKEN_ADDR="${TOKEN_ADDR:-0x0000000000000000000000000000000000000000}"
TOTAL_AMOUNT="${TOTAL_AMOUNT:-100}"
TOTAL_SHARES="${TOTAL_SHARES:-5}"
EXPIRY_AT="${EXPIRY_AT:-0}"
REMARK="${REMARK:-api-test}"
TRY_CALLBACK="${TRY_CALLBACK:-0}"
TX_HASH="${TX_HASH:-0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}"
CALLBACK_PACKET_ID="${CALLBACK_PACKET_ID:-}"
while [[ $# -gt 0 ]]; do
case "$1" in
--host) HOST="$2"; shift 2 ;;
--user-id) USER_ID="$2"; shift 2 ;;
--platform-id) PLATFORM_ID="$2"; shift 2 ;;
--group-id) GROUP_ID="$2"; shift 2 ;;
--token) TOKEN="$2"; shift 2 ;;
--try-callback) TRY_CALLBACK="1"; shift ;;
*)
echo "未知参数: $1"
exit 1
;;
esac
done
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "缺少依赖命令: $1"
exit 1
}
}
need_cmd curl
need_cmd jq
op_id() {
echo "redpacket-test-$$-$(date +%s%N)"
}
get_admin_token() {
local uid body resp token last_resp
local -a candidates=("${ADMIN_USER_ID}" "openIM123456" "imAdmin")
last_resp=""
for uid in "${candidates[@]}"; do
body="{\"secret\":\"${OPENIM_SECRET}\",\"userID\":\"${uid}\"}"
resp="$(curl -sS -X POST "${HOST}/auth/get_admin_token" \
-H "Content-Type: application/json" \
-H "operationID: $(op_id)" \
-d "$body")"
last_resp="$resp"
token="$(python3 - <<'PY' "$resp"
import json
import sys
raw = sys.argv[1]
try:
obj = json.loads(raw)
except Exception:
print("")
raise SystemExit(0)
token = ""
if isinstance(obj, dict):
data = obj.get("data")
if isinstance(data, dict):
token = data.get("token") or data.get("Token") or ""
if not token:
token = obj.get("token") or obj.get("Token") or ""
print(token)
PY
)"
if [[ -n "$token" ]]; then
echo "自动获取管理员 token 成功userID=${uid}" >&2
printf '%s' "$token"
return 0
fi
done
echo "get_admin_token raw response: $last_resp" >&2
echo "自动获取管理员 token 失败,请检查 HOST/OPENIM_SECRET/ADMIN_USER_ID 或直接设置 ADMIN_TOKEN" >&2
exit 1
}
resolve_user_token() {
if [[ -n "${TOKEN}" ]]; then
echo "使用环境变量/参数 TOKEN跳过 get_user_token" >&2
return 0
fi
need_cmd python3
if [[ -z "${ADMIN_TOKEN}" ]]; then
echo "==> ADMIN_TOKEN 未设置,尝试自动获取管理员 token" >&2
ADMIN_TOKEN="$(get_admin_token)"
fi
echo "==> 获取用户 tokenuserID=${USER_ID}" >&2
local TOKEN_RESP
TOKEN_RESP=$(curl -sS -X POST \
-H "Content-Type: application/json" \
-H "operationID: $(op_id)" \
-H "token: ${ADMIN_TOKEN}" \
-d "{\"userID\":\"${USER_ID}\",\"platformID\":${PLATFORM_ID}}" \
"${HOST}/auth/get_user_token")
local ERR_CODE
ERR_CODE=$(echo "${TOKEN_RESP}" | jq -r '.errCode // "null"')
if [[ "${ERR_CODE}" != "0" ]]; then
echo "获取用户 token 失败: ${TOKEN_RESP}" >&2
exit 1
fi
TOKEN=$(echo "${TOKEN_RESP}" | jq -r '.data.token // empty')
if [[ -z "${TOKEN}" ]]; then
echo "token 为空: ${TOKEN_RESP}" >&2
exit 1
fi
echo "用户 token 获取成功" >&2
}
if [[ -z "${GROUP_ID}" ]]; then
echo "错误:未设置 GROUP_ID。固定份红包packetType=0需要 scopeType=GROUP 且 group_id 非空。" >&2
echo "示例GROUP_ID=你的群ID USER_ID=在群内的用户 ./scripts/test/redpacket_api_test.sh" >&2
exit 1
fi
resolve_user_token
echo "==> POST /redpacket/create_order"
CREATE_BODY=$(jq -n \
--arg chainType "${CHAIN_TYPE}" \
--argjson chainID "${CHAIN_ID}" \
--arg groupID "${GROUP_ID}" \
--arg scopeType "${SCOPE_TYPE}" \
--argjson packetType "${PACKET_TYPE}" \
--arg token "${TOKEN_ADDR}" \
--arg totalAmount "${TOTAL_AMOUNT}" \
--argjson totalShares "${TOTAL_SHARES}" \
--argjson expiryAt "${EXPIRY_AT}" \
--arg remark "${REMARK}" \
--arg creatorWallet "${CREATOR_WALLET}" \
'{
chainType: $chainType,
chainID: $chainID,
groupID: $groupID,
scopeType: $scopeType,
packetType: $packetType,
token: $token,
totalAmount: $totalAmount,
totalShares: $totalShares,
expiryAt: $expiryAt,
remark: $remark,
creatorWallet: $creatorWallet
}')
CREATE_RESP=$(curl -sS -X POST \
-H "Content-Type: application/json" \
-H "operationID: $(op_id)" \
-H "token: ${TOKEN}" \
-d "${CREATE_BODY}" \
"${HOST}/redpacket/create_order")
echo "${CREATE_RESP}" | jq .
CO_ERR=$(echo "${CREATE_RESP}" | jq -r '.errCode // "null"')
if [[ "${CO_ERR}" != "0" ]]; then
echo "create_order 失败errCode=${CO_ERR})。请确认 USER_ID/TOKEN 对应用户在 GROUP_ID 群内,且 totalAmount 可被 totalShares 整除(固定份)。" >&2
exit 1
fi
BIZ_ID=$(echo "${CREATE_RESP}" | jq -r '.data.bizID // empty')
if [[ -z "${BIZ_ID}" ]]; then
echo "create_order 返回 errCode=0 但 data.bizID 为空: ${CREATE_RESP}" >&2
exit 1
fi
echo "create_order 成功bizID=${BIZ_ID}"
if [[ "${TRY_CALLBACK}" != "1" ]]; then
echo "==> 未调用 created_callback设置 TRY_CALLBACK=1 或传入 --try-callback 以继续)"
echo " 离线 EVM可设置 CALLBACK_PACKET_ID默认用时间戳十进制字符串TX_HASH 可用环境变量 TX_HASH 覆盖。"
exit 0
fi
if [[ -z "${CALLBACK_PACKET_ID}" ]]; then
CALLBACK_PACKET_ID="$(date +%s)"
fi
echo "==> POST /redpacket/created_callbackbizID=${BIZ_ID}, packetID=${CALLBACK_PACKET_ID}"
CALLBACK_BODY=$(jq -n \
--arg bizID "${BIZ_ID}" \
--arg txHash "${TX_HASH}" \
--arg packetID "${CALLBACK_PACKET_ID}" \
--arg groupID "${GROUP_ID}" \
--arg scopeType "${SCOPE_TYPE}" \
'{
bizID: $bizID,
txHash: $txHash,
packetID: $packetID,
groupID: $groupID,
scopeType: $scopeType
}')
CALLBACK_RESP=$(curl -sS -X POST \
-H "Content-Type: application/json" \
-H "operationID: $(op_id)" \
-H "token: ${TOKEN}" \
-d "${CALLBACK_BODY}" \
"${HOST}/redpacket/created_callback")
echo "${CALLBACK_RESP}" | jq .
CB_ERR=$(echo "${CALLBACK_RESP}" | jq -r '.errCode // "null"')
if [[ "${CB_ERR}" != "0" ]]; then
echo "created_callback 失败errCode=${CB_ERR})。若已配置链上客户端,请使用真实交易哈希或关闭 TRY_CALLBACK。" >&2
exit 1
fi
echo "created_callback 成功,红包状态应已更新为 ACTIVE视部署与链配置而定。"
echo "测试通过: /redpacket/create_order + /redpacket/created_callback"
Loading…
Cancel
Save