mirror of
https://github.com/openimsdk/open-im-server.git
synced 2026-06-20 23:08:25 +08:00
Merge pull request #17 from sok-im/feature/redpacket_new1
Feature/redpacket new1
This commit is contained in:
commit
e8952672a4
92
cmd/openim-rpc/openim-rpc-redpacket/README.md
Normal file
92
cmd/openim-rpc/openim-rpc-redpacket/README.md
Normal file
@ -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.
|
||||
703
cmd/openim-rpc/openim-rpc-redpacket/backend-api.md
Normal file
703
cmd/openim-rpc/openim-rpc-redpacket/backend-api.md
Normal file
@ -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`: 已提交领取 txHash,receipt 尚未解析或未确认
|
||||
- `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 不启用。服务会继续启动。
|
||||
271
cmd/openim-rpc/openim-rpc-redpacket/client-integration-guide.md
Normal file
271
cmd/openim-rpc/openim-rpc-redpacket/client-integration-guide.md
Normal file
@ -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)
|
||||
12
cmd/openim-rpc/openim-rpc-redpacket/main.go
Normal file
12
cmd/openim-rpc/openim-rpc-redpacket/main.go
Normal file
@ -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 用户”“谁控制钱包”“谁有资格领取”“链上是否最终成功”分成四层校验,后端只签发授权,不直接替用户领取,从而保持用户资产操作仍由钱包确认。
|
||||
31
config/openim-rpc-redpacket.yml
Normal file
31
config/openim-rpc-redpacket.yml
Normal file
@ -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 ]
|
||||
|
||||
|
||||
22
go.mod
22
go.mod
@ -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
|
||||
|
||||
94
go.sum
94
go.sum
@ -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")
|
||||
|
||||
245
internal/api/redpacket.go
Normal file
245
internal/api/redpacket.go
Normal file
@ -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
|
||||
}
|
||||
|
||||
217
internal/rpc/redpacket/admin.go
Normal file
217
internal/rpc/redpacket/admin.go
Normal file
@ -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)
|
||||
}
|
||||
66
internal/rpc/redpacket/chain/abi/RedPacket.json
Normal file
66
internal/rpc/redpacket/chain/abi/RedPacket.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
207
internal/rpc/redpacket/chain/client.go
Normal file
207
internal/rpc/redpacket/chain/client.go
Normal file
@ -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
|
||||
}
|
||||
215
internal/rpc/redpacket/chain/indexer.go
Normal file
215
internal/rpc/redpacket/chain/indexer.go
Normal file
@ -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")
|
||||
}
|
||||
117
internal/rpc/redpacket/chain/parser.go
Normal file
117
internal/rpc/redpacket/chain/parser.go
Normal file
@ -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)
|
||||
}
|
||||
78
internal/rpc/redpacket/chain/parser_test.go
Normal file
78
internal/rpc/redpacket/chain/parser_test.go
Normal file
@ -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())
|
||||
}
|
||||
}
|
||||
298
internal/rpc/redpacket/chain/tron.go
Normal file
298
internal/rpc/redpacket/chain/tron.go
Normal file
@ -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
|
||||
}
|
||||
261
internal/rpc/redpacket/chain/tron_indexer.go
Normal file
261
internal/rpc/redpacket/chain/tron_indexer.go
Normal file
@ -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
|
||||
}
|
||||
90
internal/rpc/redpacket/chain/tron_test.go
Normal file
90
internal/rpc/redpacket/chain/tron_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
150
internal/rpc/redpacket/redpacket.go
Normal file
150
internal/rpc/redpacket/redpacket.go
Normal file
@ -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
|
||||
}
|
||||
989
internal/rpc/redpacket/service.go
Normal file
989
internal/rpc/redpacket/service.go
Normal file
@ -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
|
||||
}
|
||||
349
internal/rpc/redpacket/wallet.go
Normal file
349
internal/rpc/redpacket/wallet.go
Normal file
@ -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 {
|
||||
|
||||
47
pkg/common/cmd/rpc_redpacket.go
Normal file
47
pkg/common/cmd/rpc_redpacket.go
Normal file
@ -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
|
||||
}
|
||||
|
||||
160
pkg/common/storage/controller/redpacket.go
Normal file
160
pkg/common/storage/controller/redpacket.go
Normal file
@ -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)
|
||||
}
|
||||
539
pkg/common/storage/database/mgo/redpacket.go
Normal file
539
pkg/common/storage/database/mgo/redpacket.go
Normal file
@ -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
|
||||
}
|
||||
55
pkg/common/storage/database/redpacket.go
Normal file
55
pkg/common/storage/database/redpacket.go
Normal file
@ -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)
|
||||
}
|
||||
107
pkg/common/storage/model/redpacket.go
Normal file
107
pkg/common/storage/model/redpacket.go
Normal file
@ -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"`
|
||||
}
|
||||
14
pkg/rpcli/redpacket.go
Normal file
14
pkg/rpcli/redpacket.go
Normal file
@ -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
|
||||
}
|
||||
261
scripts/test/redpacket_api_test.sh
Executable file
261
scripts/test/redpacket_api_test.sh
Executable file
@ -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 client,created_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 "==> 获取用户 token(userID=${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_callback(bizID=${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…
x
Reference in New Issue
Block a user