redpacket

This commit is contained in:
hawklin2017 2026-04-30 17:54:22 +08:00
parent 541471f401
commit 3cbd9a37dd
6 changed files with 1259 additions and 1701 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,615 +1,357 @@
# 红包 Go 后台对接ETH + TRON # RedPacket Go 后端对接说明ETH + TRON
这份文档按你的需求给出三部分: 本文档基于当前 OpenIM 版红包服务实现整理,重点说明 Go 后端如何接入 EVM / TRON 链能力、如何签发 claim 授权、如何解析交易事件,以及当前实现中哪些能力是完整实现、哪些仍是 mock 或待补齐。
- 后端签名(`claim` 鉴权签名ETH/TRON 通用)
- ETH 后台调用 + 通过 `txhash` 解析事件
- TRON 后台调用流程 + 通过 `txhash` 解析事件
说明:以下签名逻辑严格对应当前合约 `RedPacketBase``getSignMessage/claim` 相关代码位置:
--- - RPC 入口:`cmd/openim-rpc/openim-rpc-redpacket/main.go`
- 服务启动:`pkg/common/cmd/rpc_redpacket.go`
- 业务逻辑:`internal/rpc/redpacket/service.go`
- 管理接口:`internal/rpc/redpacket/admin.go`
- 钱包绑定:`internal/rpc/redpacket/wallet.go`
- 链客户端:`internal/rpc/redpacket/chain`
- 合约 ABI`internal/rpc/redpacket/chain/abi/RedPacket.json`
- 配置文件:`config/openim-rpc-redpacket.yml`
## 1. 依赖 ## 1. 当前架构
```bash `openim-rpc-redpacket` 已经不再是独立 Gin + GORM 服务,而是标准 OpenIM RPC 服务:
go get github.com/ethereum/go-ethereum@v1.14.12
```text
openim-api
-> /redpacket/* HTTP API
-> pbredpacket.RedPacketClient
-> openim-rpc-redpacket
-> MongoDB + EVM/TRON clients
``` ```
--- 服务启动时会初始化:
## 2. 关键合约事实(当前仓库) - MongoDB DAO`controller.NewRedPacketDatabase(...)`
- EVM client`chain.rpcURL``chain.contractAddress` 配置完整时启用
- TRON client`tron.fullNodeURL``tron.contractBase58` 配置完整时启用
- signer 私钥:当 `chain.signerPrivateKey` 配置完整时用于 claim 裸签名
- 签名结构体: 链客户端初始化失败不会阻止服务启动,但会导致链上确认、事件解析或签名 digest 获取降级。
`Claim(uint256 packetId,address claimer,uint256 authNonce,uint256 randomSeed,uint256 deadline)`
- 领取函数:
`claim(packetId, authNonce, randomSeed, deadline, signature)`
- 重点事件:
- `PacketCreated(uint256,address,uint8,address,uint256,uint256,uint256)`
- `PacketClaimed(uint256,address,uint256,uint256,uint256,uint256)`
- `PacketRefunded(uint256,address,address,uint256)`
--- ## 2. 配置
## 3. Go后端 claim 签名ETH/TRON 通用) `config/openim-rpc-redpacket.yml` 示例:
合约里验签是 `ecrecover(getSignMessage(...), v, r, s)`,所以后端要对 `digest` 做裸签名,不要加 `personal_sign` 前缀。 ```yaml
rpc:
registerIP: ""
listenIP: 0.0.0.0
autoSetPorts: false
ports: [10560]
prometheus:
enable: false
ports: [12560]
chain:
rpcURL: "https://eth-mainnet.g.alchemy.com/v2/xxx"
contractAddress: "0x..."
chainID: 1
signerPrivateKey: "0x..."
configAdminPrivateKey: "0x..."
tron:
fullNodeURL: "https://api.trongrid.io"
contractBase58: "T..."
ownerBase58: "T..."
privateKeyHex: "..."
feeLimit: 100000000
indexer:
pollInterval: 5
```
配置含义:
- `chain.rpcURL`: EVM JSON-RPC 地址
- `chain.contractAddress`: EVM RedPacket 合约地址
- `chain.chainID`: EVM 链 ID用于记录业务单与构造交易
- `chain.signerPrivateKey`: claim 授权签名私钥,应对应合约 `signer`
- `chain.configAdminPrivateKey`: 管理写链私钥,当前 EVM admin 仍是 mock
- `tron.fullNodeURL`: TRON FullNode / TronGrid 地址
- `tron.contractBase58`: TRON 合约 Base58 地址
- `tron.ownerBase58`: TRON 管理交易发送地址
- `tron.privateKeyHex`: TRON 管理交易私钥
- `tron.feeLimit`: TRON 交易 fee limit
安全建议:
- `signerPrivateKey``configAdminPrivateKey` 必须分离
- 生产不要把管理私钥明文放在普通配置文件中,建议接入 KMS/HSM 或密钥托管服务
- `signerPrivateKey` 是高频签名密钥,权限只能用于 claim 授权,不应拥有合约配置权限
## 3. Claim 签名
### 3.1 合约签名事实
当前后端签名逻辑对应合约的:
```text
getSignMessage(packetId, claimer, authNonce, randomSeed, deadline)
claim(packetId, authNonce, randomSeed, deadline, signature)
```
后端流程:
1. 业务鉴权:登录用户、钱包绑定、红包状态、重复领取、群/转账资格
2. 生成 `authNonce``randomSeed``deadline`
3. EVM client 可用时调用链上 `getSignMessage(...)` 获取 digest
4. 用 `signerPrivateKey` 对 digest 做裸签名
5. 如果 `v` 是 0/1转换为 27/28
6. 保存 `red_packet_claim_auth`
7. 返回前端调用 `claim(...)` 所需参数
注意:不要使用 `personal_sign` 对 claim digest 签名。claim 授权使用的是裸 ECDSA 签名,不带 Ethereum Signed Message 前缀。
### 3.2 Go 裸签名示例
```go ```go
package redpacket func signClaimDigest(priv *ecdsa.PrivateKey, digest [32]byte) (string, error) {
sig, err := crypto.Sign(digest[:], priv)
import ( if err != nil {
"crypto/ecdsa" return "", err
"fmt" }
"math/big" if len(sig) == 65 && sig[64] < 27 {
sig[64] += 27
"github.com/ethereum/go-ethereum/common" }
"github.com/ethereum/go-ethereum/crypto" return "0x" + hex.EncodeToString(sig), nil
)
// SignClaimDigest 对合约返回的 digest 做裸签,返回 65 字节签名r||s||v
func SignClaimDigest(priv *ecdsa.PrivateKey, digest [32]byte) ([]byte, error) {
sig, err := crypto.Sign(digest[:], priv)
if err != nil {
return nil, err
}
// go-ethereum 返回 v 为 0/1EVM 合约通常期望 27/28
sig[64] += 27
return sig, nil
}
// RecoverAndCheckSigner 本地自检(可选)
func RecoverAndCheckSigner(digest [32]byte, sig []byte, expected common.Address) error {
if len(sig) != 65 {
return fmt.Errorf("invalid sig length: %d", len(sig))
}
cpy := make([]byte, 65)
copy(cpy, sig)
if cpy[64] >= 27 {
cpy[64] -= 27
}
pub, err := crypto.SigToPub(digest[:], cpy)
if err != nil {
return err
}
got := crypto.PubkeyToAddress(*pub)
if got != expected {
return fmt.Errorf("signer mismatch, got=%s want=%s", got.Hex(), expected.Hex())
}
return nil
}
// BuildClaimTypeHash 仅当你要本地复算 digest 时才需要。
func BuildClaimTypeHash() common.Hash {
return crypto.Keccak256Hash([]byte("Claim(uint256 packetId,address claimer,uint256 authNonce,uint256 randomSeed,uint256 deadline)"))
}
// BuildClaimStructHash 本地复算 structHash可选
func BuildClaimStructHash(packetId *big.Int, claimer common.Address, authNonce, randomSeed, deadline *big.Int) common.Hash {
typeHash := BuildClaimTypeHash()
encoded := make([]byte, 0, 32*6)
encoded = append(encoded, typeHash.Bytes()...)
encoded = append(encoded, common.LeftPadBytes(packetId.Bytes(), 32)...)
encoded = append(encoded, common.LeftPadBytes(claimer.Bytes(), 32)...)
encoded = append(encoded, common.LeftPadBytes(authNonce.Bytes(), 32)...)
encoded = append(encoded, common.LeftPadBytes(randomSeed.Bytes(), 32)...)
encoded = append(encoded, common.LeftPadBytes(deadline.Bytes(), 32)...)
return crypto.Keccak256Hash(encoded)
} }
``` ```
生产建议: ### 3.3 当前降级行为
- 最稳妥方式是先链上调用 `getSignMessage(...)``digest`,再签名。
- `authNonce` 必须按 `claimer` 做幂等和防重。
- `deadline` 建议 5~30 分钟。
--- 当前代码有两个降级点:
## 4. GoETH 后台调用 + txhash 解析事件 - EVM client 不可用时,后端会用本地 `keccak256(packetID:claimer:nonce:randomSeed:deadline)` 生成 digest该 digest 不保证与合约一致,仅适合调试。
- signer 私钥未配置时,后端会返回 placeholder 签名;该签名不能通过链上验签。
### 4.1 通过 txhash 解析 `PacketCreated/PacketClaimed/PacketRefunded` 生产环境必须配置可用的 EVM client 和 signer 私钥。
```go ## 4. ETH 接入
package redpacket
import ( ### 4.1 创建红包
"context"
"encoding/json"
"fmt"
"strings"
"github.com/ethereum/go-ethereum/accounts/abi" 推荐调用顺序:
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
)
type ParsedEvent struct { 1. 后端 `CreateOrder` 生成 `biz_id`
Name string 2. 前端或托管钱包发起链上创建交易
Data map[string]any 3. 从 `PacketCreated` 事件解析 `packetId`
} 4. 调用 `CreatedCallback` 回写 `biz_id + tx_hash + packet_id`
5. 后端使用 EVM client 解析 receipt 并校验事件字段
6. 校验通过后业务单变为 `ACTIVE`
func ParseEthEventsByTxHash(ctx context.Context, rpcURL, txHashHex, contractABIJSON string) ([]ParsedEvent, error) { 当前代码中的校验点:
cli, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return nil, err
}
defer cli.Close()
txHash := common.HexToHash(txHashHex) - `tx_hash` 必填
rcpt, err := cli.TransactionReceipt(ctx, txHash) - receipt 中必须有可识别的 `PacketCreated`
if err != nil { - event 解析出的 creator / packetType / token / amount / shares / expiry 要与业务单一致
return nil, err - 如果链客户端不可用,允许请求体提供 `packet_id` fallback
}
parsedABI, err := abi.JSON(strings.NewReader(contractABIJSON)) ### 4.2 领取红包
if err != nil {
return nil, err
}
var out []ParsedEvent 推荐调用顺序:
for _, lg := range rcpt.Logs {
ev, ok := eventFromLog(parsedABI, lg)
if ok {
out = append(out, ev)
}
}
return out, nil
}
func eventFromLog(parsedABI abi.ABI, lg *types.Log) (ParsedEvent, bool) { 1. 前端确认用户已经绑定当前 EVM 钱包
if len(lg.Topics) == 0 { 2. 调用 `IssueClaimSign`
return ParsedEvent{}, false 3. 前端使用返回参数调用合约 `claim(...)`
} 4. 交易提交后调用 `ClaimResult`
for name, e := range parsedABI.Events { 5. 后端解析 `PacketClaimed`,补全 amount、authNonce、blockNumber
if e.ID != lg.Topics[0] {
continue
}
vals := map[string]any{}
// 非 indexed 参数 `ClaimResult` 当前行为:
nonIndexed, err := e.Inputs.NonIndexed().Unpack(lg.Data)
if err != nil {
return ParsedEvent{}, false
}
n := 0
idxTopic := 1
for _, input := range e.Inputs {
if input.Indexed {
if idxTopic >= len(lg.Topics) {
return ParsedEvent{}, false
}
vals[input.Name] = decodeIndexedTopic(input.Type, lg.Topics[idxTopic])
idxTopic++
} else {
vals[input.Name] = nonIndexed[n]
n++
}
}
return ParsedEvent{Name: name, Data: vals}, true
}
return ParsedEvent{}, false
}
func decodeIndexedTopic(t abi.Type, topic common.Hash) any { - 先落 `PENDING` 领取记录
switch t.T { - 能解析 receipt 时更新为 `CONFIRMED`
case abi.AddressTy: - 解析到 `PacketClaimed` 后更新红包领取进度
return common.BytesToAddress(topic.Bytes()[12:]) - 已领取份数达到 `total_shares` 时状态更新为 `COMPLETED`
default:
return topic
}
}
func PrettyPrintEvents(events []ParsedEvent) string { ### 4.3 事件解析
b, _ := json.MarshalIndent(events, "", " ")
return string(b)
}
func MustReadABIFromArtifact(artifactJSON []byte) (string, error) { EVM 事件解析由 `internal/rpc/redpacket/chain/parser.go` 负责。管理接口也提供手动解析入口:
var raw map[string]any
if err := json.Unmarshal(artifactJSON, &raw); err != nil { ```http
return "", err POST /redpacket/admin/parse_tx_events
} ```
abiObj, ok := raw["abi"]
if !ok { 请求:
return "", fmt.Errorf("abi field not found")
} ```json
abiBytes, err := json.Marshal(abiObj) {
if err != nil { "chain": "eth",
return "", err "tx_hash": "0xabc123..."
}
return string(abiBytes), nil
} }
``` ```
### 4.2 ETH 创建/领取调用(示意) 响应示例:
建议用 `abigen` 生成 Go binding 后调用(最稳)。 ```json
{
`abigen` 示例: "chain": "eth",
```bash "tx_hash": "0xabc123...",
abigen --abi abi/contracts/RedPacket.sol/RedPacket.json --pkg redpacket --type RedPacket --out redpacket_binding.go "events": [
``` {
"name": "PacketCreated",
调用流程: "data": {
1. `createFixedPacket/createRandomPacket/createTransfer` 发交易 "packetId": "10001",
2. 拿到 `txHash` 后轮询 receipt "creator": "0x1111111111111111111111111111111111111111",
3. 用上面的 `ParseEthEventsByTxHash` 解出 `PacketCreated`,拿到 `packetId` "packetType": "1"
4. 后端签名下发给前端后,前端/后端发 `claim` }
5. 用 `PacketClaimed.amount` 作为最终到账金额 }
]
---
## 5. GoTRON 后台调用 + txhash 解析事件
TRON 的 EVM 合约事件最终也是 topic/data 结构,因此事件解码可复用 EVM ABI。
### 5.1 通过 txhash 解析 TRON 事件(推荐走 `/wallet/gettransactioninfobyid`
```go
package redpacket
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
)
type tronTxInfoResp struct {
ID string `json:"id"`
Log []struct {
Address string `json:"address"` // 合约地址hex(无0x)
Topics []string `json:"topics"` // topic hex(无0x)
Data string `json:"data"` // data hex(无0x)
} `json:"log"`
}
func ParseTronEventsByTxHash(ctx context.Context, tronFullNodeURL, txID, contractABIJSON string) ([]ParsedEvent, error) {
body := map[string]string{"value": txID}
buf, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, tronFullNodeURL+"/wallet/gettransactioninfobyid", bytes.NewReader(buf))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("tron http %d: %s", resp.StatusCode, string(raw))
}
var info tronTxInfoResp
if err := json.Unmarshal(raw, &info); err != nil {
return nil, err
}
parsedABI, err := abi.JSON(strings.NewReader(contractABIJSON))
if err != nil {
return nil, err
}
out := make([]ParsedEvent, 0, len(info.Log))
for _, lg := range info.Log {
if len(lg.Topics) == 0 {
continue
}
topic0 := common.HexToHash("0x" + lg.Topics[0])
for name, e := range parsedABI.Events {
if e.ID != topic0 {
continue
}
vals := map[string]any{}
dataBytes, err := hex.DecodeString(strings.TrimPrefix(lg.Data, "0x"))
if err != nil {
return nil, err
}
nonIndexed, err := e.Inputs.NonIndexed().Unpack(dataBytes)
if err != nil {
return nil, err
}
n := 0
idxTopic := 1
for _, input := range e.Inputs {
if input.Indexed {
if idxTopic >= len(lg.Topics) {
return nil, fmt.Errorf("missing indexed topic for event %s", name)
}
t := common.HexToHash("0x" + lg.Topics[idxTopic])
vals[input.Name] = decodeIndexedTopic(input.Type, t)
idxTopic++
} else {
vals[input.Name] = nonIndexed[n]
n++
}
}
out = append(out, ParsedEvent{Name: name, Data: vals})
break
}
}
return out, nil
} }
``` ```
### 5.2 TRON 后台调用流程(实践) 核心事件:
1. 组装 ABI 参数(与 ETH 一样) - `PacketCreated`: 创建成功,提供唯一可信 `packetId`
2. 调用 TRON FullNode 的 `trigger*contract` 生成未签名交易 - `PacketClaimed`: 领取成功,提供实际领取金额
3. 用托管私钥签名交易并广播 - `PacketRefunded`: 退款成功,提供退款金额与接收方
4. 根据返回 `txID` 调用上面的 `ParseTronEventsByTxHash` 解事件
说明TRON 发交易接口在不同节点服务TronGrid/自建 FullNode/SDK 封装)字段细节略有差异,建议你在项目里固定一种(推荐固定 TronGrid 或 gotron-sdk 版本),避免线上环境差异。 ### 4.4 ETH 管理接口现状
--- 当前 `internal/rpc/redpacket/admin.go` 中 EVM 管理接口是 mock
## 6. 合约参数设置(管理员) - `SetSigner`
- `SetToken`
- `SetExpiry`
- `SetAllowAllTokens`
- `SetNativeTokenEnabled`
需要 `CONFIG_ADMIN_ROLE` 的函数: 这些接口在 EVM client 可用时只记录日志并返回成功 message不会真正发链上交易。上线前如需后端托管管理交易需要补充 EVM admin transaction 实现。
- `setSigner(address signer)`
- `setAllowAllTokens(bool allowAllTokens)`
- `setNativeTokenEnabled(bool enabled)`
- `setAllowedToken(address token, bool allowed, uint256 minShareAmount)`
- `setDefaultExpiryDuration(uint256 duration)`
对应配置事件(可按 `txhash` 解析校验): ## 5. TRON 接入
- `SignerUpdated(oldSigner, newSigner)`
- `AllowAllTokensUpdated(allowAllTokens)`
- `NativeTokenEnabledUpdated(enabled)`
- `AllowedTokenUpdated(token, allowed, minShareAmount)`
- `DefaultExpiryDurationUpdated(duration)`
### 6.1 ETHGo 设置合约参数(通用写法) ### 5.1 TRON 创建与领取
```go TRON 合约兼容 EVM ABI 的 topic/data 事件模型,但地址、签名与交易广播流程和 EVM 不同。
package redpacket
import ( 当前后端支持:
"context"
"crypto/ecdsa"
"fmt"
"math/big"
"strings"
ethereum "github.com/ethereum/go-ethereum" - 创建业务单时 `chain_type=TRON`
"github.com/ethereum/go-ethereum/accounts/abi" - `contract_address` 可从 `tron.contractBase58` 自动填充
"github.com/ethereum/go-ethereum/common" - TRON 钱包绑定 challenge 生成
"github.com/ethereum/go-ethereum/core/types" - TRON admin 写交易通过 `SendAdminTransaction(...)` 尝试调用 FullNode
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
)
// SendEthAdminTx 通用管理员写调用: 当前后端尚未完整支持:
// method 例如 "setNativeTokenEnabled"
// args 对应函数参数
func SendEthAdminTx(
ctx context.Context,
rpcURL string,
contractAddr common.Address,
priv *ecdsa.PrivateKey,
contractABIJSON string,
method string,
args ...any,
) (common.Hash, error) {
cli, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return common.Hash{}, err
}
defer cli.Close()
from := crypto.PubkeyToAddress(priv.PublicKey) - TRON 钱包绑定签名验签
nonce, err := cli.PendingNonceAt(ctx, from) - TRON claim digest 获取与 claim 签名链上闭环
if err != nil { - TRON receipt 事件完整解析与索引
return common.Hash{}, err
}
chainID, err := cli.NetworkID(ctx)
if err != nil {
return common.Hash{}, err
}
gasPrice, err := cli.SuggestGasPrice(ctx)
if err != nil {
return common.Hash{}, err
}
parsedABI, err := abi.JSON(strings.NewReader(contractABIJSON)) ### 5.2 TRON 管理交易
if err != nil {
return common.Hash{}, err
}
data, err := parsedABI.Pack(method, args...)
if err != nil {
return common.Hash{}, err
}
msg := ethereum.CallMsg{ 当前 TRON admin 使用 FullNode HTTP 流程:
From: from, To: &contractAddr, Data: data, Value: big.NewInt(0),
}
gasLimit, err := cli.EstimateGas(ctx, msg)
if err != nil {
return common.Hash{}, err
}
tx := types.NewTransaction(nonce, contractAddr, big.NewInt(0), gasLimit, gasPrice, data) ```text
signedTx, err := types.SignTx(tx, types.NewEIP155Signer(chainID), priv) triggersmartcontract
if err != nil { -> gettransactionsign
return common.Hash{}, err -> broadcasttransaction
} ```
if err = cli.SendTransaction(ctx, signedTx); err != nil {
return common.Hash{}, err
}
return signedTx.Hash(), nil
}
// 例子:开启原生币、放开所有 token、设置 token 白名单与最小份额 配置依赖:
func ExampleSetConfigEth(ctx context.Context, rpcURL, abiJSON, contractHex string, priv *ecdsa.PrivateKey, usdt common.Address) error {
contract := common.HexToAddress(contractHex)
tx1, err := SendEthAdminTx(ctx, rpcURL, contract, priv, abiJSON, "setNativeTokenEnabled", true) - `tron.fullNodeURL`
if err != nil { - `tron.contractBase58`
return err - `tron.ownerBase58`
} - `tron.privateKeyHex`
fmt.Println("setNativeTokenEnabled tx:", tx1.Hex()) - `tron.feeLimit`
tx2, err := SendEthAdminTx(ctx, rpcURL, contract, priv, abiJSON, "setAllowAllTokens", false) 管理接口会把方法映射到合约调用:
if err != nil {
return err
}
fmt.Println("setAllowAllTokens tx:", tx2.Hex())
tx3, err := SendEthAdminTx(ctx, rpcURL, contract, priv, abiJSON, "setAllowedToken", usdt, true, big.NewInt(1_000_000)) - `SetSigner` -> `setSigner`
if err != nil { - `SetToken` -> `setAllowedToken`
return err - `SetExpiry` -> `setDefaultExpiryDuration`
} - `SetAllowAllTokens` -> `setAllowAllTokens`
fmt.Println("setAllowedToken tx:", tx3.Hex()) - `SetNativeTokenEnabled` -> `setNativeTokenEnabled`
return nil ### 5.3 TRON 事件解析现状
`ParseTxEvents(chain=tron)` 当前返回:
```json
{
"chain": "tron",
"tx_hash": "7d9e...txid",
"note": "TRON event parsing not fully implemented in this version"
} }
``` ```
注意:`setAllowedToken(..., minShareAmount)` 的单位是 token 最小单位(例如 6 位精度 token`1_000_000` 代表 1 个 token 后续如果要补齐,应实现:
### 6.2 TRONGo 设置合约参数FullNode HTTP 1. 调用 `/wallet/gettransactioninfobyid`
2. 从 `log` 读取 topics/data
3. 将 TRON 地址字段规范化为 Base58 或 hex
4. 使用 `RedPacket.json` ABI 解码事件
5. 复用 EVM 的 `PacketCreated` / `PacketClaimed` / `PacketRefunded` 业务回写逻辑
TRON 推荐流程:`triggersmartcontract -> gettransactionsign -> broadcasttransaction` ## 6. 钱包绑定
```go ### 6.1 EVM 绑定
package redpacket
import ( EVM 绑定采用 SIWE 风格消息:
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/ethereum/go-ethereum/accounts/abi" - protocol: `siwe-eip4361`
) - sign method: `personal_sign`
- challenge 有效期: 10 分钟
func encodeTronParams(abiJSON, method string, args ...any) (string, error) { 确认绑定时,后端会:
parsed, err := abi.JSON(strings.NewReader(abiJSON))
if err != nil {
return "", err
}
m, ok := parsed.Methods[method]
if !ok {
return "", fmt.Errorf("method not found: %s", method)
}
packed, err := m.Inputs.Pack(args...)
if err != nil {
return "", err
}
return hex.EncodeToString(packed), nil
}
func postJSON(ctx context.Context, url string, body any, out any) error { 1. 读取 `wallet_binding_challenge`
b, _ := json.Marshal(body) 2. 检查状态为 `PENDING`
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) 3. 检查未过期
req.Header.Set("Content-Type", "application/json") 4. 用 `personalSignMessage(message)` 计算 hash
resp, err := http.DefaultClient.Do(req) 5. `SigToPub` recover 地址
if err != nil { 6. 比对 recover 地址与 challenge wallet
return err 7. challenge 更新为 `VERIFIED`
} 8. upsert `wallet_binding`
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return fmt.Errorf("http %d: %s", resp.StatusCode, string(raw))
}
if err := json.Unmarshal(raw, out); err != nil {
return err
}
return nil
}
// SendTronAdminTx 示例: ### 6.2 TRON 绑定
// selector 例子 "setNativeTokenEnabled(bool)"
// methodName 例子 "setNativeTokenEnabled"
func SendTronAdminTx(
ctx context.Context,
fullNodeURL, ownerBase58, contractBase58, selector, methodName string,
feeLimit int64,
privateKeyHex string,
abiJSON string,
args ...any,
) (string, error) {
paramHex, err := encodeTronParams(abiJSON, methodName, args...)
if err != nil {
return "", err
}
var triggerResp map[string]any TRON challenge 会生成:
err = postJSON(ctx, fullNodeURL+"/wallet/triggersmartcontract", map[string]any{
"owner_address": ownerBase58,
"contract_address": contractBase58,
"function_selector": selector,
"parameter": paramHex,
"fee_limit": feeLimit,
"call_value": 0,
"visible": true,
}, &triggerResp)
if err != nil {
return "", err
}
txObj, ok := triggerResp["transaction"] - protocol: `tron-signmessagev2`
if !ok { - sign method: `signMessageV2`
return "", fmt.Errorf("transaction not found in trigger response")
}
var signedResp map[string]any 但确认绑定当前未实现,会返回:
err = postJSON(ctx, fullNodeURL+"/wallet/gettransactionsign", map[string]any{
"transaction": txObj,
"privateKey": privateKeyHex,
}, &signedResp)
if err != nil {
return "", err
}
var broadcastResp map[string]any ```text
err = postJSON(ctx, fullNodeURL+"/wallet/broadcasttransaction", signedResp, &broadcastResp) TRON wallet binding verification is not implemented yet
if err != nil {
return "", err
}
if result, _ := broadcastResp["result"].(bool); !result {
return "", fmt.Errorf("broadcast failed: %v", broadcastResp)
}
txid, _ := broadcastResp["txid"].(string)
return txid, nil
}
``` ```
调用示例: ## 7. MongoDB 数据
- `setNativeTokenEnabled(true)`
`selector = "setNativeTokenEnabled(bool)"``methodName = "setNativeTokenEnabled"``args = true`
- `setAllowAllTokens(false)`
`selector = "setAllowAllTokens(bool)"``methodName = "setAllowAllTokens"``args = false`
- `setAllowedToken(token, true, 1_000_000)`
`selector = "setAllowedToken(address,bool,uint256)"``methodName = "setAllowedToken"``args = common.HexToAddress(tokenHexAddress), true, big.NewInt(1_000_000)`
安全建议:生产环境不要把私钥直接传给节点接口,建议改为本地离线签名或托管签名服务。 当前使用 6 个 collection
--- - `red_packet`: 红包主记录
- `red_packet_claim`: 领取记录
- `red_packet_claim_auth`: claim 签名授权记录
- `red_packet_refund`: 退款记录
- `wallet_binding_challenge`: 钱包绑定 challenge
- `wallet_binding`: 钱包绑定关系
## 7. 最小落地建议(直接可用) 关键幂等约束:
- 统一保存:`chain + txHash + packetId + eventName + rawEventJson` - `red_packet.biz_id` 唯一
- 创建成功:只认 `PacketCreated.packetId` - `red_packet_claim.claim_tx_hash` 唯一
- 领取成功:只认 `PacketClaimed.amount` - `red_packet_claim_auth.auth_nonce` 唯一
- 退款成功:只认 `PacketRefunded.amount` - `wallet_binding_challenge.challenge_id` 唯一
- 签名服务:`authNonce` 做地址维度去重;`deadline` 过期即废弃 - `wallet_binding.user_id + chain_type + wallet_address` 唯一
## 8. 部署检查清单
上线前至少确认:
- `share.yml` 中存在 `rpcRegisterName.redPacket: redPacket`
- `openim-rpc-redpacket.yml` 已加入配置目录
- `openim-api` watch service list 包含 `redPacket`
- MongoDB 可用且服务启动时能创建索引
- EVM 环境配置了有效 `rpcURL``contractAddress``signerPrivateKey`
- 生产关闭 placeholder signer 降级路径
- 管理接口补充管理员鉴权与操作审计
- 如需 ETH admin 写链,补齐当前 mock 实现
- 如需 TRON 完整闭环补齐绑定验签、事件解析、claim 签名链路

View File

@ -9,6 +9,7 @@ import (
"github.com/openimsdk/open-im-server/v3/pkg/common/config" "github.com/openimsdk/open-im-server/v3/pkg/common/config"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller" "github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/database/mgo" "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database/mgo"
"github.com/openimsdk/open-im-server/v3/pkg/rpcli"
pbredpacket "github.com/openimsdk/protocol/redpacket" pbredpacket "github.com/openimsdk/protocol/redpacket"
"github.com/openimsdk/tools/db/mongoutil" "github.com/openimsdk/tools/db/mongoutil"
"github.com/openimsdk/tools/discovery" "github.com/openimsdk/tools/discovery"
@ -25,14 +26,16 @@ type Config struct {
type redPacketServer struct { type redPacketServer struct {
pbredpacket.UnimplementedRedPacketServer pbredpacket.UnimplementedRedPacketServer
config *Config config *Config
db controller.RedPacketDatabase db controller.RedPacketDatabase
chainClient *chain.ChainClient chainClient *chain.ChainClient
tronClient *chain.TronClient tronClient *chain.TronClient
signerKey *ecdsa.PrivateKey signerKey *ecdsa.PrivateKey
groupClient *rpcli.GroupClient
relationClient *rpcli.RelationClient
} }
func Start(ctx context.Context, conf *Config, _ discovery.SvcDiscoveryRegistry, server *grpc.Server) error { func Start(ctx context.Context, conf *Config, registry discovery.SvcDiscoveryRegistry, server *grpc.Server) error {
mgoClient, err := mongoutil.NewMongoDB(ctx, conf.MongodbConfig.Build()) mgoClient, err := mongoutil.NewMongoDB(ctx, conf.MongodbConfig.Build())
if err != nil { if err != nil {
return err return err
@ -109,12 +112,23 @@ func Start(ctx context.Context, conf *Config, _ discovery.SvcDiscoveryRegistry,
} }
} }
groupConn, err := registry.GetConn(ctx, conf.Share.RpcRegisterName.Group)
if err != nil {
return err
}
friendConn, err := registry.GetConn(ctx, conf.Share.RpcRegisterName.Friend)
if err != nil {
return err
}
srv := &redPacketServer{ srv := &redPacketServer{
config: conf, config: conf,
db: repo, db: repo,
chainClient: chainClient, chainClient: chainClient,
tronClient: tronClient, tronClient: tronClient,
signerKey: signerKey, signerKey: signerKey,
groupClient: rpcli.NewGroupClient(groupConn),
relationClient: rpcli.NewRelationClient(friendConn),
} }
pbredpacket.RegisterRedPacketServer(server, srv) pbredpacket.RegisterRedPacketServer(server, srv)

View File

@ -435,16 +435,120 @@ func (s *redPacketServer) validateCreateHook(ctx context.Context, req *pbredpack
} }
} }
// validateCreateBaseFields validates the fields shared by every red packet type.
// It does not look up creator identity or scope; those are handled by the per-type hooks.
func validateCreateBaseFields(req *pbredpacket.CreateOrderReq) (*big.Int, error) {
if strings.TrimSpace(req.CreatorWallet) == "" {
return nil, errs.ErrArgs.WrapMsg("creator_wallet is required")
}
if strings.TrimSpace(req.TotalAmount) == "" {
return nil, errs.ErrArgs.WrapMsg("total_amount is required")
}
total, ok := new(big.Int).SetString(req.TotalAmount, 10)
if !ok || total.Sign() <= 0 {
return nil, errs.ErrArgs.WrapMsg("total_amount must be a positive integer string", "totalAmount", req.TotalAmount)
}
if req.ExpiryAt != 0 && req.ExpiryAt <= time.Now().Unix() {
return nil, errs.ErrArgs.WrapMsg("expiry_at must be 0 or a future unix timestamp", "expiryAt", req.ExpiryAt)
}
return total, nil
}
// validateCreatorScope verifies group membership / friend relationship for the creator
// based on the requested scope. PUBLIC scope skips relationship checks.
func (s *redPacketServer) validateCreatorScope(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
creatorUserID := mcontext.GetOpUserID(ctx)
if creatorUserID == "" {
return servererrs.ErrNoPermission.WrapMsg("op user id is empty")
}
switch normalizeScopeType(req.ScopeType) {
case "GROUP":
return s.ensureGroupEligibility(ctx, req.GroupID, creatorUserID)
case "DIRECT":
if strings.TrimSpace(req.ReceiverUserID) != "" {
if err := s.ensureFriendRelationship(ctx, creatorUserID, req.ReceiverUserID); err != nil {
return err
}
}
for _, receiverID := range req.ReceiverUserIDs {
if strings.TrimSpace(receiverID) == "" {
continue
}
if err := s.ensureFriendRelationship(ctx, creatorUserID, receiverID); err != nil {
return err
}
}
return nil
default:
return nil
}
}
// validateFixedPacketCreate validates fixed red packets:
// - shared base fields
// - total_shares > 0
// - total_amount must be divisible by total_shares (each share is an integer in min units)
// - scope-based group/friend relationship for the creator
func (s *redPacketServer) validateFixedPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error { func (s *redPacketServer) validateFixedPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
return nil total, err := validateCreateBaseFields(req)
if err != nil {
return err
}
if req.TotalShares <= 0 {
return errs.ErrArgs.WrapMsg("total_shares must be positive for fixed packet", "totalShares", req.TotalShares)
}
shares := big.NewInt(int64(req.TotalShares))
if new(big.Int).Mod(total, shares).Sign() != 0 {
return errs.ErrArgs.WrapMsg("total_amount must be divisible by total_shares for fixed packet",
"totalAmount", req.TotalAmount, "totalShares", req.TotalShares)
}
return s.validateCreatorScope(ctx, req)
} }
// validateRandomPacketCreate validates random (lucky) red packets:
// - shared base fields
// - total_shares > 0
// - total_amount >= total_shares (at least 1 min unit per share)
// - scope-based group/friend relationship for the creator
func (s *redPacketServer) validateRandomPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error { func (s *redPacketServer) validateRandomPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
return nil total, err := validateCreateBaseFields(req)
if err != nil {
return err
}
if req.TotalShares <= 0 {
return errs.ErrArgs.WrapMsg("total_shares must be positive for random packet", "totalShares", req.TotalShares)
}
shares := big.NewInt(int64(req.TotalShares))
if total.Cmp(shares) < 0 {
return errs.ErrArgs.WrapMsg("total_amount must be >= total_shares for random packet",
"totalAmount", req.TotalAmount, "totalShares", req.TotalShares)
}
return s.validateCreatorScope(ctx, req)
} }
// validateTransferPacketCreate validates transfer red packets:
// - shared base fields
// - total_shares == 1
// - exactly one receiver_user_id, must be a friend of the creator
func (s *redPacketServer) validateTransferPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error { func (s *redPacketServer) validateTransferPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
return nil if _, err := validateCreateBaseFields(req); err != nil {
return err
}
if req.TotalShares != 1 {
return errs.ErrArgs.WrapMsg("transfer packet must have total_shares == 1", "totalShares", req.TotalShares)
}
receiverUserID := strings.TrimSpace(req.ReceiverUserID)
if receiverUserID == "" {
return errs.ErrArgs.WrapMsg("receiver_user_id is required for transfer packet")
}
if len(req.ReceiverUserIDs) > 0 {
return errs.ErrArgs.WrapMsg("transfer packet only supports a single receiver_user_id")
}
creatorUserID := mcontext.GetOpUserID(ctx)
if creatorUserID == "" {
return servererrs.ErrNoPermission.WrapMsg("op user id is empty")
}
return s.ensureFriendRelationship(ctx, creatorUserID, receiverUserID)
} }
func buildFallbackCreatedPacket(rp *model.RedPacket, packetID string) *createdPacketSnapshot { func buildFallbackCreatedPacket(rp *model.RedPacket, packetID string) *createdPacketSnapshot {
@ -587,13 +691,50 @@ func (s *redPacketServer) ensureWalletBinding(ctx context.Context, userID, claim
return nil return nil
} }
// ensureGroupEligibility reserves centralized group membership checks. // ensureGroupEligibility verifies that userID is an active member of groupID.
func (s *redPacketServer) ensureGroupEligibility(ctx context.Context, groupID, userID string) error { func (s *redPacketServer) ensureGroupEligibility(ctx context.Context, groupID, userID string) error {
groupID = strings.TrimSpace(groupID)
userID = strings.TrimSpace(userID)
if groupID == "" {
return errs.ErrArgs.WrapMsg("group_id is required for group claim")
}
if userID == "" {
return errs.ErrArgs.WrapMsg("user_id is required for group claim")
}
if s.groupClient == nil {
return servererrs.ErrInternalServer.WrapMsg("group client is not initialized")
}
if _, err := s.groupClient.GetGroupMemberInfo(ctx, groupID, userID); err != nil {
if errs.ErrRecordNotFound.Is(err) {
return errs.ErrNoPermission.WrapMsg("user is not a member of the group", "groupID", groupID, "userID", userID)
}
return err
}
return nil return nil
} }
// ensureFriendRelationship reserves centralized relation validation for transfer packets. // ensureFriendRelationship verifies that creatorUserID and receiverUserID are friends
// (used by transfer red packets to require a pre-existing relationship).
func (s *redPacketServer) ensureFriendRelationship(ctx context.Context, creatorUserID, receiverUserID string) error { func (s *redPacketServer) ensureFriendRelationship(ctx context.Context, creatorUserID, receiverUserID string) error {
creatorUserID = strings.TrimSpace(creatorUserID)
receiverUserID = strings.TrimSpace(receiverUserID)
if creatorUserID == "" || receiverUserID == "" {
return errs.ErrArgs.WrapMsg("creator_user_id and receiver_user_id are required")
}
if creatorUserID == receiverUserID {
return nil
}
if s.relationClient == nil {
return servererrs.ErrInternalServer.WrapMsg("relation client is not initialized")
}
ok, err := s.relationClient.IsFriend(ctx, creatorUserID, receiverUserID)
if err != nil {
return err
}
if !ok {
return errs.ErrNoPermission.WrapMsg("creator and receiver are not friends",
"creatorUserID", creatorUserID, "receiverUserID", receiverUserID)
}
return nil return nil
} }

@ -1 +1 @@
Subproject commit 9f69daaff1f7b46b971bb7b97cd993cd6302b41e Subproject commit 34a58a77d26a3c133a4be9ce00affdca8b158ba4