diff --git a/cmd/openim-rpc/openim-rpc-redpacket/README.md b/cmd/openim-rpc/openim-rpc-redpacket/README.md new file mode 100644 index 000000000..c22d6f9f4 --- /dev/null +++ b/cmd/openim-rpc/openim-rpc-redpacket/README.md @@ -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. diff --git a/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md b/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md new file mode 100644 index 000000000..938537435 --- /dev/null +++ b/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md @@ -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: +operationID: +``` + +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 不启用。服务会继续启动。 diff --git a/cmd/openim-rpc/openim-rpc-redpacket/client-integration-guide.md b/cmd/openim-rpc/openim-rpc-redpacket/client-integration-guide.md new file mode 100644 index 000000000..aaf3b50d0 --- /dev/null +++ b/cmd/openim-rpc/openim-rpc-redpacket/client-integration-guide.md @@ -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: +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: +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: +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: +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) diff --git a/cmd/openim-rpc/openim-rpc-redpacket/main.go b/cmd/openim-rpc/openim-rpc-redpacket/main.go new file mode 100644 index 000000000..ab3cfb7df --- /dev/null +++ b/cmd/openim-rpc/openim-rpc-redpacket/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/openimsdk/open-im-server/v3/pkg/common/cmd" + "github.com/openimsdk/tools/system/program" +) + +func main() { + if err := cmd.NewRedPacketRpcCmd().Exec(); err != nil { + program.ExitWithError(err) + } +} diff --git a/cmd/openim-rpc/openim-rpc-redpacket/red-packet-go-backend-eth-tron.md b/cmd/openim-rpc/openim-rpc-redpacket/red-packet-go-backend-eth-tron.md new file mode 100644 index 000000000..ac35979dd --- /dev/null +++ b/cmd/openim-rpc/openim-rpc-redpacket/red-packet-go-backend-eth-tron.md @@ -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 签名链路 diff --git a/cmd/openim-rpc/openim-rpc-redpacket/redpacket-web3-integration-design.md b/cmd/openim-rpc/openim-rpc-redpacket/redpacket-web3-integration-design.md new file mode 100644 index 000000000..11b8d0740 --- /dev/null +++ b/cmd/openim-rpc/openim-rpc-redpacket/redpacket-web3-integration-design.md @@ -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 用户”“谁控制钱包”“谁有资格领取”“链上是否最终成功”分成四层校验,后端只签发授权,不直接替用户领取,从而保持用户资产操作仍由钱包确认。 diff --git a/config/openim-rpc-redpacket.yml b/config/openim-rpc-redpacket.yml new file mode 100644 index 000000000..58bed2edf --- /dev/null +++ b/config/openim-rpc-redpacket.yml @@ -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 diff --git a/config/share.yml b/config/share.yml index 610bad52f..fa06e5607 100644 --- a/config/share.yml +++ b/config/share.yml @@ -12,6 +12,7 @@ rpcRegisterName: captcha: captcha rtc: rtc crypto: crypto + redPacket: redPacket imAdminUserID: [ imAdmin ] diff --git a/go.mod b/go.mod index 771945bc4..aaec1268c 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index f1a738175..e2b405b71 100644 --- a/go.sum +++ b/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= diff --git a/internal/api/init.go b/internal/api/init.go index f236450f9..e11ae21c7 100644 --- a/internal/api/init.go +++ b/internal/api/init.go @@ -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") diff --git a/internal/api/redpacket.go b/internal/api/redpacket.go new file mode 100644 index 000000000..87e1f9845 --- /dev/null +++ b/internal/api/redpacket.go @@ -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) +} diff --git a/internal/api/router.go b/internal/api/router.go index ce16cd535..809dcecde 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/rpc/captcha/captcha.go b/internal/rpc/captcha/captcha.go index 206b376e5..5e5e9d75f 100644 --- a/internal/rpc/captcha/captcha.go +++ b/internal/rpc/captcha/captcha.go @@ -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 } diff --git a/internal/rpc/redpacket/admin.go b/internal/rpc/redpacket/admin.go new file mode 100644 index 000000000..e2802d7cb --- /dev/null +++ b/internal/rpc/redpacket/admin.go @@ -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) +} diff --git a/internal/rpc/redpacket/chain/abi/RedPacket.json b/internal/rpc/redpacket/chain/abi/RedPacket.json new file mode 100644 index 000000000..7040fd4fa --- /dev/null +++ b/internal/rpc/redpacket/chain/abi/RedPacket.json @@ -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" + } +] diff --git a/internal/rpc/redpacket/chain/client.go b/internal/rpc/redpacket/chain/client.go new file mode 100644 index 000000000..896e8c903 --- /dev/null +++ b/internal/rpc/redpacket/chain/client.go @@ -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 +} diff --git a/internal/rpc/redpacket/chain/indexer.go b/internal/rpc/redpacket/chain/indexer.go new file mode 100644 index 000000000..590b6049d --- /dev/null +++ b/internal/rpc/redpacket/chain/indexer.go @@ -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") +} diff --git a/internal/rpc/redpacket/chain/parser.go b/internal/rpc/redpacket/chain/parser.go new file mode 100644 index 000000000..a3e53113a --- /dev/null +++ b/internal/rpc/redpacket/chain/parser.go @@ -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) +} diff --git a/internal/rpc/redpacket/chain/parser_test.go b/internal/rpc/redpacket/chain/parser_test.go new file mode 100644 index 000000000..67448c705 --- /dev/null +++ b/internal/rpc/redpacket/chain/parser_test.go @@ -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()) + } +} diff --git a/internal/rpc/redpacket/chain/tron.go b/internal/rpc/redpacket/chain/tron.go new file mode 100644 index 000000000..08ff077da --- /dev/null +++ b/internal/rpc/redpacket/chain/tron.go @@ -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 +} diff --git a/internal/rpc/redpacket/chain/tron_indexer.go b/internal/rpc/redpacket/chain/tron_indexer.go new file mode 100644 index 000000000..526513367 --- /dev/null +++ b/internal/rpc/redpacket/chain/tron_indexer.go @@ -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 +} diff --git a/internal/rpc/redpacket/chain/tron_test.go b/internal/rpc/redpacket/chain/tron_test.go new file mode 100644 index 000000000..763bc5e48 --- /dev/null +++ b/internal/rpc/redpacket/chain/tron_test.go @@ -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) + } +} diff --git a/internal/rpc/redpacket/redpacket.go b/internal/rpc/redpacket/redpacket.go new file mode 100644 index 000000000..15b9b1139 --- /dev/null +++ b/internal/rpc/redpacket/redpacket.go @@ -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 +} diff --git a/internal/rpc/redpacket/service.go b/internal/rpc/redpacket/service.go new file mode 100644 index 000000000..71aad905a --- /dev/null +++ b/internal/rpc/redpacket/service.go @@ -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 +} diff --git a/internal/rpc/redpacket/wallet.go b/internal/rpc/redpacket/wallet.go new file mode 100644 index 000000000..f9f7de2d5 --- /dev/null +++ b/internal/rpc/redpacket/wallet.go @@ -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" +// - 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 +} diff --git a/pkg/common/cmd/constant.go b/pkg/common/cmd/constant.go index dd770f688..d2f0ad852 100644 --- a/pkg/common/cmd/constant.go +++ b/pkg/common/cmd/constant.go @@ -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 { diff --git a/pkg/common/cmd/rpc_redpacket.go b/pkg/common/cmd/rpc_redpacket.go new file mode 100644 index 000000000..bdeef818f --- /dev/null +++ b/pkg/common/cmd/rpc_redpacket.go @@ -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) +} diff --git a/pkg/common/config/config.go b/pkg/common/config/config.go index 0805e1767..40ce852b1 100644 --- a/pkg/common/config/config.go +++ b/pkg/common/config/config.go @@ -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 } diff --git a/pkg/common/storage/controller/redpacket.go b/pkg/common/storage/controller/redpacket.go new file mode 100644 index 000000000..7bdab8992 --- /dev/null +++ b/pkg/common/storage/controller/redpacket.go @@ -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) +} diff --git a/pkg/common/storage/database/mgo/redpacket.go b/pkg/common/storage/database/mgo/redpacket.go new file mode 100644 index 000000000..0cf51b4c5 --- /dev/null +++ b/pkg/common/storage/database/mgo/redpacket.go @@ -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 +} diff --git a/pkg/common/storage/database/redpacket.go b/pkg/common/storage/database/redpacket.go new file mode 100644 index 000000000..1a958e9c7 --- /dev/null +++ b/pkg/common/storage/database/redpacket.go @@ -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) +} diff --git a/pkg/common/storage/model/redpacket.go b/pkg/common/storage/model/redpacket.go new file mode 100644 index 000000000..82c2876ef --- /dev/null +++ b/pkg/common/storage/model/redpacket.go @@ -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"` +} diff --git a/pkg/rpcli/redpacket.go b/pkg/rpcli/redpacket.go new file mode 100644 index 000000000..a3e73628f --- /dev/null +++ b/pkg/rpcli/redpacket.go @@ -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 +} diff --git a/scripts/test/redpacket_api_test.sh b/scripts/test/redpacket_api_test.sh new file mode 100755 index 000000000..d90571a09 --- /dev/null +++ b/scripts/test/redpacket_api_test.sh @@ -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"