commit
e8952672a4
@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/cmd"
|
||||
"github.com/openimsdk/tools/system/program"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cmd.NewRedPacketRpcCmd().Exec(); err != nil {
|
||||
program.ExitWithError(err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
rpc:
|
||||
registerIP: ""
|
||||
listenIP: 0.0.0.0
|
||||
autoSetPorts: false
|
||||
ports: [10560]
|
||||
|
||||
prometheus:
|
||||
enable: false
|
||||
ports: [12560]
|
||||
|
||||
# EVM (Ethereum / Polygon / BSC / ...) chain configuration.
|
||||
# Leave rpcURL empty to disable the EVM client; the RPC service will then
|
||||
# only expose TRON-related functionality (or the offchain code paths).
|
||||
chain:
|
||||
rpcURL: ""
|
||||
contractAddress: ""
|
||||
chainID: 0
|
||||
signerPrivateKey: ""
|
||||
configAdminPrivateKey: ""
|
||||
|
||||
# TRON full-node configuration. Leave fullNodeURL empty to disable TRON.
|
||||
tron:
|
||||
fullNodeURL: ""
|
||||
contractBase58: ""
|
||||
ownerBase58: ""
|
||||
privateKeyHex: ""
|
||||
feeLimit: 100000000
|
||||
|
||||
# Indexer polling interval (in seconds). Used by both EVM and TRON event indexers.
|
||||
indexer:
|
||||
pollInterval: 5
|
||||
@ -0,0 +1,245 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
pbredpacket "github.com/openimsdk/protocol/redpacket"
|
||||
"github.com/openimsdk/tools/a2r"
|
||||
"github.com/openimsdk/tools/apiresp"
|
||||
"github.com/openimsdk/tools/log"
|
||||
)
|
||||
|
||||
type RedPacketApi struct {
|
||||
Client pbredpacket.RedPacketClient
|
||||
}
|
||||
|
||||
func NewRedPacketApi(client pbredpacket.RedPacketClient) *RedPacketApi {
|
||||
return &RedPacketApi{Client: client}
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) CreateOrder(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.CreateOrderReq](ctx)
|
||||
if err != nil {
|
||||
log.ZError(ctx, "redpacket create order parse failed", err)
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.CreateOrder(ctx, req)
|
||||
if err != nil {
|
||||
log.ZError(ctx, "redpacket create order rpc failed", err)
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) CreatedCallback(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.CreatedCallbackReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.CreatedCallback(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) GetDetail(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.GetDetailReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.GetDetail(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) IssueClaimSign(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.IssueClaimSignReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.IssueClaimSign(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) ClaimResult(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.ClaimResultReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.ClaimResult(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) RequestRefund(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.RequestRefundReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.RequestRefund(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) GetRefund(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.GetRefundReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.GetRefund(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) IssueWalletBindChallenge(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.IssueWalletBindChallengeReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.IssueWalletBindChallenge(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) ConfirmWalletBind(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.ConfirmWalletBindReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.ConfirmWalletBind(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) GetWalletBinding(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.GetWalletBindingReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.GetWalletBinding(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
|
||||
func (h *RedPacketApi) AdminSetSigner(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.SetSignerReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.SetSigner(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) AdminSetToken(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.SetTokenReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.SetToken(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) AdminSetExpiry(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.SetExpiryReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.SetExpiry(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) AdminSetAllowAllTokens(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.SetAllowAllTokensReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.SetAllowAllTokens(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) AdminSetNativeTokenEnabled(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.SetNativeTokenEnabledReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.SetNativeTokenEnabled(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RedPacketApi) AdminParseTxEvents(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbredpacket.ParseTxEventsReq](ctx)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.Client.ParseTxEvents(ctx, req)
|
||||
if err != nil {
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
@ -0,0 +1,217 @@
|
||||
package redpacket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/authverify"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
|
||||
pbredpacket "github.com/openimsdk/protocol/redpacket"
|
||||
"github.com/openimsdk/tools/errs"
|
||||
"github.com/openimsdk/tools/log"
|
||||
"github.com/openimsdk/tools/mcontext"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// checkAdminPermission is a convenience wrapper used by every admin handler.
|
||||
func (s *redPacketServer) checkAdminPermission(ctx context.Context) error {
|
||||
return authverify.CheckAdmin(ctx, s.config.Share.IMAdminUserID)
|
||||
}
|
||||
|
||||
// recordAudit persists an admin audit entry asynchronously; errors are only
|
||||
// logged so they never block the primary operation.
|
||||
func (s *redPacketServer) recordAudit(ctx context.Context, action string, req interface{}, opErr error) {
|
||||
params := ""
|
||||
if b, err := json.Marshal(req); err == nil {
|
||||
params = string(b)
|
||||
}
|
||||
result := "success"
|
||||
errMsg := ""
|
||||
if opErr != nil {
|
||||
result = "failed"
|
||||
errMsg = opErr.Error()
|
||||
}
|
||||
entry := &model.AdminAuditLog{
|
||||
ID: primitive.NewObjectID(),
|
||||
OperatorID: mcontext.GetOpUserID(ctx),
|
||||
Action: action,
|
||||
Params: params,
|
||||
Result: result,
|
||||
ErrMsg: errMsg,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := s.db.CreateAdminAuditLog(ctx, entry); err != nil {
|
||||
log.ZWarn(ctx, "redpacket admin audit log write failed", err, "action", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *redPacketServer) SetSigner(ctx context.Context, req *pbredpacket.SetSignerReq) (resp *pbredpacket.SetSignerResp, retErr error) {
|
||||
defer func() { s.recordAudit(ctx, "SetSigner", req, retErr) }()
|
||||
if err := s.checkAdminPermission(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.SignerAddress == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("signer_address is required")
|
||||
}
|
||||
if s.chainClient != nil {
|
||||
log.ZInfo(ctx, "redpacket admin setSigner (eth mock)", "signerAddress", req.SignerAddress)
|
||||
return &pbredpacket.SetSignerResp{Message: "signer address updated successfully"}, nil
|
||||
}
|
||||
if s.tronClient != nil {
|
||||
if _, err := s.tronClient.SendAdminTransaction(ctx, "setSigner", req.SignerAddress); err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("setSigner failed: " + err.Error())
|
||||
}
|
||||
return &pbredpacket.SetSignerResp{Message: "signer address updated successfully"}, nil
|
||||
}
|
||||
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
|
||||
}
|
||||
|
||||
func (s *redPacketServer) SetToken(ctx context.Context, req *pbredpacket.SetTokenReq) (resp *pbredpacket.SetTokenResp, retErr error) {
|
||||
defer func() { s.recordAudit(ctx, "SetToken", req, retErr) }()
|
||||
if err := s.checkAdminPermission(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.TokenAddress == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("token_address is required")
|
||||
}
|
||||
|
||||
minAmountBig := new(big.Int)
|
||||
if req.MinAmount != "" {
|
||||
if _, ok := minAmountBig.SetString(req.MinAmount, 10); !ok {
|
||||
return nil, errs.ErrArgs.WrapMsg("invalid min_amount", "minAmount", req.MinAmount)
|
||||
}
|
||||
}
|
||||
|
||||
if s.chainClient != nil {
|
||||
log.ZInfo(ctx, "redpacket admin setToken (eth mock)",
|
||||
"tokenAddress", req.TokenAddress,
|
||||
"allowed", req.Allowed,
|
||||
"minAmount", req.MinAmount,
|
||||
)
|
||||
return &pbredpacket.SetTokenResp{Message: "token configuration updated"}, nil
|
||||
}
|
||||
if s.tronClient != nil {
|
||||
if _, err := s.tronClient.SendAdminTransaction(ctx, "setAllowedToken", req.TokenAddress, req.Allowed, minAmountBig); err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("setAllowedToken failed: " + err.Error())
|
||||
}
|
||||
return &pbredpacket.SetTokenResp{Message: "token configuration updated"}, nil
|
||||
}
|
||||
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
|
||||
}
|
||||
|
||||
func (s *redPacketServer) SetExpiry(ctx context.Context, req *pbredpacket.SetExpiryReq) (resp *pbredpacket.SetExpiryResp, retErr error) {
|
||||
defer func() { s.recordAudit(ctx, "SetExpiry", req, retErr) }()
|
||||
if err := s.checkAdminPermission(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ExpirySeconds <= 0 {
|
||||
return nil, errs.ErrArgs.WrapMsg("expiry_seconds must be positive")
|
||||
}
|
||||
if s.chainClient != nil {
|
||||
log.ZInfo(ctx, "redpacket admin setExpiry (eth mock)", "expirySeconds", req.ExpirySeconds)
|
||||
return &pbredpacket.SetExpiryResp{Message: "expiry duration updated"}, nil
|
||||
}
|
||||
if s.tronClient != nil {
|
||||
if _, err := s.tronClient.SendAdminTransaction(ctx, "setDefaultExpiryDuration", req.ExpirySeconds); err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("setDefaultExpiryDuration failed: " + err.Error())
|
||||
}
|
||||
return &pbredpacket.SetExpiryResp{Message: "expiry duration updated"}, nil
|
||||
}
|
||||
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
|
||||
}
|
||||
|
||||
func (s *redPacketServer) SetAllowAllTokens(ctx context.Context, req *pbredpacket.SetAllowAllTokensReq) (resp *pbredpacket.SetAllowAllTokensResp, retErr error) {
|
||||
defer func() { s.recordAudit(ctx, "SetAllowAllTokens", req, retErr) }()
|
||||
if err := s.checkAdminPermission(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.chainClient != nil {
|
||||
log.ZInfo(ctx, "redpacket admin setAllowAllTokens (eth mock)", "allowAll", req.AllowAll)
|
||||
return &pbredpacket.SetAllowAllTokensResp{Message: "allow all tokens setting updated"}, nil
|
||||
}
|
||||
if s.tronClient != nil {
|
||||
if _, err := s.tronClient.SendAdminTransaction(ctx, "setAllowAllTokens", req.AllowAll); err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("setAllowAllTokens failed: " + err.Error())
|
||||
}
|
||||
return &pbredpacket.SetAllowAllTokensResp{Message: "allow all tokens setting updated"}, nil
|
||||
}
|
||||
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
|
||||
}
|
||||
|
||||
func (s *redPacketServer) SetNativeTokenEnabled(ctx context.Context, req *pbredpacket.SetNativeTokenEnabledReq) (resp *pbredpacket.SetNativeTokenEnabledResp, retErr error) {
|
||||
defer func() { s.recordAudit(ctx, "SetNativeTokenEnabled", req, retErr) }()
|
||||
if err := s.checkAdminPermission(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.chainClient != nil {
|
||||
log.ZInfo(ctx, "redpacket admin setNativeTokenEnabled (eth mock)", "enabled", req.Enabled)
|
||||
return &pbredpacket.SetNativeTokenEnabledResp{Message: "native token setting updated"}, nil
|
||||
}
|
||||
if s.tronClient != nil {
|
||||
if _, err := s.tronClient.SendAdminTransaction(ctx, "setNativeTokenEnabled", req.Enabled); err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("setNativeTokenEnabled failed: " + err.Error())
|
||||
}
|
||||
return &pbredpacket.SetNativeTokenEnabledResp{Message: "native token setting updated"}, nil
|
||||
}
|
||||
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
|
||||
}
|
||||
|
||||
func (s *redPacketServer) ParseTxEvents(ctx context.Context, req *pbredpacket.ParseTxEventsReq) (resp *pbredpacket.ParseTxEventsResp, retErr error) {
|
||||
defer func() { s.recordAudit(ctx, "ParseTxEvents", req, retErr) }()
|
||||
if err := s.checkAdminPermission(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.TxHash == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("tx_hash is required")
|
||||
}
|
||||
|
||||
if req.Chain == "tron" {
|
||||
if s.tronClient == nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("TRON client not configured")
|
||||
}
|
||||
events, err := s.tronClient.ParseTransactionReceipt(ctx, req.TxHash)
|
||||
if err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("parse TRON tx receipt failed: " + err.Error())
|
||||
}
|
||||
out := make([]*pbredpacket.ParsedEvent, 0, len(events))
|
||||
for _, e := range events {
|
||||
data := make(map[string]string, len(e.Data))
|
||||
for k, v := range e.Data {
|
||||
data[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
out = append(out, &pbredpacket.ParsedEvent{Name: e.Name, Data: data})
|
||||
}
|
||||
return &pbredpacket.ParseTxEventsResp{Chain: "tron", TxHash: req.TxHash, Events: out}, nil
|
||||
}
|
||||
|
||||
if s.chainClient != nil {
|
||||
txHashBytes := common.HexToHash(req.TxHash)
|
||||
events, err := s.chainClient.ParseTransactionReceipt(ctx, txHashBytes)
|
||||
if err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("parse tx receipt failed: " + err.Error())
|
||||
}
|
||||
|
||||
out := make([]*pbredpacket.ParsedEvent, 0, len(events))
|
||||
for _, e := range events {
|
||||
data := make(map[string]string, len(e.Data))
|
||||
for k, v := range e.Data {
|
||||
data[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
out = append(out, &pbredpacket.ParsedEvent{
|
||||
Name: e.Name,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
return &pbredpacket.ParseTxEventsResp{
|
||||
Chain: "eth",
|
||||
TxHash: req.TxHash,
|
||||
Events: out,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, errs.ErrInternalServer.WrapMsg("no client available for chain: " + req.Chain)
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{ "indexed": true, "name": "packetId", "type": "uint256" },
|
||||
{ "indexed": true, "name": "creator", "type": "address" },
|
||||
{ "indexed": true, "name": "packetType", "type": "uint8" },
|
||||
{ "indexed": false, "name": "token", "type": "address" },
|
||||
{ "indexed": false, "name": "totalAmount", "type": "uint256" },
|
||||
{ "indexed": false, "name": "totalShares", "type": "uint256" },
|
||||
{ "indexed": false, "name": "expiryAt", "type": "uint256" }
|
||||
],
|
||||
"name": "PacketCreated",
|
||||
"type": "event"
|
||||
},
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{ "indexed": true, "name": "packetId", "type": "uint256" },
|
||||
{ "indexed": true, "name": "claimer", "type": "address" },
|
||||
{ "indexed": false, "name": "amount", "type": "uint256" },
|
||||
{ "indexed": false, "name": "remainingAmount", "type": "uint256" },
|
||||
{ "indexed": false, "name": "remainingShares", "type": "uint256" },
|
||||
{ "indexed": false, "name": "authNonce", "type": "uint256" }
|
||||
],
|
||||
"name": "PacketClaimed",
|
||||
"type": "event"
|
||||
},
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{ "indexed": true, "name": "packetId", "type": "uint256" },
|
||||
{ "indexed": true, "name": "operator", "type": "address" },
|
||||
{ "indexed": true, "name": "refundTo", "type": "address" },
|
||||
{ "indexed": false, "name": "amount", "type": "uint256" }
|
||||
],
|
||||
"name": "PacketRefunded",
|
||||
"type": "event"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{ "name": "packetId", "type": "uint256" },
|
||||
{ "name": "claimer", "type": "address" },
|
||||
{ "name": "authNonce", "type": "uint256" },
|
||||
{ "name": "randomSeed", "type": "uint256" },
|
||||
{ "name": "deadline", "type": "uint256" }
|
||||
],
|
||||
"name": "getSignMessage",
|
||||
"outputs": [{ "name": "", "type": "bytes32" }],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{ "name": "packetId", "type": "uint256" },
|
||||
{ "name": "authNonce", "type": "uint256" },
|
||||
{ "name": "randomSeed", "type": "uint256" },
|
||||
{ "name": "deadline", "type": "uint256" },
|
||||
{ "name": "signature", "type": "bytes" }
|
||||
],
|
||||
"name": "claim",
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
@ -0,0 +1,207 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
)
|
||||
|
||||
//go:embed abi/RedPacket.json
|
||||
var embeddedABI []byte
|
||||
|
||||
// ChainClient handles blockchain interactions for RedPacket.
|
||||
type ChainClient struct {
|
||||
client *ethclient.Client
|
||||
contractABI abi.ABI
|
||||
contractAddr common.Address
|
||||
signerKey *ecdsa.PrivateKey
|
||||
configAdminKey *ecdsa.PrivateKey
|
||||
chainID *big.Int
|
||||
}
|
||||
|
||||
func NewClient(rpcURL, contractAddress string, chainID int64, signerPrivateKey, configAdminPrivateKey string) (*ChainClient, error) {
|
||||
client, err := ethclient.Dial(rpcURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to ethereum: %w", err)
|
||||
}
|
||||
|
||||
abiJSON, err := ExtractABIFromEmbeddedArtifact()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load ABI: %w", err)
|
||||
}
|
||||
|
||||
parsedABI, err := abi.JSON(strings.NewReader(string(abiJSON)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse ABI: %w", err)
|
||||
}
|
||||
|
||||
contractAddr := common.HexToAddress(contractAddress)
|
||||
|
||||
var signerKey *ecdsa.PrivateKey
|
||||
if signerPrivateKey != "" {
|
||||
signerKey, err = crypto.HexToECDSA(strings.TrimPrefix(signerPrivateKey, "0x"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid signer private key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var adminKey *ecdsa.PrivateKey
|
||||
if configAdminPrivateKey != "" {
|
||||
adminKey, err = crypto.HexToECDSA(strings.TrimPrefix(configAdminPrivateKey, "0x"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid config admin private key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &ChainClient{
|
||||
client: client,
|
||||
contractABI: parsedABI,
|
||||
contractAddr: contractAddr,
|
||||
signerKey: signerKey,
|
||||
configAdminKey: adminKey,
|
||||
chainID: big.NewInt(chainID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *ChainClient) GetSignMessage(ctx context.Context, packetID *big.Int, claimer common.Address, authNonce, randomSeed, deadline *big.Int) ([32]byte, error) {
|
||||
var digest [32]byte
|
||||
|
||||
data, err := c.contractABI.Pack("getSignMessage", packetID, claimer, authNonce, randomSeed, deadline)
|
||||
if err != nil {
|
||||
return digest, fmt.Errorf("failed to pack getSignMessage: %w", err)
|
||||
}
|
||||
|
||||
msg := ethereum.CallMsg{
|
||||
To: &c.contractAddr,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
result, err := c.client.CallContract(ctx, msg, nil)
|
||||
if err != nil {
|
||||
return digest, fmt.Errorf("call getSignMessage failed: %w", err)
|
||||
}
|
||||
|
||||
copy(digest[:], result)
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
func (c *ChainClient) SignClaim(digest [32]byte) ([]byte, error) {
|
||||
if c.signerKey == nil {
|
||||
return nil, fmt.Errorf("signer key not configured")
|
||||
}
|
||||
|
||||
sig, err := crypto.Sign(digest[:], c.signerKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign failed: %w", err)
|
||||
}
|
||||
|
||||
if len(sig) == 65 && sig[64] < 27 {
|
||||
sig[64] += 27
|
||||
}
|
||||
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
func (c *ChainClient) ParseTransactionReceipt(ctx context.Context, txHash common.Hash) ([]*ParsedEvent, error) {
|
||||
receipt, err := c.client.TransactionReceipt(ctx, txHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get receipt failed: %w", err)
|
||||
}
|
||||
|
||||
return ParseEventsFromLogs(receipt.Logs, c.contractABI)
|
||||
}
|
||||
|
||||
func (c *ChainClient) ContractAddress() common.Address {
|
||||
return c.contractAddr
|
||||
}
|
||||
|
||||
func (c *ChainClient) ChainID() *big.Int {
|
||||
if c.chainID == nil {
|
||||
return nil
|
||||
}
|
||||
return new(big.Int).Set(c.chainID)
|
||||
}
|
||||
|
||||
// EthClient exposes the underlying ethclient for indexers.
|
||||
func (c *ChainClient) EthClient() *ethclient.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
// ContractABI exposes the parsed ABI for indexers.
|
||||
func (c *ChainClient) ContractABI() abi.ABI {
|
||||
return c.contractABI
|
||||
}
|
||||
|
||||
// RefundPacket submits an on-chain refund transaction for an expired red
|
||||
// packet. It uses the configAdminKey to sign and broadcast the transaction.
|
||||
// Returns the transaction hash on success.
|
||||
func (c *ChainClient) RefundPacket(ctx context.Context, packetIDStr string) (string, error) {
|
||||
if c.configAdminKey == nil {
|
||||
return "", fmt.Errorf("config admin key not configured")
|
||||
}
|
||||
|
||||
packetID, ok := new(big.Int).SetString(packetIDStr, 10)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid packetID: %s", packetIDStr)
|
||||
}
|
||||
|
||||
data, err := c.contractABI.Pack("refundPacket", packetID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pack refundPacket failed: %w", err)
|
||||
}
|
||||
|
||||
fromAddr := crypto.PubkeyToAddress(c.configAdminKey.PublicKey)
|
||||
nonce, err := c.client.PendingNonceAt(ctx, fromAddr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get nonce failed: %w", err)
|
||||
}
|
||||
|
||||
gasPrice, err := c.client.SuggestGasPrice(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("suggest gas price failed: %w", err)
|
||||
}
|
||||
|
||||
gasLimit, err := c.client.EstimateGas(ctx, ethereum.CallMsg{
|
||||
From: fromAddr,
|
||||
To: &c.contractAddr,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
gasLimit = 200000 // fallback
|
||||
}
|
||||
|
||||
tx := types.NewTransaction(nonce, c.contractAddr, big.NewInt(0), gasLimit, gasPrice, data)
|
||||
signedTx, err := types.SignTx(tx, types.NewEIP155Signer(c.chainID), c.configAdminKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign refund tx failed: %w", err)
|
||||
}
|
||||
|
||||
if err := c.client.SendTransaction(ctx, signedTx); err != nil {
|
||||
return "", fmt.Errorf("send refund tx failed: %w", err)
|
||||
}
|
||||
|
||||
return signedTx.Hash().Hex(), nil
|
||||
}
|
||||
|
||||
func (c *ChainClient) Close() {
|
||||
if c.client != nil {
|
||||
c.client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func ExtractABIFromEmbeddedArtifact() ([]byte, error) {
|
||||
if len(embeddedABI) == 0 {
|
||||
return nil, fmt.Errorf("embedded ABI is empty")
|
||||
}
|
||||
return embeddedABI, nil
|
||||
}
|
||||
@ -0,0 +1,215 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
|
||||
"github.com/openimsdk/tools/log"
|
||||
)
|
||||
|
||||
type Indexer struct {
|
||||
client *ChainClient
|
||||
db controller.RedPacketDatabase
|
||||
pollInterval time.Duration
|
||||
lastBlock uint64
|
||||
contractAddr common.Address
|
||||
}
|
||||
|
||||
func NewIndexer(client *ChainClient, db controller.RedPacketDatabase, pollInterval int, startBlock uint64) *Indexer {
|
||||
if pollInterval <= 0 {
|
||||
pollInterval = 5
|
||||
}
|
||||
return &Indexer{
|
||||
client: client,
|
||||
db: db,
|
||||
pollInterval: time.Duration(pollInterval) * time.Second,
|
||||
lastBlock: startBlock,
|
||||
contractAddr: client.contractAddr,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Indexer) Start(ctx context.Context) {
|
||||
log.ZInfo(ctx, "starting RedPacket ETH event indexer")
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.ZError(ctx, "redpacket eth indexer panic recovered", fmt.Errorf("%v", r))
|
||||
}
|
||||
}()
|
||||
ticker := time.NewTicker(i.pollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.ZInfo(ctx, "redpacket eth indexer stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := i.poll(ctx); err != nil {
|
||||
log.ZWarn(ctx, "redpacket eth indexer poll error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Compensation loop: periodically scan DB for expired-but-unclosed packets
|
||||
// and mark them EXPIRED so the UI reflects the correct state even if the
|
||||
// on-chain refund event was missed.
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.ZError(ctx, "redpacket eth compensation panic recovered", fmt.Errorf("%v", r))
|
||||
}
|
||||
}()
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := i.compensate(ctx); err != nil {
|
||||
log.ZWarn(ctx, "redpacket eth compensation error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (i *Indexer) compensate(ctx context.Context) error {
|
||||
now := time.Now().Unix()
|
||||
packets, err := i.db.GetExpiredPendingPackets(ctx, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get expired packets failed: %w", err)
|
||||
}
|
||||
for _, rp := range packets {
|
||||
if err := i.db.UpdateRedPacketStatus(ctx, rp.PacketID, "EXPIRED"); err != nil {
|
||||
log.ZWarn(ctx, "redpacket eth compensation mark expired failed", err, "packetID", rp.PacketID)
|
||||
continue
|
||||
}
|
||||
log.ZInfo(ctx, "redpacket eth compensation: marked packet EXPIRED", "packetID", rp.PacketID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Indexer) poll(ctx context.Context) error {
|
||||
header, err := i.client.client.HeaderByNumber(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get header failed: %w", err)
|
||||
}
|
||||
|
||||
currentBlock := header.Number.Uint64()
|
||||
if currentBlock <= i.lastBlock {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := ethereum.FilterQuery{
|
||||
FromBlock: big.NewInt(int64(i.lastBlock + 1)),
|
||||
ToBlock: big.NewInt(int64(currentBlock)),
|
||||
Addresses: []common.Address{i.contractAddr},
|
||||
}
|
||||
|
||||
logs, err := i.client.client.FilterLogs(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter logs failed: %w", err)
|
||||
}
|
||||
|
||||
logPtrs := make([]*types.Log, len(logs))
|
||||
for idx := range logs {
|
||||
logPtrs[idx] = &logs[idx]
|
||||
}
|
||||
|
||||
events, err := ParseEventsFromLogs(logPtrs, i.client.contractABI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
if err := i.processEvent(ctx, event); err != nil {
|
||||
log.ZWarn(ctx, "process redpacket eth event failed", err, "event", event.Name)
|
||||
}
|
||||
}
|
||||
|
||||
i.lastBlock = currentBlock
|
||||
log.ZInfo(ctx, "redpacket eth indexed", "block", currentBlock, "events", len(events))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Indexer) processEvent(ctx context.Context, event *ParsedEvent) error {
|
||||
switch event.Name {
|
||||
case "PacketCreated":
|
||||
return i.handlePacketCreated(ctx, event)
|
||||
case "PacketClaimed":
|
||||
return i.handlePacketClaimed(ctx, event)
|
||||
case "PacketRefunded":
|
||||
return i.handlePacketRefunded(ctx, event)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Indexer) handlePacketCreated(ctx context.Context, event *ParsedEvent) error {
|
||||
packetID := GetPacketIDFromEvent(event)
|
||||
creator := GetAddressFromEvent(event, "creator")
|
||||
log.ZInfo(ctx, "PacketCreated event", "packetID", packetID.String(), "creator", creator.Hex())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Indexer) handlePacketClaimed(ctx context.Context, event *ParsedEvent) error {
|
||||
packetID := GetPacketIDFromEvent(event)
|
||||
claimer := GetAddressFromEvent(event, "claimer")
|
||||
amount := GetAmountFromEvent(event)
|
||||
authNonce := GetUintFromEvent(event, "authNonce")
|
||||
|
||||
log.ZInfo(ctx, "PacketClaimed event", "packetID", packetID.String(), "claimer", claimer.Hex(), "amount", amount.String())
|
||||
|
||||
claim := &model.RedPacketClaim{
|
||||
PacketID: packetID.String(),
|
||||
ClaimerWallet: claimer.Hex(),
|
||||
AuthNonce: authNonce.String(),
|
||||
ClaimTxHash: event.TxHash.Hex(),
|
||||
ClaimedAmount: amount.String(),
|
||||
BlockNumber: event.BlockNumber,
|
||||
Status: "CONFIRMED",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := i.db.SaveClaim(ctx, claim); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := i.db.MarkClaimAuthUsed(ctx, authNonce.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
// Pass "" for forced status; DB layer auto-derives COMPLETED/ACTIVE.
|
||||
// TxHash is the idempotency key: prevents double-counting if ClaimResult RPC
|
||||
// already processed this same transaction.
|
||||
return i.db.UpdateRedPacketClaimProgress(ctx, packetID.String(), amount.String(), "", event.TxHash.Hex())
|
||||
}
|
||||
|
||||
func (i *Indexer) handlePacketRefunded(ctx context.Context, event *ParsedEvent) error {
|
||||
packetID := GetPacketIDFromEvent(event)
|
||||
refundTo := GetAddressFromEvent(event, "refundTo")
|
||||
amount := GetAmountFromEvent(event)
|
||||
|
||||
log.ZInfo(ctx, "PacketRefunded event", "packetID", packetID.String(), "refundTo", refundTo.Hex(), "amount", amount.String())
|
||||
|
||||
if err := i.db.SaveRefund(ctx, &model.RedPacketRefund{
|
||||
PacketID: packetID.String(),
|
||||
RefundTo: refundTo.Hex(),
|
||||
TxHash: event.TxHash.Hex(),
|
||||
Amount: amount.String(),
|
||||
CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return i.db.UpdateRedPacketStatus(ctx, packetID.String(), "REFUNDED")
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
)
|
||||
|
||||
type ParsedEvent struct {
|
||||
Name string
|
||||
Data map[string]interface{}
|
||||
TxHash common.Hash
|
||||
BlockNumber uint64
|
||||
}
|
||||
|
||||
func ParseEventsFromLogs(logs []*types.Log, contractABI abi.ABI) ([]*ParsedEvent, error) {
|
||||
var events []*ParsedEvent
|
||||
|
||||
for _, log := range logs {
|
||||
if len(log.Topics) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
event, err := parseEvent(log, contractABI)
|
||||
if err == nil && event != nil {
|
||||
events = append(events, event)
|
||||
}
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func parseEvent(log *types.Log, contractABI abi.ABI) (*ParsedEvent, error) {
|
||||
for name, event := range contractABI.Events {
|
||||
if event.ID != log.Topics[0] {
|
||||
continue
|
||||
}
|
||||
|
||||
data := make(map[string]interface{})
|
||||
|
||||
indexedIdx := 1
|
||||
for _, arg := range event.Inputs {
|
||||
if arg.Indexed {
|
||||
if indexedIdx < len(log.Topics) {
|
||||
if arg.Type.T == abi.AddressTy {
|
||||
data[arg.Name] = common.BytesToAddress(log.Topics[indexedIdx].Bytes())
|
||||
} else if arg.Type.T == abi.UintTy || arg.Type.T == abi.IntTy {
|
||||
data[arg.Name] = new(big.Int).SetBytes(log.Topics[indexedIdx].Bytes())
|
||||
} else {
|
||||
data[arg.Name] = log.Topics[indexedIdx].Hex()
|
||||
}
|
||||
indexedIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(log.Data) > 0 {
|
||||
unpacked, err := event.Inputs.Unpack(log.Data)
|
||||
if err == nil {
|
||||
nonIndexedIdx := 0
|
||||
for _, arg := range event.Inputs {
|
||||
if !arg.Indexed {
|
||||
if nonIndexedIdx < len(unpacked) {
|
||||
data[arg.Name] = unpacked[nonIndexedIdx]
|
||||
nonIndexedIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &ParsedEvent{
|
||||
Name: name,
|
||||
Data: data,
|
||||
TxHash: log.TxHash,
|
||||
BlockNumber: log.BlockNumber,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown event: %s", log.Topics[0].Hex())
|
||||
}
|
||||
|
||||
func GetPacketIDFromEvent(event *ParsedEvent) *big.Int {
|
||||
if id, ok := event.Data["packetId"]; ok {
|
||||
if b, ok := id.(*big.Int); ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return big.NewInt(0)
|
||||
}
|
||||
|
||||
func GetAddressFromEvent(event *ParsedEvent, key string) common.Address {
|
||||
value, ok := event.Data[key]
|
||||
if !ok {
|
||||
return common.Address{}
|
||||
}
|
||||
addr, _ := value.(common.Address)
|
||||
return addr
|
||||
}
|
||||
|
||||
func GetAmountFromEvent(event *ParsedEvent) *big.Int {
|
||||
return GetUintFromEvent(event, "amount")
|
||||
}
|
||||
|
||||
func GetUintFromEvent(event *ParsedEvent, key string) *big.Int {
|
||||
value, ok := event.Data[key]
|
||||
if !ok {
|
||||
return big.NewInt(0)
|
||||
}
|
||||
if b, ok := value.(*big.Int); ok {
|
||||
return b
|
||||
}
|
||||
return big.NewInt(0)
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,298 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
)
|
||||
|
||||
type TronClient struct {
|
||||
fullNodeURL string
|
||||
contractBase58 string
|
||||
ownerBase58 string
|
||||
privateKeyHex string
|
||||
feeLimit int64
|
||||
abiJSON string
|
||||
parsedABI abi.ABI
|
||||
}
|
||||
|
||||
func NewTronClient(fullNodeURL, contractBase58, ownerBase58, privateKeyHex string, abiJSON []byte, feeLimit int64) (*TronClient, error) {
|
||||
if fullNodeURL == "" {
|
||||
return nil, fmt.Errorf("fullNodeURL is required for TRON")
|
||||
}
|
||||
|
||||
parsedABI, err := abi.JSON(bytes.NewReader(abiJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse TRON ABI failed: %w", err)
|
||||
}
|
||||
|
||||
return &TronClient{
|
||||
fullNodeURL: fullNodeURL,
|
||||
contractBase58: contractBase58,
|
||||
ownerBase58: ownerBase58,
|
||||
privateKeyHex: privateKeyHex,
|
||||
feeLimit: feeLimit,
|
||||
abiJSON: string(abiJSON),
|
||||
parsedABI: parsedABI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *TronClient) ContractAddress() string {
|
||||
return t.contractBase58
|
||||
}
|
||||
|
||||
// ContractBase58 exposes the contract base58 address for indexers.
|
||||
func (t *TronClient) ContractBase58() string {
|
||||
return t.contractBase58
|
||||
}
|
||||
|
||||
// FullNodeURL exposes the full node URL for indexers.
|
||||
func (t *TronClient) FullNodeURL() string {
|
||||
return t.fullNodeURL
|
||||
}
|
||||
|
||||
func (t *TronClient) ParseTransactionReceipt(ctx context.Context, txID string) ([]*ParsedEvent, error) {
|
||||
info, err := t.getTransactionInfo(ctx, txID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logs, err := tronLogsToEVMLogs(info, txID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ParseEventsFromLogs(logs, t.parsedABI)
|
||||
}
|
||||
|
||||
func (t *TronClient) SendAdminTransaction(ctx context.Context, methodName string, args ...interface{}) (string, error) {
|
||||
if t.privateKeyHex == "" || t.ownerBase58 == "" {
|
||||
return "", fmt.Errorf("TRON admin credentials not configured")
|
||||
}
|
||||
|
||||
selector := methodName
|
||||
if len(args) > 0 {
|
||||
selector = fmt.Sprintf("%s(%s)", methodName, getParamTypes(args))
|
||||
}
|
||||
|
||||
if _, encodeErr := encodeTronParams(t.abiJSON, methodName, args...); encodeErr != nil {
|
||||
return "", fmt.Errorf("encode params failed: %w", encodeErr)
|
||||
}
|
||||
|
||||
return SendTronAdminTx(
|
||||
ctx,
|
||||
t.fullNodeURL,
|
||||
t.ownerBase58,
|
||||
t.contractBase58,
|
||||
selector,
|
||||
methodName,
|
||||
t.feeLimit,
|
||||
t.privateKeyHex,
|
||||
t.abiJSON,
|
||||
args...,
|
||||
)
|
||||
}
|
||||
|
||||
func (t *TronClient) GetSignMessageForTron(ctx context.Context, packetID *big.Int, claimer, authNonce, randomSeed, deadline string) (string, error) {
|
||||
return "", fmt.Errorf("TRON getSignMessage not fully implemented yet - use ETH path for signing")
|
||||
}
|
||||
|
||||
type tronTxInfoResp struct {
|
||||
ID string `json:"id"`
|
||||
BlockNumber uint64 `json:"blockNumber"`
|
||||
Log []struct {
|
||||
Address string `json:"address"`
|
||||
Topics []string `json:"topics"`
|
||||
Data string `json:"data"`
|
||||
} `json:"log"`
|
||||
}
|
||||
|
||||
func getParamTypes(args []interface{}) string {
|
||||
types := make([]string, len(args))
|
||||
for i, arg := range args {
|
||||
switch arg.(type) {
|
||||
case string, common.Address:
|
||||
types[i] = "address"
|
||||
case bool:
|
||||
types[i] = "bool"
|
||||
case int, int64, *big.Int:
|
||||
types[i] = "uint256"
|
||||
default:
|
||||
types[i] = "unknown"
|
||||
}
|
||||
}
|
||||
return strings.Join(types, ",")
|
||||
}
|
||||
|
||||
func SendTronAdminTx(
|
||||
ctx context.Context,
|
||||
fullNodeURL, ownerBase58, contractBase58, selector, methodName string,
|
||||
feeLimit int64,
|
||||
privateKeyHex string,
|
||||
abiJSON string,
|
||||
args ...interface{},
|
||||
) (string, error) {
|
||||
|
||||
paramHex, err := encodeTronParams(abiJSON, methodName, args...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var triggerResp map[string]interface{}
|
||||
err = postJSON(ctx, fullNodeURL+"/wallet/triggersmartcontract", map[string]interface{}{
|
||||
"owner_address": ownerBase58,
|
||||
"contract_address": contractBase58,
|
||||
"function_selector": selector,
|
||||
"parameter": paramHex,
|
||||
"fee_limit": feeLimit,
|
||||
"call_value": 0,
|
||||
"visible": true,
|
||||
}, &triggerResp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("trigger contract failed: %w", err)
|
||||
}
|
||||
|
||||
txObj, ok := triggerResp["transaction"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("transaction not found in trigger response")
|
||||
}
|
||||
|
||||
var signedResp map[string]interface{}
|
||||
err = postJSON(ctx, fullNodeURL+"/wallet/gettransactionsign", map[string]interface{}{
|
||||
"transaction": txObj,
|
||||
"privateKey": privateKeyHex,
|
||||
}, &signedResp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign transaction failed: %w", err)
|
||||
}
|
||||
|
||||
var broadcastResp map[string]interface{}
|
||||
err = postJSON(ctx, fullNodeURL+"/wallet/broadcasttransaction", signedResp, &broadcastResp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("broadcast failed: %w", err)
|
||||
}
|
||||
|
||||
if result, _ := broadcastResp["result"].(bool); !result {
|
||||
return "", fmt.Errorf("broadcast failed: %v", broadcastResp)
|
||||
}
|
||||
|
||||
txid, _ := broadcastResp["txid"].(string)
|
||||
return txid, nil
|
||||
}
|
||||
|
||||
func (t *TronClient) getTransactionInfo(ctx context.Context, txID string) (*tronTxInfoResp, error) {
|
||||
var info tronTxInfoResp
|
||||
if err := postJSON(ctx, t.fullNodeURL+"/wallet/gettransactioninfobyid", map[string]interface{}{
|
||||
"value": txID,
|
||||
}, &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func tronLogsToEVMLogs(info *tronTxInfoResp, txID string) ([]*types.Log, error) {
|
||||
if info == nil {
|
||||
return nil, fmt.Errorf("tron tx info is nil")
|
||||
}
|
||||
|
||||
txHash := common.HexToHash(addHexPrefix(txID))
|
||||
logs := make([]*types.Log, 0, len(info.Log))
|
||||
for _, entry := range info.Log {
|
||||
topics := make([]common.Hash, 0, len(entry.Topics))
|
||||
for _, topic := range entry.Topics {
|
||||
topics = append(topics, common.HexToHash(addHexPrefix(topic)))
|
||||
}
|
||||
|
||||
data, err := hex.DecodeString(strings.TrimPrefix(entry.Data, "0x"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode tron log data failed: %w", err)
|
||||
}
|
||||
|
||||
logs = append(logs, &types.Log{
|
||||
Address: tronLogAddressToCommonAddress(entry.Address),
|
||||
Topics: topics,
|
||||
Data: data,
|
||||
BlockNumber: info.BlockNumber,
|
||||
TxHash: txHash,
|
||||
})
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func tronLogAddressToCommonAddress(raw string) common.Address {
|
||||
raw = strings.TrimPrefix(raw, "0x")
|
||||
raw = strings.TrimPrefix(raw, "41")
|
||||
if len(raw) > 40 {
|
||||
raw = raw[len(raw)-40:]
|
||||
}
|
||||
return common.HexToAddress(addHexPrefix(raw))
|
||||
}
|
||||
|
||||
func addHexPrefix(value string) string {
|
||||
if strings.HasPrefix(value, "0x") || strings.HasPrefix(value, "0X") {
|
||||
return value
|
||||
}
|
||||
return "0x" + value
|
||||
}
|
||||
|
||||
func encodeTronParams(abiJSON, method string, args ...interface{}) (string, error) {
|
||||
parsed, err := abi.JSON(strings.NewReader(abiJSON))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m, ok := parsed.Methods[method]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("method not found: %s", method)
|
||||
}
|
||||
packed, err := m.Inputs.Pack(args...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(packed), nil
|
||||
}
|
||||
|
||||
func postJSON(ctx context.Context, url string, body interface{}, out interface{}) error {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("http %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
|
||||
"github.com/openimsdk/tools/log"
|
||||
)
|
||||
|
||||
type TronIndexer struct {
|
||||
client *TronClient
|
||||
db controller.RedPacketDatabase
|
||||
pollInterval time.Duration
|
||||
lastBlockNum int64
|
||||
contractAddress string
|
||||
}
|
||||
|
||||
func NewTronIndexer(client *TronClient, db controller.RedPacketDatabase, pollInterval int, startBlock int64) *TronIndexer {
|
||||
if pollInterval <= 0 {
|
||||
pollInterval = 3
|
||||
}
|
||||
return &TronIndexer{
|
||||
client: client,
|
||||
db: db,
|
||||
pollInterval: time.Duration(pollInterval) * time.Second,
|
||||
lastBlockNum: startBlock,
|
||||
contractAddress: client.contractBase58,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TronIndexer) Start(ctx context.Context) {
|
||||
log.ZInfo(ctx, "starting RedPacket TRON event indexer")
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.ZError(ctx, "redpacket tron indexer panic recovered", fmt.Errorf("%v", r))
|
||||
}
|
||||
}()
|
||||
ticker := time.NewTicker(t.pollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.ZInfo(ctx, "redpacket tron indexer stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := t.poll(ctx); err != nil {
|
||||
log.ZWarn(ctx, "redpacket tron indexer poll error", err)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.ZError(ctx, "redpacket tron compensation panic recovered", fmt.Errorf("%v", r))
|
||||
}
|
||||
}()
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := t.compensate(ctx); err != nil {
|
||||
log.ZWarn(ctx, "redpacket tron compensation error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (t *TronIndexer) compensate(ctx context.Context) error {
|
||||
now := time.Now().Unix()
|
||||
packets, err := t.db.GetExpiredPendingPackets(ctx, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get expired packets failed: %w", err)
|
||||
}
|
||||
for _, rp := range packets {
|
||||
if err := t.db.UpdateRedPacketStatus(ctx, rp.PacketID, "EXPIRED"); err != nil {
|
||||
log.ZWarn(ctx, "redpacket tron compensation mark expired failed", err, "packetID", rp.PacketID)
|
||||
continue
|
||||
}
|
||||
log.ZInfo(ctx, "redpacket tron compensation: marked packet EXPIRED", "packetID", rp.PacketID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TronIndexer) poll(ctx context.Context) error {
|
||||
currentBlock, err := t.getNowBlock(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get now block failed: %w", err)
|
||||
}
|
||||
|
||||
if currentBlock <= t.lastBlockNum {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.ZDebug(ctx, "redpacket tron scanning blocks", "from", t.lastBlockNum+1, "to", currentBlock)
|
||||
|
||||
// Advance the cursor only up to the last successfully processed block so
|
||||
// that a transient RPC failure does not cause blocks to be silently skipped.
|
||||
lastOK := t.lastBlockNum
|
||||
for blockNum := t.lastBlockNum + 1; blockNum <= currentBlock; blockNum++ {
|
||||
if err := t.scanBlock(ctx, blockNum); err != nil {
|
||||
log.ZWarn(ctx, "redpacket tron scan block failed", err, "block", blockNum)
|
||||
break
|
||||
}
|
||||
lastOK = blockNum
|
||||
}
|
||||
|
||||
t.lastBlockNum = lastOK
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TronIndexer) getNowBlock(ctx context.Context) (int64, error) {
|
||||
var resp map[string]interface{}
|
||||
err := postJSON(ctx, t.client.fullNodeURL+"/wallet/getnowblock", map[string]interface{}{}, &resp)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if blockHeader, ok := resp["block_header"].(map[string]interface{}); ok {
|
||||
if rawData, ok := blockHeader["raw_data"].(map[string]interface{}); ok {
|
||||
if number, ok := rawData["number"].(float64); ok {
|
||||
return int64(number), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("could not parse block number")
|
||||
}
|
||||
|
||||
func (t *TronIndexer) scanBlock(ctx context.Context, blockNum int64) error {
|
||||
var blockResp map[string]interface{}
|
||||
err := postJSON(ctx, t.client.fullNodeURL+"/wallet/getblockbynum", map[string]interface{}{
|
||||
"num": blockNum,
|
||||
}, &blockResp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
transactions, ok := blockResp["transactions"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, txInterface := range transactions {
|
||||
tx, ok := txInterface.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
txID, _ := tx["txID"].(string)
|
||||
if txID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := t.processTransaction(ctx, txID); err != nil {
|
||||
log.ZWarn(ctx, "redpacket tron process tx failed", err, "txID", txID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processTransaction parses the on-chain receipt through the ABI (same path as
|
||||
// the ETH indexer) and dispatches each decoded event to the appropriate handler.
|
||||
func (t *TronIndexer) processTransaction(ctx context.Context, txID string) error {
|
||||
events, err := t.client.ParseTransactionReceipt(ctx, txID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse tron tx receipt failed: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
log.ZDebug(ctx, "redpacket tron event detected", "event", event.Name, "txID", txID)
|
||||
switch event.Name {
|
||||
case "PacketCreated":
|
||||
if err := t.handleTronPacketCreated(ctx, event, txID); err != nil {
|
||||
log.ZWarn(ctx, "redpacket tron handlePacketCreated failed", err, "txID", txID)
|
||||
}
|
||||
case "PacketClaimed":
|
||||
if err := t.handleTronPacketClaimed(ctx, event, txID); err != nil {
|
||||
log.ZWarn(ctx, "redpacket tron handlePacketClaimed failed", err, "txID", txID)
|
||||
}
|
||||
case "PacketRefunded":
|
||||
if err := t.handleTronPacketRefunded(ctx, event, txID); err != nil {
|
||||
log.ZWarn(ctx, "redpacket tron handlePacketRefunded failed", err, "txID", txID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TronIndexer) handleTronPacketCreated(ctx context.Context, event *ParsedEvent, txID string) error {
|
||||
packetID := GetPacketIDFromEvent(event)
|
||||
creator := GetAddressFromEvent(event, "creator")
|
||||
log.ZInfo(ctx, "tron PacketCreated event", "packetID", packetID.String(), "creator", creator.Hex(), "txID", txID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TronIndexer) handleTronPacketClaimed(ctx context.Context, event *ParsedEvent, txID string) error {
|
||||
packetID := GetPacketIDFromEvent(event)
|
||||
claimer := GetAddressFromEvent(event, "claimer")
|
||||
amount := GetAmountFromEvent(event)
|
||||
authNonce := GetUintFromEvent(event, "authNonce")
|
||||
|
||||
log.ZInfo(ctx, "tron PacketClaimed event", "packetID", packetID.String(), "claimer", claimer.Hex(), "amount", amount.String(), "txID", txID)
|
||||
|
||||
claim := &model.RedPacketClaim{
|
||||
PacketID: packetID.String(),
|
||||
ClaimerWallet: claimer.Hex(),
|
||||
AuthNonce: authNonce.String(),
|
||||
ClaimTxHash: txID,
|
||||
ClaimedAmount: amount.String(),
|
||||
BlockNumber: event.BlockNumber,
|
||||
Status: "CONFIRMED",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := t.db.SaveClaim(ctx, claim); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := t.db.MarkClaimAuthUsed(ctx, authNonce.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
// Pass "" for forced status; DB layer auto-derives COMPLETED/ACTIVE.
|
||||
// txID is the idempotency key: prevents double-counting if ClaimResult RPC
|
||||
// already processed this same transaction.
|
||||
return t.db.UpdateRedPacketClaimProgress(ctx, packetID.String(), amount.String(), "", txID)
|
||||
}
|
||||
|
||||
func (t *TronIndexer) handleTronPacketRefunded(ctx context.Context, event *ParsedEvent, txID string) error {
|
||||
packetID := GetPacketIDFromEvent(event)
|
||||
refundTo := GetAddressFromEvent(event, "refundTo")
|
||||
amount := GetAmountFromEvent(event)
|
||||
|
||||
log.ZInfo(ctx, "tron PacketRefunded event", "packetID", packetID.String(), "refundTo", refundTo.Hex(), "amount", amount.String(), "txID", txID)
|
||||
|
||||
if err := t.db.SaveRefund(ctx, &model.RedPacketRefund{
|
||||
PacketID: packetID.String(),
|
||||
RefundTo: refundTo.Hex(),
|
||||
TxHash: txID,
|
||||
Amount: amount.String(),
|
||||
CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.db.UpdateRedPacketStatus(ctx, packetID.String(), "REFUNDED")
|
||||
}
|
||||
|
||||
func (t *TronIndexer) GetLastProcessedBlock() int64 {
|
||||
return t.lastBlockNum
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
package redpacket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/openimsdk/open-im-server/v3/internal/rpc/redpacket/chain"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/config"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/database/mgo"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/rpcli"
|
||||
pbredpacket "github.com/openimsdk/protocol/redpacket"
|
||||
"github.com/openimsdk/tools/db/mongoutil"
|
||||
"github.com/openimsdk/tools/discovery"
|
||||
"github.com/openimsdk/tools/log"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
RpcConfig config.RedPacket
|
||||
MongodbConfig config.Mongo
|
||||
Share config.Share
|
||||
Discovery config.Discovery
|
||||
}
|
||||
|
||||
type redPacketServer struct {
|
||||
pbredpacket.UnimplementedRedPacketServer
|
||||
config *Config
|
||||
db controller.RedPacketDatabase
|
||||
chainClient *chain.ChainClient
|
||||
tronClient *chain.TronClient
|
||||
signerKey *ecdsa.PrivateKey
|
||||
groupClient *rpcli.GroupClient
|
||||
relationClient *rpcli.RelationClient
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, conf *Config, registry discovery.SvcDiscoveryRegistry, server *grpc.Server) error {
|
||||
mgoClient, err := mongoutil.NewMongoDB(ctx, conf.MongodbConfig.Build())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db := mgoClient.GetDB()
|
||||
|
||||
rpDB, err := mgo.NewRedPacketMongo(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
claimDB, err := mgo.NewRedPacketClaimMongo(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
claimAuthDB, err := mgo.NewRedPacketClaimAuthMongo(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refundDB, err := mgo.NewRedPacketRefundMongo(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
challengeDB, err := mgo.NewWalletBindingChallengeMongo(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bindingDB, err := mgo.NewWalletBindingMongo(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auditLogDB, err := mgo.NewAdminAuditLogMongo(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repo := controller.NewRedPacketDatabase(rpDB, claimDB, claimAuthDB, refundDB, challengeDB, bindingDB, auditLogDB)
|
||||
|
||||
chainClient, err := chain.NewClient(
|
||||
conf.RpcConfig.Chain.RPCURL,
|
||||
conf.RpcConfig.Chain.ContractAddress,
|
||||
conf.RpcConfig.Chain.ChainID,
|
||||
conf.RpcConfig.Chain.SignerPrivateKey,
|
||||
conf.RpcConfig.Chain.ConfigAdminPrivateKey,
|
||||
)
|
||||
if err != nil {
|
||||
log.ZWarn(ctx, "redpacket eth client init failed, continuing without it", err)
|
||||
chainClient = nil
|
||||
}
|
||||
|
||||
var tronClient *chain.TronClient
|
||||
if conf.RpcConfig.Tron.FullNodeURL != "" {
|
||||
abiJSON, abiErr := chain.ExtractABIFromEmbeddedArtifact()
|
||||
if abiErr != nil {
|
||||
log.ZWarn(ctx, "redpacket tron load abi failed", abiErr)
|
||||
} else {
|
||||
tronClient, err = chain.NewTronClient(
|
||||
conf.RpcConfig.Tron.FullNodeURL,
|
||||
conf.RpcConfig.Tron.ContractBase58,
|
||||
conf.RpcConfig.Tron.OwnerBase58,
|
||||
conf.RpcConfig.Tron.PrivateKeyHex,
|
||||
abiJSON,
|
||||
conf.RpcConfig.Tron.FeeLimit,
|
||||
)
|
||||
if err != nil {
|
||||
log.ZWarn(ctx, "redpacket tron client init failed", err)
|
||||
tronClient = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var signerKey *ecdsa.PrivateKey
|
||||
if k := conf.RpcConfig.Chain.SignerPrivateKey; k != "" {
|
||||
sk, parseErr := crypto.HexToECDSA(k)
|
||||
if parseErr != nil {
|
||||
log.ZWarn(ctx, "redpacket signer private key parse failed", parseErr)
|
||||
} else {
|
||||
signerKey = sk
|
||||
}
|
||||
}
|
||||
|
||||
groupConn, err := registry.GetConn(ctx, conf.Share.RpcRegisterName.Group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
friendConn, err := registry.GetConn(ctx, conf.Share.RpcRegisterName.Friend)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := &redPacketServer{
|
||||
config: conf,
|
||||
db: repo,
|
||||
chainClient: chainClient,
|
||||
tronClient: tronClient,
|
||||
signerKey: signerKey,
|
||||
groupClient: rpcli.NewGroupClient(groupConn),
|
||||
relationClient: rpcli.NewRelationClient(friendConn),
|
||||
}
|
||||
|
||||
pbredpacket.RegisterRedPacketServer(server, srv)
|
||||
|
||||
if chainClient != nil {
|
||||
ethIndexer := chain.NewIndexer(chainClient, repo, conf.RpcConfig.Indexer.PollInterval, 0)
|
||||
ethIndexer.Start(ctx)
|
||||
}
|
||||
if tronClient != nil {
|
||||
tronIndexer := chain.NewTronIndexer(tronClient, repo, conf.RpcConfig.Indexer.PollInterval, 0)
|
||||
tronIndexer.Start(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,989 @@
|
||||
package redpacket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/google/uuid"
|
||||
"github.com/openimsdk/open-im-server/v3/internal/rpc/redpacket/chain"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/servererrs"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
|
||||
pbredpacket "github.com/openimsdk/protocol/redpacket"
|
||||
"github.com/openimsdk/tools/errs"
|
||||
"github.com/openimsdk/tools/log"
|
||||
"github.com/openimsdk/tools/mcontext"
|
||||
)
|
||||
|
||||
func (s *redPacketServer) CreateOrder(ctx context.Context, req *pbredpacket.CreateOrderReq) (*pbredpacket.CreateOrderResp, error) {
|
||||
currentUserID := mcontext.GetOpUserID(ctx)
|
||||
if currentUserID == "" {
|
||||
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
|
||||
}
|
||||
|
||||
bizID := uuid.NewString()
|
||||
chainType, err := normalizeChainType(req.ChainType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopeType := normalizeScopeType(req.ScopeType)
|
||||
if err := validateCreateScope(scopeType, req.GroupID, req.ReceiverUserID, req.ReceiverUserIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateCreateHook(ctx, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
chainID := req.ChainID
|
||||
contractAddress := strings.TrimSpace(req.ContractAddress)
|
||||
if chainType == "EVM" && s.chainClient != nil {
|
||||
if chainID == 0 {
|
||||
if chainValue := s.chainClient.ChainID(); chainValue != nil {
|
||||
chainID = chainValue.Int64()
|
||||
}
|
||||
}
|
||||
if contractAddress == "" {
|
||||
contractAddress = s.chainClient.ContractAddress().Hex()
|
||||
}
|
||||
}
|
||||
if chainType == "TRON" && s.tronClient != nil && contractAddress == "" {
|
||||
contractAddress = s.tronClient.ContractAddress()
|
||||
}
|
||||
|
||||
rp := &model.RedPacket{
|
||||
BizID: bizID,
|
||||
ChainType: chainType,
|
||||
ChainID: chainID,
|
||||
ContractAddress: contractAddress,
|
||||
CreatorUserID: currentUserID,
|
||||
CreatorWallet: req.CreatorWallet,
|
||||
GroupID: req.GroupID,
|
||||
ScopeType: scopeType,
|
||||
ReceiverUserID: req.ReceiverUserID,
|
||||
ReceiverUserIDs: append([]string(nil), req.ReceiverUserIDs...),
|
||||
PacketType: req.PacketType,
|
||||
Token: req.Token,
|
||||
TotalAmount: req.TotalAmount,
|
||||
TotalShares: req.TotalShares,
|
||||
ExpiryAt: req.ExpiryAt,
|
||||
Status: "PENDING",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.db.CreateRedPacket(ctx, rp); err != nil {
|
||||
log.ZError(ctx, "create redpacket failed", err, "bizID", bizID)
|
||||
return nil, servererrs.ErrDatabase.WrapMsg("failed to create red packet")
|
||||
}
|
||||
|
||||
return &pbredpacket.CreateOrderResp{BizID: bizID}, nil
|
||||
}
|
||||
|
||||
func (s *redPacketServer) CreatedCallback(ctx context.Context, req *pbredpacket.CreatedCallbackReq) (*pbredpacket.CreatedCallbackResp, error) {
|
||||
opUserID := mcontext.GetOpUserID(ctx)
|
||||
if opUserID == "" {
|
||||
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
|
||||
}
|
||||
if strings.TrimSpace(req.BizID) == "" || strings.TrimSpace(req.TxHash) == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("biz_id and tx_hash are required")
|
||||
}
|
||||
|
||||
rp, err := s.db.GetRedPacketByBizID(ctx, req.BizID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rp.CreatorUserID != opUserID {
|
||||
return nil, servererrs.ErrNoPermission.WrapMsg("only the creator can submit the creation callback")
|
||||
}
|
||||
|
||||
groupID := firstNonEmpty(req.GroupID, rp.GroupID)
|
||||
scopeType := normalizeScopeType(firstNonEmpty(req.ScopeType, rp.ScopeType))
|
||||
receiverUserID := firstNonEmpty(req.ReceiverUserID, rp.ReceiverUserID)
|
||||
receiverUserIDs := rp.ReceiverUserIDs
|
||||
if len(req.ReceiverUserIDs) > 0 {
|
||||
receiverUserIDs = append([]string(nil), req.ReceiverUserIDs...)
|
||||
}
|
||||
|
||||
if err := validateCreateScope(scopeType, groupID, receiverUserID, receiverUserIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdPacket, err := s.resolveCreatedPacket(ctx, rp, req.TxHash, req.PacketID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.db.UpdateRedPacketCreated(ctx, &model.RedPacket{
|
||||
BizID: req.BizID,
|
||||
ChainType: rp.ChainType,
|
||||
PacketID: createdPacket.PacketID,
|
||||
ChainID: createdPacket.ChainID,
|
||||
ContractAddress: createdPacket.ContractAddress,
|
||||
TxHash: req.TxHash,
|
||||
GroupID: groupID,
|
||||
ScopeType: scopeType,
|
||||
ReceiverUserID: receiverUserID,
|
||||
ReceiverUserIDs: receiverUserIDs,
|
||||
Status: "ACTIVE",
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pbredpacket.CreatedCallbackResp{}, nil
|
||||
}
|
||||
|
||||
func (s *redPacketServer) GetDetail(ctx context.Context, req *pbredpacket.GetDetailReq) (*pbredpacket.GetDetailResp, error) {
|
||||
if strings.TrimSpace(req.PacketID) == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("packet_id is required")
|
||||
}
|
||||
|
||||
rp, err := s.db.GetRedPacketByPacketID(ctx, req.PacketID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, err := s.db.GetClaimsByPacketID(ctx, req.PacketID)
|
||||
if err != nil {
|
||||
claims = nil
|
||||
}
|
||||
|
||||
return &pbredpacket.GetDetailResp{
|
||||
Record: redPacketModelToProto(rp),
|
||||
Claims: claimsModelToProto(claims),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *redPacketServer) IssueClaimSign(ctx context.Context, req *pbredpacket.IssueClaimSignReq) (*pbredpacket.IssueClaimSignResp, error) {
|
||||
currentUserID := mcontext.GetOpUserID(ctx)
|
||||
if currentUserID == "" {
|
||||
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
|
||||
}
|
||||
if strings.TrimSpace(req.PacketID) == "" || strings.TrimSpace(req.Claimer) == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("packet_id and claimer are required")
|
||||
}
|
||||
if err := s.canClaim(ctx, req.PacketID, req.Claimer, currentUserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
packetIDBig := new(big.Int)
|
||||
if _, ok := packetIDBig.SetString(req.PacketID, 10); !ok {
|
||||
return nil, errs.ErrArgs.WrapMsg("invalid packet_id", "packetID", req.PacketID)
|
||||
}
|
||||
|
||||
claimerAddr := common.HexToAddress(req.Claimer)
|
||||
nonce := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
authNonceBig := new(big.Int)
|
||||
authNonceBig.SetString(nonce, 10)
|
||||
deadline := time.Now().Add(5 * time.Minute).Unix()
|
||||
randomSeedBig := new(big.Int)
|
||||
if req.RandomSeed != "" && req.RandomSeed != "0" {
|
||||
if _, ok := randomSeedBig.SetString(req.RandomSeed, 10); !ok {
|
||||
return nil, errs.ErrArgs.WrapMsg("invalid random_seed", "randomSeed", req.RandomSeed)
|
||||
}
|
||||
} else {
|
||||
randomSeedBig.SetInt64(time.Now().UnixNano())
|
||||
}
|
||||
deadlineBig := big.NewInt(deadline)
|
||||
|
||||
var digest [32]byte
|
||||
var err error
|
||||
if s.chainClient != nil {
|
||||
digest, err = s.chainClient.GetSignMessage(ctx, packetIDBig, claimerAddr, authNonceBig, randomSeedBig, deadlineBig)
|
||||
if err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("getSignMessage failed: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
digest = crypto.Keccak256Hash([]byte(fmt.Sprintf("%s:%s:%s:%s:%d", req.PacketID, req.Claimer, nonce, randomSeedBig.String(), deadline)))
|
||||
}
|
||||
|
||||
var signature []byte
|
||||
if s.signerKey != nil {
|
||||
signature, err = crypto.Sign(digest[:], s.signerKey)
|
||||
if err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("sign failed: " + err.Error())
|
||||
}
|
||||
if len(signature) == 65 && signature[64] < 27 {
|
||||
signature[64] += 27
|
||||
}
|
||||
} else {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("signer key not configured; cannot issue claim signature")
|
||||
}
|
||||
|
||||
sigHex := "0x" + hex.EncodeToString(signature)
|
||||
|
||||
auth := &model.RedPacketClaimAuth{
|
||||
PacketID: req.PacketID,
|
||||
Claimer: req.Claimer,
|
||||
AuthNonce: nonce,
|
||||
RandomSeed: randomSeedBig.String(),
|
||||
Deadline: deadline,
|
||||
Signature: sigHex,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.db.CreateClaimAuth(ctx, auth); err != nil {
|
||||
return nil, servererrs.ErrDatabase.WrapMsg("save claim auth failed: " + err.Error())
|
||||
}
|
||||
|
||||
return &pbredpacket.IssueClaimSignResp{
|
||||
AuthNonce: nonce,
|
||||
Deadline: deadline,
|
||||
Signature: sigHex,
|
||||
RandomSeed: randomSeedBig.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *redPacketServer) ClaimResult(ctx context.Context, req *pbredpacket.ClaimResultReq) (*pbredpacket.ClaimResultResp, error) {
|
||||
currentUserID := mcontext.GetOpUserID(ctx)
|
||||
if currentUserID == "" {
|
||||
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
|
||||
}
|
||||
if strings.TrimSpace(req.PacketID) == "" || strings.TrimSpace(req.Claimer) == "" || strings.TrimSpace(req.TxHash) == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("packet_id, claimer and tx_hash are required")
|
||||
}
|
||||
|
||||
rp, err := s.db.GetRedPacketByPacketID(ctx, req.PacketID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateClaimBase(rp, currentUserID, req.Claimer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claim := &model.RedPacketClaim{
|
||||
PacketID: req.PacketID,
|
||||
UserID: currentUserID,
|
||||
ClaimerWallet: req.Claimer,
|
||||
ClaimTxHash: req.TxHash,
|
||||
Status: "PENDING",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.db.SaveClaim(ctx, claim); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claimedEvent, err := s.resolveClaimedEvent(ctx, rp, req.TxHash)
|
||||
if err != nil {
|
||||
log.ZWarn(ctx, "resolve claim event failed", err, "txHash", req.TxHash)
|
||||
return &pbredpacket.ClaimResultResp{}, nil
|
||||
}
|
||||
if claimedEvent == nil {
|
||||
return &pbredpacket.ClaimResultResp{}, nil
|
||||
}
|
||||
if !strings.EqualFold(claimedEvent.ClaimerWallet, req.Claimer) {
|
||||
return nil, errs.ErrArgs.WrapMsg(fmt.Sprintf("claim event claimer mismatch: got %s want %s", claimedEvent.ClaimerWallet, req.Claimer))
|
||||
}
|
||||
|
||||
confirmed := &model.RedPacketClaim{
|
||||
PacketID: req.PacketID,
|
||||
UserID: currentUserID,
|
||||
ClaimerWallet: claimedEvent.ClaimerWallet,
|
||||
AuthNonce: claimedEvent.AuthNonce,
|
||||
ClaimTxHash: req.TxHash,
|
||||
ClaimedAmount: claimedEvent.Amount,
|
||||
BlockNumber: claimedEvent.BlockNumber,
|
||||
Status: "CONFIRMED",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := s.db.SaveClaim(ctx, confirmed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claimedEvent.AuthNonce != "" {
|
||||
if err := s.db.MarkClaimAuthUsed(ctx, claimedEvent.AuthNonce); err != nil {
|
||||
log.ZWarn(ctx, "mark claim auth used failed", err, "authNonce", claimedEvent.AuthNonce)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass "" for status so the DB layer auto-derives COMPLETED/ACTIVE.
|
||||
// Pass req.TxHash as the idempotency key so concurrent indexer processing
|
||||
// of the same transaction cannot double-count the claim.
|
||||
if err := s.db.UpdateRedPacketClaimProgress(ctx, req.PacketID, claimedEvent.Amount, "", req.TxHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pbredpacket.ClaimResultResp{}, nil
|
||||
}
|
||||
|
||||
// canClaim runs the claim-eligibility check (formerly RedPacketService.CanClaim).
|
||||
func (s *redPacketServer) canClaim(ctx context.Context, packetID, claimer, userID string) error {
|
||||
rp, err := s.db.GetRedPacketByPacketID(ctx, packetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateClaimBase(rp, userID, claimer); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureWalletBinding(ctx, userID, claimer, rp.ChainType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch rp.PacketType {
|
||||
case 0:
|
||||
return s.validateFixedPacketClaim(ctx, rp, userID, claimer)
|
||||
case 1:
|
||||
return s.validateRandomPacketClaim(ctx, rp, userID, claimer)
|
||||
case 2:
|
||||
return s.validateTransferPacketClaim(ctx, rp, userID, claimer)
|
||||
default:
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("unsupported packet_type: %d", rp.PacketType))
|
||||
}
|
||||
}
|
||||
|
||||
type claimedEventSnapshot struct {
|
||||
ClaimerWallet string
|
||||
AuthNonce string
|
||||
Amount string
|
||||
BlockNumber uint64
|
||||
}
|
||||
|
||||
type createdPacketSnapshot struct {
|
||||
PacketID string
|
||||
ChainID int64
|
||||
ContractAddress string
|
||||
CreatorWallet string
|
||||
PacketType int32
|
||||
Token string
|
||||
TotalAmount string
|
||||
TotalShares int32
|
||||
ExpiryAt int64
|
||||
}
|
||||
|
||||
func (s *redPacketServer) resolveCreatedPacket(ctx context.Context, rp *model.RedPacket, txHashHex, fallbackPacketID string) (*createdPacketSnapshot, error) {
|
||||
switch rp.ChainType {
|
||||
case "EVM":
|
||||
// Offline mode: no chain client configured; caller must supply packet_id directly.
|
||||
if s.chainClient == nil {
|
||||
if fallbackPacketID == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("packet_id is required when EVM client is unavailable")
|
||||
}
|
||||
return buildFallbackCreatedPacket(rp, fallbackPacketID), nil
|
||||
}
|
||||
|
||||
events, err := s.chainClient.ParseTransactionReceipt(ctx, common.HexToHash(txHashHex))
|
||||
if err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("parse created tx failed: " + err.Error())
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
if event.Name != "PacketCreated" {
|
||||
continue
|
||||
}
|
||||
createdPacket := buildCreatedPacketSnapshot(rp, event)
|
||||
if chainValue := s.chainClient.ChainID(); chainValue != nil {
|
||||
createdPacket.ChainID = chainValue.Int64()
|
||||
}
|
||||
createdPacket.ContractAddress = s.chainClient.ContractAddress().Hex()
|
||||
if err := validateCreatedPacket(rp, createdPacket); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return createdPacket, nil
|
||||
}
|
||||
return nil, errs.ErrInternalServer.WrapMsg("PacketCreated event not found in tx: " + txHashHex)
|
||||
case "TRON":
|
||||
// Offline mode: no chain client configured; caller must supply packet_id directly.
|
||||
if s.tronClient == nil {
|
||||
if fallbackPacketID == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("packet_id is required when TRON client is unavailable")
|
||||
}
|
||||
return buildFallbackCreatedPacket(rp, fallbackPacketID), nil
|
||||
}
|
||||
|
||||
events, err := s.tronClient.ParseTransactionReceipt(ctx, txHashHex)
|
||||
if err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("parse tron created tx failed: " + err.Error())
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
if event.Name != "PacketCreated" {
|
||||
continue
|
||||
}
|
||||
createdPacket := buildCreatedPacketSnapshot(rp, event)
|
||||
createdPacket.ContractAddress = firstNonEmpty(s.tronClient.ContractAddress(), rp.ContractAddress)
|
||||
if err := validateCreatedPacket(rp, createdPacket); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return createdPacket, nil
|
||||
}
|
||||
return nil, errs.ErrInternalServer.WrapMsg("PacketCreated event not found in TRON tx: " + txHashHex)
|
||||
default:
|
||||
return nil, errs.ErrArgs.WrapMsg("unsupported chain_type: " + rp.ChainType)
|
||||
}
|
||||
}
|
||||
|
||||
// validateCreateHook reserves a centralized validation extension point split by packet type.
|
||||
func (s *redPacketServer) validateCreateHook(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
|
||||
switch req.PacketType {
|
||||
case 0:
|
||||
return s.validateFixedPacketCreate(ctx, req)
|
||||
case 1:
|
||||
return s.validateRandomPacketCreate(ctx, req)
|
||||
case 2:
|
||||
return s.validateTransferPacketCreate(ctx, req)
|
||||
default:
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("unsupported packet_type: %d", req.PacketType))
|
||||
}
|
||||
}
|
||||
|
||||
// validateCreateBaseFields validates the fields shared by every red packet type.
|
||||
// It does not look up creator identity or scope; those are handled by the per-type hooks.
|
||||
func validateCreateBaseFields(req *pbredpacket.CreateOrderReq) (*big.Int, error) {
|
||||
if strings.TrimSpace(req.CreatorWallet) == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("creator_wallet is required")
|
||||
}
|
||||
if strings.TrimSpace(req.TotalAmount) == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("total_amount is required")
|
||||
}
|
||||
total, ok := new(big.Int).SetString(req.TotalAmount, 10)
|
||||
if !ok || total.Sign() <= 0 {
|
||||
return nil, errs.ErrArgs.WrapMsg("total_amount must be a positive integer string", "totalAmount", req.TotalAmount)
|
||||
}
|
||||
if req.ExpiryAt != 0 && req.ExpiryAt <= time.Now().Unix() {
|
||||
return nil, errs.ErrArgs.WrapMsg("expiry_at must be 0 or a future unix timestamp", "expiryAt", req.ExpiryAt)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// validateCreatorScope verifies group membership / friend relationship for the creator
|
||||
// based on the requested scope. PUBLIC scope skips relationship checks.
|
||||
func (s *redPacketServer) validateCreatorScope(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
|
||||
creatorUserID := mcontext.GetOpUserID(ctx)
|
||||
if creatorUserID == "" {
|
||||
return servererrs.ErrNoPermission.WrapMsg("op user id is empty")
|
||||
}
|
||||
switch normalizeScopeType(req.ScopeType) {
|
||||
case "GROUP":
|
||||
return s.ensureGroupEligibility(ctx, req.GroupID, creatorUserID)
|
||||
case "DIRECT":
|
||||
if strings.TrimSpace(req.ReceiverUserID) != "" {
|
||||
if err := s.ensureFriendRelationship(ctx, creatorUserID, req.ReceiverUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, receiverID := range req.ReceiverUserIDs {
|
||||
if strings.TrimSpace(receiverID) == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.ensureFriendRelationship(ctx, creatorUserID, receiverID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// validateFixedPacketCreate validates fixed red packets:
|
||||
// - shared base fields
|
||||
// - scope_type must be GROUP (fixed packets are group-only; claim validators require group_id)
|
||||
// - 0 < total_shares <= maxTotalShares
|
||||
// - total_amount must be divisible by total_shares (each share is an integer in min units)
|
||||
// - creator must be an active member of the group
|
||||
func (s *redPacketServer) validateFixedPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
|
||||
total, err := validateCreateBaseFields(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalizeScopeType(req.ScopeType) != "GROUP" {
|
||||
return errs.ErrArgs.WrapMsg("fixed packet must use scope_type=GROUP")
|
||||
}
|
||||
if req.TotalShares <= 0 {
|
||||
return errs.ErrArgs.WrapMsg("total_shares must be positive for fixed packet", "totalShares", req.TotalShares)
|
||||
}
|
||||
if req.TotalShares > maxTotalShares {
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("total_shares must not exceed %d for fixed packet", maxTotalShares), "totalShares", req.TotalShares)
|
||||
}
|
||||
shares := big.NewInt(int64(req.TotalShares))
|
||||
if new(big.Int).Mod(total, shares).Sign() != 0 {
|
||||
return errs.ErrArgs.WrapMsg("total_amount must be divisible by total_shares for fixed packet",
|
||||
"totalAmount", req.TotalAmount, "totalShares", req.TotalShares)
|
||||
}
|
||||
return s.validateCreatorScope(ctx, req)
|
||||
}
|
||||
|
||||
// validateRandomPacketCreate validates random (lucky) red packets:
|
||||
// - shared base fields
|
||||
// - scope_type must be GROUP (random packets are group-only; claim validators require group_id)
|
||||
// - 0 < total_shares <= maxTotalShares
|
||||
// - total_amount >= total_shares (at least 1 min unit per share)
|
||||
// - creator must be an active member of the group
|
||||
func (s *redPacketServer) validateRandomPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
|
||||
total, err := validateCreateBaseFields(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalizeScopeType(req.ScopeType) != "GROUP" {
|
||||
return errs.ErrArgs.WrapMsg("random packet must use scope_type=GROUP")
|
||||
}
|
||||
if req.TotalShares <= 0 {
|
||||
return errs.ErrArgs.WrapMsg("total_shares must be positive for random packet", "totalShares", req.TotalShares)
|
||||
}
|
||||
if req.TotalShares > maxTotalShares {
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("total_shares must not exceed %d for random packet", maxTotalShares), "totalShares", req.TotalShares)
|
||||
}
|
||||
shares := big.NewInt(int64(req.TotalShares))
|
||||
if total.Cmp(shares) < 0 {
|
||||
return errs.ErrArgs.WrapMsg("total_amount must be >= total_shares for random packet",
|
||||
"totalAmount", req.TotalAmount, "totalShares", req.TotalShares)
|
||||
}
|
||||
return s.validateCreatorScope(ctx, req)
|
||||
}
|
||||
|
||||
// validateTransferPacketCreate validates transfer red packets:
|
||||
// - shared base fields
|
||||
// - scope_type must be DIRECT (transfer is a 1-to-1 direct send)
|
||||
// - total_shares == 1
|
||||
// - exactly one receiver_user_id (receiver_user_ids must be empty)
|
||||
// - receiver must not be the creator (no self-transfer)
|
||||
// - creator and receiver must be friends
|
||||
func (s *redPacketServer) validateTransferPacketCreate(ctx context.Context, req *pbredpacket.CreateOrderReq) error {
|
||||
if _, err := validateCreateBaseFields(req); err != nil {
|
||||
return err
|
||||
}
|
||||
if normalizeScopeType(req.ScopeType) != "DIRECT" {
|
||||
return errs.ErrArgs.WrapMsg("transfer packet must use scope_type=DIRECT")
|
||||
}
|
||||
if req.TotalShares != 1 {
|
||||
return errs.ErrArgs.WrapMsg("transfer packet must have total_shares == 1", "totalShares", req.TotalShares)
|
||||
}
|
||||
// Reject ambiguous input: receiver_user_ids is not applicable for transfer.
|
||||
if len(req.ReceiverUserIDs) > 0 {
|
||||
return errs.ErrArgs.WrapMsg("transfer packet uses receiver_user_id (singular), not receiver_user_ids")
|
||||
}
|
||||
receiverUserID := strings.TrimSpace(req.ReceiverUserID)
|
||||
if receiverUserID == "" {
|
||||
return errs.ErrArgs.WrapMsg("receiver_user_id is required for transfer packet")
|
||||
}
|
||||
creatorUserID := mcontext.GetOpUserID(ctx)
|
||||
if creatorUserID == "" {
|
||||
return servererrs.ErrNoPermission.WrapMsg("op user id is empty")
|
||||
}
|
||||
if creatorUserID == receiverUserID {
|
||||
return errs.ErrArgs.WrapMsg("transfer packet cannot be sent to yourself")
|
||||
}
|
||||
return s.ensureFriendRelationship(ctx, creatorUserID, receiverUserID)
|
||||
}
|
||||
|
||||
func buildFallbackCreatedPacket(rp *model.RedPacket, packetID string) *createdPacketSnapshot {
|
||||
return &createdPacketSnapshot{
|
||||
PacketID: packetID,
|
||||
ChainID: rp.ChainID,
|
||||
ContractAddress: rp.ContractAddress,
|
||||
CreatorWallet: strings.ToLower(rp.CreatorWallet),
|
||||
PacketType: rp.PacketType,
|
||||
Token: normalizeTokenAddress(rp.Token),
|
||||
TotalAmount: rp.TotalAmount,
|
||||
TotalShares: rp.TotalShares,
|
||||
ExpiryAt: rp.ExpiryAt,
|
||||
}
|
||||
}
|
||||
|
||||
func buildCreatedPacketSnapshot(rp *model.RedPacket, event *chain.ParsedEvent) *createdPacketSnapshot {
|
||||
return &createdPacketSnapshot{
|
||||
PacketID: chain.GetPacketIDFromEvent(event).String(),
|
||||
ChainID: rp.ChainID,
|
||||
ContractAddress: rp.ContractAddress,
|
||||
CreatorWallet: strings.ToLower(chain.GetAddressFromEvent(event, "creator").Hex()),
|
||||
PacketType: int32(chain.GetUintFromEvent(event, "packetType").Int64()),
|
||||
Token: strings.ToLower(chain.GetAddressFromEvent(event, "token").Hex()),
|
||||
TotalAmount: chain.GetUintFromEvent(event, "totalAmount").String(),
|
||||
TotalShares: int32(chain.GetUintFromEvent(event, "totalShares").Int64()),
|
||||
ExpiryAt: chain.GetUintFromEvent(event, "expiryAt").Int64(),
|
||||
}
|
||||
}
|
||||
|
||||
func validateCreatedPacket(rp *model.RedPacket, createdPacket *createdPacketSnapshot) error {
|
||||
if createdPacket == nil {
|
||||
return errs.ErrInternalServer.WrapMsg("created packet is nil")
|
||||
}
|
||||
if createdPacket.CreatorWallet != "" && strings.ToLower(rp.CreatorWallet) != createdPacket.CreatorWallet {
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("creator mismatch: got %s want %s", createdPacket.CreatorWallet, rp.CreatorWallet))
|
||||
}
|
||||
if createdPacket.PacketType != rp.PacketType {
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("packet type mismatch: got %d want %d", createdPacket.PacketType, rp.PacketType))
|
||||
}
|
||||
if createdPacket.TotalAmount != rp.TotalAmount {
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("total amount mismatch: got %s want %s", createdPacket.TotalAmount, rp.TotalAmount))
|
||||
}
|
||||
if createdPacket.TotalShares != rp.TotalShares {
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("total shares mismatch: got %d want %d", createdPacket.TotalShares, rp.TotalShares))
|
||||
}
|
||||
expectedToken := normalizeTokenAddress(rp.Token)
|
||||
if createdPacket.Token != expectedToken {
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("token mismatch: got %s want %s", createdPacket.Token, expectedToken))
|
||||
}
|
||||
if rp.ExpiryAt > 0 && createdPacket.ExpiryAt != rp.ExpiryAt {
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("expiry mismatch: got %d want %d", createdPacket.ExpiryAt, rp.ExpiryAt))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateClaimBase(rp *model.RedPacket, userID, claimer string) error {
|
||||
if rp == nil {
|
||||
return servererrs.ErrRecordNotFound.WrapMsg("packet not found")
|
||||
}
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return errs.ErrArgs.WrapMsg("user_id is required")
|
||||
}
|
||||
if strings.TrimSpace(claimer) == "" {
|
||||
return errs.ErrArgs.WrapMsg("claimer is required")
|
||||
}
|
||||
// Check status first to give precise error messages for each terminal state.
|
||||
switch rp.Status {
|
||||
case "ACTIVE":
|
||||
// ok, continue to expiry check
|
||||
case "REFUNDED":
|
||||
return errs.ErrArgs.WrapMsg("packet has been refunded")
|
||||
case "EXPIRED":
|
||||
return errs.ErrArgs.WrapMsg("packet has expired")
|
||||
default:
|
||||
return errs.ErrArgs.WrapMsg("packet is not claimable, current status: " + rp.Status)
|
||||
}
|
||||
// Guard against the race where status is still ACTIVE but expiry has passed.
|
||||
if rp.ExpiryAt > 0 && rp.ExpiryAt <= time.Now().Unix() {
|
||||
return errs.ErrArgs.WrapMsg("packet has expired")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *redPacketServer) validateFixedPacketClaim(ctx context.Context, rp *model.RedPacket, userID, claimer string) error {
|
||||
if strings.TrimSpace(rp.GroupID) == "" {
|
||||
return errs.ErrArgs.WrapMsg("group_id is required for fixed packet claim")
|
||||
}
|
||||
if err := s.ensureNotClaimed(ctx, rp.PacketID, userID, claimer); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ensureGroupEligibility(ctx, rp.GroupID, userID)
|
||||
}
|
||||
|
||||
func (s *redPacketServer) validateRandomPacketClaim(ctx context.Context, rp *model.RedPacket, userID, claimer string) error {
|
||||
if strings.TrimSpace(rp.GroupID) == "" {
|
||||
return errs.ErrArgs.WrapMsg("group_id is required for random packet claim")
|
||||
}
|
||||
if err := s.ensureNotClaimed(ctx, rp.PacketID, userID, claimer); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ensureGroupEligibility(ctx, rp.GroupID, userID)
|
||||
}
|
||||
|
||||
func (s *redPacketServer) validateTransferPacketClaim(ctx context.Context, rp *model.RedPacket, userID, claimer string) error {
|
||||
if err := s.ensureNotClaimed(ctx, rp.PacketID, userID, claimer); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rp.ReceiverUserID) == "" {
|
||||
return errs.ErrArgs.WrapMsg("receiver_user_id is required for transfer claim")
|
||||
}
|
||||
if rp.ReceiverUserID != userID {
|
||||
return errs.ErrNoPermission.WrapMsg("user is not the designated receiver")
|
||||
}
|
||||
return s.ensureFriendRelationship(ctx, rp.CreatorUserID, userID)
|
||||
}
|
||||
|
||||
func (s *redPacketServer) ensureNotClaimed(ctx context.Context, packetID, userID, claimer string) error {
|
||||
if strings.TrimSpace(userID) != "" {
|
||||
claim, err := s.db.GetClaimByPacketIDAndUserID(ctx, packetID, userID)
|
||||
if err == nil && claim != nil && claim.Status != "FAILED" {
|
||||
return errs.ErrArgs.WrapMsg("user already claimed")
|
||||
}
|
||||
if err != nil && !errs.ErrRecordNotFound.Is(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
claim, err := s.db.GetClaimByPacketIDAndClaimer(ctx, packetID, claimer)
|
||||
if err == nil && claim != nil && claim.Status != "FAILED" {
|
||||
return errs.ErrArgs.WrapMsg("already claimed")
|
||||
}
|
||||
if err != nil && !errs.ErrRecordNotFound.Is(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *redPacketServer) ensureWalletBinding(ctx context.Context, userID, claimer, chainType string) error {
|
||||
if _, err := s.db.GetActiveWalletBinding(ctx, userID, chainType, claimer); err != nil {
|
||||
if errs.ErrRecordNotFound.Is(err) {
|
||||
return errs.ErrNoPermission.WrapMsg("wallet is not bound to user")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureGroupEligibility verifies that userID is an active member of groupID.
|
||||
func (s *redPacketServer) ensureGroupEligibility(ctx context.Context, groupID, userID string) error {
|
||||
groupID = strings.TrimSpace(groupID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if groupID == "" {
|
||||
return errs.ErrArgs.WrapMsg("group_id is required for group claim")
|
||||
}
|
||||
if userID == "" {
|
||||
return errs.ErrArgs.WrapMsg("user_id is required for group claim")
|
||||
}
|
||||
if s.groupClient == nil {
|
||||
return servererrs.ErrInternalServer.WrapMsg("group client is not initialized")
|
||||
}
|
||||
if _, err := s.groupClient.GetGroupMemberInfo(ctx, groupID, userID); err != nil {
|
||||
if errs.ErrRecordNotFound.Is(err) {
|
||||
return errs.ErrNoPermission.WrapMsg("user is not a member of the group", "groupID", groupID, "userID", userID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureFriendRelationship verifies that userA and userB are mutual friends.
|
||||
// It is used in two contexts:
|
||||
// - validateCreatorScope (DIRECT scope): checking that each listed receiver is
|
||||
// a friend of the creator. In that path userA == userB is theoretically possible
|
||||
// (creator adding themselves to a list), which is allowed here; the transfer
|
||||
// validator has its own explicit self-transfer prohibition.
|
||||
// - validateTransferPacketClaim: re-confirming the friendship at claim time.
|
||||
//
|
||||
// Self-transfer is intentionally allowed at this level; call sites that need to
|
||||
// prohibit it (e.g. validateTransferPacketCreate) must do so before calling here.
|
||||
func (s *redPacketServer) ensureFriendRelationship(ctx context.Context, userA, userB string) error {
|
||||
userA = strings.TrimSpace(userA)
|
||||
userB = strings.TrimSpace(userB)
|
||||
if userA == "" || userB == "" {
|
||||
return errs.ErrArgs.WrapMsg("both user IDs are required for friend relationship check")
|
||||
}
|
||||
if userA == userB {
|
||||
return nil
|
||||
}
|
||||
if s.relationClient == nil {
|
||||
return servererrs.ErrInternalServer.WrapMsg("relation client is not initialized")
|
||||
}
|
||||
ok, err := s.relationClient.IsFriend(ctx, userA, userB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errs.ErrNoPermission.WrapMsg("users are not friends", "userA", userA, "userB", userB)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *redPacketServer) resolveClaimedEvent(ctx context.Context, rp *model.RedPacket, txHash string) (*claimedEventSnapshot, error) {
|
||||
var (
|
||||
events []*chain.ParsedEvent
|
||||
err error
|
||||
)
|
||||
|
||||
switch rp.ChainType {
|
||||
case "EVM":
|
||||
if s.chainClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
events, err = s.chainClient.ParseTransactionReceipt(ctx, common.HexToHash(txHash))
|
||||
case "TRON":
|
||||
if s.tronClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
events, err = s.tronClient.ParseTransactionReceipt(ctx, txHash)
|
||||
default:
|
||||
return nil, errs.ErrArgs.WrapMsg("unsupported chain_type: " + rp.ChainType)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
if event.Name != "PacketClaimed" {
|
||||
continue
|
||||
}
|
||||
packetID := chain.GetPacketIDFromEvent(event).String()
|
||||
claimerWallet := strings.ToLower(chain.GetAddressFromEvent(event, "claimer").Hex())
|
||||
if packetID != rp.PacketID {
|
||||
return nil, errs.ErrArgs.WrapMsg(fmt.Sprintf("claim event packet mismatch: got %s want %s", packetID, rp.PacketID))
|
||||
}
|
||||
return &claimedEventSnapshot{
|
||||
ClaimerWallet: claimerWallet,
|
||||
AuthNonce: chain.GetUintFromEvent(event, "authNonce").String(),
|
||||
Amount: chain.GetAmountFromEvent(event).String(),
|
||||
BlockNumber: event.BlockNumber,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// maxTotalShares caps the number of shares to prevent abuse.
|
||||
const maxTotalShares = 10_000
|
||||
|
||||
func normalizeScopeType(scopeType string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(scopeType)) {
|
||||
case "GROUP", "DIRECT", "PUBLIC":
|
||||
return strings.ToUpper(strings.TrimSpace(scopeType))
|
||||
default:
|
||||
return "PUBLIC"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeChainType(chainType string) (string, error) {
|
||||
switch strings.ToUpper(strings.TrimSpace(chainType)) {
|
||||
case "EVM":
|
||||
return "EVM", nil
|
||||
case "TRON":
|
||||
return "TRON", nil
|
||||
default:
|
||||
return "", errs.ErrArgs.WrapMsg("unsupported chain_type: " + chainType)
|
||||
}
|
||||
}
|
||||
|
||||
func validateCreateScope(scopeType, groupID, receiverUserID string, receiverUserIDs []string) error {
|
||||
switch scopeType {
|
||||
case "GROUP":
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return errs.ErrArgs.WrapMsg("group_id is required when scope_type=GROUP")
|
||||
}
|
||||
case "DIRECT":
|
||||
if strings.TrimSpace(receiverUserID) == "" && len(receiverUserIDs) == 0 {
|
||||
return errs.ErrArgs.WrapMsg("receiver_user_id or receiver_user_ids is required when scope_type=DIRECT")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeTokenAddress(token string) string {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return strings.ToLower(common.Address{}.Hex())
|
||||
}
|
||||
return strings.ToLower(common.HexToAddress(token).Hex())
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redPacketModelToProto(rp *model.RedPacket) *pbredpacket.RedPacketRecord {
|
||||
if rp == nil {
|
||||
return nil
|
||||
}
|
||||
return &pbredpacket.RedPacketRecord{
|
||||
BizID: rp.BizID,
|
||||
ChainType: rp.ChainType,
|
||||
PacketID: rp.PacketID,
|
||||
ChainID: rp.ChainID,
|
||||
ContractAddress: rp.ContractAddress,
|
||||
CreatorUserID: rp.CreatorUserID,
|
||||
CreatorWallet: rp.CreatorWallet,
|
||||
GroupID: rp.GroupID,
|
||||
ScopeType: rp.ScopeType,
|
||||
ReceiverUserID: rp.ReceiverUserID,
|
||||
ReceiverUserIDs: append([]string(nil), rp.ReceiverUserIDs...),
|
||||
PacketType: rp.PacketType,
|
||||
Token: rp.Token,
|
||||
TotalAmount: rp.TotalAmount,
|
||||
TotalShares: rp.TotalShares,
|
||||
ClaimedAmount: rp.ClaimedAmount,
|
||||
ClaimedShares: rp.ClaimedShares,
|
||||
ExpiryAt: rp.ExpiryAt,
|
||||
TxHash: rp.TxHash,
|
||||
Status: rp.Status,
|
||||
CreatedAt: rp.CreatedAt.Unix(),
|
||||
UpdatedAt: rp.UpdatedAt.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// RequestRefund allows the red-packet creator to submit an on-chain refund
|
||||
// transaction for an expired packet. The indexer will asynchronously pick up
|
||||
// the on-chain RefundPacket event and mark the packet as REFUNDED in the DB.
|
||||
func (s *redPacketServer) RequestRefund(ctx context.Context, req *pbredpacket.RequestRefundReq) (*pbredpacket.RequestRefundResp, error) {
|
||||
currentUserID := mcontext.GetOpUserID(ctx)
|
||||
if currentUserID == "" {
|
||||
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
|
||||
}
|
||||
if req.GetPacketID() == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("packet_id is required")
|
||||
}
|
||||
|
||||
rp, err := s.db.GetRedPacketByPacketID(ctx, req.GetPacketID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rp.CreatorUserID != currentUserID {
|
||||
return nil, errs.ErrNoPermission.WrapMsg("only the creator can request a refund")
|
||||
}
|
||||
if rp.Status == "REFUNDED" {
|
||||
return &pbredpacket.RequestRefundResp{TxHash: "", Status: "REFUNDED"}, nil
|
||||
}
|
||||
if rp.ExpiryAt > 0 && time.Now().Unix() < rp.ExpiryAt {
|
||||
return nil, errs.ErrArgs.WrapMsg("red packet has not expired yet")
|
||||
}
|
||||
|
||||
// Submit the on-chain refund transaction.
|
||||
var txHash string
|
||||
if s.chainClient != nil {
|
||||
txHash, err = s.chainClient.RefundPacket(ctx, rp.PacketID)
|
||||
if err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("submit refund tx failed: " + err.Error())
|
||||
}
|
||||
} else if s.tronClient != nil {
|
||||
packetIDBig, ok := new(big.Int).SetString(rp.PacketID, 10)
|
||||
if !ok {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("invalid packet id format")
|
||||
}
|
||||
txHash, err = s.tronClient.SendAdminTransaction(ctx, "refundPacket", packetIDBig)
|
||||
if err != nil {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("submit tron refund tx failed: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
|
||||
}
|
||||
|
||||
log.ZInfo(ctx, "redpacket refund submitted", "packetID", rp.PacketID, "txHash", txHash)
|
||||
return &pbredpacket.RequestRefundResp{TxHash: txHash, Status: "PENDING"}, nil
|
||||
}
|
||||
|
||||
func (s *redPacketServer) GetRefund(ctx context.Context, req *pbredpacket.GetRefundReq) (*pbredpacket.GetRefundResp, error) {
|
||||
if req.GetPacketID() == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("packet_id is required")
|
||||
}
|
||||
refund, err := s.db.GetRefundByPacketID(ctx, req.GetPacketID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pbredpacket.GetRefundResp{
|
||||
PacketID: refund.PacketID,
|
||||
RefundTo: refund.RefundTo,
|
||||
TxHash: refund.TxHash,
|
||||
Amount: refund.Amount,
|
||||
CreatedAt: refund.CreatedAt.Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func claimsModelToProto(claims []*model.RedPacketClaim) []*pbredpacket.RedPacketClaimRecord {
|
||||
out := make([]*pbredpacket.RedPacketClaimRecord, 0, len(claims))
|
||||
for _, c := range claims {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, &pbredpacket.RedPacketClaimRecord{
|
||||
PacketID: c.PacketID,
|
||||
UserID: c.UserID,
|
||||
ClaimerWallet: c.ClaimerWallet,
|
||||
AuthNonce: c.AuthNonce,
|
||||
ClaimTxHash: c.ClaimTxHash,
|
||||
ClaimedAmount: c.ClaimedAmount,
|
||||
BlockNumber: c.BlockNumber,
|
||||
Status: c.Status,
|
||||
CreatedAt: c.CreatedAt.Unix(),
|
||||
UpdatedAt: c.UpdatedAt.Unix(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -0,0 +1,349 @@
|
||||
package redpacket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/google/uuid"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/servererrs"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
|
||||
pbredpacket "github.com/openimsdk/protocol/redpacket"
|
||||
"github.com/openimsdk/tools/errs"
|
||||
"github.com/openimsdk/tools/mcontext"
|
||||
)
|
||||
|
||||
func (s *redPacketServer) IssueWalletBindChallenge(ctx context.Context, req *pbredpacket.IssueWalletBindChallengeReq) (*pbredpacket.IssueWalletBindChallengeResp, error) {
|
||||
currentUserID := mcontext.GetOpUserID(ctx)
|
||||
if currentUserID == "" {
|
||||
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
|
||||
}
|
||||
|
||||
chainType, err := normalizeChainType(req.ChainType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
walletAddress := strings.TrimSpace(req.WalletAddress)
|
||||
if walletAddress == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("wallet_address is required")
|
||||
}
|
||||
|
||||
challengeID := uuid.NewString()
|
||||
nonce := uuid.NewString()
|
||||
issuedAt := time.Now().UTC()
|
||||
expiresAt := issuedAt.Add(10 * time.Minute)
|
||||
|
||||
protocol := "siwe-eip4361"
|
||||
signMethod := "personal_sign"
|
||||
message := buildEVMBindMessage(currentUserID, walletAddress, req.Domain, req.Uri, req.ChainID, challengeID, nonce, issuedAt, expiresAt)
|
||||
if chainType == "TRON" {
|
||||
protocol = "tron-signmessagev2"
|
||||
signMethod = "signMessageV2"
|
||||
message = buildTRONBindMessage(currentUserID, walletAddress, req.ChainID, challengeID, nonce, issuedAt, expiresAt)
|
||||
}
|
||||
|
||||
challenge := &model.WalletBindingChallenge{
|
||||
ChallengeID: challengeID,
|
||||
UserID: currentUserID,
|
||||
ChainType: chainType,
|
||||
ChainID: req.ChainID,
|
||||
WalletAddress: walletAddress,
|
||||
Nonce: nonce,
|
||||
Message: message,
|
||||
Protocol: protocol,
|
||||
SignMethod: signMethod,
|
||||
Status: "PENDING",
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: issuedAt,
|
||||
UpdatedAt: issuedAt,
|
||||
}
|
||||
if err := s.db.CreateWalletBindingChallenge(ctx, challenge); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &pbredpacket.IssueWalletBindChallengeResp{
|
||||
ChallengeID: challengeID,
|
||||
UserID: currentUserID,
|
||||
ChainType: chainType,
|
||||
ChainID: req.ChainID,
|
||||
Wallet: walletAddress,
|
||||
Protocol: protocol,
|
||||
SignMethod: signMethod,
|
||||
Nonce: nonce,
|
||||
Message: message,
|
||||
IssuedAt: issuedAt.Format(time.RFC3339),
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *redPacketServer) ConfirmWalletBind(ctx context.Context, req *pbredpacket.ConfirmWalletBindReq) (*pbredpacket.ConfirmWalletBindResp, error) {
|
||||
if strings.TrimSpace(req.ChallengeID) == "" || strings.TrimSpace(req.Signature) == "" {
|
||||
return nil, errs.ErrArgs.WrapMsg("challenge_id and signature are required")
|
||||
}
|
||||
challenge, err := s.db.GetWalletBindingChallenge(ctx, req.ChallengeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if challenge.Status != "PENDING" {
|
||||
return nil, errs.ErrArgs.WrapMsg("challenge is not pending")
|
||||
}
|
||||
if time.Now().UTC().After(challenge.ExpiresAt) {
|
||||
challenge.Status = "EXPIRED"
|
||||
challenge.UpdatedAt = time.Now()
|
||||
_ = s.db.UpdateWalletBindingChallenge(ctx, challenge)
|
||||
return nil, errs.ErrArgs.WrapMsg("challenge is expired")
|
||||
}
|
||||
|
||||
var verifyErr error
|
||||
switch challenge.ChainType {
|
||||
case "EVM":
|
||||
verifyErr = verifyEVMBindSignature(challenge.Message, challenge.WalletAddress, req.Signature)
|
||||
case "TRON":
|
||||
verifyErr = verifyTRONBindSignature(challenge.Message, challenge.WalletAddress, req.Signature)
|
||||
default:
|
||||
return nil, errs.ErrArgs.WrapMsg("unsupported chain_type: " + challenge.ChainType)
|
||||
}
|
||||
if verifyErr != nil {
|
||||
challenge.Status = "FAILED"
|
||||
challenge.Signature = req.Signature
|
||||
challenge.UpdatedAt = time.Now()
|
||||
_ = s.db.UpdateWalletBindingChallenge(ctx, challenge)
|
||||
return nil, verifyErr
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
challenge.Status = "VERIFIED"
|
||||
challenge.Signature = req.Signature
|
||||
challenge.VerifiedAt = &now
|
||||
challenge.UpdatedAt = now
|
||||
if err := s.db.UpdateWalletBindingChallenge(ctx, challenge); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
binding := &model.WalletBinding{
|
||||
UserID: challenge.UserID,
|
||||
ChainType: challenge.ChainType,
|
||||
ChainID: challenge.ChainID,
|
||||
WalletAddress: challenge.WalletAddress,
|
||||
Status: "ACTIVE",
|
||||
ChallengeID: challenge.ChallengeID,
|
||||
VerifiedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.db.UpsertWalletBinding(ctx, binding); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &pbredpacket.ConfirmWalletBindResp{
|
||||
UserID: binding.UserID,
|
||||
ChainType: binding.ChainType,
|
||||
ChainID: binding.ChainID,
|
||||
WalletAddress: binding.WalletAddress,
|
||||
Status: binding.Status,
|
||||
VerifiedAt: binding.VerifiedAt.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *redPacketServer) GetWalletBinding(ctx context.Context, req *pbredpacket.GetWalletBindingReq) (*pbredpacket.GetWalletBindingResp, error) {
|
||||
currentUserID := mcontext.GetOpUserID(ctx)
|
||||
if currentUserID == "" {
|
||||
return nil, servererrs.ErrNoPermission.WrapMsg("op user id is empty")
|
||||
}
|
||||
|
||||
normalizedChainType, err := normalizeChainType(req.ChainType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binding, err := s.db.GetActiveWalletBinding(ctx, currentUserID, normalizedChainType, req.WalletAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pbredpacket.GetWalletBindingResp{
|
||||
UserID: binding.UserID,
|
||||
ChainType: binding.ChainType,
|
||||
ChainID: binding.ChainID,
|
||||
WalletAddress: binding.WalletAddress,
|
||||
Status: binding.Status,
|
||||
ChallengeID: binding.ChallengeID,
|
||||
VerifiedAt: binding.VerifiedAt.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildEVMBindMessage(userID, walletAddress, domainIn, uriIn string, chainID int64, challengeID, nonce string, issuedAt, expiresAt time.Time) string {
|
||||
domain := strings.TrimSpace(domainIn)
|
||||
if domain == "" {
|
||||
domain = "redpacket"
|
||||
}
|
||||
uri := strings.TrimSpace(uriIn)
|
||||
if uri == "" {
|
||||
uri = "https://redpacket.local/wallet-bind"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s wants you to sign in with your Ethereum account:\n", domain)
|
||||
b.WriteString(strings.TrimSpace(walletAddress))
|
||||
b.WriteString("\n\n")
|
||||
fmt.Fprintf(&b, "Bind wallet %s to user %s.\n", strings.TrimSpace(walletAddress), strings.TrimSpace(userID))
|
||||
fmt.Fprintf(&b, "URI: %s\n", uri)
|
||||
fmt.Fprintf(&b, "Version: 1\n")
|
||||
fmt.Fprintf(&b, "Chain ID: %d\n", chainID)
|
||||
fmt.Fprintf(&b, "Nonce: %s\n", nonce)
|
||||
fmt.Fprintf(&b, "Issued At: %s\n", issuedAt.Format(time.RFC3339))
|
||||
fmt.Fprintf(&b, "Expiration Time: %s\n", expiresAt.Format(time.RFC3339))
|
||||
fmt.Fprintf(&b, "Request ID: %s", challengeID)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildTRONBindMessage(userID, walletAddress string, chainID int64, challengeID, nonce string, issuedAt, expiresAt time.Time) string {
|
||||
return fmt.Sprintf(
|
||||
"Bind TRON wallet %s to user %s\nchallenge_id: %s\nnonce: %s\nchain_id: %d\nissued_at: %s\nexpires_at: %s",
|
||||
strings.TrimSpace(walletAddress),
|
||||
strings.TrimSpace(userID),
|
||||
challengeID,
|
||||
nonce,
|
||||
chainID,
|
||||
issuedAt.Format(time.RFC3339),
|
||||
expiresAt.Format(time.RFC3339),
|
||||
)
|
||||
}
|
||||
|
||||
func verifyEVMBindSignature(message, walletAddress, signature string) error {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return errs.ErrArgs.WrapMsg("bind message is empty")
|
||||
}
|
||||
if !common.IsHexAddress(walletAddress) {
|
||||
return errs.ErrArgs.WrapMsg("invalid evm wallet address")
|
||||
}
|
||||
|
||||
sig, err := hex.DecodeString(strings.TrimPrefix(signature, "0x"))
|
||||
if err != nil {
|
||||
return errs.ErrArgs.WrapMsg("decode signature failed: " + err.Error())
|
||||
}
|
||||
if len(sig) != 65 {
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("invalid signature length: %d", len(sig)))
|
||||
}
|
||||
if sig[64] >= 27 {
|
||||
sig[64] -= 27
|
||||
}
|
||||
if sig[64] > 1 {
|
||||
return errs.ErrArgs.WrapMsg("invalid signature recovery id")
|
||||
}
|
||||
|
||||
hash := crypto.Keccak256Hash([]byte(personalSignMessage(message)))
|
||||
pubKey, err := crypto.SigToPub(hash.Bytes(), sig)
|
||||
if err != nil {
|
||||
return errs.ErrInternalServer.WrapMsg("recover signer failed: " + err.Error())
|
||||
}
|
||||
|
||||
recovered := crypto.PubkeyToAddress(*pubKey)
|
||||
if !strings.EqualFold(recovered.Hex(), walletAddress) {
|
||||
return errs.ErrNoPermission.WrapMsg("signature does not match wallet address")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalSignMessage(message string) string {
|
||||
return fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message)
|
||||
}
|
||||
|
||||
// verifyTRONBindSignature verifies a TRON signMessageV2 (TronLink) signature.
|
||||
// TRON uses the same secp256k1 curve as Ethereum; the only differences are:
|
||||
// - message prefix: "\x19TRON Signed Message:\n<decimal_len>"
|
||||
// - wallet address: base58check-encoded with a leading 0x41 byte
|
||||
func verifyTRONBindSignature(message, walletAddress, signature string) error {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return errs.ErrArgs.WrapMsg("bind message is empty")
|
||||
}
|
||||
|
||||
sig, err := hex.DecodeString(strings.TrimPrefix(signature, "0x"))
|
||||
if err != nil {
|
||||
return errs.ErrArgs.WrapMsg("decode tron signature failed: " + err.Error())
|
||||
}
|
||||
if len(sig) != 65 {
|
||||
return errs.ErrArgs.WrapMsg(fmt.Sprintf("invalid tron signature length: %d", len(sig)))
|
||||
}
|
||||
// Some TRON wallets encode v as 27/28; normalise to 0/1.
|
||||
if sig[64] >= 27 {
|
||||
sig[64] -= 27
|
||||
}
|
||||
|
||||
prefix := fmt.Sprintf("\x19TRON Signed Message:\n%d", len(message))
|
||||
hash := crypto.Keccak256Hash([]byte(prefix + message))
|
||||
|
||||
pubKey, err := crypto.SigToPub(hash.Bytes(), sig)
|
||||
if err != nil {
|
||||
return errs.ErrInternalServer.WrapMsg("recover tron signer failed: " + err.Error())
|
||||
}
|
||||
|
||||
// Derive the raw 20-byte address (identical derivation to Ethereum).
|
||||
recoveredAddr := crypto.PubkeyToAddress(*pubKey)
|
||||
|
||||
// Decode the TRON base58check address to its 20 raw bytes.
|
||||
addrBytes, err := decodeTRONAddress(walletAddress)
|
||||
if err != nil {
|
||||
return errs.ErrArgs.WrapMsg("invalid tron address: " + err.Error())
|
||||
}
|
||||
|
||||
if !bytes.Equal(recoveredAddr.Bytes(), addrBytes) {
|
||||
return errs.ErrNoPermission.WrapMsg("tron signature does not match wallet address")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeTRONAddress decodes a TRON base58check address and returns the 20
|
||||
// raw address bytes (i.e., without the leading 0x41 network prefix byte).
|
||||
func decodeTRONAddress(addr string) ([]byte, error) {
|
||||
decoded := tronBase58Decode(addr)
|
||||
if len(decoded) != 25 {
|
||||
return nil, fmt.Errorf("invalid length %d", len(decoded))
|
||||
}
|
||||
|
||||
payload := decoded[:21]
|
||||
checksum := decoded[21:25]
|
||||
h1 := sha256.Sum256(payload)
|
||||
h2 := sha256.Sum256(h1[:])
|
||||
if !bytes.Equal(h2[:4], checksum) {
|
||||
return nil, fmt.Errorf("invalid base58check checksum")
|
||||
}
|
||||
if payload[0] != 0x41 {
|
||||
return nil, fmt.Errorf("invalid tron address prefix byte: 0x%02x", payload[0])
|
||||
}
|
||||
return payload[1:], nil
|
||||
}
|
||||
|
||||
const tronBase58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||
|
||||
func tronBase58Decode(s string) []byte {
|
||||
n := new(big.Int)
|
||||
base := big.NewInt(58)
|
||||
for _, c := range s {
|
||||
idx := strings.IndexRune(tronBase58Alphabet, c)
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
n.Mul(n, base)
|
||||
n.Add(n, big.NewInt(int64(idx)))
|
||||
}
|
||||
|
||||
decoded := n.Bytes()
|
||||
leadingOnes := 0
|
||||
for _, c := range s {
|
||||
if c == '1' {
|
||||
leadingOnes++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
out := make([]byte, leadingOnes+len(decoded))
|
||||
copy(out[leadingOnes:], decoded)
|
||||
return out
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/openimsdk/open-im-server/v3/internal/rpc/redpacket"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/startrpc"
|
||||
"github.com/openimsdk/open-im-server/v3/version"
|
||||
"github.com/openimsdk/tools/system/program"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type RedPacketRpcCmd struct {
|
||||
*RootCmd
|
||||
ctx context.Context
|
||||
configMap map[string]any
|
||||
redPacketConfig *redpacket.Config
|
||||
}
|
||||
|
||||
func NewRedPacketRpcCmd() *RedPacketRpcCmd {
|
||||
var redPacketConfig redpacket.Config
|
||||
ret := &RedPacketRpcCmd{redPacketConfig: &redPacketConfig}
|
||||
ret.configMap = map[string]any{
|
||||
OpenIMRPCRedPacketCfgFileName: &redPacketConfig.RpcConfig,
|
||||
MongodbConfigFileName: &redPacketConfig.MongodbConfig,
|
||||
ShareFileName: &redPacketConfig.Share,
|
||||
DiscoveryConfigFilename: &redPacketConfig.Discovery,
|
||||
}
|
||||
ret.RootCmd = NewRootCmd(program.GetProcessName(), WithConfigMap(ret.configMap))
|
||||
ret.ctx = context.WithValue(context.Background(), "version", version.Version)
|
||||
ret.Command.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
return ret.runE()
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c *RedPacketRpcCmd) Exec() error {
|
||||
return c.Execute()
|
||||
}
|
||||
|
||||
func (c *RedPacketRpcCmd) runE() error {
|
||||
return startrpc.Start(c.ctx, &c.redPacketConfig.Discovery, &c.redPacketConfig.RpcConfig.Prometheus, c.redPacketConfig.RpcConfig.RPC.ListenIP,
|
||||
c.redPacketConfig.RpcConfig.RPC.RegisterIP, c.redPacketConfig.RpcConfig.RPC.AutoSetPorts, c.redPacketConfig.RpcConfig.RPC.Ports,
|
||||
c.Index(), c.redPacketConfig.Share.RpcRegisterName.RedPacket, &c.redPacketConfig.Share, c.redPacketConfig,
|
||||
nil,
|
||||
redpacket.Start)
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/database"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
|
||||
)
|
||||
|
||||
// RedPacketDatabase is a façade aggregating all redpacket-related collections.
|
||||
// It mirrors the legacy Repository interface so the rpc service layer stays
|
||||
// unaware of the underlying storage.
|
||||
type RedPacketDatabase interface {
|
||||
CreateRedPacket(ctx context.Context, rp *model.RedPacket) error
|
||||
GetRedPacketByBizID(ctx context.Context, bizID string) (*model.RedPacket, error)
|
||||
GetRedPacketByPacketID(ctx context.Context, packetID string) (*model.RedPacket, error)
|
||||
UpdateRedPacketCreated(ctx context.Context, rp *model.RedPacket) error
|
||||
UpdateRedPacketStatus(ctx context.Context, packetID, status string) error
|
||||
UpdateRedPacketClaimProgress(ctx context.Context, packetID, claimedAmount, status, claimTxHash string) error
|
||||
GetExpiredPendingPackets(ctx context.Context, nowUnix int64) ([]*model.RedPacket, error)
|
||||
|
||||
CreateClaimAuth(ctx context.Context, auth *model.RedPacketClaimAuth) error
|
||||
GetClaimAuth(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error)
|
||||
MarkClaimAuthUsed(ctx context.Context, authNonce string) error
|
||||
|
||||
SaveClaim(ctx context.Context, claim *model.RedPacketClaim) error
|
||||
GetClaimByPacketIDAndClaimer(ctx context.Context, packetID, claimer string) (*model.RedPacketClaim, error)
|
||||
GetClaimByPacketIDAndUserID(ctx context.Context, packetID, userID string) (*model.RedPacketClaim, error)
|
||||
GetClaimsByPacketID(ctx context.Context, packetID string) ([]*model.RedPacketClaim, error)
|
||||
|
||||
SaveRefund(ctx context.Context, refund *model.RedPacketRefund) error
|
||||
GetRefundByPacketID(ctx context.Context, packetID string) (*model.RedPacketRefund, error)
|
||||
|
||||
CreateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error
|
||||
GetWalletBindingChallenge(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error)
|
||||
UpdateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error
|
||||
|
||||
UpsertWalletBinding(ctx context.Context, binding *model.WalletBinding) error
|
||||
GetActiveWalletBinding(ctx context.Context, userID, chainType, walletAddress string) (*model.WalletBinding, error)
|
||||
|
||||
CreateAdminAuditLog(ctx context.Context, entry *model.AdminAuditLog) error
|
||||
}
|
||||
|
||||
type redPacketDatabase struct {
|
||||
rp database.RedPacket
|
||||
claim database.RedPacketClaim
|
||||
claimAuth database.RedPacketClaimAuth
|
||||
refund database.RedPacketRefund
|
||||
challenge database.WalletBindingChallenge
|
||||
binding database.WalletBinding
|
||||
auditLog database.AdminAuditLog
|
||||
}
|
||||
|
||||
func NewRedPacketDatabase(
|
||||
rp database.RedPacket,
|
||||
claim database.RedPacketClaim,
|
||||
claimAuth database.RedPacketClaimAuth,
|
||||
refund database.RedPacketRefund,
|
||||
challenge database.WalletBindingChallenge,
|
||||
binding database.WalletBinding,
|
||||
auditLog database.AdminAuditLog,
|
||||
) RedPacketDatabase {
|
||||
return &redPacketDatabase{
|
||||
rp: rp,
|
||||
claim: claim,
|
||||
claimAuth: claimAuth,
|
||||
refund: refund,
|
||||
challenge: challenge,
|
||||
binding: binding,
|
||||
auditLog: auditLog,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) CreateRedPacket(ctx context.Context, rp *model.RedPacket) error {
|
||||
return d.rp.Create(ctx, rp)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) GetRedPacketByBizID(ctx context.Context, bizID string) (*model.RedPacket, error) {
|
||||
return d.rp.GetByBizID(ctx, bizID)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) GetRedPacketByPacketID(ctx context.Context, packetID string) (*model.RedPacket, error) {
|
||||
return d.rp.GetByPacketID(ctx, packetID)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) UpdateRedPacketCreated(ctx context.Context, rp *model.RedPacket) error {
|
||||
return d.rp.UpdateCreated(ctx, rp)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) UpdateRedPacketStatus(ctx context.Context, packetID, status string) error {
|
||||
return d.rp.UpdateStatus(ctx, packetID, status)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) UpdateRedPacketClaimProgress(ctx context.Context, packetID, claimedAmount, status, claimTxHash string) error {
|
||||
return d.rp.UpdateClaimProgress(ctx, packetID, claimedAmount, status, claimTxHash)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) CreateClaimAuth(ctx context.Context, auth *model.RedPacketClaimAuth) error {
|
||||
return d.claimAuth.Create(ctx, auth)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) GetClaimAuth(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error) {
|
||||
return d.claimAuth.Get(ctx, packetID, claimer)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) MarkClaimAuthUsed(ctx context.Context, authNonce string) error {
|
||||
return d.claimAuth.MarkUsed(ctx, authNonce)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) SaveClaim(ctx context.Context, claim *model.RedPacketClaim) error {
|
||||
return d.claim.Save(ctx, claim)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) GetClaimByPacketIDAndClaimer(ctx context.Context, packetID, claimer string) (*model.RedPacketClaim, error) {
|
||||
return d.claim.GetByPacketIDAndClaimer(ctx, packetID, claimer)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) GetClaimByPacketIDAndUserID(ctx context.Context, packetID, userID string) (*model.RedPacketClaim, error) {
|
||||
return d.claim.GetByPacketIDAndUserID(ctx, packetID, userID)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) GetClaimsByPacketID(ctx context.Context, packetID string) ([]*model.RedPacketClaim, error) {
|
||||
return d.claim.ListByPacketID(ctx, packetID)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) SaveRefund(ctx context.Context, refund *model.RedPacketRefund) error {
|
||||
return d.refund.Save(ctx, refund)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) GetRefundByPacketID(ctx context.Context, packetID string) (*model.RedPacketRefund, error) {
|
||||
return d.refund.GetByPacketID(ctx, packetID)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) GetExpiredPendingPackets(ctx context.Context, nowUnix int64) ([]*model.RedPacket, error) {
|
||||
return d.rp.GetExpiredPending(ctx, nowUnix)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) CreateAdminAuditLog(ctx context.Context, entry *model.AdminAuditLog) error {
|
||||
return d.auditLog.Create(ctx, entry)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) CreateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error {
|
||||
return d.challenge.Create(ctx, challenge)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) GetWalletBindingChallenge(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error) {
|
||||
return d.challenge.Get(ctx, challengeID)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) UpdateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error {
|
||||
return d.challenge.Update(ctx, challenge)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) UpsertWalletBinding(ctx context.Context, binding *model.WalletBinding) error {
|
||||
return d.binding.Upsert(ctx, binding)
|
||||
}
|
||||
|
||||
func (d *redPacketDatabase) GetActiveWalletBinding(ctx context.Context, userID, chainType, walletAddress string) (*model.WalletBinding, error) {
|
||||
return d.binding.GetActive(ctx, userID, chainType, walletAddress)
|
||||
}
|
||||
@ -0,0 +1,539 @@
|
||||
package mgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/database"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
|
||||
"github.com/openimsdk/tools/errs"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// ---- RedPacket ----
|
||||
|
||||
type RedPacketMgo struct {
|
||||
coll *mongo.Collection
|
||||
}
|
||||
|
||||
func NewRedPacketMongo(db *mongo.Database) (database.RedPacket, error) {
|
||||
coll := db.Collection("red_packet")
|
||||
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "biz_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "packet_id", Value: 1}},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "group_id", Value: 1}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RedPacketMgo{coll: coll}, nil
|
||||
}
|
||||
|
||||
func (m *RedPacketMgo) Create(ctx context.Context, rp *model.RedPacket) error {
|
||||
_, err := m.coll.InsertOne(ctx, rp)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *RedPacketMgo) GetByBizID(ctx context.Context, bizID string) (*model.RedPacket, error) {
|
||||
var rp model.RedPacket
|
||||
err := m.coll.FindOne(ctx, bson.M{"biz_id": bizID}).Decode(&rp)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errs.ErrRecordNotFound.WrapMsg("red packet not found", "bizID", bizID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &rp, nil
|
||||
}
|
||||
|
||||
func (m *RedPacketMgo) GetByPacketID(ctx context.Context, packetID string) (*model.RedPacket, error) {
|
||||
var rp model.RedPacket
|
||||
err := m.coll.FindOne(ctx, bson.M{"packet_id": packetID}).Decode(&rp)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errs.ErrRecordNotFound.WrapMsg("red packet not found", "packetID", packetID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &rp, nil
|
||||
}
|
||||
|
||||
func (m *RedPacketMgo) UpdateCreated(ctx context.Context, rp *model.RedPacket) error {
|
||||
updates := bson.M{
|
||||
"chain_type": rp.ChainType,
|
||||
"packet_id": rp.PacketID,
|
||||
"tx_hash": rp.TxHash,
|
||||
"chain_id": rp.ChainID,
|
||||
"contract_address": rp.ContractAddress,
|
||||
"group_id": rp.GroupID,
|
||||
"scope_type": rp.ScopeType,
|
||||
"receiver_user_id": rp.ReceiverUserID,
|
||||
"receiver_user_ids": rp.ReceiverUserIDs,
|
||||
"status": rp.Status,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
res, err := m.coll.UpdateOne(ctx, bson.M{"biz_id": rp.BizID}, bson.M{"$set": updates})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return errs.ErrRecordNotFound.WrapMsg("red packet not found", "bizID", rp.BizID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *RedPacketMgo) UpdateStatus(ctx context.Context, packetID, status string) error {
|
||||
res, err := m.coll.UpdateOne(ctx, bson.M{"packet_id": packetID},
|
||||
bson.M{"$set": bson.M{"status": status, "updated_at": time.Now()}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return errs.ErrRecordNotFound.WrapMsg("red packet not found", "packetID", packetID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *RedPacketMgo) UpdateClaimProgress(ctx context.Context, packetID, claimedAmount, status, claimTxHash string) error {
|
||||
var rp model.RedPacket
|
||||
err := m.coll.FindOne(ctx, bson.M{"packet_id": packetID}).Decode(&rp)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return errs.ErrRecordNotFound.WrapMsg("red packet not found", "packetID", packetID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
totalClaimed := addNumericStrings(rp.ClaimedAmount, claimedAmount)
|
||||
nextShares := rp.ClaimedShares + 1
|
||||
|
||||
// Auto-derive status when the caller does not force one.
|
||||
nextStatus := status
|
||||
if nextStatus == "" {
|
||||
if rp.PacketType == 2 {
|
||||
nextStatus = "COMPLETED"
|
||||
} else if rp.TotalShares > 0 && nextShares >= rp.TotalShares {
|
||||
nextStatus = "COMPLETED"
|
||||
} else {
|
||||
tcBig, tok := new(big.Int).SetString(totalClaimed, 10)
|
||||
taBig, taok := new(big.Int).SetString(rp.TotalAmount, 10)
|
||||
if tok && taok && tcBig.Cmp(taBig) >= 0 {
|
||||
nextStatus = "COMPLETED"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setFields := bson.M{
|
||||
"claimed_amount": totalClaimed,
|
||||
"claimed_shares": nextShares,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if nextStatus != "" {
|
||||
setFields["status"] = nextStatus
|
||||
}
|
||||
|
||||
// The $addToSet + $ne filter makes the whole update idempotent per claimTxHash:
|
||||
// if two code paths (RPC handler and indexer) both attempt to process the same
|
||||
// transaction, only the first UpdateOne will match and the second is a no-op.
|
||||
filter := bson.M{"packet_id": packetID}
|
||||
if claimTxHash != "" {
|
||||
filter["processed_claim_hashes"] = bson.M{"$ne": claimTxHash}
|
||||
}
|
||||
update := bson.M{"$set": setFields}
|
||||
if claimTxHash != "" {
|
||||
update["$addToSet"] = bson.M{"processed_claim_hashes": claimTxHash}
|
||||
}
|
||||
|
||||
_, err = m.coll.UpdateOne(ctx, filter, update)
|
||||
return err
|
||||
}
|
||||
|
||||
func addNumericStrings(current, delta string) string {
|
||||
left := new(big.Int)
|
||||
if current != "" {
|
||||
left.SetString(current, 10)
|
||||
}
|
||||
right := new(big.Int)
|
||||
if delta != "" {
|
||||
right.SetString(delta, 10)
|
||||
}
|
||||
return new(big.Int).Add(left, right).String()
|
||||
}
|
||||
|
||||
// ---- RedPacketClaim ----
|
||||
|
||||
type RedPacketClaimMgo struct {
|
||||
coll *mongo.Collection
|
||||
}
|
||||
|
||||
func NewRedPacketClaimMongo(db *mongo.Database) (database.RedPacketClaim, error) {
|
||||
coll := db.Collection("red_packet_claim")
|
||||
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "claim_tx_hash", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "packet_id", Value: 1}, {Key: "user_id", Value: 1}},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "packet_id", Value: 1}, {Key: "claimer_wallet", Value: 1}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RedPacketClaimMgo{coll: coll}, nil
|
||||
}
|
||||
|
||||
func (m *RedPacketClaimMgo) Save(ctx context.Context, claim *model.RedPacketClaim) error {
|
||||
if claim.UserID != "" {
|
||||
var existing model.RedPacketClaim
|
||||
err := m.coll.FindOne(ctx, bson.M{
|
||||
"packet_id": claim.PacketID,
|
||||
"user_id": claim.UserID,
|
||||
}).Decode(&existing)
|
||||
if err == nil {
|
||||
updates := bson.M{
|
||||
"claimer_wallet": claim.ClaimerWallet,
|
||||
"auth_nonce": claim.AuthNonce,
|
||||
"claim_tx_hash": claim.ClaimTxHash,
|
||||
"claimed_amount": claim.ClaimedAmount,
|
||||
"block_number": claim.BlockNumber,
|
||||
"status": claim.Status,
|
||||
"updated_at": claim.UpdatedAt,
|
||||
}
|
||||
_, err := m.coll.UpdateOne(ctx,
|
||||
bson.M{"packet_id": claim.PacketID, "user_id": claim.UserID},
|
||||
bson.M{"$set": updates})
|
||||
return err
|
||||
}
|
||||
if err != mongo.ErrNoDocuments {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err := m.coll.UpdateOne(ctx,
|
||||
bson.M{"claim_tx_hash": claim.ClaimTxHash},
|
||||
bson.M{"$set": claim},
|
||||
options.Update().SetUpsert(true),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *RedPacketClaimMgo) GetByPacketIDAndClaimer(ctx context.Context, packetID, claimer string) (*model.RedPacketClaim, error) {
|
||||
var claim model.RedPacketClaim
|
||||
err := m.coll.FindOne(ctx,
|
||||
bson.M{"packet_id": packetID, "claimer_wallet": claimer},
|
||||
options.FindOne().SetSort(bson.D{{Key: "created_at", Value: -1}}),
|
||||
).Decode(&claim)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errs.ErrRecordNotFound.WrapMsg("claim not found", "packetID", packetID, "claimer", claimer)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &claim, nil
|
||||
}
|
||||
|
||||
func (m *RedPacketClaimMgo) GetByPacketIDAndUserID(ctx context.Context, packetID, userID string) (*model.RedPacketClaim, error) {
|
||||
var claim model.RedPacketClaim
|
||||
err := m.coll.FindOne(ctx,
|
||||
bson.M{"packet_id": packetID, "user_id": userID},
|
||||
options.FindOne().SetSort(bson.D{{Key: "created_at", Value: -1}}),
|
||||
).Decode(&claim)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errs.ErrRecordNotFound.WrapMsg("claim not found", "packetID", packetID, "userID", userID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &claim, nil
|
||||
}
|
||||
|
||||
func (m *RedPacketClaimMgo) ListByPacketID(ctx context.Context, packetID string) ([]*model.RedPacketClaim, error) {
|
||||
cursor, err := m.coll.Find(ctx,
|
||||
bson.M{"packet_id": packetID},
|
||||
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var claims []*model.RedPacketClaim
|
||||
if err := cursor.All(ctx, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ---- RedPacketClaimAuth ----
|
||||
|
||||
type RedPacketClaimAuthMgo struct {
|
||||
coll *mongo.Collection
|
||||
}
|
||||
|
||||
func NewRedPacketClaimAuthMongo(db *mongo.Database) (database.RedPacketClaimAuth, error) {
|
||||
coll := db.Collection("red_packet_claim_auth")
|
||||
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "auth_nonce", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "packet_id", Value: 1}, {Key: "claimer", Value: 1}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RedPacketClaimAuthMgo{coll: coll}, nil
|
||||
}
|
||||
|
||||
func (m *RedPacketClaimAuthMgo) Create(ctx context.Context, auth *model.RedPacketClaimAuth) error {
|
||||
_, err := m.coll.InsertOne(ctx, auth)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *RedPacketClaimAuthMgo) Get(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error) {
|
||||
var auth model.RedPacketClaimAuth
|
||||
err := m.coll.FindOne(ctx, bson.M{
|
||||
"packet_id": packetID,
|
||||
"claimer": claimer,
|
||||
"used": false,
|
||||
}).Decode(&auth)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errs.ErrRecordNotFound.WrapMsg("claim auth not found", "packetID", packetID, "claimer", claimer)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &auth, nil
|
||||
}
|
||||
|
||||
func (m *RedPacketClaimAuthMgo) MarkUsed(ctx context.Context, authNonce string) error {
|
||||
res, err := m.coll.UpdateOne(ctx,
|
||||
bson.M{"auth_nonce": authNonce},
|
||||
bson.M{"$set": bson.M{"used": true}},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return errs.ErrRecordNotFound.WrapMsg("claim auth not found", "authNonce", authNonce)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- RedPacketRefund ----
|
||||
|
||||
type RedPacketRefundMgo struct {
|
||||
coll *mongo.Collection
|
||||
}
|
||||
|
||||
func NewRedPacketRefundMongo(db *mongo.Database) (database.RedPacketRefund, error) {
|
||||
coll := db.Collection("red_packet_refund")
|
||||
_, err := coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "tx_hash", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RedPacketRefundMgo{coll: coll}, nil
|
||||
}
|
||||
|
||||
func (m *RedPacketRefundMgo) Save(ctx context.Context, refund *model.RedPacketRefund) error {
|
||||
_, err := m.coll.UpdateOne(ctx,
|
||||
bson.M{"tx_hash": refund.TxHash},
|
||||
bson.M{"$setOnInsert": refund},
|
||||
options.Update().SetUpsert(true),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *RedPacketRefundMgo) GetByPacketID(ctx context.Context, packetID string) (*model.RedPacketRefund, error) {
|
||||
var r model.RedPacketRefund
|
||||
err := m.coll.FindOne(ctx, bson.M{"packet_id": packetID}).Decode(&r)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errs.ErrRecordNotFound.WrapMsg("refund not found", "packetID", packetID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ---- WalletBindingChallenge ----
|
||||
|
||||
type WalletBindingChallengeMgo struct {
|
||||
coll *mongo.Collection
|
||||
}
|
||||
|
||||
func NewWalletBindingChallengeMongo(db *mongo.Database) (database.WalletBindingChallenge, error) {
|
||||
coll := db.Collection("wallet_binding_challenge")
|
||||
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "challenge_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "user_id", Value: 1}},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "wallet_address", Value: 1}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WalletBindingChallengeMgo{coll: coll}, nil
|
||||
}
|
||||
|
||||
func (m *WalletBindingChallengeMgo) Create(ctx context.Context, challenge *model.WalletBindingChallenge) error {
|
||||
_, err := m.coll.InsertOne(ctx, challenge)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *WalletBindingChallengeMgo) Get(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error) {
|
||||
var c model.WalletBindingChallenge
|
||||
err := m.coll.FindOne(ctx, bson.M{"challenge_id": challengeID}).Decode(&c)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errs.ErrRecordNotFound.WrapMsg("wallet binding challenge not found", "challengeID", challengeID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (m *WalletBindingChallengeMgo) Update(ctx context.Context, c *model.WalletBindingChallenge) error {
|
||||
updates := bson.M{
|
||||
"status": c.Status,
|
||||
"signature": c.Signature,
|
||||
"verified_at": c.VerifiedAt,
|
||||
"updated_at": c.UpdatedAt,
|
||||
}
|
||||
res, err := m.coll.UpdateOne(ctx, bson.M{"challenge_id": c.ChallengeID}, bson.M{"$set": updates})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return errs.ErrRecordNotFound.WrapMsg("wallet binding challenge not found", "challengeID", c.ChallengeID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- WalletBinding ----
|
||||
|
||||
type WalletBindingMgo struct {
|
||||
coll *mongo.Collection
|
||||
}
|
||||
|
||||
func NewWalletBindingMongo(db *mongo.Database) (database.WalletBinding, error) {
|
||||
coll := db.Collection("wallet_binding")
|
||||
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "chain_type", Value: 1}, {Key: "wallet_address", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "user_id", Value: 1}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WalletBindingMgo{coll: coll}, nil
|
||||
}
|
||||
|
||||
// GetExpiredPending returns red packets that have expired but are still in
|
||||
// "ACTIVE" status (i.e., on-chain creation confirmed, not yet fully claimed or refunded).
|
||||
func (m *RedPacketMgo) GetExpiredPending(ctx context.Context, now int64) ([]*model.RedPacket, error) {
|
||||
cur, err := m.coll.Find(ctx, bson.M{
|
||||
"status": "ACTIVE",
|
||||
"expiry_at": bson.M{"$lt": now, "$gt": 0},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
var out []*model.RedPacket
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *WalletBindingMgo) Upsert(ctx context.Context, b *model.WalletBinding) error {
|
||||
filter := bson.M{
|
||||
"user_id": b.UserID,
|
||||
"chain_type": b.ChainType,
|
||||
"wallet_address": b.WalletAddress,
|
||||
}
|
||||
updates := bson.M{
|
||||
"chain_id": b.ChainID,
|
||||
"status": b.Status,
|
||||
"challenge_id": b.ChallengeID,
|
||||
"verified_at": b.VerifiedAt,
|
||||
"revoked_at": b.RevokedAt,
|
||||
"updated_at": b.UpdatedAt,
|
||||
}
|
||||
setOnInsert := bson.M{
|
||||
"created_at": b.CreatedAt,
|
||||
}
|
||||
_, err := m.coll.UpdateOne(ctx, filter,
|
||||
bson.M{"$set": updates, "$setOnInsert": setOnInsert},
|
||||
options.Update().SetUpsert(true),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *WalletBindingMgo) GetActive(ctx context.Context, userID, chainType, walletAddress string) (*model.WalletBinding, error) {
|
||||
var b model.WalletBinding
|
||||
err := m.coll.FindOne(ctx, bson.M{
|
||||
"user_id": userID,
|
||||
"chain_type": chainType,
|
||||
"wallet_address": walletAddress,
|
||||
"status": "ACTIVE",
|
||||
}).Decode(&b)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errs.ErrRecordNotFound.WrapMsg("active wallet binding not found", "userID", userID, "chainType", chainType, "walletAddress", walletAddress)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// ---- AdminAuditLog ----
|
||||
|
||||
type AdminAuditLogMgo struct {
|
||||
coll *mongo.Collection
|
||||
}
|
||||
|
||||
func NewAdminAuditLogMongo(db *mongo.Database) (database.AdminAuditLog, error) {
|
||||
coll := db.Collection("admin_audit_log")
|
||||
_, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
|
||||
{Keys: bson.D{{Key: "operator_id", Value: 1}}},
|
||||
{Keys: bson.D{{Key: "created_at", Value: -1}}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminAuditLogMgo{coll: coll}, nil
|
||||
}
|
||||
|
||||
func (m *AdminAuditLogMgo) Create(ctx context.Context, entry *model.AdminAuditLog) error {
|
||||
_, err := m.coll.InsertOne(ctx, entry)
|
||||
return err
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
|
||||
)
|
||||
|
||||
type RedPacket interface {
|
||||
Create(ctx context.Context, rp *model.RedPacket) error
|
||||
GetByBizID(ctx context.Context, bizID string) (*model.RedPacket, error)
|
||||
GetByPacketID(ctx context.Context, packetID string) (*model.RedPacket, error)
|
||||
UpdateCreated(ctx context.Context, rp *model.RedPacket) error
|
||||
UpdateStatus(ctx context.Context, packetID, status string) error
|
||||
// UpdateClaimProgress atomically increments the claim counter for packetID.
|
||||
// claimTxHash is used as an idempotency key so that re-processing the same
|
||||
// on-chain transaction never double-counts. When status is empty the method
|
||||
// auto-derives the correct status (COMPLETED or ACTIVE).
|
||||
UpdateClaimProgress(ctx context.Context, packetID, claimedAmount, status, claimTxHash string) error
|
||||
// GetExpiredPending returns ACTIVE packets whose expiry_at < now (unix seconds).
|
||||
GetExpiredPending(ctx context.Context, now int64) ([]*model.RedPacket, error)
|
||||
}
|
||||
|
||||
type RedPacketClaim interface {
|
||||
Save(ctx context.Context, claim *model.RedPacketClaim) error
|
||||
GetByPacketIDAndClaimer(ctx context.Context, packetID, claimer string) (*model.RedPacketClaim, error)
|
||||
GetByPacketIDAndUserID(ctx context.Context, packetID, userID string) (*model.RedPacketClaim, error)
|
||||
ListByPacketID(ctx context.Context, packetID string) ([]*model.RedPacketClaim, error)
|
||||
}
|
||||
|
||||
type RedPacketClaimAuth interface {
|
||||
Create(ctx context.Context, auth *model.RedPacketClaimAuth) error
|
||||
Get(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error)
|
||||
MarkUsed(ctx context.Context, authNonce string) error
|
||||
}
|
||||
|
||||
type RedPacketRefund interface {
|
||||
Save(ctx context.Context, refund *model.RedPacketRefund) error
|
||||
GetByPacketID(ctx context.Context, packetID string) (*model.RedPacketRefund, error)
|
||||
}
|
||||
|
||||
type AdminAuditLog interface {
|
||||
Create(ctx context.Context, log *model.AdminAuditLog) error
|
||||
}
|
||||
|
||||
type WalletBindingChallenge interface {
|
||||
Create(ctx context.Context, challenge *model.WalletBindingChallenge) error
|
||||
Get(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error)
|
||||
Update(ctx context.Context, challenge *model.WalletBindingChallenge) error
|
||||
}
|
||||
|
||||
type WalletBinding interface {
|
||||
Upsert(ctx context.Context, binding *model.WalletBinding) error
|
||||
GetActive(ctx context.Context, userID, chainType, walletAddress string) (*model.WalletBinding, error)
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type RedPacket struct {
|
||||
BizID string `bson:"biz_id"`
|
||||
ChainType string `bson:"chain_type"`
|
||||
PacketID string `bson:"packet_id"`
|
||||
ChainID int64 `bson:"chain_id"`
|
||||
ContractAddress string `bson:"contract_address"`
|
||||
CreatorUserID string `bson:"creator_user_id"`
|
||||
CreatorWallet string `bson:"creator_wallet"`
|
||||
GroupID string `bson:"group_id"`
|
||||
ScopeType string `bson:"scope_type"`
|
||||
ReceiverUserID string `bson:"receiver_user_id"`
|
||||
ReceiverUserIDs []string `bson:"receiver_user_ids"`
|
||||
PacketType int32 `bson:"packet_type"`
|
||||
Token string `bson:"token"`
|
||||
TotalAmount string `bson:"total_amount"`
|
||||
TotalShares int32 `bson:"total_shares"`
|
||||
ClaimedAmount string `bson:"claimed_amount"`
|
||||
ClaimedShares int32 `bson:"claimed_shares"`
|
||||
ProcessedClaimHashes []string `bson:"processed_claim_hashes"`
|
||||
ExpiryAt int64 `bson:"expiry_at"`
|
||||
TxHash string `bson:"tx_hash"`
|
||||
Status string `bson:"status"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
type RedPacketClaim struct {
|
||||
PacketID string `bson:"packet_id"`
|
||||
UserID string `bson:"user_id"`
|
||||
ClaimerWallet string `bson:"claimer_wallet"`
|
||||
AuthNonce string `bson:"auth_nonce"`
|
||||
ClaimTxHash string `bson:"claim_tx_hash"`
|
||||
ClaimedAmount string `bson:"claimed_amount"`
|
||||
BlockNumber uint64 `bson:"block_number"`
|
||||
Status string `bson:"status"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
type RedPacketClaimAuth struct {
|
||||
PacketID string `bson:"packet_id"`
|
||||
Claimer string `bson:"claimer"`
|
||||
AuthNonce string `bson:"auth_nonce"`
|
||||
RandomSeed string `bson:"random_seed"`
|
||||
Deadline int64 `bson:"deadline"`
|
||||
Signature string `bson:"signature"`
|
||||
Used bool `bson:"used"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
}
|
||||
|
||||
type RedPacketRefund struct {
|
||||
PacketID string `bson:"packet_id"`
|
||||
RefundTo string `bson:"refund_to"`
|
||||
TxHash string `bson:"tx_hash"`
|
||||
Amount string `bson:"amount"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
}
|
||||
|
||||
type WalletBindingChallenge struct {
|
||||
ChallengeID string `bson:"challenge_id"`
|
||||
UserID string `bson:"user_id"`
|
||||
ChainType string `bson:"chain_type"`
|
||||
ChainID int64 `bson:"chain_id"`
|
||||
WalletAddress string `bson:"wallet_address"`
|
||||
Nonce string `bson:"nonce"`
|
||||
Message string `bson:"message"`
|
||||
Protocol string `bson:"protocol"`
|
||||
SignMethod string `bson:"sign_method"`
|
||||
Status string `bson:"status"`
|
||||
Signature string `bson:"signature"`
|
||||
ExpiresAt time.Time `bson:"expires_at"`
|
||||
VerifiedAt *time.Time `bson:"verified_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
type WalletBinding struct {
|
||||
UserID string `bson:"user_id"`
|
||||
ChainType string `bson:"chain_type"`
|
||||
ChainID int64 `bson:"chain_id"`
|
||||
WalletAddress string `bson:"wallet_address"`
|
||||
Status string `bson:"status"`
|
||||
ChallengeID string `bson:"challenge_id"`
|
||||
VerifiedAt time.Time `bson:"verified_at"`
|
||||
RevokedAt *time.Time `bson:"revoked_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
// AdminAuditLog records each admin operation for accountability.
|
||||
type AdminAuditLog struct {
|
||||
ID primitive.ObjectID `bson:"_id"`
|
||||
OperatorID string `bson:"operator_id"`
|
||||
Action string `bson:"action"`
|
||||
Params string `bson:"params"` // JSON-encoded request
|
||||
Result string `bson:"result"` // "success" | "failed"
|
||||
ErrMsg string `bson:"err_msg"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package rpcli
|
||||
|
||||
import (
|
||||
pbredpacket "github.com/openimsdk/protocol/redpacket"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func NewRedPacketClient(cc grpc.ClientConnInterface) *RedPacketClient {
|
||||
return &RedPacketClient{pbredpacket.NewRedPacketClient(cc)}
|
||||
}
|
||||
|
||||
type RedPacketClient struct {
|
||||
pbredpacket.RedPacketClient
|
||||
}
|
||||
Loading…
Reference in new issue