diff --git a/cmd/openim-rpc/openim-rpc-redpacket/README.md b/cmd/openim-rpc/openim-rpc-redpacket/README.md index 6a65d4269..894c29d90 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/README.md +++ b/cmd/openim-rpc/openim-rpc-redpacket/README.md @@ -3,6 +3,7 @@ A Web3 Red Packet service supporting Ethereum and TRON, following the design documents: - `backend-api.md` - API specifications +- `client-integration-guide.md` - Frontend / gateway integration guide - `redpacket-web3-integration-design.md` - Architecture and flows - `red-packet-go-backend-eth-tron.md` - Blockchain integration details @@ -14,9 +15,30 @@ A Web3 Red Packet service supporting Ethereum and TRON, following the design doc - ✅ Claim signature issuance (`/api/redpacket/claim-sign`) - ✅ Claim result reporting - ✅ SQLite/MySQL support -- ✅ Blockchain signature logic ready for ETH/TRON +- ✅ EVM signature generation via `getSignMessage(...)` +- ✅ Basic EVM event indexing for claim/refund synchronization +- ✅ Idempotent claim/refund persistence by transaction hash - ✅ Admin configuration endpoints +## Current Status + +This service is runnable and suitable for continued iteration, but it is not yet fully production-complete. + +Working well now: + +- EVM-side claim signing uses the real `authNonce` in the digest +- Claim pre-checks cover packet existence, active status, expiry, and already-claimed cases +- EVM ABI and event parsing are aligned with the current contract events +- Claim and refund events can be persisted idempotently + +Still incomplete: + +- ETH admin endpoints are still mostly mock behavior +- `PacketCreated` indexing is not yet fully wired for automatic order reconciliation +- TRON `getSignMessage` flow is not complete +- TRON event decoding is still a scaffold +- Admin APIs still need authentication and audit controls + ## Quick Start ```bash @@ -64,7 +86,7 @@ curl -X POST http://localhost:8080/api/redpacket/create-order \ │ ├── model/ # Database models (GORM) │ ├── repository/ # Data access layer │ ├── service/ # Business logic -│ └── chain/ # Blockchain integration (to be expanded) +│ └── chain/ # Blockchain integration and event indexing ├── pkg/resp/ # Response helpers ├── router/ # Route definitions ├── main.go @@ -72,27 +94,19 @@ curl -X POST http://localhost:8080/api/redpacket/create-order \ └── README.md ``` -## Next Steps (from design docs) - -1. **Full Blockchain Integration** - - Implement `ChainClient` for ETH and TRON - - Add event indexer for `PacketCreated`, `PacketClaimed`, `PacketRefunded` - - Implement proper signature generation using `getSignMessage` +## Recommended Next Steps -2. **Advanced Features** - - Admin configuration APIs (`setSigner`, `setToken`, etc.) - - Refund logic - - Rate limiting and authentication - - Monitoring and metrics - -3. **Production** - - Add proper authentication middleware - - Configure production database - - Set up monitoring and logging - - Deploy with Docker/K8s +1. Implement real ETH admin transactions for signer/token/expiry configuration +2. Finish `PacketCreated` indexing and automatic order reconciliation +3. Complete TRON `getSignMessage` and reliable event decoding +4. Add authentication, audit, and rate limiting for sensitive endpoints +5. Extend end-to-end test coverage See the three design documents for detailed specifications. ## API Documentation -See `backend-api.md` for complete API reference with examples. +See: + +- `backend-api.md` for complete API reference with request / response examples +- `client-integration-guide.md` for frontend, wallet-binding, and claim-sign integration steps diff --git a/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md b/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md index 411820883..d1ccf73de 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md +++ b/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md @@ -202,19 +202,59 @@ curl "http://127.0.0.1:8080/api/redpacket/detail?packet_id=10001" 先做业务鉴权,再发放 `claim(...)` 所需签名参数。 +#### 鉴权说明 + +- 该接口不再信任请求体中的 `user_id` +- 当前领取用户从 RPC / 网关注入的登录上下文中获取 +- 服务端要求请求上下文里存在 `opUserID` +- 如果缺少登录上下文,接口会直接拒绝 + +#### 请求头 + +- `token`: 用户登录 token + +> 约定:上游网关或鉴权中间件需要先解析 token,并把当前登录用户写入请求上下文中的 `opUserID`。 + #### 请求体 ```json { "packet_id": "10001", "claimer": "0x3333333333333333333333333333333333333333", - "user_id": "u2002", "random_seed": "0" } ``` > `random_seed` 可选;传 `0` 或空时后端自动生成。 +#### 字段说明 + +- `packet_id`: 红包链上 ID +- `claimer`: 本次真正发起链上 `claim(...)` 的钱包地址 +- `random_seed`: 可选随机种子;空或 `0` 时后端自动生成 + +#### 服务端处理逻辑 + +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. 返回前端发链所需参数 + +#### 成功后前端下一步 + +前端拿到响应后,直接调用链上: + +```text +claim(packetId, authNonce, randomSeed, deadline, signature) +``` + #### 成功响应 ```json @@ -241,6 +281,33 @@ curl "http://127.0.0.1:8080/api/redpacket/detail?packet_id=10001" } ``` +同一用户已领取: + +```json +{ + "code": 403, + "message": "user already claimed" +} +``` + +钱包未绑定: + +```json +{ + "code": 403, + "message": "wallet is not bound to user" +} +``` + +缺少登录上下文: + +```json +{ + "code": 403, + "message": "op user id missing in context" +} +``` + 签名服务异常: ```json @@ -258,17 +325,44 @@ curl "http://127.0.0.1:8080/api/redpacket/detail?packet_id=10001" 前端在领取交易提交后可调用该接口预写记录。最终状态仍以链监听(indexer)为准。 +#### 鉴权说明 + +- 该接口不再接收可信 `user_id` +- 当前用户从 RPC / 网关注入的登录上下文中获取 +- 服务端要求请求上下文里存在 `opUserID` + +#### 请求头 + +- `token`: 用户登录 token + #### 请求体 ```json { "packet_id": "10001", - "claimer_wallet": "0x3333333333333333333333333333333333333333", - "tx_hash": "0xdef456...", - "auth_nonce": "328840239847239847" + "claimer": "0x3333333333333333333333333333333333333333", + "tx_hash": "0xdef456..." } ``` +#### 字段说明 + +- `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 @@ -285,8 +379,130 @@ curl "http://127.0.0.1:8080/api/redpacket/detail?packet_id=10001" ```json { - "code": 400, - "message": "packet_id and tx_hash are required" + "code": 403, + "message": "op user id missing in context" +} +``` + +--- + +## 6) 钱包绑定挑战 + +### POST `/api/redpacket/wallet-bind/challenge` + +生成钱包绑定挑战消息,前端拿到消息后调用钱包签名。 + +#### 鉴权说明 + +- 该接口不再信任请求体中的 `user_id` +- 当前用户从 RPC / 网关注入的登录上下文中获取 + +#### 请求头 + +- `token`: 用户登录 token + +#### 请求体 + +```json +{ + "chain_type": "EVM", + "chain_id": 1, + "wallet_address": "0x3333333333333333333333333333333333333333", + "domain": "redpacket.example.com", + "uri": "https://redpacket.example.com/wallet-bind" +} +``` + +#### 成功响应 + +```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" + } +} +``` + +#### 前端下一步 + +前端收到响应后: + +1. 使用 `sign_method` 指定的钱包方法对 `message` 进行签名 +2. 把 `challenge_id + signature` 提交给 `/api/redpacket/wallet-bind/confirm` + +--- + +## 7) 钱包绑定确认 + +### POST `/api/redpacket/wallet-bind/confirm` + +提交钱包签名,服务端验签成功后建立钱包绑定关系。 + +#### 请求体 + +```json +{ + "challenge_id": "1f7d9b0d-7b43-4d84-bb11-65f2ecf7e321", + "signature": "0x8f..." +} +``` + +#### 成功响应 + +```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" + } +} +``` + +--- + +## 8) 查询钱包绑定 + +### GET `/api/redpacket/wallet-bind/detail?chain_type={chainType}&wallet_address={walletAddress}` + +查询当前登录用户与指定钱包地址的绑定详情。 + +#### 鉴权说明 + +- `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" + } } ``` @@ -503,4 +719,3 @@ TRON 未配置: 6. 钱包调用合约 `claim(...)` 7. 可选:`POST /api/redpacket/claim-result` 8. 详情页查询:`GET /api/redpacket/detail?packet_id=...` - 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/config/config.yaml b/cmd/openim-rpc/openim-rpc-redpacket/config/config.yaml index fb57425ab..fed6d2f91 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/config/config.yaml +++ b/cmd/openim-rpc/openim-rpc-redpacket/config/config.yaml @@ -17,7 +17,7 @@ tron: contract_base58: "" owner_base58: "" private_key_hex: "" - fee_limit: 100000000 + fee_limit: 10000000000 indexer: poll_interval: 5 diff --git a/cmd/openim-rpc/openim-rpc-redpacket/internal/authctx/user.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/authctx/user.go new file mode 100644 index 000000000..bd277b742 --- /dev/null +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/authctx/user.go @@ -0,0 +1,49 @@ +package authctx + +import ( + "context" + "fmt" + "strings" + + "github.com/gin-gonic/gin" +) + +const opUserIDKey = "opUserID" + +type userIDContextKey struct{} + +func WithCurrentUserID(ctx context.Context, userID string) context.Context { + return context.WithValue(ctx, userIDContextKey{}, strings.TrimSpace(userID)) +} + +func CurrentUserID(ctx context.Context) (string, error) { + if ctx == nil { + return "", fmt.Errorf("request context is nil") + } + if userID, ok := ctx.Value(userIDContextKey{}).(string); ok && strings.TrimSpace(userID) != "" { + return strings.TrimSpace(userID), nil + } + if userID, ok := ctx.Value(opUserIDKey).(string); ok && strings.TrimSpace(userID) != "" { + return strings.TrimSpace(userID), nil + } + return "", fmt.Errorf("op user id missing in context") +} + +func BindCurrentUserID(c *gin.Context) error { + if c == nil { + return fmt.Errorf("gin context is nil") + } + userID := strings.TrimSpace(c.GetString(opUserIDKey)) + if userID == "" { + if value := c.Request.Context().Value(opUserIDKey); value != nil { + if fromCtx, ok := value.(string); ok { + userID = strings.TrimSpace(fromCtx) + } + } + } + if userID == "" { + return fmt.Errorf("op user id missing in context") + } + c.Request = c.Request.WithContext(WithCurrentUserID(c.Request.Context(), userID)) + return nil +} diff --git a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/abi/RedPacket.json b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/abi/RedPacket.json index 7dd45a7cb..7040fd4fa 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/abi/RedPacket.json +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/abi/RedPacket.json @@ -4,7 +4,7 @@ "inputs": [ { "indexed": true, "name": "packetId", "type": "uint256" }, { "indexed": true, "name": "creator", "type": "address" }, - { "indexed": false, "name": "packetType", "type": "uint8" }, + { "indexed": true, "name": "packetType", "type": "uint8" }, { "indexed": false, "name": "token", "type": "address" }, { "indexed": false, "name": "totalAmount", "type": "uint256" }, { "indexed": false, "name": "totalShares", "type": "uint256" }, @@ -19,9 +19,9 @@ { "indexed": true, "name": "packetId", "type": "uint256" }, { "indexed": true, "name": "claimer", "type": "address" }, { "indexed": false, "name": "amount", "type": "uint256" }, - { "indexed": false, "name": "authNonce", "type": "uint256" }, - { "indexed": false, "name": "randomSeed", "type": "uint256" }, - { "indexed": false, "name": "blockNumber", "type": "uint256" } + { "indexed": false, "name": "remainingAmount", "type": "uint256" }, + { "indexed": false, "name": "remainingShares", "type": "uint256" }, + { "indexed": false, "name": "authNonce", "type": "uint256" } ], "name": "PacketClaimed", "type": "event" @@ -30,6 +30,7 @@ "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" } ], @@ -62,4 +63,4 @@ "stateMutability": "nonpayable", "type": "function" } -] \ No newline at end of file +] diff --git a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/client.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/client.go index 718d7ba85..5228fd2b2 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/client.go +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/client.go @@ -3,6 +3,7 @@ package chain import ( "context" "crypto/ecdsa" + _ "embed" "fmt" "math/big" "strings" @@ -14,14 +15,17 @@ import ( "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 + client *ethclient.Client + contractABI abi.ABI + contractAddr common.Address + signerKey *ecdsa.PrivateKey configAdminKey *ecdsa.PrivateKey - chainID *big.Int + chainID *big.Int } // NewClient creates a new ChainClient @@ -122,6 +126,17 @@ func (c *ChainClient) ParseTransactionReceipt(ctx context.Context, txHash common 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) +} + // Close closes the client connection func (c *ChainClient) Close() { if c.client != nil { @@ -131,13 +146,8 @@ func (c *ChainClient) Close() { // ExtractABIFromEmbeddedArtifact returns the embedded contract ABI func ExtractABIFromEmbeddedArtifact() ([]byte, error) { - // In production, this would be embedded with go:embed - // For now, we return a simple version. In real implementation, use: - // var abiJSON embed.FS - // data, _ := abiJSON.ReadFile("abi/RedPacket.json") - return []byte(`[ - {"anonymous":false,"inputs":[{"indexed":true,"name":"packetId","type":"uint256"},{"indexed":true,"name":"creator","type":"address"},{"indexed":false,"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":"authNonce","type":"uint256"},{"indexed":false,"name":"randomSeed","type":"uint256"},{"indexed":false,"name":"blockNumber","type":"uint256"}],"name":"PacketClaimed","type":"event"}, - {"anonymous":false,"inputs":[{"indexed":true,"name":"packetId","type":"uint256"},{"indexed":true,"name":"refundTo","type":"address"},{"indexed":false,"name":"amount","type":"uint256"}],"name":"PacketRefunded","type":"event"} - ]`), nil + if len(embeddedABI) == 0 { + return nil, fmt.Errorf("embedded ABI is empty") + } + return embeddedABI, nil } diff --git a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/indexer.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/indexer.go index aa0af0c52..c41ff9906 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/indexer.go +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/indexer.go @@ -17,10 +17,10 @@ import ( // Indexer listens to blockchain events and updates database type Indexer struct { - client *ChainClient - repo repository.Repository + client *ChainClient + repo repository.Repository pollInterval time.Duration - lastBlock uint64 + lastBlock uint64 contractAddr common.Address } @@ -124,7 +124,7 @@ func (i *Indexer) processEvent(ctx context.Context, event *ParsedEvent, logs []* func (i *Indexer) handlePacketCreated(ctx context.Context, event *ParsedEvent) error { packetID := GetPacketIDFromEvent(event) - creator := GetClaimerFromEvent(event) // creator is indexed as second topic + creator := GetAddressFromEvent(event, "creator") log.Printf("📦 PacketCreated: packetId=%s, creator=%s", packetID.String(), creator.Hex()) @@ -135,29 +135,53 @@ func (i *Indexer) handlePacketCreated(ctx context.Context, event *ParsedEvent) e func (i *Indexer) handlePacketClaimed(ctx context.Context, event *ParsedEvent) error { packetID := GetPacketIDFromEvent(event) - claimer := GetClaimerFromEvent(event) + claimer := GetAddressFromEvent(event, "claimer") amount := GetAmountFromEvent(event) + authNonce := GetUintFromEvent(event, "authNonce") - log.Printf("🎁 PacketClaimed: packetId=%s, claimer=%s, amount=%s", + log.Printf("🎁 PacketClaimed: packetId=%s, claimer=%s, amount=%s", packetID.String(), claimer.Hex(), amount.String()) - // Create claim record 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(), } - return i.repo.CreateClaim(ctx, claim) + if err := i.repo.SaveClaim(ctx, claim); err != nil { + return err + } + if err := i.repo.MarkClaimAuthUsed(ctx, authNonce.String()); err != nil { + return err + } + + return i.repo.UpdateRedPacketClaimProgress(ctx, packetID.String(), amount.String(), "") } func (i *Indexer) handlePacketRefunded(ctx context.Context, event *ParsedEvent) error { packetID := GetPacketIDFromEvent(event) - refundTo := GetClaimerFromEvent(event) // refundTo is indexed + operator := GetAddressFromEvent(event, "operator") + refundTo := GetAddressFromEvent(event, "refundTo") + amount := GetAmountFromEvent(event) - log.Printf("♻️ PacketRefunded: packetId=%s, refundTo=%s", packetID.String(), refundTo.Hex()) + log.Printf("♻️ PacketRefunded: packetId=%s, operator=%s, refundTo=%s, amount=%s", + packetID.String(), operator.Hex(), refundTo.Hex(), amount.String()) - // TODO: Update packet status to REFUNDED - return nil + if err := i.repo.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.repo.UpdateRedPacketStatus(ctx, packetID.String(), "REFUNDED") } diff --git a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/parser.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/parser.go index 42c34d098..fcf7a1f46 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/parser.go +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/parser.go @@ -11,8 +11,10 @@ import ( // ParsedEvent represents a parsed blockchain event type ParsedEvent struct { - Name string - Data map[string]interface{} + Name string + Data map[string]interface{} + TxHash common.Hash + BlockNumber uint64 } // ParseEventsFromLogs parses logs using the contract ABI @@ -75,8 +77,10 @@ func parseEvent(log *types.Log, contractABI abi.ABI) (*ParsedEvent, error) { } return &ParsedEvent{ - Name: name, - Data: data, + Name: name, + Data: data, + TxHash: log.TxHash, + BlockNumber: log.BlockNumber, }, nil } @@ -94,21 +98,28 @@ func GetPacketIDFromEvent(event *ParsedEvent) *big.Int { } // GetClaimerFromEvent extracts claimer address from event -func GetClaimerFromEvent(event *ParsedEvent) common.Address { - if claimer, ok := event.Data["claimer"]; ok { - if addr, ok := claimer.(common.Address); ok { - return addr - } +func GetAddressFromEvent(event *ParsedEvent, key string) common.Address { + value, ok := event.Data[key] + if !ok { + return common.Address{} } - return common.Address{} + addr, _ := value.(common.Address) + return addr } // GetAmountFromEvent extracts amount from event func GetAmountFromEvent(event *ParsedEvent) *big.Int { - if amount, ok := event.Data["amount"]; ok { - if b, ok := amount.(*big.Int); ok { - return b - } + return GetUintFromEvent(event, "amount") +} + +// GetUintFromEvent extracts a uint field from event data. +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/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/parser_test.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/parser_test.go new file mode 100644 index 000000000..67448c705 --- /dev/null +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/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/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/tron.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/tron.go index 6ce703ec8..11fd8167f 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/tron.go +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/tron.go @@ -13,17 +13,18 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" ) // TronClient handles TRON blockchain interactions using HTTP JSON-RPC type TronClient struct { - fullNodeURL string + fullNodeURL string contractBase58 string - ownerBase58 string - privateKeyHex string - feeLimit int64 - abiJSON string - parsedABI abi.ABI + ownerBase58 string + privateKeyHex string + feeLimit int64 + abiJSON string + parsedABI abi.ABI } // NewTronClient creates a new TRON client @@ -48,6 +49,24 @@ func NewTronClient(fullNodeURL, contractBase58, ownerBase58, privateKeyHex strin }, nil } +func (t *TronClient) ContractAddress() string { + return t.contractBase58 +} + +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) +} + // SendAdminTransaction sends an admin transaction on TRON (setSigner, setToken, etc.) func (t *TronClient) SendAdminTransaction(ctx context.Context, methodName string, args ...interface{}) (string, error) { if t.privateKeyHex == "" || t.ownerBase58 == "" { @@ -86,6 +105,16 @@ func (t *TronClient) GetSignMessageForTron(ctx context.Context, packetID *big.In 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"` +} + // Helper functions func getParamTypes(args []interface{}) string { @@ -165,6 +194,62 @@ func SendTronAdminTx( 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 { diff --git a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/tron_indexer.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/tron_indexer.go index 721c30038..5b524a9e0 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/tron_indexer.go +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/tron_indexer.go @@ -15,9 +15,9 @@ type TronIndexer struct { client *TronClient repo repository.Repository pollInterval time.Duration - lastBlockNum int64 // TRON uses block numbers + lastBlockNum int64 // TRON uses block numbers contractAddress string - processedTxs map[string]bool // Simple dedup for this session + processedTxs map[string]bool // Simple dedup for this session } // NewTronIndexer creates a new TRON event indexer @@ -223,7 +223,7 @@ func (t *TronIndexer) handleTronPacketClaimed(ctx context.Context, logData map[s Status: "CONFIRMED", } - if err := t.repo.CreateClaim(ctx, claim); err != nil { + if err := t.repo.SaveClaim(ctx, claim); err != nil { log.Printf("Failed to save TRON claim: %v", err) } } diff --git a/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/tron_test.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/chain/tron_test.go new file mode 100644 index 000000000..763bc5e48 --- /dev/null +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/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/cmd/openim-rpc/openim-rpc-redpacket/internal/handler/redpacket.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/handler/redpacket.go index 4a318c01b..b8cb18bf7 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/internal/handler/redpacket.go +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/handler/redpacket.go @@ -3,6 +3,7 @@ package handler import ( "net/http" + "redpacket/internal/authctx" "redpacket/internal/service" "redpacket/pkg/resp" @@ -18,6 +19,11 @@ func NewRedPacketHandler(rpSvc *service.RedPacketService) *RedPacketHandler { } func (h *RedPacketHandler) CreateOrder(c *gin.Context) { + if err := authctx.BindCurrentUserID(c); err != nil { + resp.Forbidden(c, err.Error()) + return + } + var req service.CreateOrderRequest if err := c.ShouldBindJSON(&req); err != nil { resp.BadRequest(c, "invalid request body: "+err.Error()) @@ -65,10 +71,14 @@ func (h *RedPacketHandler) Detail(c *gin.Context) { } func (h *RedPacketHandler) ClaimSign(c *gin.Context) { + if err := authctx.BindCurrentUserID(c); err != nil { + resp.Forbidden(c, err.Error()) + return + } + var req struct { PacketID string `json:"packet_id" binding:"required"` Claimer string `json:"claimer" binding:"required"` - UserID string `json:"user_id" binding:"required"` RandomSeed string `json:"random_seed"` } @@ -77,12 +87,7 @@ func (h *RedPacketHandler) ClaimSign(c *gin.Context) { return } - if err := h.rpSvc.CanClaim(c.Request.Context(), req.PacketID, req.Claimer, req.UserID); err != nil { - resp.Forbidden(c, err.Error()) - return - } - - result, err := h.rpSvc.IssueClaimSign(c.Request.Context(), req.PacketID, req.Claimer, req.UserID, req.RandomSeed) + result, err := h.rpSvc.IssueClaimSign(c.Request.Context(), req.PacketID, req.Claimer, req.RandomSeed) if err != nil { resp.InternalError(c, "failed to issue claim signature: "+err.Error()) return @@ -92,6 +97,11 @@ func (h *RedPacketHandler) ClaimSign(c *gin.Context) { } func (h *RedPacketHandler) ClaimResult(c *gin.Context) { + if err := authctx.BindCurrentUserID(c); err != nil { + resp.Forbidden(c, err.Error()) + return + } + var req service.ClaimResultRequest if err := c.ShouldBindJSON(&req); err != nil { resp.BadRequest(c, "invalid request body: "+err.Error()) @@ -105,3 +115,62 @@ func (h *RedPacketHandler) ClaimResult(c *gin.Context) { resp.OK(c, gin.H{"ok": true}) } + +func (h *RedPacketHandler) WalletBindChallenge(c *gin.Context) { + if err := authctx.BindCurrentUserID(c); err != nil { + resp.Forbidden(c, err.Error()) + return + } + + var req service.WalletBindChallengeRequest + if err := c.ShouldBindJSON(&req); err != nil { + resp.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + result, err := h.rpSvc.IssueWalletBindChallenge(c.Request.Context(), &req) + if err != nil { + resp.Fail(c, http.StatusBadRequest, 400, err.Error()) + return + } + + resp.OK(c, result) +} + +func (h *RedPacketHandler) WalletBindConfirm(c *gin.Context) { + var req service.WalletBindConfirmRequest + if err := c.ShouldBindJSON(&req); err != nil { + resp.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + result, err := h.rpSvc.ConfirmWalletBind(c.Request.Context(), &req) + if err != nil { + resp.Fail(c, http.StatusBadRequest, 400, err.Error()) + return + } + + resp.OK(c, result) +} + +func (h *RedPacketHandler) WalletBindDetail(c *gin.Context) { + if err := authctx.BindCurrentUserID(c); err != nil { + resp.Forbidden(c, err.Error()) + return + } + + chainType := c.Query("chain_type") + walletAddress := c.Query("wallet_address") + if chainType == "" || walletAddress == "" { + resp.BadRequest(c, "chain_type and wallet_address are required") + return + } + + result, err := h.rpSvc.GetWalletBinding(c.Request.Context(), "", chainType, walletAddress) + if err != nil { + resp.Fail(c, http.StatusNotFound, 404, err.Error()) + return + } + + resp.OK(c, result) +} diff --git a/cmd/openim-rpc/openim-rpc-redpacket/internal/model/model.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/model/model.go index b8f9eb3ff..b3e63b423 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/internal/model/model.go +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/model/model.go @@ -7,15 +7,22 @@ import ( type RedPacket struct { ID uint `gorm:"primarykey" json:"id"` BizID string `gorm:"uniqueIndex;size:64" json:"biz_id"` + ChainType string `gorm:"index;size:16" json:"chain_type"` // EVM, TRON PacketID string `gorm:"index;size:32" json:"packet_id"` ChainID int64 `json:"chain_id"` ContractAddress string `json:"contract_address"` CreatorUserID string `gorm:"size:64" json:"creator_user_id"` CreatorWallet string `gorm:"size:66" json:"creator_wallet"` + GroupID string `gorm:"index;size:64" json:"group_id"` + ScopeType string `gorm:"size:20" json:"scope_type"` // GROUP, DIRECT, PUBLIC + ReceiverUserID string `gorm:"size:64" json:"receiver_user_id"` + ReceiverUserIDs string `gorm:"type:text" json:"receiver_user_ids"` PacketType int32 `json:"packet_type"` // 0=fixed, 1=random, 2=transfer Token string `gorm:"size:66" json:"token"` TotalAmount string `gorm:"size:50" json:"total_amount"` TotalShares int32 `json:"total_shares"` + ClaimedAmount string `gorm:"size:50" json:"claimed_amount"` + ClaimedShares int32 `json:"claimed_shares"` ExpiryAt int64 `json:"expiry_at"` TxHash string `gorm:"size:66" json:"tx_hash"` Status string `gorm:"size:20" json:"status"` // PENDING, ACTIVE, EXPIRED, COMPLETED, REFUNDED @@ -24,35 +31,69 @@ type RedPacket struct { } type RedPacketClaim struct { - ID uint `gorm:"primarykey" json:"id"` - PacketID string `gorm:"index;size:32" json:"packet_id"` - ClaimerWallet string `gorm:"size:66" json:"claimer_wallet"` - AuthNonce string `gorm:"size:32" json:"auth_nonce"` - ClaimTxHash string `gorm:"size:66" json:"claim_tx_hash"` - ClaimedAmount string `gorm:"size:50" json:"claimed_amount"` - BlockNumber uint64 `json:"block_number"` - Status string `gorm:"size:20" json:"status"` // PENDING, CONFIRMED, FAILED - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type RedPacketClaimAuth struct { ID uint `gorm:"primarykey" json:"id"` - PacketID string `gorm:"index;size:32" json:"packet_id"` - Claimer string `gorm:"size:66" json:"claimer"` - AuthNonce string `gorm:"uniqueIndex;size:32" json:"auth_nonce"` - RandomSeed string `gorm:"size:32" json:"random_seed"` - Deadline int64 `json:"deadline"` - Signature string `gorm:"size:132" json:"signature"` - Used bool `json:"used"` + PacketID string `gorm:"index;index:idx_packet_user;size:32" json:"packet_id"` + UserID string `gorm:"index;index:idx_packet_user;size:64" json:"user_id"` + ClaimerWallet string `gorm:"size:66" json:"claimer_wallet"` + AuthNonce string `gorm:"size:32" json:"auth_nonce"` + ClaimTxHash string `gorm:"uniqueIndex;size:66" json:"claim_tx_hash"` + ClaimedAmount string `gorm:"size:50" json:"claimed_amount"` + BlockNumber uint64 `json:"block_number"` + Status string `gorm:"size:20" json:"status"` // PENDING, CONFIRMED, FAILED CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -type RedPacketRefund struct { +type RedPacketClaimAuth struct { ID uint `gorm:"primarykey" json:"id"` PacketID string `gorm:"index;size:32" json:"packet_id"` - RefundTo string `gorm:"size:66" json:"refund_to"` - TxHash string `gorm:"size:66" json:"tx_hash"` - Amount string `gorm:"size:50" json:"amount"` + Claimer string `gorm:"size:66" json:"claimer"` + AuthNonce string `gorm:"uniqueIndex;size:32" json:"auth_nonce"` + RandomSeed string `gorm:"size:32" json:"random_seed"` + Deadline int64 `json:"deadline"` + Signature string `gorm:"size:132" json:"signature"` + Used bool `json:"used"` CreatedAt time.Time `json:"created_at"` } + +type RedPacketRefund struct { + ID uint `gorm:"primarykey" json:"id"` + PacketID string `gorm:"index;size:32" json:"packet_id"` + RefundTo string `gorm:"size:66" json:"refund_to"` + TxHash string `gorm:"uniqueIndex;size:66" json:"tx_hash"` + Amount string `gorm:"size:50" json:"amount"` + CreatedAt time.Time `json:"created_at"` +} + +type WalletBindingChallenge struct { + ID uint `gorm:"primarykey" json:"id"` + ChallengeID string `gorm:"uniqueIndex;size:64" json:"challenge_id"` + UserID string `gorm:"index;size:64" json:"user_id"` + ChainType string `gorm:"index;size:16" json:"chain_type"` + ChainID int64 `json:"chain_id"` + WalletAddress string `gorm:"index;size:128" json:"wallet_address"` + Nonce string `gorm:"size:64" json:"nonce"` + Message string `gorm:"type:text" json:"message"` + Protocol string `gorm:"size:32" json:"protocol"` + SignMethod string `gorm:"size:32" json:"sign_method"` + Status string `gorm:"size:20" json:"status"` // PENDING, VERIFIED, EXPIRED, FAILED + Signature string `gorm:"type:text" json:"signature"` + ExpiresAt time.Time `json:"expires_at"` + VerifiedAt *time.Time `json:"verified_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type WalletBinding struct { + ID uint `gorm:"primarykey" json:"id"` + UserID string `gorm:"index:idx_user_chain_wallet,unique;size:64" json:"user_id"` + ChainType string `gorm:"index:idx_user_chain_wallet,unique;size:16" json:"chain_type"` + ChainID int64 `json:"chain_id"` + WalletAddress string `gorm:"index:idx_user_chain_wallet,unique;size:128" json:"wallet_address"` + Status string `gorm:"size:20" json:"status"` // ACTIVE, REVOKED + ChallengeID string `gorm:"size:64" json:"challenge_id"` + VerifiedAt time.Time `json:"verified_at"` + RevokedAt *time.Time `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/cmd/openim-rpc/openim-rpc-redpacket/internal/repository/repo.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/repository/repo.go index bb8ce25ca..4baf10864 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/internal/repository/repo.go +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/repository/repo.go @@ -2,22 +2,34 @@ package repository import ( "context" + "math/big" "redpacket/internal/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type Repository 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) - UpdateRedPacketTxHash(ctx context.Context, bizID, txHash, packetID string) error + UpdateRedPacketCreated(ctx context.Context, rp *model.RedPacket) error + UpdateRedPacketStatus(ctx context.Context, packetID, status string) error + UpdateRedPacketClaimProgress(ctx context.Context, packetID, claimedAmount, status string) 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 - CreateClaim(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) + SaveClaim(ctx context.Context, claim *model.RedPacketClaim) error GetClaimsByPacketID(ctx context.Context, packetID string) ([]model.RedPacketClaim, error) + SaveRefund(ctx context.Context, refund *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) } type repository struct { @@ -44,16 +56,54 @@ func (r *repository) GetRedPacketByPacketID(ctx context.Context, packetID string return &rp, err } -func (r *repository) UpdateRedPacketTxHash(ctx context.Context, bizID, txHash, packetID string) error { +func (r *repository) UpdateRedPacketCreated(ctx context.Context, rp *model.RedPacket) error { return r.db.WithContext(ctx).Model(&model.RedPacket{}). - Where("biz_id = ?", bizID). + Where("biz_id = ?", rp.BizID). Updates(map[string]interface{}{ - "tx_hash": txHash, - "packet_id": packetID, - "status": "ACTIVE", + "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, }).Error } +func (r *repository) UpdateRedPacketStatus(ctx context.Context, packetID, status string) error { + return r.db.WithContext(ctx).Model(&model.RedPacket{}). + Where("packet_id = ?", packetID). + Update("status", status).Error +} + +func (r *repository) UpdateRedPacketClaimProgress(ctx context.Context, packetID, claimedAmount, status string) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var rp model.RedPacket + if err := tx.Where("packet_id = ?", packetID).First(&rp).Error; err != nil { + return err + } + + totalClaimed := addNumericStrings(rp.ClaimedAmount, claimedAmount) + nextShares := rp.ClaimedShares + 1 + + updates := map[string]interface{}{ + "claimed_amount": totalClaimed, + "claimed_shares": nextShares, + "updated_at": gorm.Expr("CURRENT_TIMESTAMP"), + } + if status != "" { + updates["status"] = status + } + + return tx.Model(&model.RedPacket{}). + Where("id = ?", rp.ID). + Updates(updates).Error + }) +} + func (r *repository) CreateClaimAuth(ctx context.Context, auth *model.RedPacketClaimAuth) error { return r.db.WithContext(ctx).Create(auth).Error } @@ -70,8 +120,62 @@ func (r *repository) MarkClaimAuthUsed(ctx context.Context, authNonce string) er Update("used", true).Error } -func (r *repository) CreateClaim(ctx context.Context, claim *model.RedPacketClaim) error { - return r.db.WithContext(ctx).Create(claim).Error +func (r *repository) GetClaimByPacketIDAndClaimer(ctx context.Context, packetID, claimer string) (*model.RedPacketClaim, error) { + var claim model.RedPacketClaim + err := r.db.WithContext(ctx). + Where("packet_id = ? AND claimer_wallet = ?", packetID, claimer). + Order("created_at desc"). + First(&claim).Error + return &claim, err +} + +func (r *repository) GetClaimByPacketIDAndUserID(ctx context.Context, packetID, userID string) (*model.RedPacketClaim, error) { + var claim model.RedPacketClaim + err := r.db.WithContext(ctx). + Where("packet_id = ? AND user_id = ?", packetID, userID). + Order("created_at desc"). + First(&claim).Error + return &claim, err +} + +func (r *repository) SaveClaim(ctx context.Context, claim *model.RedPacketClaim) error { + if claim.UserID != "" { + var existing model.RedPacketClaim + err := r.db.WithContext(ctx). + Where("packet_id = ? AND user_id = ?", claim.PacketID, claim.UserID). + First(&existing).Error + if err == nil { + claim.ID = existing.ID + return r.db.WithContext(ctx).Model(&model.RedPacketClaim{}). + Where("id = ?", existing.ID). + Updates(map[string]interface{}{ + "claimer_wallet": existing.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, + }).Error + } + if err != nil && err != gorm.ErrRecordNotFound { + return err + } + } + + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "claim_tx_hash"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "user_id", + "packet_id", + "claimer_wallet", + "auth_nonce", + "claimed_amount", + "block_number", + "status", + "updated_at", + }), + }).Create(claim).Error } func (r *repository) GetClaimsByPacketID(ctx context.Context, packetID string) ([]model.RedPacketClaim, error) { @@ -79,3 +183,69 @@ func (r *repository) GetClaimsByPacketID(ctx context.Context, packetID string) ( err := r.db.WithContext(ctx).Where("packet_id = ?", packetID).Order("created_at desc").Find(&claims).Error return claims, err } + +func (r *repository) SaveRefund(ctx context.Context, refund *model.RedPacketRefund) error { + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "tx_hash"}}, + DoNothing: true, + }).Create(refund).Error +} + +func (r *repository) CreateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error { + return r.db.WithContext(ctx).Create(challenge).Error +} + +func (r *repository) GetWalletBindingChallenge(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error) { + var challenge model.WalletBindingChallenge + err := r.db.WithContext(ctx).Where("challenge_id = ?", challengeID).First(&challenge).Error + return &challenge, err +} + +func (r *repository) UpdateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error { + return r.db.WithContext(ctx).Model(&model.WalletBindingChallenge{}). + Where("challenge_id = ?", challenge.ChallengeID). + Updates(map[string]interface{}{ + "status": challenge.Status, + "signature": challenge.Signature, + "verified_at": challenge.VerifiedAt, + "updated_at": challenge.UpdatedAt, + }).Error +} + +func (r *repository) UpsertWalletBinding(ctx context.Context, binding *model.WalletBinding) error { + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "user_id"}, + {Name: "chain_type"}, + {Name: "wallet_address"}, + }, + DoUpdates: clause.AssignmentColumns([]string{ + "chain_id", + "status", + "challenge_id", + "verified_at", + "revoked_at", + "updated_at", + }), + }).Create(binding).Error +} + +func (r *repository) GetActiveWalletBinding(ctx context.Context, userID, chainType, walletAddress string) (*model.WalletBinding, error) { + var binding model.WalletBinding + err := r.db.WithContext(ctx). + Where("user_id = ? AND chain_type = ? AND wallet_address = ? AND status = ?", userID, chainType, walletAddress, "ACTIVE"). + First(&binding).Error + return &binding, 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() +} diff --git a/cmd/openim-rpc/openim-rpc-redpacket/internal/service/redpacket.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/service/redpacket.go index 7ad5dcb75..65c8035e2 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/internal/service/redpacket.go +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/service/redpacket.go @@ -4,10 +4,13 @@ import ( "context" "crypto/ecdsa" "encoding/hex" + "encoding/json" "fmt" "math/big" + "strings" "time" + "redpacket/internal/authctx" "redpacket/internal/chain" "redpacket/internal/model" "redpacket/internal/repository" @@ -15,39 +18,66 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/google/uuid" + "gorm.io/gorm" ) type RedPacketService struct { repo repository.Repository chainClient *chain.ChainClient + tronClient *chain.TronClient signerKey *ecdsa.PrivateKey } type CreateOrderRequest struct { - CreatorUserID string `json:"creator_user_id" binding:"required"` - CreatorWallet string `json:"creator_wallet" binding:"required"` - PacketType int32 `json:"packet_type" binding:"required"` - Token string `json:"token"` - TotalAmount string `json:"total_amount" binding:"required"` - TotalShares int32 `json:"total_shares" binding:"required"` - ExpiryAt int64 `json:"expiry_at"` - Remark string `json:"remark"` + ChainType string `json:"chain_type" binding:"required"` + ChainID int64 `json:"chain_id"` + ContractAddress string `json:"contract_address"` + CreatorUserID string `json:"creator_user_id"` + CreatorWallet string `json:"creator_wallet" binding:"required"` + GroupID string `json:"group_id"` + ScopeType string `json:"scope_type"` + ReceiverUserID string `json:"receiver_user_id"` + ReceiverUserIDs []string `json:"receiver_user_ids"` + PacketType int32 `json:"packet_type" binding:"required"` + Token string `json:"token"` + TotalAmount string `json:"total_amount" binding:"required"` + TotalShares int32 `json:"total_shares" binding:"required"` + ExpiryAt int64 `json:"expiry_at"` + Remark string `json:"remark"` } type CreatedCallbackRequest struct { - BizID string `json:"biz_id" binding:"required"` - TxHash string `json:"tx_hash" binding:"required"` - PacketID string `json:"packet_id" binding:"required"` + BizID string `json:"biz_id" binding:"required"` + TxHash string `json:"tx_hash" binding:"required"` + PacketID string `json:"packet_id"` + GroupID string `json:"group_id"` + ScopeType string `json:"scope_type"` + ReceiverUserID string `json:"receiver_user_id"` + ReceiverUserIDs []string `json:"receiver_user_ids"` } type ClaimResultRequest struct { PacketID string `json:"packet_id" binding:"required"` Claimer string `json:"claimer" binding:"required"` - UserID string `json:"user_id"` + UserID string `json:"-"` TxHash string `json:"tx_hash" binding:"required"` } -func NewRedPacketService(repo repository.Repository, chainClient *chain.ChainClient, signerPrivateKey string) *RedPacketService { +type WalletBindChallengeRequest struct { + UserID string `json:"user_id"` + ChainType string `json:"chain_type" binding:"required"` + ChainID int64 `json:"chain_id"` + WalletAddress string `json:"wallet_address" binding:"required"` + Domain string `json:"domain"` + URI string `json:"uri"` +} + +type WalletBindConfirmRequest struct { + ChallengeID string `json:"challenge_id" binding:"required"` + Signature string `json:"signature" binding:"required"` +} + +func NewRedPacketService(repo repository.Repository, chainClient *chain.ChainClient, tronClient *chain.TronClient, signerPrivateKey string) *RedPacketService { var signerKey *ecdsa.PrivateKey if signerPrivateKey != "" { var err error @@ -61,17 +91,63 @@ func NewRedPacketService(repo repository.Repository, chainClient *chain.ChainCli return &RedPacketService{ repo: repo, chainClient: chainClient, + tronClient: tronClient, signerKey: signerKey, } } func (s *RedPacketService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (map[string]interface{}, error) { + currentUserID, err := authctx.CurrentUserID(ctx) + if err != nil { + return nil, err + } + req.CreatorUserID = currentUserID + 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 + } + + receiverUserIDs, err := encodeReceiverUserIDs(req.ReceiverUserIDs) + if err != nil { + return nil, fmt.Errorf("encode receiver_user_ids failed: %w", 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: req.CreatorUserID, CreatorWallet: req.CreatorWallet, + GroupID: req.GroupID, + ScopeType: scopeType, + ReceiverUserID: req.ReceiverUserID, + ReceiverUserIDs: receiverUserIDs, PacketType: req.PacketType, Token: req.Token, TotalAmount: req.TotalAmount, @@ -92,7 +168,44 @@ func (s *RedPacketService) CreateOrder(ctx context.Context, req *CreateOrderRequ } func (s *RedPacketService) CreatedCallback(ctx context.Context, req *CreatedCallbackRequest) error { - return s.repo.UpdateRedPacketTxHash(ctx, req.BizID, req.TxHash, req.PacketID) + rp, err := s.repo.GetRedPacketByBizID(ctx, req.BizID) + if err != nil { + return fmt.Errorf("biz record not found: %s", req.BizID) + } + + 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, err = encodeReceiverUserIDs(req.ReceiverUserIDs) + if err != nil { + return fmt.Errorf("encode receiver_user_ids failed: %w", err) + } + } + + if err := validateCreateScope(scopeType, groupID, receiverUserID, decodeReceiverUserIDs(receiverUserIDs)); err != nil { + return err + } + + createdPacket, err := s.resolveCreatedPacket(ctx, rp, req.TxHash, req.PacketID) + if err != nil { + return err + } + + return s.repo.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", + }) } func (s *RedPacketService) GetDetail(ctx context.Context, packetID string) (map[string]interface{}, error) { @@ -113,51 +226,75 @@ func (s *RedPacketService) GetDetail(ctx context.Context, packetID string) (map[ } func (s *RedPacketService) CanClaim(ctx context.Context, packetID, claimer, userID string) error { - // Check if packet exists and is active rp, err := s.repo.GetRedPacketByPacketID(ctx, packetID) if err != nil { return fmt.Errorf("packet not found: %s", packetID) } - if rp.Status != "ACTIVE" { - return fmt.Errorf("packet is not active, current status: %s", rp.Status) + if err := validateClaimBase(rp, userID, claimer); err != nil { + return err + } + if err := s.ensureWalletBinding(ctx, userID, claimer, rp.ChainType); err != nil { + return err } - // TODO: Add more checks - expiry, already claimed by this user, etc. - // For now we allow the claim - return nil + 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 fmt.Errorf("unsupported packet_type: %d", rp.PacketType) + } } // SignClaim generates signature for claim operation -func (s *RedPacketService) IssueClaimSign(ctx context.Context, packetID, claimer, userID, randomSeed string) (map[string]interface{}, error) { +func (s *RedPacketService) IssueClaimSign(ctx context.Context, packetID, claimer, randomSeed string) (map[string]interface{}, error) { + userID, err := authctx.CurrentUserID(ctx) + if err != nil { + return nil, err + } + if err := s.CanClaim(ctx, packetID, claimer, userID); err != nil { + return nil, err + } + packetIDBig := new(big.Int) - packetIDBig.SetString(packetID, 10) + if _, ok := packetIDBig.SetString(packetID, 10); !ok { + return nil, fmt.Errorf("invalid packet_id: %s", packetID) + } claimerAddr := common.HexToAddress(claimer) // Generate nonce and deadline (5 minute expiry) nonce := fmt.Sprintf("%d", time.Now().UnixNano()) + authNonceBig := new(big.Int) + if _, ok := authNonceBig.SetString(nonce, 10); !ok { + return nil, fmt.Errorf("invalid auth nonce") + } deadline := time.Now().Add(5 * time.Minute).Unix() randomSeedBig := new(big.Int) if randomSeed != "" && randomSeed != "0" { - randomSeedBig.SetString(randomSeed, 10) + if _, ok := randomSeedBig.SetString(randomSeed, 10); !ok { + return nil, fmt.Errorf("invalid random_seed: %s", randomSeed) + } } else { randomSeedBig.SetInt64(time.Now().UnixNano()) } deadlineBig := big.NewInt(deadline) var digest [32]byte - var err error if s.chainClient != nil { // Use real contract call to getSignMessage - digest, err = s.chainClient.GetSignMessage(ctx, packetIDBig, claimerAddr, big.NewInt(0), randomSeedBig, deadlineBig) + digest, err = s.chainClient.GetSignMessage(ctx, packetIDBig, claimerAddr, authNonceBig, randomSeedBig, deadlineBig) if err != nil { return nil, fmt.Errorf("getSignMessage failed: %w", err) } } else { // Fallback for testing - digest = crypto.Keccak256Hash([]byte(fmt.Sprintf("%s%s%s", packetID, claimer, nonce))) + digest = crypto.Keccak256Hash([]byte(fmt.Sprintf("%s:%s:%s:%s:%d", packetID, claimer, nonce, randomSeedBig.String(), deadline))) } // Sign the digest @@ -199,14 +336,733 @@ func (s *RedPacketService) IssueClaimSign(ctx context.Context, packetID, claimer } func (s *RedPacketService) ClaimResult(ctx context.Context, req *ClaimResultRequest) error { + userID, err := authctx.CurrentUserID(ctx) + if err != nil { + return err + } + req.UserID = userID + + rp, err := s.repo.GetRedPacketByPacketID(ctx, req.PacketID) + if err != nil { + return fmt.Errorf("packet not found: %s", req.PacketID) + } + + if err := validateClaimBase(rp, req.UserID, req.Claimer); err != nil { + return err + } + claim := &model.RedPacketClaim{ PacketID: req.PacketID, + UserID: req.UserID, ClaimerWallet: req.Claimer, ClaimTxHash: req.TxHash, + Status: "PENDING", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := s.repo.SaveClaim(ctx, claim); err != nil { + return err + } + + claimedEvent, err := s.resolveClaimedEvent(ctx, rp, req.TxHash) + if err != nil { + return nil + } + if claimedEvent == nil { + return nil + } + if !strings.EqualFold(claimedEvent.ClaimerWallet, req.Claimer) { + return fmt.Errorf("claim event claimer mismatch: got %s want %s", claimedEvent.ClaimerWallet, req.Claimer) + } + + confirmed := &model.RedPacketClaim{ + PacketID: req.PacketID, + UserID: req.UserID, + 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.repo.SaveClaim(ctx, confirmed); err != nil { + return err + } + + if claimedEvent.AuthNonce != "" { + if err := s.repo.MarkClaimAuthUsed(ctx, claimedEvent.AuthNonce); err != nil { + return err + } + } - return s.repo.CreateClaim(ctx, claim) + nextStatus := derivePacketStatusAfterClaim(rp, claimedEvent.Amount) + return s.repo.UpdateRedPacketClaimProgress(ctx, req.PacketID, claimedEvent.Amount, nextStatus) +} + +func (s *RedPacketService) IssueWalletBindChallenge(ctx context.Context, req *WalletBindChallengeRequest) (map[string]interface{}, error) { + currentUserID, err := authctx.CurrentUserID(ctx) + if err != nil { + return nil, err + } + req.UserID = currentUserID + + chainType, err := normalizeChainType(req.ChainType) + if err != nil { + return nil, err + } + + walletAddress := strings.TrimSpace(req.WalletAddress) + if walletAddress == "" { + return nil, fmt.Errorf("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(req, challengeID, nonce, issuedAt, expiresAt) + if chainType == "TRON" { + protocol = "tron-signmessagev2" + signMethod = "signMessageV2" + message = buildTRONBindMessage(req, challengeID, nonce, issuedAt, expiresAt) + } + + challenge := &model.WalletBindingChallenge{ + ChallengeID: challengeID, + UserID: req.UserID, + 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.repo.CreateWalletBindingChallenge(ctx, challenge); err != nil { + return nil, err + } + + return map[string]interface{}{ + "challenge_id": challengeID, + "user_id": req.UserID, + "chain_type": chainType, + "chain_id": req.ChainID, + "wallet": walletAddress, + "protocol": protocol, + "sign_method": signMethod, + "nonce": nonce, + "message": message, + "issued_at": issuedAt.Format(time.RFC3339), + "expires_at": expiresAt.Format(time.RFC3339), + }, nil +} + +func (s *RedPacketService) ConfirmWalletBind(ctx context.Context, req *WalletBindConfirmRequest) (map[string]interface{}, error) { + challenge, err := s.repo.GetWalletBindingChallenge(ctx, req.ChallengeID) + if err != nil { + return nil, fmt.Errorf("challenge not found: %s", req.ChallengeID) + } + if challenge.Status != "PENDING" { + return nil, fmt.Errorf("challenge is not pending") + } + if time.Now().UTC().After(challenge.ExpiresAt) { + challenge.Status = "EXPIRED" + challenge.UpdatedAt = time.Now() + _ = s.repo.UpdateWalletBindingChallenge(ctx, challenge) + return nil, fmt.Errorf("challenge is expired") + } + + switch challenge.ChainType { + case "EVM": + if err := verifyEVMBindSignature(challenge.Message, challenge.WalletAddress, req.Signature); err != nil { + challenge.Status = "FAILED" + challenge.Signature = req.Signature + challenge.UpdatedAt = time.Now() + _ = s.repo.UpdateWalletBindingChallenge(ctx, challenge) + return nil, err + } + case "TRON": + return nil, fmt.Errorf("TRON wallet binding verification is not implemented yet") + default: + return nil, fmt.Errorf("unsupported chain_type: %s", challenge.ChainType) + } + + now := time.Now().UTC() + challenge.Status = "VERIFIED" + challenge.Signature = req.Signature + challenge.VerifiedAt = &now + challenge.UpdatedAt = now + if err := s.repo.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.repo.UpsertWalletBinding(ctx, binding); err != nil { + return nil, err + } + + return map[string]interface{}{ + "user_id": binding.UserID, + "chain_type": binding.ChainType, + "chain_id": binding.ChainID, + "wallet_address": binding.WalletAddress, + "status": binding.Status, + "verified_at": binding.VerifiedAt.Format(time.RFC3339), + }, nil +} + +func (s *RedPacketService) GetWalletBinding(ctx context.Context, userID, chainType, walletAddress string) (map[string]interface{}, error) { + currentUserID, err := authctx.CurrentUserID(ctx) + if err != nil { + return nil, err + } + userID = currentUserID + + normalizedChainType, err := normalizeChainType(chainType) + if err != nil { + return nil, err + } + binding, err := s.repo.GetActiveWalletBinding(ctx, userID, normalizedChainType, walletAddress) + if err != nil { + return nil, fmt.Errorf("active wallet binding not found") + } + return map[string]interface{}{ + "user_id": binding.UserID, + "chain_type": binding.ChainType, + "chain_id": binding.ChainID, + "wallet_address": binding.WalletAddress, + "status": binding.Status, + "challenge_id": binding.ChallengeID, + "verified_at": binding.VerifiedAt.Format(time.RFC3339), + }, nil +} + +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 *RedPacketService) resolveCreatedPacket(ctx context.Context, rp *model.RedPacket, txHashHex, fallbackPacketID string) (*createdPacketSnapshot, error) { + switch rp.ChainType { + case "EVM": + if s.chainClient == nil { + if fallbackPacketID == "" { + return nil, fmt.Errorf("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 { + if fallbackPacketID == "" { + return nil, fmt.Errorf("parse created tx failed: %w", err) + } + return buildFallbackCreatedPacket(rp, fallbackPacketID), nil + } + + 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 + } + + if fallbackPacketID == "" { + return nil, fmt.Errorf("PacketCreated event not found in tx: %s", txHashHex) + } + return buildFallbackCreatedPacket(rp, fallbackPacketID), nil + case "TRON": + if s.tronClient == nil { + if fallbackPacketID == "" { + return nil, fmt.Errorf("packet_id is required when TRON client is unavailable") + } + return buildFallbackCreatedPacket(rp, fallbackPacketID), nil + } + + events, err := s.tronClient.ParseTransactionReceipt(ctx, txHashHex) + if err != nil { + if fallbackPacketID == "" { + return nil, fmt.Errorf("parse tron created tx failed: %w", err) + } + return buildFallbackCreatedPacket(rp, fallbackPacketID), nil + } + + 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 + } + + if fallbackPacketID == "" { + return nil, fmt.Errorf("PacketCreated event not found in TRON tx: %s", txHashHex) + } + return buildFallbackCreatedPacket(rp, fallbackPacketID), nil + default: + return nil, fmt.Errorf("unsupported chain_type: %s", rp.ChainType) + } +} + +// validateCreateHook reserves a centralized validation extension point for +// create-order. Concrete centralized checks are deferred, but validation is +// already split by packet type so future rules can evolve independently. +func (s *RedPacketService) validateCreateHook(ctx context.Context, req *CreateOrderRequest) 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 fmt.Errorf("unsupported packet_type: %d", req.PacketType) + } +} + +// validateFixedPacketCreate reserves centralized checks for fixed red packets. +// todo: validate creator identity, group validity, and group membership. +func (s *RedPacketService) validateFixedPacketCreate(ctx context.Context, req *CreateOrderRequest) error { + return nil +} + +// validateRandomPacketCreate reserves centralized checks for random red packets. +// todo: validate creator identity, group validity, and group membership. +func (s *RedPacketService) validateRandomPacketCreate(ctx context.Context, req *CreateOrderRequest) error { + return nil +} + +// validateTransferPacketCreate reserves centralized checks for transfer packets. +// todo: validate creator identity and sender/receiver relationship. +func (s *RedPacketService) validateTransferPacketCreate(ctx context.Context, req *CreateOrderRequest) error { + return nil +} + +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 fmt.Errorf("created packet is nil") + } + + if createdPacket.CreatorWallet != "" && strings.ToLower(rp.CreatorWallet) != createdPacket.CreatorWallet { + return fmt.Errorf("creator mismatch: got %s want %s", createdPacket.CreatorWallet, rp.CreatorWallet) + } + if createdPacket.PacketType != rp.PacketType { + return fmt.Errorf("packet type mismatch: got %d want %d", createdPacket.PacketType, rp.PacketType) + } + if createdPacket.TotalAmount != rp.TotalAmount { + return fmt.Errorf("total amount mismatch: got %s want %s", createdPacket.TotalAmount, rp.TotalAmount) + } + if createdPacket.TotalShares != rp.TotalShares { + return fmt.Errorf("total shares mismatch: got %d want %d", createdPacket.TotalShares, rp.TotalShares) + } + expectedToken := normalizeTokenAddress(rp.Token) + if createdPacket.Token != expectedToken { + return fmt.Errorf("token mismatch: got %s want %s", createdPacket.Token, expectedToken) + } + if rp.ExpiryAt > 0 && createdPacket.ExpiryAt != rp.ExpiryAt { + return fmt.Errorf("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 fmt.Errorf("packet not found") + } + if strings.TrimSpace(userID) == "" { + return fmt.Errorf("user_id is required") + } + if strings.TrimSpace(claimer) == "" { + return fmt.Errorf("claimer is required") + } + if rp.Status != "ACTIVE" { + return fmt.Errorf("packet is not active, current status: %s", rp.Status) + } + if rp.ExpiryAt > 0 && rp.ExpiryAt <= time.Now().Unix() { + return fmt.Errorf("packet is expired") + } + if rp.Status == "REFUNDED" { + return fmt.Errorf("packet is refunded") + } + return nil +} + +func (s *RedPacketService) validateFixedPacketClaim(ctx context.Context, rp *model.RedPacket, userID, claimer string) error { + if strings.TrimSpace(rp.GroupID) == "" { + return fmt.Errorf("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 *RedPacketService) validateRandomPacketClaim(ctx context.Context, rp *model.RedPacket, userID, claimer string) error { + if strings.TrimSpace(rp.GroupID) == "" { + return fmt.Errorf("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 *RedPacketService) 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 fmt.Errorf("receiver_user_id is required for transfer claim") + } + if rp.ReceiverUserID != userID { + return fmt.Errorf("user is not the designated receiver") + } + return s.ensureFriendRelationship(ctx, rp.CreatorUserID, userID) +} + +func (s *RedPacketService) ensureNotClaimed(ctx context.Context, packetID, userID, claimer string) error { + if strings.TrimSpace(userID) != "" { + claim, err := s.repo.GetClaimByPacketIDAndUserID(ctx, packetID, userID) + if err == nil && claim != nil && claim.Status != "FAILED" { + return fmt.Errorf("user already claimed") + } + if err != nil && err != gorm.ErrRecordNotFound { + return fmt.Errorf("failed to check user claim status: %w", err) + } + } + + claim, err := s.repo.GetClaimByPacketIDAndClaimer(ctx, packetID, claimer) + if err == nil && claim != nil && claim.Status != "FAILED" { + return fmt.Errorf("already claimed") + } + if err != nil && err != gorm.ErrRecordNotFound { + return fmt.Errorf("failed to check claim status: %w", err) + } + return nil +} + +// ensureWalletBinding reserves the centralized identity check between Web2 +// user identity and wallet address used for claiming. +func (s *RedPacketService) ensureWalletBinding(ctx context.Context, userID, claimer, chainType string) error { + if _, err := s.repo.GetActiveWalletBinding(ctx, userID, chainType, claimer); err != nil { + if err == gorm.ErrRecordNotFound { + return fmt.Errorf("wallet is not bound to user") + } + return fmt.Errorf("check wallet binding failed: %w", err) + } + return nil +} + +// ensureGroupEligibility reserves centralized group validation, including +// whether the group exists and whether the user is currently a member. +func (s *RedPacketService) ensureGroupEligibility(ctx context.Context, groupID, userID string) error { + return nil +} + +// ensureFriendRelationship reserves centralized relation validation for +// transfer packets. +func (s *RedPacketService) ensureFriendRelationship(ctx context.Context, creatorUserID, receiverUserID string) error { + return nil +} + +func (s *RedPacketService) 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, fmt.Errorf("unsupported chain_type: %s", 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, fmt.Errorf("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 +} + +func derivePacketStatusAfterClaim(rp *model.RedPacket, claimedAmount string) string { + if rp == nil { + return "" + } + if rp.PacketType == 2 { + return "COMPLETED" + } + + nextShares := rp.ClaimedShares + 1 + if rp.TotalShares > 0 && nextShares >= rp.TotalShares { + return "COMPLETED" + } + + totalClaimed := addNumericStrings(rp.ClaimedAmount, claimedAmount) + if rp.TotalAmount != "" && totalClaimed == rp.TotalAmount { + return "COMPLETED" + } + + return "ACTIVE" +} + +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() +} + +func buildEVMBindMessage(req *WalletBindChallengeRequest, challengeID, nonce string, issuedAt, expiresAt time.Time) string { + domain := strings.TrimSpace(req.Domain) + if domain == "" { + domain = "redpacket" + } + uri := strings.TrimSpace(req.URI) + 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(req.WalletAddress)) + b.WriteString("\n\n") + fmt.Fprintf(&b, "Bind wallet %s to user %s.\n", strings.TrimSpace(req.WalletAddress), strings.TrimSpace(req.UserID)) + fmt.Fprintf(&b, "URI: %s\n", uri) + fmt.Fprintf(&b, "Version: 1\n") + fmt.Fprintf(&b, "Chain ID: %d\n", req.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(req *WalletBindChallengeRequest, 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(req.WalletAddress), + strings.TrimSpace(req.UserID), + challengeID, + nonce, + req.ChainID, + issuedAt.Format(time.RFC3339), + expiresAt.Format(time.RFC3339), + ) +} + +func verifyEVMBindSignature(message, walletAddress, signature string) error { + if strings.TrimSpace(message) == "" { + return fmt.Errorf("bind message is empty") + } + if !common.IsHexAddress(walletAddress) { + return fmt.Errorf("invalid evm wallet address") + } + + sig, err := hex.DecodeString(strings.TrimPrefix(signature, "0x")) + if err != nil { + return fmt.Errorf("decode signature failed: %w", err) + } + if len(sig) != 65 { + return fmt.Errorf("invalid signature length: %d", len(sig)) + } + if sig[64] >= 27 { + sig[64] -= 27 + } + if sig[64] > 1 { + return fmt.Errorf("invalid signature recovery id") + } + + hash := crypto.Keccak256Hash([]byte(personalSignMessage(message))) + pubKey, err := crypto.SigToPub(hash.Bytes(), sig) + if err != nil { + return fmt.Errorf("recover signer failed: %w", err) + } + + recovered := crypto.PubkeyToAddress(*pubKey) + if !strings.EqualFold(recovered.Hex(), walletAddress) { + return fmt.Errorf("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) +} + +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 "", fmt.Errorf("unsupported chain_type: %s", chainType) + } +} + +func validateCreateScope(scopeType, groupID, receiverUserID string, receiverUserIDs []string) error { + switch scopeType { + case "GROUP": + if strings.TrimSpace(groupID) == "" { + return fmt.Errorf("group_id is required when scope_type=GROUP") + } + case "DIRECT": + if strings.TrimSpace(receiverUserID) == "" && len(receiverUserIDs) == 0 { + return fmt.Errorf("receiver_user_id or receiver_user_ids is required when scope_type=DIRECT") + } + } + return nil +} + +func encodeReceiverUserIDs(userIDs []string) (string, error) { + if len(userIDs) == 0 { + return "", nil + } + encoded, err := json.Marshal(userIDs) + if err != nil { + return "", err + } + return string(encoded), nil +} + +func decodeReceiverUserIDs(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + var userIDs []string + if err := json.Unmarshal([]byte(raw), &userIDs); err != nil { + return nil + } + return userIDs +} + +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 "" } diff --git a/cmd/openim-rpc/openim-rpc-redpacket/internal/service/redpacket_test.go b/cmd/openim-rpc/openim-rpc-redpacket/internal/service/redpacket_test.go new file mode 100644 index 000000000..56f4a85d0 --- /dev/null +++ b/cmd/openim-rpc/openim-rpc-redpacket/internal/service/redpacket_test.go @@ -0,0 +1,386 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "redpacket/internal/authctx" + "redpacket/internal/model" + "redpacket/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newTestService(t *testing.T) (*RedPacketService, repository.Repository) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("gorm.Open() error = %v", err) + } + + if err := db.AutoMigrate( + &model.RedPacket{}, + &model.RedPacketClaim{}, + &model.RedPacketClaimAuth{}, + &model.RedPacketRefund{}, + &model.WalletBindingChallenge{}, + &model.WalletBinding{}, + ); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + + repo := repository.New(db) + svc := NewRedPacketService(repo, nil, nil, "") + return svc, repo +} + +func seedWalletBinding(t *testing.T, repo repository.Repository, userID, chainType, wallet string) { + t.Helper() + + err := repo.UpsertWalletBinding(context.Background(), &model.WalletBinding{ + UserID: userID, + ChainType: chainType, + WalletAddress: wallet, + Status: "ACTIVE", + ChallengeID: "test-challenge", + VerifiedAt: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + if err != nil { + t.Fatalf("UpsertWalletBinding() error = %v", err) + } +} + +func TestCanClaimRejectsExpiredAndAlreadyClaimed(t *testing.T) { + svc, repo := newTestService(t) + ctx := authctx.WithCurrentUserID(context.Background(), "u2") + + activePacket := &model.RedPacket{ + BizID: "biz-active", + ChainType: "EVM", + PacketID: "1001", + CreatorUserID: "u1", + CreatorWallet: "0xabc", + GroupID: "g-active", + Status: "ACTIVE", + ExpiryAt: time.Now().Add(10 * time.Minute).Unix(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := repo.CreateRedPacket(ctx, activePacket); err != nil { + t.Fatalf("CreateRedPacket(active) error = %v", err) + } + seedWalletBinding(t, repo, "u2", "EVM", "0xclaimer") + + claim := &model.RedPacketClaim{ + PacketID: "1001", + ClaimerWallet: "0xclaimer", + ClaimTxHash: "0xtx1", + Status: "CONFIRMED", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := repo.SaveClaim(ctx, claim); err != nil { + t.Fatalf("SaveClaim() error = %v", err) + } + + if err := svc.CanClaim(ctx, "1001", "0xclaimer", "u2"); err == nil || err.Error() != "already claimed" { + t.Fatalf("expected already claimed error, got %v", err) + } + + expiredPacket := &model.RedPacket{ + BizID: "biz-expired", + ChainType: "EVM", + PacketID: "1002", + CreatorUserID: "u1", + CreatorWallet: "0xabc", + GroupID: "g-expired", + Status: "ACTIVE", + ExpiryAt: time.Now().Add(-1 * time.Minute).Unix(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := repo.CreateRedPacket(ctx, expiredPacket); err != nil { + t.Fatalf("CreateRedPacket(expired) error = %v", err) + } + seedWalletBinding(t, repo, "u3", "EVM", "0xfresh") + + if err := svc.CanClaim(authctx.WithCurrentUserID(context.Background(), "u3"), "1002", "0xfresh", "u3"); err == nil || err.Error() != "packet is expired" { + t.Fatalf("expected expired error, got %v", err) + } +} + +func TestCanClaimRejectsAlreadyClaimedByUserID(t *testing.T) { + svc, repo := newTestService(t) + ctx := authctx.WithCurrentUserID(context.Background(), "u2") + + packet := &model.RedPacket{ + BizID: "biz-user-claimed", + ChainType: "EVM", + PacketID: "1003", + CreatorUserID: "u1", + CreatorWallet: "0xabc", + GroupID: "g-user-claimed", + Status: "ACTIVE", + ExpiryAt: time.Now().Add(10 * time.Minute).Unix(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := repo.CreateRedPacket(ctx, packet); err != nil { + t.Fatalf("CreateRedPacket() error = %v", err) + } + seedWalletBinding(t, repo, "u2", "EVM", "0xanother-wallet") + + claim := &model.RedPacketClaim{ + PacketID: "1003", + UserID: "u2", + ClaimerWallet: "0xclaimer", + ClaimTxHash: "0xtx-user-claimed", + Status: "CONFIRMED", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := repo.SaveClaim(ctx, claim); err != nil { + t.Fatalf("SaveClaim() error = %v", err) + } + + if err := svc.CanClaim(ctx, "1003", "0xanother-wallet", "u2"); err == nil || err.Error() != "user already claimed" { + t.Fatalf("expected user already claimed error, got %v", err) + } +} + +func TestCanClaimUsesPacketTypeRules(t *testing.T) { + svc, repo := newTestService(t) + ctx := authctx.WithCurrentUserID(context.Background(), "u2") + + groupPacket := &model.RedPacket{ + BizID: "biz-group", + ChainType: "EVM", + PacketID: "1101", + CreatorUserID: "u1", + CreatorWallet: "0xabc", + PacketType: 0, + Status: "ACTIVE", + ExpiryAt: time.Now().Add(10 * time.Minute).Unix(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := repo.CreateRedPacket(ctx, groupPacket); err != nil { + t.Fatalf("CreateRedPacket(group) error = %v", err) + } + seedWalletBinding(t, repo, "u2", "EVM", "0xclaimer") + if err := svc.CanClaim(ctx, "1101", "0xclaimer", "u2"); err == nil || err.Error() != "group_id is required for fixed packet claim" { + t.Fatalf("expected missing group_id error, got %v", err) + } + + transferPacket := &model.RedPacket{ + BizID: "biz-transfer", + ChainType: "EVM", + PacketID: "1102", + CreatorUserID: "u1", + CreatorWallet: "0xabc", + PacketType: 2, + ReceiverUserID: "u-receiver", + Status: "ACTIVE", + ExpiryAt: time.Now().Add(10 * time.Minute).Unix(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := repo.CreateRedPacket(ctx, transferPacket); err != nil { + t.Fatalf("CreateRedPacket(transfer) error = %v", err) + } + seedWalletBinding(t, repo, "u-other", "EVM", "0xclaimer") + if err := svc.CanClaim(ctx, "1102", "0xclaimer", "u-other"); err == nil || err.Error() != "user is not the designated receiver" { + t.Fatalf("expected designated receiver error, got %v", err) + } +} + +func TestCreateOrderPersistsScopeFields(t *testing.T) { + svc, repo := newTestService(t) + ctx := authctx.WithCurrentUserID(context.Background(), "u-create") + + result, err := svc.CreateOrder(ctx, &CreateOrderRequest{ + ChainType: "EVM", + CreatorWallet: "0x1111111111111111111111111111111111111111", + GroupID: "g-100", + ScopeType: "group", + PacketType: 1, + Token: "0x2222222222222222222222222222222222222222", + TotalAmount: "1000", + TotalShares: 10, + ReceiverUserIDs: []string{"u2", "u3"}, + }) + if err != nil { + t.Fatalf("CreateOrder() error = %v", err) + } + + bizID, _ := result["biz_id"].(string) + record, err := repo.GetRedPacketByBizID(ctx, bizID) + if err != nil { + t.Fatalf("GetRedPacketByBizID() error = %v", err) + } + + if record.ScopeType != "GROUP" { + t.Fatalf("scope type mismatch: got %s", record.ScopeType) + } + if record.ChainType != "EVM" { + t.Fatalf("chain type mismatch: got %s", record.ChainType) + } + if record.GroupID != "g-100" { + t.Fatalf("group id mismatch: got %s", record.GroupID) + } + + var got []string + if err := json.Unmarshal([]byte(record.ReceiverUserIDs), &got); err != nil { + t.Fatalf("Unmarshal(receiver_user_ids) error = %v", err) + } + if len(got) != 2 || got[0] != "u2" || got[1] != "u3" { + t.Fatalf("receiver_user_ids mismatch: got %+v", got) + } +} + +func TestCreatedCallbackUpdatesBindingAndScope(t *testing.T) { + svc, repo := newTestService(t) + ctx := authctx.WithCurrentUserID(context.Background(), "u-create") + + result, err := svc.CreateOrder(ctx, &CreateOrderRequest{ + ChainType: "TRON", + CreatorWallet: "0x1111111111111111111111111111111111111111", + PacketType: 2, + Token: "0x0000000000000000000000000000000000000000", + TotalAmount: "1000", + TotalShares: 1, + }) + if err != nil { + t.Fatalf("CreateOrder() error = %v", err) + } + + bizID, _ := result["biz_id"].(string) + err = svc.CreatedCallback(ctx, &CreatedCallbackRequest{ + BizID: bizID, + TxHash: "0xabc123", + PacketID: "3001", + ScopeType: "DIRECT", + ReceiverUserID: "u-receiver", + }) + if err != nil { + t.Fatalf("CreatedCallback() error = %v", err) + } + + record, err := repo.GetRedPacketByBizID(ctx, bizID) + if err != nil { + t.Fatalf("GetRedPacketByBizID() error = %v", err) + } + + if record.PacketID != "3001" { + t.Fatalf("packet id mismatch: got %s", record.PacketID) + } + if record.ChainType != "TRON" { + t.Fatalf("chain type mismatch: got %s", record.ChainType) + } + if record.TxHash != "0xabc123" { + t.Fatalf("tx hash mismatch: got %s", record.TxHash) + } + if record.Status != "ACTIVE" { + t.Fatalf("status mismatch: got %s", record.Status) + } + if record.ScopeType != "DIRECT" { + t.Fatalf("scope type mismatch: got %s", record.ScopeType) + } + if record.ReceiverUserID != "u-receiver" { + t.Fatalf("receiver user mismatch: got %s", record.ReceiverUserID) + } +} + +func TestIssueClaimSignValidatesInputsAndPersistsAuth(t *testing.T) { + svc, repo := newTestService(t) + ctx := authctx.WithCurrentUserID(context.Background(), "u2") + + packet := &model.RedPacket{ + BizID: "biz-sign", + ChainType: "EVM", + PacketID: "2001", + CreatorUserID: "u1", + CreatorWallet: "0xabc", + GroupID: "g-sign", + Status: "ACTIVE", + ExpiryAt: time.Now().Add(10 * time.Minute).Unix(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := repo.CreateRedPacket(ctx, packet); err != nil { + t.Fatalf("CreateRedPacket() error = %v", err) + } + seedWalletBinding(t, repo, "u2", "EVM", "0x1111111111111111111111111111111111111111") + + if _, err := svc.IssueClaimSign(ctx, "bad-packet-id", "0x1111111111111111111111111111111111111111", "0"); err == nil { + t.Fatalf("expected invalid packet id error") + } + + result, err := svc.IssueClaimSign(ctx, "2001", "0x1111111111111111111111111111111111111111", "123") + if err != nil { + t.Fatalf("IssueClaimSign() error = %v", err) + } + + auth, err := repo.GetClaimAuth(ctx, "2001", "0x1111111111111111111111111111111111111111") + if err != nil { + t.Fatalf("GetClaimAuth() error = %v", err) + } + if auth.AuthNonce == "" { + t.Fatalf("expected auth nonce to be persisted") + } + if auth.RandomSeed != "123" { + t.Fatalf("random seed mismatch: got %s", auth.RandomSeed) + } + if result["auth_nonce"] == "" { + t.Fatalf("expected auth_nonce in response") + } +} + +func TestClaimResultPersistsPendingWithoutChainParser(t *testing.T) { + svc, repo := newTestService(t) + ctx := authctx.WithCurrentUserID(context.Background(), "u2") + + packet := &model.RedPacket{ + BizID: "biz-claim-result", + ChainType: "EVM", + PacketID: "2101", + CreatorUserID: "u1", + CreatorWallet: "0xabc", + GroupID: "g-1", + PacketType: 0, + Status: "ACTIVE", + ExpiryAt: time.Now().Add(10 * time.Minute).Unix(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := repo.CreateRedPacket(ctx, packet); err != nil { + t.Fatalf("CreateRedPacket() error = %v", err) + } + seedWalletBinding(t, repo, "u2", "EVM", "0x1111111111111111111111111111111111111111") + + err := svc.ClaimResult(ctx, &ClaimResultRequest{ + PacketID: "2101", + Claimer: "0x1111111111111111111111111111111111111111", + TxHash: "0xtx-claim", + }) + if err != nil { + t.Fatalf("ClaimResult() error = %v", err) + } + + claim, err := repo.GetClaimByPacketIDAndClaimer(ctx, "2101", "0x1111111111111111111111111111111111111111") + if err != nil { + t.Fatalf("GetClaimByPacketIDAndClaimer() error = %v", err) + } + if claim.Status != "PENDING" { + t.Fatalf("claim status mismatch: got %s", claim.Status) + } + if claim.UserID != "u2" { + t.Fatalf("user id mismatch: got %s", claim.UserID) + } +} diff --git a/cmd/openim-rpc/openim-rpc-redpacket/main.go b/cmd/openim-rpc/openim-rpc-redpacket/main.go index 04c264e5d..8cc704efc 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/main.go +++ b/cmd/openim-rpc/openim-rpc-redpacket/main.go @@ -46,6 +46,8 @@ func main() { &model.RedPacketClaim{}, &model.RedPacketClaimAuth{}, &model.RedPacketRefund{}, + &model.WalletBindingChallenge{}, + &model.WalletBinding{}, ); err != nil { log.Fatalf("failed to auto-migrate: %v", err) } @@ -63,10 +65,6 @@ func main() { // Continue without blockchain for now - can be configured later } - // Create repository and service - repo := repository.New(db) - rpSvc := service.NewRedPacketService(repo, chainClient, cfg.Chain.SignerPrivateKey) - // Create TRON client if configured var tronClient *chain.TronClient if cfg.Tron.FullNodeURL != "" { @@ -91,6 +89,10 @@ func main() { } } + // Create repository and service + repo := repository.New(db) + rpSvc := service.NewRedPacketService(repo, chainClient, tronClient, cfg.Chain.SignerPrivateKey) + // Create admin service and handler adminSvc := service.NewAdminService(chainClient, tronClient) adminHandler := handler.NewAdminHandler(adminSvc) diff --git a/cmd/openim-rpc/openim-rpc-redpacket/router/router.go b/cmd/openim-rpc/openim-rpc-redpacket/router/router.go index cdd8cd11d..cdceafca5 100644 --- a/cmd/openim-rpc/openim-rpc-redpacket/router/router.go +++ b/cmd/openim-rpc/openim-rpc-redpacket/router/router.go @@ -19,6 +19,9 @@ func Setup(r *gin.Engine, rpHandler *handler.RedPacketHandler, adminHandler *han api.GET("/detail", rpHandler.Detail) api.POST("/claim-sign", rpHandler.ClaimSign) api.POST("/claim-result", rpHandler.ClaimResult) + api.POST("/wallet-bind/challenge", rpHandler.WalletBindChallenge) + api.POST("/wallet-bind/confirm", rpHandler.WalletBindConfirm) + api.GET("/wallet-bind/detail", rpHandler.WalletBindDetail) } // Admin APIs - should be protected with authentication in production