From 3cbd9a37dd4c1f7b6b4db5744a1446ee7b0ce558 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:54:22 +0800 Subject: [PATCH] redpacket --- .../openim-rpc-redpacket/backend-api.md | 894 ++++++++-------- .../red-packet-go-backend-eth-tron.md | 840 ++++++---------- .../redpacket-web3-integration-design.md | 951 ++++++------------ internal/rpc/redpacket/redpacket.go | 36 +- internal/rpc/redpacket/service.go | 151 ++- protocol | 2 +- 6 files changed, 1216 insertions(+), 1658 deletions(-) diff --git a/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md b/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md index d1ccf73de..938537435 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md +++ b/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md @@ -1,341 +1,311 @@ # 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` -- Base URL(本地默认):`http://127.0.0.1:8080` -- 统一响应格式: +## 1. 基础约定 -```json -{ - "code": 0, - "message": "ok", - "data": {} -} -``` +### 1.1 Base URL -- 错误响应格式: +网关地址由 `openim-api` 部署决定,例如: -```json -{ - "code": 400, - "message": "invalid request body: ..." -} +```text +http://127.0.0.1:10002 ``` -## 健康检查 - -### GET `/health` - -用于服务存活探测。 +红包接口统一挂在: -#### 响应示例 - -```json -{ - "status": "ok" -} +```text +/redpacket ``` ---- - -## 用户侧接口 - -## 1) 创建业务订单 +### 1.2 鉴权 -### POST `/api/redpacket/create-order` +当前 `internal/api/router.go` 的 `Whitelist` 未包含 `/redpacket/*`,因此所有红包 HTTP 接口默认都需要登录 token。 -链上发交易前先创建业务订单,返回 `biz_id`。 +请求头: -#### 请求体 - -```json -{ - "creator_user_id": "u1001", - "creator_wallet": "0x1111111111111111111111111111111111111111", - "packet_type": 1, - "token": "0x2222222222222222222222222222222222222222", - "total_amount": "1000000000000000000", - "total_shares": 10, - "expiry_at": 0 -} +```http +token: +operationID: ``` -#### 字段说明 - -- `packet_type`: `0` 固定红包,`1` 拼手气红包,`2` 转账红包 -- `total_amount`: 链上最小单位的十进制字符串 -- `expiry_at`: Unix 秒时间戳,`0` 表示使用合约默认过期时间 +RPC 层不信任请求体中的 `user_id`。当前登录用户统一从 `mcontext.GetOpUserID(ctx)` 读取。 -#### 成功响应 +### 1.3 请求字段命名 -```json -{ - "code": 0, - "message": "ok", - "data": { - "biz_id": "f8a0f87e-d9cb-4d4a-8350-7bd43ab2e9a4" - } -} -``` - -#### 失败响应示例 - -```json -{ - "code": 400, - "message": "invalid token address" -} -``` +HTTP 请求建议使用 snake_case。网关使用 `a2r.ParseRequestNotCheck` 解析到 protobuf 请求对象。 ---- +示例: -## 2) 创建结果回写 +- HTTP: `packet_id` +- protobuf Go 字段: `PacketID` -### POST `/api/redpacket/created-callback` +### 1.4 响应格式 -前端在链上创建交易确认后,回写 `tx_hash` 和 `packet_id`。 +网关使用 `apiresp.GinSuccess` / `apiresp.GinError` 包装响应。不同 OpenIM 版本的外层字段可能略有差异,下面示例重点展示 `data` 内容。 -#### 请求体 +成功示意: ```json { - "biz_id": "f8a0f87e-d9cb-4d4a-8350-7bd43ab2e9a4", - "tx_hash": "0xabc123...", - "packet_id": "10001" + "errCode": 0, + "errMsg": "", + "data": {} } ``` -#### 成功响应 +失败示意: ```json { - "code": 0, - "message": "ok", - "data": { - "ok": true - } + "errCode": 1001, + "errMsg": "packet_id is required" } ``` -#### 失败响应示例 +## 2. 接口总览 -```json -{ - "code": 400, - "message": "biz_id is required" -} -``` +用户侧接口: ---- +- `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` -## 3) 红包详情 +管理员接口: -### GET `/api/redpacket/detail?packet_id={packetId}` +- `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 创建红包业务单 -```bash -curl "http://127.0.0.1:8080/api/redpacket/detail?packet_id=10001" +```text +POST /redpacket/create_order +gRPC: CreateOrder(CreateOrderReq) returns (CreateOrderResp) ``` -#### 成功响应 +链上创建红包前调用,服务端创建一条 `PENDING` 业务记录并返回 `biz_id`。 + +请求示例: ```json { - "code": 0, - "message": "ok", - "data": { - "biz_record": { - "id": 1, - "biz_id": "f8a0f87e-d9cb-4d4a-8350-7bd43ab2e9a4", - "packet_id": "10001", - "chain_id": 1, - "contract_address": "0xA1f42567559aBA5Ff0aac84cdE1AaF1F9DbB888F", - "creator_user_id": "u1001", - "creator_wallet": "0x1111111111111111111111111111111111111111", - "packet_type": 1, - "token": "0x2222222222222222222222222222222222222222", - "total_amount": "1000000000000000000", - "total_shares": 10, - "expiry_at": 0, - "tx_hash": "0xabc123...", - "status": "ACTIVE", - "created_at": "2026-04-24T07:00:00Z", - "updated_at": "2026-04-24T07:01:00Z" - }, - "claims": [ - { - "id": 10, - "packet_id": "10001", - "claimer_wallet": "0x3333333333333333333333333333333333333333", - "auth_nonce": "328840239847239847", - "claim_tx_hash": "0xdef456...", - "claimed_amount": "123456789", - "block_number": 1234567, - "status": "CONFIRMED", - "created_at": "2026-04-24T07:10:00Z", - "updated_at": "2026-04-24T07:10:00Z" - } - ] - } + "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 { - "code": 404, - "message": "packet not found: 10001" + "biz_id": "f8a0f87e-d9cb-4d4a-8350-7bd43ab2e9a4" } ``` ---- - -## 4) 申请领取签名 - -### POST `/api/redpacket/claim-sign` - -先做业务鉴权,再发放 `claim(...)` 所需签名参数。 - -#### 鉴权说明 +服务端写入: -- 该接口不再信任请求体中的 `user_id` -- 当前领取用户从 RPC / 网关注入的登录上下文中获取 -- 服务端要求请求上下文里存在 `opUserID` -- 如果缺少登录上下文,接口会直接拒绝 +- collection: `red_packet` +- status: `PENDING` +- creatorUserID: 来自登录上下文,不来自请求体 -#### 请求头 +### 3.2 创建交易回写 -- `token`: 用户登录 token +```text +POST /redpacket/created_callback +gRPC: CreatedCallback(CreatedCallbackReq) returns (CreatedCallbackResp) +``` -> 约定:上游网关或鉴权中间件需要先解析 token,并把当前登录用户写入请求上下文中的 `opUserID`。 +链上创建交易确认后调用,用于把 `biz_id` 与链上 `packet_id` / `tx_hash` 绑定。 -#### 请求体 +请求示例: ```json { + "biz_id": "f8a0f87e-d9cb-4d4a-8350-7bd43ab2e9a4", + "tx_hash": "0xabc123...", "packet_id": "10001", - "claimer": "0x3333333333333333333333333333333333333333", - "random_seed": "0" + "group_id": "g001", + "scope_type": "GROUP", + "receiver_user_id": "", + "receiver_user_ids": [] } ``` -> `random_seed` 可选;传 `0` 或空时后端自动生成。 - -#### 字段说明 +成功响应 `data`: -- `packet_id`: 红包链上 ID -- `claimer`: 本次真正发起链上 `claim(...)` 的钱包地址 -- `random_seed`: 可选随机种子;空或 `0` 时后端自动生成 - -#### 服务端处理逻辑 +```json +{} +``` -1. 从请求上下文提取当前登录用户 ID -2. 校验红包是否存在、是否过期、是否仍可领取 -3. 校验当前登录用户与 `claimer` 钱包地址的绑定关系 -4. 校验当前用户在该红包下是否已领取 -5. 校验当前钱包在该红包下是否已领取 -6. 按红包类型校验群资格 / 指定接收人资格 -7. 生成 `auth_nonce`、`deadline`、`random_seed` -8. 调合约 `getSignMessage(packetId, claimer, authNonce, randomSeed, deadline)` 获取摘要 -9. 使用后端 `signer` 私钥对摘要裸签名 -10. 落库 `red_packet_claim_auth` -11. 返回前端发链所需参数 +服务端逻辑: -#### 成功后前端下一步 +- `biz_id` 与 `tx_hash` 必填 +- 如果链客户端可用,会解析交易 receipt 中的 `PacketCreated` +- 解析成功后校验 creator、packetType、token、amount、shares、expiry 是否与业务单一致 +- 如果链客户端不可用或解析失败,但请求提供了 `packet_id`,会使用 fallback +- 成功后更新 `red_packet.status=ACTIVE` -前端拿到响应后,直接调用链上: +### 3.3 查询红包详情 ```text -claim(packetId, authNonce, randomSeed, deadline, signature) +POST /redpacket/detail +gRPC: GetDetail(GetDetailReq) returns (GetDetailResp) ``` -#### 成功响应 +请求示例: ```json { - "code": 0, - "message": "ok", - "data": { - "auth_nonce": "328840239847239847", - "deadline": 1777012345, - "signature": "0x7b1e...a2", - "random_seed": "8888812345" - } + "packet_id": "10001" } ``` -#### 常见失败响应 - -无资格领取: +成功响应 `data`: ```json { - "code": 403, - "message": "already claimed" + "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 + } + ] } ``` -同一用户已领取: +说明: -```json -{ - "code": 403, - "message": "user already claimed" -} -``` +- `created_at` / `updated_at` 为 Unix 秒 +- `claims` 按 Mongo 查询返回,DAO 层按 `created_at desc` 排序 -钱包未绑定: +### 3.4 申请领取签名 -```json -{ - "code": 403, - "message": "wallet is not bound to user" -} +```text +POST /redpacket/issue_claim_sign +gRPC: IssueClaimSign(IssueClaimSignReq) returns (IssueClaimSignResp) ``` -缺少登录上下文: +请求示例: ```json { - "code": 403, - "message": "op user id missing in context" + "packet_id": "10001", + "claimer": "0x3333333333333333333333333333333333333333", + "random_seed": "0" } ``` -签名服务异常: +成功响应 `data`: ```json { - "code": 500, - "message": "failed to issue claim signature: getSignMessage: ..." + "auth_nonce": "328840239847239847", + "deadline": 1777012345, + "signature": "0x7b1e...a2", + "random_seed": "8888812345" } ``` ---- - -## 5) 领取结果回写(可选) - -### POST `/api/redpacket/claim-result` +校验逻辑: -前端在领取交易提交后可调用该接口预写记录。最终状态仍以链监听(indexer)为准。 +1. 当前用户必须存在:`mcontext.GetOpUserID(ctx) != ""` +2. `packet_id` 与 `claimer` 必填 +3. 红包必须存在且 `status=ACTIVE` +4. 未过期、未退款 +5. 当前用户与 `claimer` 钱包必须有 `ACTIVE` 绑定 +6. 同一用户 / 同一钱包不能重复领取 +7. 固定红包和拼手气红包要求 `group_id` 存在 +8. 转账红包要求当前用户为 `receiver_user_id` -#### 鉴权说明 +签名逻辑: -- 该接口不再接收可信 `user_id` -- 当前用户从 RPC / 网关注入的登录上下文中获取 -- 服务端要求请求上下文里存在 `opUserID` +- EVM client 可用时调用 `getSignMessage(packetId, claimer, authNonce, randomSeed, deadline)` 获取 digest +- 使用 `chain.signerPrivateKey` 裸签 digest +- `v` 从 0/1 调整为 27/28 +- 如果 signer 私钥未配置,当前代码会返回 placeholder 签名,仅适合本地调试 -#### 请求头 +### 3.5 领取结果回写 -- `token`: 用户登录 token +```text +POST /redpacket/claim_result +gRPC: ClaimResult(ClaimResultReq) returns (ClaimResultResp) +``` -#### 请求体 +请求示例: ```json { @@ -345,63 +315,28 @@ claim(packetId, authNonce, randomSeed, deadline, signature) } ``` -#### 字段说明 - -- `packet_id`: 红包链上 ID -- `claimer`: 发起链上领取的钱包地址 -- `tx_hash`: 领取交易哈希 - -#### 服务端处理逻辑 - -1. 从请求上下文提取当前登录用户 ID -2. 先落一条 `PENDING` 领取记录 -3. 如果当前节点能立即解析该交易 receipt,则补全: - - `auth_nonce` - - `claimed_amount` - - `block_number` - - `status=CONFIRMED` -4. 如果当前节点暂时拿不到 receipt,则保持 `PENDING` -5. 最终仍以链监听器写入结果为准 - -#### 成功响应 - -```json -{ - "code": 0, - "message": "ok", - "data": { - "ok": true - } -} -``` - -#### 失败响应示例 +成功响应 `data`: ```json -{ - "code": 403, - "message": "op user id missing in context" -} +{} ``` ---- - -## 6) 钱包绑定挑战 - -### POST `/api/redpacket/wallet-bind/challenge` - -生成钱包绑定挑战消息,前端拿到消息后调用钱包签名。 +服务端逻辑: -#### 鉴权说明 +- 先保存一条 `PENDING` claim +- 若能立即解析 `PacketClaimed` 事件,则更新为 `CONFIRMED` +- 成功解析后会累计 `claimed_amount` / `claimed_shares` +- 红包领完时状态变为 `COMPLETED` +- 如果 receipt 暂不可用,保持 `PENDING`,等待 indexer 补偿 -- 该接口不再信任请求体中的 `user_id` -- 当前用户从 RPC / 网关注入的登录上下文中获取 +### 3.6 发起钱包绑定挑战 -#### 请求头 - -- `token`: 用户登录 token +```text +POST /redpacket/wallet_bind/challenge +gRPC: IssueWalletBindChallenge(IssueWalletBindChallengeReq) +``` -#### 请求体 +请求示例: ```json { @@ -413,44 +348,38 @@ claim(packetId, authNonce, randomSeed, deadline, signature) } ``` -#### 成功响应 +成功响应 `data`: ```json { - "code": 0, - "message": "ok", - "data": { - "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:\n0x3333333333333333333333333333333333333333\n\nBind wallet 0x3333333333333333333333333333333333333333 to user u2002.\nURI: https://redpacket.example.com/wallet-bind\nVersion: 1\nChain ID: 1\nNonce: 7b7d8d48-9db6-4e95-9daa-40e9517a2a85\nIssued At: 2026-04-30T03:00:00Z\nExpiration Time: 2026-04-30T03:10:00Z\nRequest ID: 1f7d9b0d-7b43-4d84-bb11-65f2ecf7e321", - "issued_at": "2026-04-30T03:00:00Z", - "expires_at": "2026-04-30T03:10:00Z" - } + "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" } ``` -#### 前端下一步 - -前端收到响应后: - -1. 使用 `sign_method` 指定的钱包方法对 `message` 进行签名 -2. 把 `challenge_id + signature` 提交给 `/api/redpacket/wallet-bind/confirm` - ---- +说明: -## 7) 钱包绑定确认 +- EVM 使用 `siwe-eip4361` + `personal_sign` +- TRON 使用 `tron-signmessagev2` + `signMessageV2` +- challenge 有效期为 10 分钟 -### POST `/api/redpacket/wallet-bind/confirm` +### 3.7 确认钱包绑定 -提交钱包签名,服务端验签成功后建立钱包绑定关系。 +```text +POST /redpacket/wallet_bind/confirm +gRPC: ConfirmWalletBind(ConfirmWalletBindReq) +``` -#### 请求体 +请求示例: ```json { @@ -459,263 +388,316 @@ claim(packetId, authNonce, randomSeed, deadline, signature) } ``` -#### 成功响应 +成功响应 `data`: ```json { - "code": 0, - "message": "ok", - "data": { - "user_id": "u2002", - "chain_type": "EVM", - "chain_id": 1, - "wallet_address": "0x3333333333333333333333333333333333333333", - "status": "ACTIVE", - "verified_at": "2026-04-30T03:01:00Z" - } + "user_id": "u2002", + "chain_type": "EVM", + "chain_id": 1, + "wallet_address": "0x3333333333333333333333333333333333333333", + "status": "ACTIVE", + "verified_at": "2026-04-30T03:01:00Z" } ``` ---- - -## 8) 查询钱包绑定 - -### GET `/api/redpacket/wallet-bind/detail?chain_type={chainType}&wallet_address={walletAddress}` +当前限制: -查询当前登录用户与指定钱包地址的绑定详情。 +- EVM 验签已实现 +- TRON 验签当前返回 `TRON wallet binding verification is not implemented yet` -#### 鉴权说明 +### 3.8 查询钱包绑定 -- `user_id` 从登录上下文中获取,不需要也不应该由前端传入 - -#### 成功响应 - -```json -{ - "code": 0, - "message": "ok", - "data": { - "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" - } -} +```text +POST /redpacket/wallet_bind/detail +gRPC: GetWalletBinding(GetWalletBindingReq) ``` ---- - -## 管理员接口(建议加鉴权) - -以下接口属于管理员写链操作,依赖后端配置的 `config_admin_private_key`。 - -## 6) 设置 signer - -### POST `/admin/redpacket/set-signer` - -#### 请求体 +请求示例: ```json { - "new_signer": "0x4444444444444444444444444444444444444444" + "chain_type": "EVM", + "wallet_address": "0x3333333333333333333333333333333333333333" } ``` -#### 成功响应 +成功响应 `data`: ```json { - "code": 0, - "message": "ok", - "data": { - "tx_hash": "0xaaa111..." - } + "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. 管理员接口 -## 7) 设置 token 白名单与最小份额 +### 4.1 设置 signer -### POST `/admin/redpacket/set-token` +```text +POST /redpacket/admin/set_signer +gRPC: SetSigner(SetSignerReq) +``` -#### 请求体 +请求: ```json { - "token": "0x2222222222222222222222222222222222222222", - "allowed": true, - "min_share_amount": "1000000" + "signer_address": "0x4444444444444444444444444444444444444444" } ``` -#### 成功响应 +响应: ```json { - "code": 0, - "message": "ok", - "data": { - "tx_hash": "0xbbb222..." - } + "message": "signer address updated successfully" } ``` ---- +### 4.2 设置 token 白名单 -## 8) 设置默认过期时间 - -### POST `/admin/redpacket/set-expiry` +```text +POST /redpacket/admin/set_token +gRPC: SetToken(SetTokenReq) +``` -#### 请求体 +请求: ```json { - "duration": "86400" + "token_address": "0x2222222222222222222222222222222222222222", + "allowed": true, + "min_amount": "1000000" } ``` -#### 成功响应 +响应: ```json { - "code": 0, - "message": "ok", - "data": { - "tx_hash": "0xccc333..." - } + "message": "token configuration updated" } ``` ---- - -## 9) 设置是否允许所有 token +### 4.3 设置默认过期时间 -### POST `/admin/redpacket/set-allow-all-tokens` - -#### 请求体 - -```json -{ - "allow": false -} +```text +POST /redpacket/admin/set_expiry +gRPC: SetExpiry(SetExpiryReq) ``` -#### 成功响应 +请求: ```json { - "code": 0, - "message": "ok", - "data": { - "tx_hash": "0xddd444..." - } + "expiry_seconds": 86400 } ``` ---- - -## 10) 设置原生币开关 +### 4.4 设置是否允许所有 token -### POST `/admin/redpacket/set-native-token` - -#### 请求体 - -```json -{ - "enabled": true -} +```text +POST /redpacket/admin/set_allow_all_tokens +gRPC: SetAllowAllTokens(SetAllowAllTokensReq) ``` -#### 成功响应 +请求: ```json { - "code": 0, - "message": "ok", - "data": { - "tx_hash": "0xeee555..." - } + "allow_all": false } ``` ---- +### 4.5 设置原生币开关 -## 11) 按交易哈希解析事件 - -### POST `/admin/redpacket/parse-tx-events` - -支持 ETH/TRON 事件解码。 +```text +POST /redpacket/admin/set_native_token_enabled +gRPC: SetNativeTokenEnabled(SetNativeTokenEnabledReq) +``` -#### 请求体(ETH) +请求: ```json { - "chain": "eth", - "tx_hash": "0xabc123..." + "enabled": true } ``` -#### 请求体(TRON) +### 4.6 解析交易事件 + +```text +POST /redpacket/admin/parse_tx_events +gRPC: ParseTxEvents(ParseTxEventsReq) +``` + +请求: ```json { - "chain": "tron", - "tx_hash": "7d9e...txid" + "tx_hash": "0xabc123...", + "chain": "eth" } ``` -#### 成功响应(示例) +EVM 响应: ```json { - "code": 0, - "message": "ok", - "data": [ + "chain": "eth", + "tx_hash": "0xabc123...", + "events": [ { "name": "PacketCreated", "data": { "packetId": "10001", - "creator": "0x1111111111111111111111111111111111111111", - "packetType": 1 + "creator": "0x1111111111111111111111111111111111111111" } } ] } ``` -#### 失败响应示例 - -TRON 未配置: +TRON 当前响应: ```json { - "code": 503, - "message": "TRON client is not configured" + "chain": "tron", + "tx_hash": "7d9e...txid", + "note": "TRON event parsing not fully implemented in this version" } ``` -参数非法: +### 4.7 管理接口当前行为边界 -```json -{ - "code": 400, - "message": "chain must be \"eth\" or \"tron\"" -} -``` +- 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 +``` -1. `POST /api/redpacket/create-order` -2. 钱包发链上创建交易 -3. 解析 `PacketCreated.packetId` -4. `POST /api/redpacket/created-callback` -5. 用户领取前:`POST /api/redpacket/claim-sign` -6. 钱包调用合约 `claim(...)` -7. 可选:`POST /api/redpacket/claim-result` -8. 详情页查询:`GET /api/redpacket/detail?packet_id=...` +`chain.rpcURL` 为空时 EVM client 初始化会失败并降级;`tron.fullNodeURL` 为空时 TRON client 不启用。服务会继续启动。 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 index 025c9d026..ac35979dd 100644 --- 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 @@ -1,615 +1,357 @@ -# 红包 Go 后台对接(ETH + TRON) +# RedPacket Go 后端对接说明(ETH + TRON) -这份文档按你的需求给出三部分: -- 后端签名(`claim` 鉴权签名,ETH/TRON 通用) -- ETH 后台调用 + 通过 `txhash` 解析事件 -- TRON 后台调用流程 + 通过 `txhash` 解析事件 +本文档基于当前 OpenIM 版红包服务实现整理,重点说明 Go 后端如何接入 EVM / TRON 链能力、如何签发 claim 授权、如何解析交易事件,以及当前实现中哪些能力是完整实现、哪些仍是 mock 或待补齐。 -说明:以下签名逻辑严格对应当前合约 `RedPacketBase` 的 `getSignMessage/claim`。 +相关代码位置: ---- +- 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. 依赖 +## 1. 当前架构 -```bash -go get github.com/ethereum/go-ethereum@v1.14.12 +`openim-rpc-redpacket` 已经不再是独立 Gin + GORM 服务,而是标准 OpenIM RPC 服务: + +```text +openim-api + -> /redpacket/* HTTP API + -> pbredpacket.RedPacketClient + -> openim-rpc-redpacket + -> MongoDB + EVM/TRON clients ``` ---- +服务启动时会初始化: -## 2. 关键合约事实(当前仓库) +- MongoDB DAO:`controller.NewRedPacketDatabase(...)` +- EVM client:当 `chain.rpcURL` 与 `chain.contractAddress` 配置完整时启用 +- TRON client:当 `tron.fullNodeURL` 与 `tron.contractBase58` 配置完整时启用 +- signer 私钥:当 `chain.signerPrivateKey` 配置完整时用于 claim 裸签名 -- 签名结构体: - `Claim(uint256 packetId,address claimer,uint256 authNonce,uint256 randomSeed,uint256 deadline)` -- 领取函数: - `claim(packetId, authNonce, randomSeed, deadline, signature)` -- 重点事件: - - `PacketCreated(uint256,address,uint8,address,uint256,uint256,uint256)` - - `PacketClaimed(uint256,address,uint256,uint256,uint256,uint256)` - - `PacketRefunded(uint256,address,address,uint256)` +链客户端初始化失败不会阻止服务启动,但会导致链上确认、事件解析或签名 digest 获取降级。 ---- +## 2. 配置 -## 3. Go:后端 claim 签名(ETH/TRON 通用) +`config/openim-rpc-redpacket.yml` 示例: -合约里验签是 `ecrecover(getSignMessage(...), v, r, s)`,所以后端要对 `digest` 做裸签名,不要加 `personal_sign` 前缀。 +```yaml +rpc: + registerIP: "" + listenIP: 0.0.0.0 + autoSetPorts: false + ports: [10560] -```go -package redpacket - -import ( - "crypto/ecdsa" - "fmt" - "math/big" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" -) - -// SignClaimDigest 对合约返回的 digest 做裸签,返回 65 字节签名(r||s||v) -func SignClaimDigest(priv *ecdsa.PrivateKey, digest [32]byte) ([]byte, error) { - sig, err := crypto.Sign(digest[:], priv) - if err != nil { - return nil, err - } - // go-ethereum 返回 v 为 0/1;EVM 合约通常期望 27/28 - sig[64] += 27 - return sig, nil -} +prometheus: + enable: false + ports: [12560] -// RecoverAndCheckSigner 本地自检(可选) -func RecoverAndCheckSigner(digest [32]byte, sig []byte, expected common.Address) error { - if len(sig) != 65 { - return fmt.Errorf("invalid sig length: %d", len(sig)) - } - cpy := make([]byte, 65) - copy(cpy, sig) - if cpy[64] >= 27 { - cpy[64] -= 27 - } - pub, err := crypto.SigToPub(digest[:], cpy) - if err != nil { - return err - } - got := crypto.PubkeyToAddress(*pub) - if got != expected { - return fmt.Errorf("signer mismatch, got=%s want=%s", got.Hex(), expected.Hex()) - } - return nil -} +chain: + rpcURL: "https://eth-mainnet.g.alchemy.com/v2/xxx" + contractAddress: "0x..." + chainID: 1 + signerPrivateKey: "0x..." + configAdminPrivateKey: "0x..." -// BuildClaimTypeHash 仅当你要本地复算 digest 时才需要。 -func BuildClaimTypeHash() common.Hash { - return crypto.Keccak256Hash([]byte("Claim(uint256 packetId,address claimer,uint256 authNonce,uint256 randomSeed,uint256 deadline)")) -} +tron: + fullNodeURL: "https://api.trongrid.io" + contractBase58: "T..." + ownerBase58: "T..." + privateKeyHex: "..." + feeLimit: 100000000 -// BuildClaimStructHash 本地复算 structHash(可选)。 -func BuildClaimStructHash(packetId *big.Int, claimer common.Address, authNonce, randomSeed, deadline *big.Int) common.Hash { - typeHash := BuildClaimTypeHash() - encoded := make([]byte, 0, 32*6) - encoded = append(encoded, typeHash.Bytes()...) - encoded = append(encoded, common.LeftPadBytes(packetId.Bytes(), 32)...) - encoded = append(encoded, common.LeftPadBytes(claimer.Bytes(), 32)...) - encoded = append(encoded, common.LeftPadBytes(authNonce.Bytes(), 32)...) - encoded = append(encoded, common.LeftPadBytes(randomSeed.Bytes(), 32)...) - encoded = append(encoded, common.LeftPadBytes(deadline.Bytes(), 32)...) - return crypto.Keccak256Hash(encoded) -} +indexer: + pollInterval: 5 ``` -生产建议: -- 最稳妥方式是先链上调用 `getSignMessage(...)` 拿 `digest`,再签名。 -- `authNonce` 必须按 `claimer` 做幂等和防重。 -- `deadline` 建议 5~30 分钟。 +配置含义: ---- +- `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 -## 4. Go:ETH 后台调用 + txhash 解析事件 +安全建议: -### 4.1 通过 txhash 解析 `PacketCreated/PacketClaimed/PacketRefunded` +- `signerPrivateKey` 与 `configAdminPrivateKey` 必须分离 +- 生产不要把管理私钥明文放在普通配置文件中,建议接入 KMS/HSM 或密钥托管服务 +- `signerPrivateKey` 是高频签名密钥,权限只能用于 claim 授权,不应拥有合约配置权限 -```go -package redpacket - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "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/ethclient" -) - -type ParsedEvent struct { - Name string - Data map[string]any -} +## 3. Claim 签名 -func ParseEthEventsByTxHash(ctx context.Context, rpcURL, txHashHex, contractABIJSON string) ([]ParsedEvent, error) { - cli, err := ethclient.DialContext(ctx, rpcURL) - if err != nil { - return nil, err - } - defer cli.Close() - - txHash := common.HexToHash(txHashHex) - rcpt, err := cli.TransactionReceipt(ctx, txHash) - if err != nil { - return nil, err - } - - parsedABI, err := abi.JSON(strings.NewReader(contractABIJSON)) - if err != nil { - return nil, err - } - - var out []ParsedEvent - for _, lg := range rcpt.Logs { - ev, ok := eventFromLog(parsedABI, lg) - if ok { - out = append(out, ev) - } - } - return out, nil -} +### 3.1 合约签名事实 -func eventFromLog(parsedABI abi.ABI, lg *types.Log) (ParsedEvent, bool) { - if len(lg.Topics) == 0 { - return ParsedEvent{}, false - } - for name, e := range parsedABI.Events { - if e.ID != lg.Topics[0] { - continue - } - vals := map[string]any{} - - // 非 indexed 参数 - nonIndexed, err := e.Inputs.NonIndexed().Unpack(lg.Data) - if err != nil { - return ParsedEvent{}, false - } - n := 0 - idxTopic := 1 - for _, input := range e.Inputs { - if input.Indexed { - if idxTopic >= len(lg.Topics) { - return ParsedEvent{}, false - } - vals[input.Name] = decodeIndexedTopic(input.Type, lg.Topics[idxTopic]) - idxTopic++ - } else { - vals[input.Name] = nonIndexed[n] - n++ - } - } - return ParsedEvent{Name: name, Data: vals}, true - } - return ParsedEvent{}, false -} +当前后端签名逻辑对应合约的: -func decodeIndexedTopic(t abi.Type, topic common.Hash) any { - switch t.T { - case abi.AddressTy: - return common.BytesToAddress(topic.Bytes()[12:]) - default: - return topic - } -} +```text +getSignMessage(packetId, claimer, authNonce, randomSeed, deadline) +claim(packetId, authNonce, randomSeed, deadline, signature) +``` -func PrettyPrintEvents(events []ParsedEvent) string { - b, _ := json.MarshalIndent(events, "", " ") - return string(b) -} +后端流程: + +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 裸签名示例 -func MustReadABIFromArtifact(artifactJSON []byte) (string, error) { - var raw map[string]any - if err := json.Unmarshal(artifactJSON, &raw); err != nil { - return "", err - } - abiObj, ok := raw["abi"] - if !ok { - return "", fmt.Errorf("abi field not found") - } - abiBytes, err := json.Marshal(abiObj) - if err != nil { - return "", err - } - return string(abiBytes), nil +```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 } ``` -### 4.2 ETH 创建/领取调用(示意) +### 3.3 当前降级行为 -建议用 `abigen` 生成 Go binding 后调用(最稳)。 +当前代码有两个降级点: -`abigen` 示例: -```bash -abigen --abi abi/contracts/RedPacket.sol/RedPacket.json --pkg redpacket --type RedPacket --out redpacket_binding.go -``` +- EVM client 不可用时,后端会用本地 `keccak256(packetID:claimer:nonce:randomSeed:deadline)` 生成 digest;该 digest 不保证与合约一致,仅适合调试。 +- signer 私钥未配置时,后端会返回 placeholder 签名;该签名不能通过链上验签。 -调用流程: -1. `createFixedPacket/createRandomPacket/createTransfer` 发交易 -2. 拿到 `txHash` 后轮询 receipt -3. 用上面的 `ParseEthEventsByTxHash` 解出 `PacketCreated`,拿到 `packetId` -4. 后端签名下发给前端后,前端/后端发 `claim` -5. 用 `PacketClaimed.amount` 作为最终到账金额 +生产环境必须配置可用的 EVM client 和 signer 私钥。 ---- +## 4. ETH 接入 -## 5. Go:TRON 后台调用 + txhash 解析事件 +### 4.1 创建红包 -TRON 的 EVM 合约事件最终也是 topic/data 结构,因此事件解码可复用 EVM ABI。 +推荐调用顺序: -### 5.1 通过 txhash 解析 TRON 事件(推荐走 `/wallet/gettransactioninfobyid`) +1. 后端 `CreateOrder` 生成 `biz_id` +2. 前端或托管钱包发起链上创建交易 +3. 从 `PacketCreated` 事件解析 `packetId` +4. 调用 `CreatedCallback` 回写 `biz_id + tx_hash + packet_id` +5. 后端使用 EVM client 解析 receipt 并校验事件字段 +6. 校验通过后业务单变为 `ACTIVE` -```go -package redpacket - -import ( - "bytes" - "context" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common" -) - -type tronTxInfoResp struct { - ID string `json:"id"` - Log []struct { - Address string `json:"address"` // 合约地址hex(无0x) - Topics []string `json:"topics"` // topic hex(无0x) - Data string `json:"data"` // data hex(无0x) - } `json:"log"` +当前代码中的校验点: + +- `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..." } +``` -func ParseTronEventsByTxHash(ctx context.Context, tronFullNodeURL, txID, contractABIJSON string) ([]ParsedEvent, error) { - body := map[string]string{"value": txID} - buf, _ := json.Marshal(body) - - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, tronFullNodeURL+"/wallet/gettransactioninfobyid", bytes.NewReader(buf)) - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - raw, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("tron http %d: %s", resp.StatusCode, string(raw)) - } - - var info tronTxInfoResp - if err := json.Unmarshal(raw, &info); err != nil { - return nil, err - } - - parsedABI, err := abi.JSON(strings.NewReader(contractABIJSON)) - if err != nil { - return nil, err - } - - out := make([]ParsedEvent, 0, len(info.Log)) - for _, lg := range info.Log { - if len(lg.Topics) == 0 { - continue - } - topic0 := common.HexToHash("0x" + lg.Topics[0]) - - for name, e := range parsedABI.Events { - if e.ID != topic0 { - continue - } - vals := map[string]any{} - - dataBytes, err := hex.DecodeString(strings.TrimPrefix(lg.Data, "0x")) - if err != nil { - return nil, err - } - nonIndexed, err := e.Inputs.NonIndexed().Unpack(dataBytes) - if err != nil { - return nil, err - } - - n := 0 - idxTopic := 1 - for _, input := range e.Inputs { - if input.Indexed { - if idxTopic >= len(lg.Topics) { - return nil, fmt.Errorf("missing indexed topic for event %s", name) - } - t := common.HexToHash("0x" + lg.Topics[idxTopic]) - vals[input.Name] = decodeIndexedTopic(input.Type, t) - idxTopic++ - } else { - vals[input.Name] = nonIndexed[n] - n++ - } - } - - out = append(out, ParsedEvent{Name: name, Data: vals}) - break - } - } - - return out, nil +响应示例: + +```json +{ + "chain": "eth", + "tx_hash": "0xabc123...", + "events": [ + { + "name": "PacketCreated", + "data": { + "packetId": "10001", + "creator": "0x1111111111111111111111111111111111111111", + "packetType": "1" + } + } + ] } ``` -### 5.2 TRON 后台调用流程(实践) +核心事件: -1. 组装 ABI 参数(与 ETH 一样) -2. 调用 TRON FullNode 的 `trigger*contract` 生成未签名交易 -3. 用托管私钥签名交易并广播 -4. 根据返回 `txID` 调用上面的 `ParseTronEventsByTxHash` 解事件 +- `PacketCreated`: 创建成功,提供唯一可信 `packetId` +- `PacketClaimed`: 领取成功,提供实际领取金额 +- `PacketRefunded`: 退款成功,提供退款金额与接收方 -说明:TRON 发交易接口在不同节点服务(TronGrid/自建 FullNode/SDK 封装)字段细节略有差异,建议你在项目里固定一种(推荐固定 TronGrid 或 gotron-sdk 版本),避免线上环境差异。 +### 4.4 ETH 管理接口现状 ---- +当前 `internal/rpc/redpacket/admin.go` 中 EVM 管理接口是 mock: -## 6. 合约参数设置(管理员) +- `SetSigner` +- `SetToken` +- `SetExpiry` +- `SetAllowAllTokens` +- `SetNativeTokenEnabled` -需要 `CONFIG_ADMIN_ROLE` 的函数: -- `setSigner(address signer)` -- `setAllowAllTokens(bool allowAllTokens)` -- `setNativeTokenEnabled(bool enabled)` -- `setAllowedToken(address token, bool allowed, uint256 minShareAmount)` -- `setDefaultExpiryDuration(uint256 duration)` +这些接口在 EVM client 可用时只记录日志并返回成功 message,不会真正发链上交易。上线前如需后端托管管理交易,需要补充 EVM admin transaction 实现。 -对应配置事件(可按 `txhash` 解析校验): -- `SignerUpdated(oldSigner, newSigner)` -- `AllowAllTokensUpdated(allowAllTokens)` -- `NativeTokenEnabledUpdated(enabled)` -- `AllowedTokenUpdated(token, allowed, minShareAmount)` -- `DefaultExpiryDurationUpdated(duration)` +## 5. TRON 接入 -### 6.1 ETH:Go 设置合约参数(通用写法) +### 5.1 TRON 创建与领取 -```go -package redpacket - -import ( - "context" - "crypto/ecdsa" - "fmt" - "math/big" - "strings" - - ethereum "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" -) - -// SendEthAdminTx 通用管理员写调用: -// method 例如 "setNativeTokenEnabled" -// args 对应函数参数 -func SendEthAdminTx( - ctx context.Context, - rpcURL string, - contractAddr common.Address, - priv *ecdsa.PrivateKey, - contractABIJSON string, - method string, - args ...any, -) (common.Hash, error) { - cli, err := ethclient.DialContext(ctx, rpcURL) - if err != nil { - return common.Hash{}, err - } - defer cli.Close() - - from := crypto.PubkeyToAddress(priv.PublicKey) - nonce, err := cli.PendingNonceAt(ctx, from) - if err != nil { - return common.Hash{}, err - } - chainID, err := cli.NetworkID(ctx) - if err != nil { - return common.Hash{}, err - } - gasPrice, err := cli.SuggestGasPrice(ctx) - if err != nil { - return common.Hash{}, err - } - - parsedABI, err := abi.JSON(strings.NewReader(contractABIJSON)) - if err != nil { - return common.Hash{}, err - } - data, err := parsedABI.Pack(method, args...) - if err != nil { - return common.Hash{}, err - } - - msg := ethereum.CallMsg{ - From: from, To: &contractAddr, Data: data, Value: big.NewInt(0), - } - gasLimit, err := cli.EstimateGas(ctx, msg) - if err != nil { - return common.Hash{}, err - } - - tx := types.NewTransaction(nonce, contractAddr, big.NewInt(0), gasLimit, gasPrice, data) - signedTx, err := types.SignTx(tx, types.NewEIP155Signer(chainID), priv) - if err != nil { - return common.Hash{}, err - } - if err = cli.SendTransaction(ctx, signedTx); err != nil { - return common.Hash{}, err - } - return signedTx.Hash(), nil -} +TRON 合约兼容 EVM ABI 的 topic/data 事件模型,但地址、签名与交易广播流程和 EVM 不同。 -// 例子:开启原生币、放开所有 token、设置 token 白名单与最小份额 -func ExampleSetConfigEth(ctx context.Context, rpcURL, abiJSON, contractHex string, priv *ecdsa.PrivateKey, usdt common.Address) error { - contract := common.HexToAddress(contractHex) - - tx1, err := SendEthAdminTx(ctx, rpcURL, contract, priv, abiJSON, "setNativeTokenEnabled", true) - if err != nil { - return err - } - fmt.Println("setNativeTokenEnabled tx:", tx1.Hex()) - - tx2, err := SendEthAdminTx(ctx, rpcURL, contract, priv, abiJSON, "setAllowAllTokens", false) - if err != nil { - return err - } - fmt.Println("setAllowAllTokens tx:", tx2.Hex()) - - tx3, err := SendEthAdminTx(ctx, rpcURL, contract, priv, abiJSON, "setAllowedToken", usdt, true, big.NewInt(1_000_000)) - if err != nil { - return err - } - fmt.Println("setAllowedToken tx:", tx3.Hex()) - - return nil -} +当前后端支持: + +- 创建业务单时 `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 ``` -注意:`setAllowedToken(..., minShareAmount)` 的单位是 token 最小单位(例如 6 位精度 token,`1_000_000` 代表 1 个 token)。 +配置依赖: -### 6.2 TRON:Go 设置合约参数(FullNode HTTP) +- `tron.fullNodeURL` +- `tron.contractBase58` +- `tron.ownerBase58` +- `tron.privateKeyHex` +- `tron.feeLimit` -TRON 推荐流程:`triggersmartcontract -> gettransactionsign -> broadcasttransaction`。 +管理接口会把方法映射到合约调用: -```go -package redpacket - -import ( - "bytes" - "context" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/ethereum/go-ethereum/accounts/abi" -) - -func encodeTronParams(abiJSON, method string, args ...any) (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 -} +- `SetSigner` -> `setSigner` +- `SetToken` -> `setAllowedToken` +- `SetExpiry` -> `setDefaultExpiryDuration` +- `SetAllowAllTokens` -> `setAllowAllTokens` +- `SetNativeTokenEnabled` -> `setNativeTokenEnabled` -func postJSON(ctx context.Context, url string, body any, out any) error { - b, _ := json.Marshal(body) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - raw, _ := io.ReadAll(resp.Body) - 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 -} +### 5.3 TRON 事件解析现状 + +`ParseTxEvents(chain=tron)` 当前返回: -// SendTronAdminTx 示例: -// selector 例子 "setNativeTokenEnabled(bool)" -// methodName 例子 "setNativeTokenEnabled" -func SendTronAdminTx( - ctx context.Context, - fullNodeURL, ownerBase58, contractBase58, selector, methodName string, - feeLimit int64, - privateKeyHex string, - abiJSON string, - args ...any, -) (string, error) { - paramHex, err := encodeTronParams(abiJSON, methodName, args...) - if err != nil { - return "", err - } - - var triggerResp map[string]any - err = postJSON(ctx, fullNodeURL+"/wallet/triggersmartcontract", map[string]any{ - "owner_address": ownerBase58, - "contract_address": contractBase58, - "function_selector": selector, - "parameter": paramHex, - "fee_limit": feeLimit, - "call_value": 0, - "visible": true, - }, &triggerResp) - if err != nil { - return "", err - } - - txObj, ok := triggerResp["transaction"] - if !ok { - return "", fmt.Errorf("transaction not found in trigger response") - } - - var signedResp map[string]any - err = postJSON(ctx, fullNodeURL+"/wallet/gettransactionsign", map[string]any{ - "transaction": txObj, - "privateKey": privateKeyHex, - }, &signedResp) - if err != nil { - return "", err - } - - var broadcastResp map[string]any - err = postJSON(ctx, fullNodeURL+"/wallet/broadcasttransaction", signedResp, &broadcastResp) - if err != nil { - return "", err - } - if result, _ := broadcastResp["result"].(bool); !result { - return "", fmt.Errorf("broadcast failed: %v", broadcastResp) - } - - txid, _ := broadcastResp["txid"].(string) - return txid, nil +```json +{ + "chain": "tron", + "tx_hash": "7d9e...txid", + "note": "TRON event parsing not fully implemented in this version" } ``` -调用示例: -- `setNativeTokenEnabled(true)`: - `selector = "setNativeTokenEnabled(bool)"`,`methodName = "setNativeTokenEnabled"`,`args = true` -- `setAllowAllTokens(false)`: - `selector = "setAllowAllTokens(bool)"`,`methodName = "setAllowAllTokens"`,`args = false` -- `setAllowedToken(token, true, 1_000_000)`: - `selector = "setAllowedToken(address,bool,uint256)"`,`methodName = "setAllowedToken"`,`args = common.HexToAddress(tokenHexAddress), true, big.NewInt(1_000_000)` +后续如果要补齐,应实现: + +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. 部署检查清单 -## 7. 最小落地建议(直接可用) +上线前至少确认: -- 统一保存:`chain + txHash + packetId + eventName + rawEventJson` -- 创建成功:只认 `PacketCreated.packetId` -- 领取成功:只认 `PacketClaimed.amount` -- 退款成功:只认 `PacketRefunded.amount` -- 签名服务:`authNonce` 做地址维度去重;`deadline` 过期即废弃 +- `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 index 888d7b595..11b8d0740 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/redpacket-web3-integration-design.md +++ b/cmd/openim-rpc/openim-rpc-redpacket/redpacket-web3-integration-design.md @@ -1,751 +1,430 @@ # RedPacket Web3 接入设计文档 -## 1. 文档目标 +本文档描述红包系统在当前 OpenIM 架构中的 Web3 接入设计。内容以当前代码为准,覆盖前端、钱包、API 网关、RPC 服务、MongoDB、EVM/TRON 合约交互与事件回写。 -本文档用于指导 `RedPacket` 红包系统的 Web3 接入落地,覆盖: +## 1. 设计目标 -- 整体架构设计 -- 前端 / 钱包 / 后端 / 合约 / 监听服务 的职责划分 -- 初始化与配置流程 -- 创建红包流程 -- 领取红包流程 -- 退款流程 -- 关键接口定义 -- 关键数据流与安全边界 +业务目标: -本文档基于当前 `RedPacket` 合约规则整理: +- 支持固定红包、拼手气红包、待领取转账 +- 支持 EVM 链红包创建、领取签名、事件解析 +- 支持 TRON 链配置预留与部分管理交易能力 +- 支持用户钱包绑定,领取前强制校验“OpenIM 用户 ID + 钱包地址”的绑定关系 +- 通过 API 网关对外提供 HTTP 接口,内部保持标准 OpenIM gRPC 服务形态 -- 链上 `packetId` 由合约自增生成,创建成功后通过 `PacketCreated` 事件回传。fileciteturn1file1 -- `claim` 必须携带后端签名,签名消息绑定 `packetId + claimer + authNonce + randomSeed + deadline`,并与 `msg.sender` 强绑定。fileciteturn1file1 -- `createTransfer` 创建时不传 `recipient`,实际可领取人由后端签名中的 `claimer` 决定。fileciteturn1file1 -- 建议后端通过 `getSignMessage(...)` 获取 digest 后做裸签名,避免 `signMessage` 前缀导致链上验签失败。fileciteturn1file4 +安全目标: ---- +- 钱包归属必须先绑定后领取 +- claim 授权必须绑定 `packetId + claimer + authNonce + randomSeed + deadline` +- 同一用户、同一钱包对同一红包都不能重复领取 +- signer 私钥只用于 claim 授权,不用于合约配置 +- 管理类交易与高频签名职责分离 -## 2. 设计目标 +工程目标: -### 2.1 业务目标 +- RPC 服务接入 OpenIM 的配置、服务发现、日志和 MongoDB 体系 +- HTTP API 只做参数解析和 RPC 转发 +- MongoDB 保存业务状态、签名授权、领取记录和钱包绑定关系 +- 链上事件作为最终一致性的依据 -支持以下红包能力: +## 2. 当前系统边界 -- 普通红包(固定金额) -- 拼手气红包(随机金额) -- 待领取转账(创建时不传接收地址,领取时由后端鉴权)fileciteturn1file1 +已经实现: -### 2.2 安全目标 +- `openim-rpc-redpacket` 标准 RPC 入口 +- `/redpacket/*` API 网关路由 +- MongoDB 存储模型和 DAO +- EVM claim digest 获取、裸签名、事件解析 +- EVM 钱包绑定验签 +- TRON 钱包绑定 challenge 生成 +- TRON admin transaction 调用框架 +- 创建回写、领取回写、详情查询 -系统需明确区分两类链上信任地址: +仍需补齐: -1. **参数配置地址(configAdmin)** - - 用于调用配置类函数 - - 例如:`setSigner`、`setAllowAllTokens`、`setNativeTokenEnabled`、`setAllowedToken`、`setDefaultExpiryDuration`。fileciteturn1file4 - -2. **业务签名地址(signer)** - - 用于后端签发领取授权 - - 合约 `claim` 时通过验签校验是否为可信签名地址 - -### 2.3 工程目标 - -- 前端只负责钱包连接、读链、发交易、展示状态 -- 后端负责业务鉴权、nonce 管理、签名发放、审计落库 -- 合约负责最终状态机、权限控制、验签、防重放 -- 监听服务负责链上事件消费、对账与最终一致性 - ---- +- EVM admin 写链当前是 mock +- TRON 钱包绑定签名验签未实现 +- TRON 交易事件解析未完整实现 +- refund API 当前未对外暴露,仅有退款模型与事件预留 +- 管理接口未做独立管理员鉴权 +- 自动 indexer loop 当前只是配置和代码结构预留,主要回写仍依赖 callback / parse ## 3. 总体架构 -## 3.1 架构图 - ```mermaid flowchart LR - U[用户] --> FE[前端 / H5 / App] - FE --> W[钱包 Wallet] - FE --> BE[业务后端 API] - - BE --> AuthSvc[签名服务\n持有 signer 私钥] - BE --> AdminSvc[配置服务\n持有 configAdmin 私钥] - BE --> DB[(业务库 / 审计库)] - - W --> RP[RedPacket 合约] - AuthSvc --> RP - AdminSvc --> RP - - RP --> Indexer[链监听 / 索引服务] - Indexer --> DB + 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 ``` -## 3.2 模块职责 - -### 前端 / H5 / App - -负责: - -- 连接钱包 -- 获取当前链地址 -- 读取红包状态 -- 创建红包前预校验 -- 发起创建交易 -- 调后端获取领取签名 -- 发起领取交易 -- 解析交易回执与事件 - -### 钱包 - -负责: - -- 用户签名交易 -- 广播创建 / 领取 / 退款交易 -- 提供当前地址与网络信息 - -### 业务后端 API - -负责: - -- 业务单管理 -- 创建结果落库 -- 领取资格鉴权 -- 签名发放接口 -- 配置管理接口 -- 审计与风控 - -### 签名服务 - -负责: - -- 使用 `signer` 私钥对领取摘要做裸签名 -- 不参与链上参数修改 -- 不应持有配置类权限 - -### 配置服务 - -负责: - -- 使用 `configAdmin` 私钥调用配置类交易 -- 负责 `signer` 轮换与 token 配置变更 -- 不参与高频 claim 签名发放 - -### RedPacket 合约 - -负责: - -- 红包状态管理 -- 红包 ID 自增 -- 创建 / 领取 / 退款规则执行 -- claim 验签 -- nonce 防重放 -- 事件输出 - -### 链监听 / 索引服务 - -负责: - -- 监听 `PacketCreated / PacketClaimed / PacketRefunded` -- 解析事件并更新数据库 -- 做对账与最终一致性 - ---- - -## 4. 合约角色与权限模型 - -## 4.1 推荐角色 - -建议合约维护以下 3 类地址: - -- `owner`:最高权限,建议多签控制 -- `configAdmin`:参数配置地址 -- `signer`:后端业务签名地址 - -## 4.2 权限建议 - -| 角色 | 用途 | 是否高频 | 建议托管方式 | -|---|---|---:|---| -| `owner` | 设置 `configAdmin`、兜底治理 | 否 | 多签 / 冷钱包 | -| `configAdmin` | 修改 `signer`、token 配置、默认过期时间 | 低频 | KMS / HSM / 运维专用钱包 | -| `signer` | 签发 claim 授权 | 高频 | 独立签名服务 | - -## 4.3 合约与服务端的鉴权方式 - -链上无法识别“某个后端进程”,只能识别两种身份: +模块职责: -1. **交易发送者地址** - - 用于配置类操作 - - 通过 `msg.sender` 校验 +- App/H5/Web:连接钱包、发起链上交易、调用后端接口、展示红包状态 +- 钱包:签名交易、签名绑定 challenge、广播交易 +- openim-api:解析 HTTP 请求、注入登录用户上下文、调用 gRPC client +- openim-rpc-redpacket:业务鉴权、签名、存储、链上 receipt 解析 +- MongoDB:保存业务状态和审计数据 +- RedPacket 合约:维护链上红包状态、验签、防重放、转账结算 +- EVM/TRON client:链上读写、事件解析、管理交易预留 -2. **消息签名者地址** - - 用于领取授权 - - 通过 `ECDSA.recover(signature)` 校验 +## 4. 核心数据模型 -因此: +### 4.1 红包主记录 -- 配置鉴权依赖 `msg.sender == configAdmin` 或 `owner` -- claim 鉴权依赖 `recover(signature) == signer` +collection: `red_packet` ---- +保存内容: -## 5. 关键业务规则 +- `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` -### 5.1 红包 ID 规则 +### 4.2 领取授权 -- 链上红包 ID 由 `nextPacketId` 自增生成。fileciteturn1file1 -- 前端和后端都不能自己猜 `packetId`。 -- 创建成功后必须从 `PacketCreated` 事件中解析 `packetId`。fileciteturn1file0 +collection: `red_packet_claim_auth` -### 5.2 待领取转账规则 +保存每次签名发放: -- `createTransfer` 不接收 `recipient` 参数。fileciteturn1file1 -- 实际领取人由后端签名中的 `claimer` 决定。fileciteturn1file1 - -### 5.3 claim 鉴权规则 +- `packet_id` +- `claimer` +- `auth_nonce` +- `random_seed` +- `deadline` +- `signature` +- `used` +- `created_at` -`claim` 必须携带后端签名,签名字段绑定: +`auth_nonce` 建唯一索引,用于防止重复授权 nonce。 -- `packetId` -- `claimer` -- `authNonce` -- `randomSeed` -- `deadline` fileciteturn1file1 +### 4.3 领取记录 -并且签名应与 `msg.sender` 强绑定,不能被其他地址复用。fileciteturn1file1 +collection: `red_packet_claim` -### 5.4 过期规则 +保存链上领取回写结果: -- 红包过期后不可继续领取。fileciteturn1file1 -- 过期后可调用 `refund(packetId)` 退回剩余金额。fileciteturn1file1 -- 允许创建人或管理员调用退款。fileciteturn1file4 +- `packet_id` +- `user_id` +- `claimer_wallet` +- `auth_nonce` +- `claim_tx_hash` +- `claimed_amount` +- `block_number` +- `status` -### 5.5 最小份额规则 +重复领取判断同时检查: -不同 token 可在 `setAllowedToken(token, allowed, minShareAmount)` 中配置最小份额。fileciteturn1file1 +- `packet_id + user_id` +- `packet_id + claimer_wallet` -创建校验: +### 4.4 钱包绑定 -- 固定红包:`totalAmount / totalShares >= minShareAmount` -- 拼手气红包:`totalAmount >= totalShares * minShareAmount` -- 转账:`amount >= minShareAmount` fileciteturn1file1 +collection: ---- +- `wallet_binding_challenge` +- `wallet_binding` -## 6. 关键交互时序图 +绑定流程先保存 challenge,验签通过后 upsert active binding。领取签名前必须存在 `ACTIVE` 绑定。 -## 6.1 初始化与配置流程 +## 5. 创建红包流程 ```mermaid sequenceDiagram autonumber - participant Owner as Owner/多签 - participant ConfigSvc as 配置服务(configAdmin私钥) - participant RP as RedPacket合约 - participant DB as 审计库 - - Owner->>RP: setConfigAdmin(configAdminAddress) - RP-->>Owner: tx success - - ConfigSvc->>RP: setSigner(signerAddress) - RP-->>ConfigSvc: tx success - - ConfigSvc->>RP: setAllowAllTokens(...) - RP-->>ConfigSvc: tx success - - ConfigSvc->>RP: setNativeTokenEnabled(...) - RP-->>ConfigSvc: tx success - - ConfigSvc->>RP: setAllowedToken(token, allowed, minShareAmount) - RP-->>ConfigSvc: tx success - - ConfigSvc->>RP: setDefaultExpiryDuration(duration) - RP-->>ConfigSvc: tx success - - ConfigSvc->>DB: 记录配置变更审计 + 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 ``` -### 图意概述 - -该流程用于完成合约上线后的初始参数配置与权限分层。`owner` 负责设置 `configAdmin`,而日常配置由 `configAdmin` 地址发起。`signer` 地址由配置服务设置,用于后续领取签名验证。 - -### 边界条件 - -- `signer` 与 `configAdmin` 必须是不同地址,避免签名服务被攻破后直接具备配置权限。 -- `owner` 建议使用多签地址,不建议使用个人热钱包。 -- 所有配置写操作都应带链上事件与业务侧审计单。 - -### 异常路径与回退 - -- 如果配置交易失败,前端/后台应展示链上 revert 原因。 -- 如果设置 `signer` 失败,旧 `signer` 应继续有效,避免线上 claim 全量失败。 -- 如果 token 配置更新失败,前端仍应以链上真实配置为准。 - -### 性能与容量假设 +关键规则: -- 配置操作为低频操作,可接受链上确认延迟。 -- 配置写入频率极低,因此可优先保障安全性而非吞吐。 +- `biz_id` 由后端生成,链上没有该字段 +- `packet_id` 的可信来源是 `PacketCreated` +- 业务单先落 `PENDING`,链上确认后更新为 `ACTIVE` +- 如果 EVM client 可用,后端会校验 receipt 事件与业务单参数是否一致 +- `creator_user_id` 从 token 上下文获取,不能由前端传入 -### 版本与兼容性 +scope 规则: -- 若后续扩展角色(如 `pauser` / `upgrader`),建议继续沿用分权设计。 -- 配置事件建议保持向后兼容,便于监听服务稳定消费。 +- `PUBLIC`: 公开红包,不要求 `group_id` +- `GROUP`: 群红包,必须传 `group_id` +- `DIRECT`: 指定用户红包,必须传 `receiver_user_id` 或 `receiver_user_ids` ---- - -## 6.2 创建红包流程 +## 6. 钱包绑定流程 ```mermaid sequenceDiagram autonumber - participant U as 用户 - participant FE as 前端 - participant Wallet as 钱包 - participant RP as RedPacket合约 - participant BE as 业务后端 - participant DB as 业务库 - participant Indexer as 链监听服务 - - U->>FE: 打开创建红包页面 - FE->>RP: getAllTokenConfigs() - RP-->>FE: token配置 - - U->>FE: 输入金额/份数/过期时间/类型 - FE->>RP: getCreateValidation(token, packetType, totalAmount, totalShares) - RP-->>FE: 校验结果 - - alt 校验失败 - FE-->>U: 提示失败原因 - else 校验通过 - FE->>Wallet: 检查余额/allowance - Wallet-->>FE: 返回余额/授权状态 - - FE->>RP: staticCall(createXXX(...)) - RP-->>FE: 模拟通过 - - FE->>Wallet: 发起 createXXX 交易 - Wallet->>RP: createFixedPacket/createRandomPacket/createTransfer - RP-->>Wallet: tx hash - Wallet-->>FE: tx hash - - FE->>Wallet: wait receipt - Wallet-->>FE: receipt + logs - - FE->>FE: 解析 PacketCreated - FE->>FE: 得到 packetId - - FE->>BE: created-callback(bizId, txHash, packetId) - BE->>DB: 保存 bizId <-> txHash <-> packetId - - Indexer->>RP: 监听 PacketCreated - RP-->>Indexer: PacketCreated(...) - Indexer->>DB: 对账/补写 - - FE-->>U: 展示创建成功 - end + 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 ``` -### 图意概述 - -创建红包流程分为:读配置、权威校验、余额与授权检查、链上模拟、正式创建、事件解析、后端落库。`packetId` 的唯一可信来源是 `PacketCreated` 事件。fileciteturn1file0 - -### 边界条件 - -- 原生币需额外预留 gas,不应把余额全部作为 `totalAmount`。 -- ERC20 创建前需检查 `allowance >= totalAmount`。 -- `expiryAt == 0` 时由合约使用默认过期时间。fileciteturn1file4 - -### 异常路径与回退 +EVM 绑定: -- `getCreateValidation(...)` 返回 `passed == false` 时,应直接用 `code` 透传失败原因。fileciteturn1file3 -- `staticCall` 成功并不保证正式交易 100% 成功,链上配置变化、余额变化都可能导致最终失败。fileciteturn1file3 -- 若前端回传 `packetId` 失败,可由监听服务通过 `txHash` 和事件补写。 +- 使用 SIWE 风格 message +- `sign_method=personal_sign` +- 后端使用 Ethereum Signed Message 前缀 recover 地址 +- recover 地址必须等于 challenge 的 `wallet_address` -### 性能与容量假设 +TRON 绑定: -- 创建链路以用户交互为主,整体延迟由钱包签名和链确认决定。 -- `getAllTokenConfigs()` 适合页面初始化时缓存,减少重复读链。fileciteturn1file3 +- 当前仅生成 challenge +- `sign_method=signMessageV2` +- confirm 阶段尚未实现验签 -### 版本与兼容性 +安全边界: -- 创建页应优先依赖聚合只读接口,避免未来 token 规则变化导致前端多处改动。 -- 若未来扩展红包类型,建议继续复用 `getCreateValidation(...)` 做统一校验出口。 +- challenge 默认 10 分钟过期 +- challenge 只能从 `PENDING` 确认一次 +- 领取签名前必须查询到 active binding ---- - -## 6.3 领取红包流程 +## 7. 领取红包流程 ```mermaid sequenceDiagram autonumber - participant U as 用户 - participant FE as 前端 - participant Wallet as 钱包 - participant BE as 业务后端 - participant AuthSvc as 签名服务(signer私钥) - participant DB as 业务库 - participant RP as RedPacket合约 - participant Indexer as 链监听服务 - - U->>FE: 打开红包详情页 - FE->>Wallet: 获取当前地址 - Wallet-->>FE: userAddress - - FE->>RP: getPacketInfoForUser(packetId, userAddress) - RP-->>FE: packet/status/alreadyClaimed/canClaimByChain - - alt 链上预判不可领取 - FE-->>U: 展示不可领取状态 - else 可领取 - U->>FE: 点击领取 - FE->>BE: POST /claim-sign(packetId, claimer, randomSeed) - - BE->>DB: 查询业务资格/业务单 - DB-->>BE: 返回业务状态 - - alt 鉴权失败 - BE-->>FE: 拒绝签名 - FE-->>U: 提示无资格领取 - else 鉴权通过 - BE->>DB: 生成 authNonce - DB-->>BE: authNonce - - BE->>RP: getSignMessage(packetId, claimer, authNonce, randomSeed, deadline) - RP-->>BE: digest - - BE->>AuthSvc: 使用 signer 私钥裸签 digest - AuthSvc-->>BE: signature - - BE->>DB: 保存签名发放记录 - BE-->>FE: authNonce + randomSeed + deadline + signature - - FE->>Wallet: 调用 claim(packetId, authNonce, randomSeed, deadline, signature) - Wallet->>RP: 发起 claim - - RP->>RP: 校验 packet 状态 - RP->>RP: 校验 alreadyClaimed == false - RP->>RP: 校验 authNonce 未使用 - RP->>RP: recover(signature) == signer - RP->>RP: 计算领取金额 - RP->>RP: 更新红包剩余状态 - RP-->>Wallet: tx hash - - Wallet-->>FE: tx hash - FE->>Wallet: wait receipt - Wallet-->>FE: receipt + logs - - FE->>FE: 解析 PacketClaimed.amount - FE-->>U: 展示领取成功与实际金额 - - Indexer->>RP: 监听 PacketClaimed - RP-->>Indexer: PacketClaimed(...) - Indexer->>DB: 更新领取记录/红包状态 - end - end + 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 ``` -### 图意概述 - -领取链路是整个系统最核心的链路。前端只能做链上预判,最终是否允许领取由后端业务鉴权 + 后端签名 + 合约验签三者共同决定。`claim` 不是纯前端直连模式,而是“前端 + 后端签名服务 + 合约”三方联动。fileciteturn1file1 - -### 边界条件 - -- `authNonce` 必须对每个 `claimer` 唯一,不可重复。fileciteturn1file4 -- `deadline` 建议短时有效,如 5~30 分钟。fileciteturn1file4 -- `claimer` 应严格使用当前连接钱包地址,避免签给 A 地址却由 B 地址调用。 -- 拼手气红包最终领取金额必须以链上 `PacketClaimed.amount` 为准,前端不要本地复算。fileciteturn1file2 - -### 异常路径与回退 +领取前后端校验: -- 后端鉴权失败:直接拒绝签名。 -- `invalid signature`:签名人错误、参数不一致、`claimer` 被篡改、摘要计算不一致。fileciteturn1file1 -- `claim nonce used`:同地址重复使用 `authNonce`。fileciteturn1file1 -- `packet expired`:红包过期。fileciteturn1file1 +- 登录用户存在 +- 红包存在 +- 红包状态为 `ACTIVE` +- 红包未过期 +- 当前用户绑定了 `claimer` 钱包 +- 当前用户未领取 +- 当前钱包未领取 +- 群红包必须有关联群 +- 转账红包必须匹配指定接收用户 -### 性能与容量假设 +claim 签名字段: -- claim 为高频路径,签名服务应尽量轻量,避免承担复杂配置职责。 -- 建议签名接口短链路完成,仅依赖必要的业务状态查询与 nonce 生成。 -- 监听服务需具备幂等更新能力,防止事件重复消费。 - -### 版本与兼容性 +- `packetId` +- `claimer` +- `authNonce` +- `randomSeed` +- `deadline` -- 若签名结构变更,应同步升级合约 `CLAIM_TYPEHASH`、后端签名逻辑与前端参数组装。 -- 若未来切换 signer 地址,保留 `setSigner(...)` 即可平滑轮换。fileciteturn1file4 +前端必须原样把后端返回的参数传给合约 `claim(...)`。任一字段变化都会导致验签失败。 ---- +## 8. 管理配置流程 -## 6.4 过期退款流程 +管理员接口位于: -```mermaid -sequenceDiagram - autonumber - participant U as 创建人/管理员 - participant FE as 前端/后台 - participant Wallet as 钱包 - participant RP as RedPacket合约 - participant Indexer as 链监听服务 - participant DB as 业务库 - - U->>FE: 点击退款 - FE->>RP: 查询 packet 状态 - RP-->>FE: creator/expiryAt/refunded/remainingAmount - - alt 不可退款 - FE-->>U: 提示失败 - else 可退款 - FE->>Wallet: 调用 refund(packetId) - Wallet->>RP: 发起退款交易 - - RP->>RP: 校验 packet 存在 - RP->>RP: 校验已过期 - RP->>RP: 校验调用方是创建人或管理员 - RP->>RP: 退还 remainingAmount - RP->>RP: 标记 refunded = true - RP-->>Wallet: tx hash - - Wallet-->>FE: tx hash - FE-->>U: 提示退款已提交 - - Indexer->>RP: 监听 PacketRefunded - RP-->>Indexer: PacketRefunded(...) - Indexer->>DB: 更新状态为 REFUNDED - end +```text +/redpacket/admin/* ``` -### 图意概述 - -退款链路只允许在红包过期后执行,且调用方必须是创建人或管理员。成功后需通过 `PacketRefunded` 事件更新业务状态。fileciteturn1file4 - -### 边界条件 - -- 退款前必须确认 `refunded == false`。 -- 已领取完的红包理论上剩余金额为 0,退款交易应仍保持一致性处理。 -- 管理员退款与创建人退款都应有审计落库。 - -### 异常路径与回退 - -- 未过期调用应直接 revert。 -- 非创建人/管理员调用应直接拒绝。 -- 如果退款交易已提交但后端未更新,可由监听服务补偿。 - -### 性能与容量假设 - -- 退款为低频操作,对吞吐要求低。 -- 事件驱动回写可以接受秒级到分钟级延迟。 - -### 版本与兼容性 - -- 若未来增加自动退款策略,可在不改变 `refund(packetId)` 主接口的前提下扩展调度能力。 - ---- - -## 7. 关键接口表 - -## 7.1 合约接口表 - -| 分类 | 接口 | 参数 | 返回 | 说明 | -|---|---|---|---|---| -| 创建 | `createFixedPacket` | `token, totalAmount, totalShares, expiryAt` | `packetId` / tx receipt | 创建固定金额红包。fileciteturn1file4 | -| 创建 | `createRandomPacket` | `token, totalAmount, totalShares, expiryAt` | `packetId` / tx receipt | 创建拼手气红包。fileciteturn1file4 | -| 创建 | `createTransfer` | `token, amount, expiryAt` | `packetId` / tx receipt | 创建待领取转账,不传 recipient。fileciteturn1file1turn1file4 | -| 领取 | `claim` | `packetId, authNonce, randomSeed, deadline, signature` | tx receipt | 必须携带后端签名。fileciteturn1file1turn1file4 | -| 退款 | `refund` | `packetId` | tx receipt | 红包过期后退款。fileciteturn1file4 | -| 管理 | `setSigner` | `signer` | tx receipt | 设置验签地址。fileciteturn1file4 | -| 管理 | `setAllowAllTokens` | `allowAllTokens` | tx receipt | 设置是否允许所有 token。fileciteturn1file4 | -| 管理 | `setNativeTokenEnabled` | `enabled` | tx receipt | 设置原生币开关。fileciteturn1file4 | -| 管理 | `setAllowedToken` | `token, allowed, minShareAmount` | tx receipt | 设置 token 白名单与最小份额。fileciteturn1file1turn1file4 | -| 管理 | `setDefaultExpiryDuration` | `duration` | tx receipt | 设置默认过期时间。fileciteturn1file4 | -| 只读 | `getSignMessage` | `packetId, claimer, authNonce, randomSeed, deadline` | `bytes32 digest` | 后端获取摘要再裸签名。fileciteturn1file4 | -| 只读 | `getPacketInfoForUser` | `packetId, user` | `packet, status, alreadyClaimed, canClaimByChain` | 前端聚合查询红包状态。fileciteturn1file3 | -| 只读 | `getAllTokenConfigs` | - | token 配置聚合结果 | 页面初始化时获取 token 配置。fileciteturn1file3 | -| 只读 | `getCreateValidation` | `token, packetType, totalAmount, totalShares` | `passed/code/...` | 创建前权威校验。fileciteturn1file3 | - -## 7.2 后端 API 接口表 - -| 分类 | 接口 | 方法 | 关键入参 | 关键出参 | 说明 | -|---|---|---|---|---|---| -| 创建 | `/api/redpacket/create-order` | `POST` | 业务发红包参数 | `bizId` | 创建业务单,链前预落库 | -| 创建回写 | `/api/redpacket/created-callback` | `POST` | `bizId, txHash, packetId` | `ok` | 创建交易成功后回写链上 `packetId` | -| 详情 | `/api/redpacket/detail` | `GET` | `packetId` | 红包业务详情 | 返回分享页需要的业务信息 | -| 领取签名 | `/api/redpacket/claim-sign` | `POST` | `packetId, claimer, randomSeed` | `authNonce, deadline, signature` | 业务鉴权 + 发放 claim 授权 | -| 领取回写 | `/api/redpacket/claim-result` | `POST` | `packetId, txHash` | `ok` | 可选,最终仍以监听服务为准 | -| 配置 | `/admin/redpacket/set-signer` | `POST` | `newSigner` | `txHash` | 变更 signer | -| 配置 | `/admin/redpacket/set-token` | `POST` | `token, allowed, minShareAmount` | `txHash` | 更新 token 配置 | -| 配置 | `/admin/redpacket/set-expiry` | `POST` | `duration` | `txHash` | 更新默认过期时间 | - -## 7.3 事件表 - -| 事件 | 字段 | 用途 | -|---|---|---| -| `PacketCreated` | `packetId, creator, packetType, token, totalAmount, totalShares, expiryAt` | 创建成功后的唯一 `packetId` 来源。fileciteturn1file0turn1file1 | -| `PacketClaimed` | `packetId, claimer, amount, remainingAmount, remainingShares, authNonce` | 领取成功与实际领取金额来源。fileciteturn1file2 | -| `PacketRefunded` | `packetId, operator, refundTo, amount` | 退款确认与状态同步。fileciteturn1file4 | - ---- - -## 8. 关键数据表建议 +当前对外接口: -## 8.1 红包主表 `red_packet` - -| 字段 | 说明 | -|---|---| -| `id` | 自增主键 | -| `biz_id` | 业务单号 | -| `packet_id` | 链上红包 ID | -| `chain_id` | 链 ID | -| `contract_address` | 合约地址 | -| `creator_user_id` | 发红包业务用户 ID | -| `creator_wallet` | 发红包钱包地址 | -| `packet_type` | 红包类型 | -| `token` | token 地址 | -| `total_amount` | 总金额 | -| `total_shares` | 总份数 | -| `expiry_at` | 过期时间 | -| `tx_hash` | 创建交易哈希 | -| `status` | 业务状态 | -| `created_at` | 创建时间 | +- `set_signer` +- `set_token` +- `set_expiry` +- `set_allow_all_tokens` +- `set_native_token_enabled` +- `parse_tx_events` -## 8.2 领取授权表 `red_packet_claim_auth` +设计分权: -| 字段 | 说明 | -|---|---| -| `id` | 主键 | -| `packet_id` | 红包 ID | -| `claimer_wallet` | 领取地址 | -| `auth_nonce` | 授权 nonce | -| `random_seed` | 随机参数 | -| `deadline` | 过期时间 | -| `signature` | 后端签名 | -| `used` | 是否已使用 | -| `user_id` | 业务用户 ID | -| `created_at` | 创建时间 | - -## 8.3 领取记录表 `red_packet_claim` - -| 字段 | 说明 | -|---|---| -| `id` | 主键 | -| `packet_id` | 红包 ID | -| `claimer_wallet` | 领取地址 | -| `auth_nonce` | 使用的 nonce | -| `claim_tx_hash` | 领取交易哈希 | -| `claimed_amount` | 实际领取金额 | -| `block_number` | 区块号 | -| `status` | 状态 | -| `created_at` | 创建时间 | - -## 8.4 退款记录表 `red_packet_refund` - -| 字段 | 说明 | -|---|---| -| `id` | 主键 | -| `packet_id` | 红包 ID | -| `refund_tx_hash` | 退款交易哈希 | -| `refund_to` | 退款目标地址 | -| `amount` | 退款金额 | -| `status` | 状态 | -| `created_at` | 创建时间 | - ---- - -## 9. 前端接入建议 - -## 9.1 创建页 - -推荐顺序: +- owner / 多签:最高权限 +- config admin:低频参数配置 +- signer:高频 claim 授权签名 -1. 调 `getAllTokenConfigs()` 初始化页面配置。fileciteturn1file3 -2. 用户输入金额/份数后调 `getCreateValidation(...)`。fileciteturn1file3 -3. 检查余额 / allowance。 -4. 调 `staticCall` 做链上模拟。fileciteturn1file3 -5. 发创建交易。 -6. 从 `PacketCreated` 解析 `packetId`。fileciteturn1file0 -7. 回传后端落库。 +当前实现边界: -## 9.2 详情页 / 领取页 +- EVM 管理接口是 mock,只返回成功 message +- TRON 管理接口会尝试通过 FullNode 发交易 +- API 层未做独立管理员角色校验,生产必须补齐 -推荐顺序: +## 9. 事件与最终一致性 -1. 调 `getPacketInfoForUser(packetId, userAddress)`。fileciteturn1file3 -2. 若链上预判可领,展示领取按钮。 -3. 点击领取后先调后端 `/claim-sign`。 -4. 拿到 `authNonce + deadline + signature` 后再发 `claim(...)`。 -5. 从 `PacketClaimed.amount` 获取真实领取金额。fileciteturn1file2 +核心事件: ---- +- `PacketCreated`: 创建成功,获得链上 `packetId` +- `PacketClaimed`: 领取成功,获得真实领取金额 +- `PacketRefunded`: 退款成功,获得退款目标与金额 -## 10. 安全设计建议 +当前一致性策略: -## 10.1 分权 +- 创建阶段由 `created_callback` 回写,并在 EVM client 可用时解析 receipt 校验 +- 领取阶段由 `claim_result` 先保存 `PENDING`,能解析 receipt 时立即确认 +- 后续 indexer 可基于 `indexer.pollInterval` 扩展为后台轮询与补偿 -必须分离: +幂等建议: -- `configAdmin` 私钥 -- `signer` 私钥 +- 以 `tx_hash` 做领取回写幂等 +- 以 `biz_id` 做创建业务单幂等 +- 以 `packet_id + user_id` 和 `packet_id + claimer_wallet` 做重复领取判断 +- 事件重复消费时,只允许状态向前推进,不回退已确认状态 -不要使用同一把私钥同时做: +## 10. API 设计摘要 -- 配置交易 -- claim 签名 +用户侧: -## 10.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`: 钱包绑定 challenge +- `POST /redpacket/wallet_bind/confirm`: 钱包绑定确认 +- `POST /redpacket/wallet_bind/detail`: 查询当前用户的钱包绑定 -- `authNonce` 必须唯一,建议按 `claimer` 维度发号。fileciteturn1file4 -- claim 成功后链上立即标记 nonce 已使用。 +管理员侧: -## 10.3 签名规范 +- `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` -- 推荐通过 `getSignMessage(...)` 获取 digest。fileciteturn1file4 -- 后端对 digest 做裸签名。 -- 不要使用 `signMessage`,否则会添加前缀导致验签失败。fileciteturn1file4 +## 11. 前端接入建议 -## 10.4 审计与对账 +创建页: -- 所有配置变更写审计单 -- 所有签名发放写记录 -- 所有链上事件由监听服务落最终状态 -- `txHash -> packetId`、`packetId -> claim records` 都要可追溯。fileciteturn1file0 +1. 用户选择红包类型、金额、份数、过期时间和链 +2. 调用 `create_order` +3. 钱包发起链上创建交易 +4. 从 receipt 解析 `PacketCreated.packetId` +5. 调用 `created_callback` +6. 展示分享页或详情页 ---- +领取页: -## 11. 常见失败原因 +1. 查询 `detail` +2. 检查当前钱包是否已绑定 +3. 未绑定则先走 wallet bind +4. 调用 `issue_claim_sign` +5. 钱包发起 `claim(...)` +6. 调用 `claim_result` +7. 刷新 `detail` -| 错误 | 含义 | -|---|---| -| `invalid signature` | 签名不匹配、签名人错误、claimer 不匹配、参数被篡改。fileciteturn1file1 | -| `claim nonce used` | 同地址重复使用授权 nonce。fileciteturn1file1 | -| `packet expired` | 红包已过期。fileciteturn1file1 | -| `random packet amount too small` | 拼手气总额不满足最小份额。fileciteturn1file1 | -| `fixed packet amount too small` | 固定红包单份金额小于最小份额。fileciteturn1file1 | -| `transfer amount too small` | 转账金额小于最小份额。fileciteturn1file1 | -| `token not allowed` | token 未开放或被禁用。fileciteturn1file3 | -| `native token disabled` | 原生币红包未开放。fileciteturn1file3 | +钱包绑定页: ---- +1. 获取当前钱包地址和 chain type +2. 调用 `wallet_bind/challenge` +3. 按 `sign_method` 调钱包签名 +4. 调用 `wallet_bind/confirm` +5. 调用 `wallet_bind/detail` 验证绑定状态 -## 12. 落地建议 +## 12. 风险与待办 -推荐按以下顺序推进: +必须尽快处理: -1. **先完成合约分权改造** - - 增加 `configAdmin` - - 保留 `setSigner` - - claim 使用 `signer` 验签 +- 修复 `protocol/redpacket/redpacket.proto` 与当前 `internal/api` / `internal/rpc` 使用的 protobuf 类型不一致问题 +- 补充管理员接口的 OpenIM 管理员权限校验 +- 移除或保护 placeholder signature 降级路径 +- EVM admin 从 mock 改为真实交易或明确只允许前端钱包管理 -2. **再完成后端两类服务拆分** - - 配置服务 - - 签名服务 +按业务优先级处理: -3. **再接前端创建与领取流程** - - 创建页 - - 红包详情页 - - claim 签名获取接口 +- 补齐 TRON 绑定验签 +- 补齐 TRON 事件解析 +- 增加 refund HTTP/RPC 接口 +- 增加后台 indexer loop 与事件补偿 +- 增加管理员操作审计 collection -4. **最后补监听与审计** - - 事件消费 - - 对账补偿 - - 配置与签名审计 +上线检查: ---- +- API 网关能发现 `redPacket` RPC 服务 +- MongoDB 索引创建成功 +- signer 地址与合约 signer 一致 +- EVM RPC 能稳定获取 receipt +- claim 签名在测试链可通过合约验签 +- 钱包绑定 recover 地址与实际钱包一致 -## 13. 一句话总结 +## 13. 总结 -这套红包 Web3 接入的核心不是“前端直接调合约”,而是: +当前 RedPacket 的核心链路是: -> **前端负责发交易与展示,后端负责业务鉴权与签名发放,合约负责最终状态机与验签,监听服务负责最终一致性。** +```text +OpenIM 登录身份 + -> 钱包绑定 + -> 业务鉴权 + -> 后端 signer 裸签 claim digest + -> 前端钱包发 claim 交易 + -> 链上事件回写 MongoDB +``` +这条链路把“谁是 OpenIM 用户”“谁控制钱包”“谁有资格领取”“链上是否最终成功”分成四层校验,后端只签发授权,不直接替用户领取,从而保持用户资产操作仍由钱包确认。 diff --git a/internal/rpc/redpacket/redpacket.go b/internal/rpc/redpacket/redpacket.go index 1a9d7f653..360b50e74 100644 --- a/internal/rpc/redpacket/redpacket.go +++ b/internal/rpc/redpacket/redpacket.go @@ -9,6 +9,7 @@ import ( "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" @@ -25,14 +26,16 @@ type Config struct { type redPacketServer struct { pbredpacket.UnimplementedRedPacketServer - config *Config - db controller.RedPacketDatabase - chainClient *chain.ChainClient - tronClient *chain.TronClient - signerKey *ecdsa.PrivateKey + 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, _ discovery.SvcDiscoveryRegistry, server *grpc.Server) error { +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 @@ -109,12 +112,23 @@ func Start(ctx context.Context, conf *Config, _ discovery.SvcDiscoveryRegistry, } } + 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, + config: conf, + db: repo, + chainClient: chainClient, + tronClient: tronClient, + signerKey: signerKey, + groupClient: rpcli.NewGroupClient(groupConn), + relationClient: rpcli.NewRelationClient(friendConn), } pbredpacket.RegisterRedPacketServer(server, srv) diff --git a/internal/rpc/redpacket/service.go b/internal/rpc/redpacket/service.go index 22e2f15dc..220ee9cba 100644 --- a/internal/rpc/redpacket/service.go +++ b/internal/rpc/redpacket/service.go @@ -435,16 +435,120 @@ func (s *redPacketServer) validateCreateHook(ctx context.Context, req *pbredpack } } +// 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 +// - total_shares > 0 +// - total_amount must be divisible by total_shares (each share is an integer in min units) +// - scope-based group/friend relationship for the creator func (s *redPacketServer) validateFixedPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error { - return nil + total, err := validateCreateBaseFields(req) + if err != nil { + return err + } + if req.TotalShares <= 0 { + return errs.ErrArgs.WrapMsg("total_shares must be positive for fixed packet", "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 +// - total_shares > 0 +// - total_amount >= total_shares (at least 1 min unit per share) +// - scope-based group/friend relationship for the creator func (s *redPacketServer) validateRandomPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error { - return nil + total, err := validateCreateBaseFields(req) + if err != nil { + return err + } + if req.TotalShares <= 0 { + return errs.ErrArgs.WrapMsg("total_shares must be positive for random packet", "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 +// - total_shares == 1 +// - exactly one receiver_user_id, must be a friend of the creator func (s *redPacketServer) validateTransferPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error { - return nil + if _, err := validateCreateBaseFields(req); err != nil { + return err + } + if req.TotalShares != 1 { + return errs.ErrArgs.WrapMsg("transfer packet must have total_shares == 1", "totalShares", req.TotalShares) + } + receiverUserID := strings.TrimSpace(req.ReceiverUserID) + if receiverUserID == "" { + return errs.ErrArgs.WrapMsg("receiver_user_id is required for transfer packet") + } + if len(req.ReceiverUserIDs) > 0 { + return errs.ErrArgs.WrapMsg("transfer packet only supports a single receiver_user_id") + } + creatorUserID := mcontext.GetOpUserID(ctx) + if creatorUserID == "" { + return servererrs.ErrNoPermission.WrapMsg("op user id is empty") + } + return s.ensureFriendRelationship(ctx, creatorUserID, receiverUserID) } func buildFallbackCreatedPacket(rp *model.RedPacket, packetID string) *createdPacketSnapshot { @@ -587,13 +691,50 @@ func (s *redPacketServer) ensureWalletBinding(ctx context.Context, userID, claim return nil } -// ensureGroupEligibility reserves centralized group membership checks. +// 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 reserves centralized relation validation for transfer packets. +// ensureFriendRelationship verifies that creatorUserID and receiverUserID are friends +// (used by transfer red packets to require a pre-existing relationship). func (s *redPacketServer) ensureFriendRelationship(ctx context.Context, creatorUserID, receiverUserID string) error { + creatorUserID = strings.TrimSpace(creatorUserID) + receiverUserID = strings.TrimSpace(receiverUserID) + if creatorUserID == "" || receiverUserID == "" { + return errs.ErrArgs.WrapMsg("creator_user_id and receiver_user_id are required") + } + if creatorUserID == receiverUserID { + return nil + } + if s.relationClient == nil { + return servererrs.ErrInternalServer.WrapMsg("relation client is not initialized") + } + ok, err := s.relationClient.IsFriend(ctx, creatorUserID, receiverUserID) + if err != nil { + return err + } + if !ok { + return errs.ErrNoPermission.WrapMsg("creator and receiver are not friends", + "creatorUserID", creatorUserID, "receiverUserID", receiverUserID) + } return nil } diff --git a/protocol b/protocol index 9f69daaff..34a58a77d 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 9f69daaff1f7b46b971bb7b97cd993cd6302b41e +Subproject commit 34a58a77d26a3c133a4be9ce00affdca8b158ba4