redpacket

This commit is contained in:
hawklin2017 2026-04-30 18:34:34 +08:00
parent 6962845799
commit d2333cd3f8
14 changed files with 547 additions and 75 deletions

View File

@ -88,6 +88,34 @@ func (h *RedPacketApi) ClaimResult(ctx *gin.Context) {
apiresp.GinSuccess(ctx, resp) 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) { func (h *RedPacketApi) IssueWalletBindChallenge(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbredpacket.IssueWalletBindChallengeReq](ctx) req, err := a2r.ParseRequestNotCheck[pbredpacket.IssueWalletBindChallengeReq](ctx)
if err != nil { if err != nil {

View File

@ -377,6 +377,8 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
redpacketGroup.POST("/detail", rp.GetDetail) redpacketGroup.POST("/detail", rp.GetDetail)
redpacketGroup.POST("/issue_claim_sign", rp.IssueClaimSign) redpacketGroup.POST("/issue_claim_sign", rp.IssueClaimSign)
redpacketGroup.POST("/claim_result", rp.ClaimResult) redpacketGroup.POST("/claim_result", rp.ClaimResult)
redpacketGroup.POST("/request_refund", rp.RequestRefund)
redpacketGroup.POST("/get_refund", rp.GetRefund)
redpacketGroup.POST("/wallet_bind/challenge", rp.IssueWalletBindChallenge) redpacketGroup.POST("/wallet_bind/challenge", rp.IssueWalletBindChallenge)
redpacketGroup.POST("/wallet_bind/confirm", rp.ConfirmWalletBind) redpacketGroup.POST("/wallet_bind/confirm", rp.ConfirmWalletBind)
redpacketGroup.POST("/wallet_bind/detail", rp.GetWalletBinding) redpacketGroup.POST("/wallet_bind/detail", rp.GetWalletBinding)

View File

@ -2,16 +2,58 @@ package redpacket
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"math/big" "math/big"
"time"
"github.com/ethereum/go-ethereum/common" "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" pbredpacket "github.com/openimsdk/protocol/redpacket"
"github.com/openimsdk/tools/errs" "github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log" "github.com/openimsdk/tools/log"
"github.com/openimsdk/tools/mcontext"
"go.mongodb.org/mongo-driver/bson/primitive"
) )
func (s *redPacketServer) SetSigner(ctx context.Context, req *pbredpacket.SetSignerReq) (*pbredpacket.SetSignerResp, error) { // 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 == "" { if req.SignerAddress == "" {
return nil, errs.ErrArgs.WrapMsg("signer_address is required") return nil, errs.ErrArgs.WrapMsg("signer_address is required")
} }
@ -28,7 +70,11 @@ func (s *redPacketServer) SetSigner(ctx context.Context, req *pbredpacket.SetSig
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured") return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
} }
func (s *redPacketServer) SetToken(ctx context.Context, req *pbredpacket.SetTokenReq) (*pbredpacket.SetTokenResp, error) { 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 == "" { if req.TokenAddress == "" {
return nil, errs.ErrArgs.WrapMsg("token_address is required") return nil, errs.ErrArgs.WrapMsg("token_address is required")
} }
@ -55,7 +101,11 @@ func (s *redPacketServer) SetToken(ctx context.Context, req *pbredpacket.SetToke
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured") return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
} }
func (s *redPacketServer) SetExpiry(ctx context.Context, req *pbredpacket.SetExpiryReq) (*pbredpacket.SetExpiryResp, error) { 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 { if req.ExpirySeconds <= 0 {
return nil, errs.ErrArgs.WrapMsg("expiry_seconds must be positive") return nil, errs.ErrArgs.WrapMsg("expiry_seconds must be positive")
} }
@ -72,7 +122,11 @@ func (s *redPacketServer) SetExpiry(ctx context.Context, req *pbredpacket.SetExp
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured") return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
} }
func (s *redPacketServer) SetAllowAllTokens(ctx context.Context, req *pbredpacket.SetAllowAllTokensReq) (*pbredpacket.SetAllowAllTokensResp, error) { 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 { if s.chainClient != nil {
log.ZInfo(ctx, "redpacket admin setAllowAllTokens (eth mock)", "allowAll", req.AllowAll) log.ZInfo(ctx, "redpacket admin setAllowAllTokens (eth mock)", "allowAll", req.AllowAll)
return &pbredpacket.SetAllowAllTokensResp{Message: "allow all tokens setting updated"}, nil return &pbredpacket.SetAllowAllTokensResp{Message: "allow all tokens setting updated"}, nil
@ -86,7 +140,11 @@ func (s *redPacketServer) SetAllowAllTokens(ctx context.Context, req *pbredpacke
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured") return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
} }
func (s *redPacketServer) SetNativeTokenEnabled(ctx context.Context, req *pbredpacket.SetNativeTokenEnabledReq) (*pbredpacket.SetNativeTokenEnabledResp, error) { 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 { if s.chainClient != nil {
log.ZInfo(ctx, "redpacket admin setNativeTokenEnabled (eth mock)", "enabled", req.Enabled) log.ZInfo(ctx, "redpacket admin setNativeTokenEnabled (eth mock)", "enabled", req.Enabled)
return &pbredpacket.SetNativeTokenEnabledResp{Message: "native token setting updated"}, nil return &pbredpacket.SetNativeTokenEnabledResp{Message: "native token setting updated"}, nil
@ -100,7 +158,11 @@ func (s *redPacketServer) SetNativeTokenEnabled(ctx context.Context, req *pbredp
return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured") return nil, errs.ErrInternalServer.WrapMsg("no blockchain client configured")
} }
func (s *redPacketServer) ParseTxEvents(ctx context.Context, req *pbredpacket.ParseTxEventsReq) (*pbredpacket.ParseTxEventsResp, error) { 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 == "" { if req.TxHash == "" {
return nil, errs.ErrArgs.WrapMsg("tx_hash is required") return nil, errs.ErrArgs.WrapMsg("tx_hash is required")
} }

View File

@ -11,6 +11,7 @@ import (
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/ethclient"
) )
@ -141,6 +142,57 @@ func (c *ChainClient) ContractABI() abi.ABI {
return c.contractABI 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() { func (c *ChainClient) Close() {
if c.client != nil { if c.client != nil {
c.client.Close() c.client.Close()

View File

@ -41,7 +41,6 @@ func (i *Indexer) Start(ctx context.Context) {
go func() { go func() {
ticker := time.NewTicker(i.pollInterval) ticker := time.NewTicker(i.pollInterval)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -54,6 +53,40 @@ func (i *Indexer) Start(ctx context.Context) {
} }
} }
}() }()
// 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() {
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 { func (i *Indexer) poll(ctx context.Context) error {

View File

@ -39,7 +39,6 @@ func (t *TronIndexer) Start(ctx context.Context) {
go func() { go func() {
ticker := time.NewTicker(t.pollInterval) ticker := time.NewTicker(t.pollInterval)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -53,6 +52,37 @@ func (t *TronIndexer) Start(ctx context.Context) {
} }
} }
}() }()
go func() {
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 { func (t *TronIndexer) poll(ctx context.Context) error {
@ -131,84 +161,86 @@ func (t *TronIndexer) scanBlock(ctx context.Context, blockNum int64) error {
return nil 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 { func (t *TronIndexer) processTransaction(ctx context.Context, txID string) error {
var txInfo map[string]interface{} events, err := t.client.ParseTransactionReceipt(ctx, txID)
err := postJSON(ctx, t.client.fullNodeURL+"/wallet/gettransactioninfobyid", map[string]interface{}{
"value": txID,
}, &txInfo)
if err != nil { if err != nil {
return err return fmt.Errorf("parse tron tx receipt failed: %w", err)
} }
contractAddress := t.client.contractBase58 for _, event := range events {
if logs, ok := txInfo["log"].([]interface{}); ok && len(logs) > 0 { log.ZDebug(ctx, "redpacket tron event detected", "event", event.Name, "txID", txID)
for _, logEntry := range logs { switch event.Name {
if logMap, ok := logEntry.(map[string]interface{}); ok { case "PacketCreated":
if address, ok := logMap["address"].(string); ok && address == contractAddress { if err := t.handleTronPacketCreated(ctx, event, txID); err != nil {
eventType := t.parseTronEvent(logMap) log.ZWarn(ctx, "redpacket tron handlePacketCreated failed", err, "txID", txID)
log.ZDebug(ctx, "redpacket tron event detected", "event", eventType, "txID", txID) }
case "PacketClaimed":
switch eventType { if err := t.handleTronPacketClaimed(ctx, event, txID); err != nil {
case "PacketCreated": log.ZWarn(ctx, "redpacket tron handlePacketClaimed failed", err, "txID", txID)
t.handleTronPacketCreated(ctx, logMap, txID) }
case "PacketClaimed": case "PacketRefunded":
t.handleTronPacketClaimed(ctx, logMap, txID) if err := t.handleTronPacketRefunded(ctx, event, txID); err != nil {
case "PacketRefunded": log.ZWarn(ctx, "redpacket tron handlePacketRefunded failed", err, "txID", txID)
t.handleTronPacketRefunded(ctx, logMap, txID)
}
}
} }
} }
} }
return nil return nil
} }
func (t *TronIndexer) parseTronEvent(logEntry map[string]interface{}) string { func (t *TronIndexer) handleTronPacketCreated(ctx context.Context, event *ParsedEvent, txID string) error {
if topics, ok := logEntry["topics"].([]interface{}); ok && len(topics) > 0 { packetID := GetPacketIDFromEvent(event)
if topic0, ok := topics[0].(string); ok { creator := GetAddressFromEvent(event, "creator")
switch topic0 { log.ZInfo(ctx, "tron PacketCreated event", "packetID", packetID.String(), "creator", creator.Hex(), "txID", txID)
case "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0": return nil
return "Transfer"
default:
return "UnknownEvent"
}
}
}
return "UnknownEvent"
} }
func (t *TronIndexer) handleTronPacketCreated(ctx context.Context, logData map[string]interface{}, txID string) { func (t *TronIndexer) handleTronPacketClaimed(ctx context.Context, event *ParsedEvent, txID string) error {
log.ZInfo(ctx, "tron PacketCreated event", "txID", txID) packetID := GetPacketIDFromEvent(event)
} claimer := GetAddressFromEvent(event, "claimer")
amount := GetAmountFromEvent(event)
authNonce := GetUintFromEvent(event, "authNonce")
func (t *TronIndexer) handleTronPacketClaimed(ctx context.Context, logData map[string]interface{}, txID string) { log.ZInfo(ctx, "tron PacketClaimed event", "packetID", packetID.String(), "claimer", claimer.Hex(), "amount", amount.String(), "txID", txID)
log.ZInfo(ctx, "tron PacketClaimed event", "txID", txID)
claimer := "unknown"
amount := "0"
if topics, ok := logData["topics"].([]interface{}); ok && len(topics) > 1 {
if claimerTopic, ok := topics[1].(string); ok {
claimer = claimerTopic
}
}
claim := &model.RedPacketClaim{ claim := &model.RedPacketClaim{
PacketID: "tron-packet-" + txID[:8], PacketID: packetID.String(),
ClaimerWallet: claimer, ClaimerWallet: claimer.Hex(),
AuthNonce: authNonce.String(),
ClaimTxHash: txID, ClaimTxHash: txID,
ClaimedAmount: amount, ClaimedAmount: amount.String(),
BlockNumber: event.BlockNumber,
Status: "CONFIRMED", Status: "CONFIRMED",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
} }
if err := t.db.SaveClaim(ctx, claim); err != nil { if err := t.db.SaveClaim(ctx, claim); err != nil {
log.ZWarn(ctx, "redpacket tron save claim failed", err) return err
} }
if err := t.db.MarkClaimAuthUsed(ctx, authNonce.String()); err != nil {
return err
}
return t.db.UpdateRedPacketClaimProgress(ctx, packetID.String(), amount.String(), "")
} }
func (t *TronIndexer) handleTronPacketRefunded(ctx context.Context, logData map[string]interface{}, txID string) { func (t *TronIndexer) handleTronPacketRefunded(ctx context.Context, event *ParsedEvent, txID string) error {
log.ZInfo(ctx, "tron PacketRefunded event", "txID", txID) 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 { func (t *TronIndexer) GetLastProcessedBlock() int64 {

View File

@ -66,8 +66,12 @@ func Start(ctx context.Context, conf *Config, registry discovery.SvcDiscoveryReg
if err != nil { if err != nil {
return err return err
} }
auditLogDB, err := mgo.NewAdminAuditLogMongo(db)
if err != nil {
return err
}
repo := controller.NewRedPacketDatabase(rpDB, claimDB, claimAuthDB, refundDB, challengeDB, bindingDB) repo := controller.NewRedPacketDatabase(rpDB, claimDB, claimAuthDB, refundDB, challengeDB, bindingDB, auditLogDB)
chainClient, err := chain.NewClient( chainClient, err := chain.NewClient(
conf.RpcConfig.Chain.RPCURL, conf.RpcConfig.Chain.RPCURL,

View File

@ -895,6 +895,73 @@ func redPacketModelToProto(rp *model.RedPacket) *pbredpacket.RedPacketRecord {
} }
} }
// 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 { func claimsModelToProto(claims []*model.RedPacketClaim) []*pbredpacket.RedPacketClaimRecord {
out := make([]*pbredpacket.RedPacketClaimRecord, 0, len(claims)) out := make([]*pbredpacket.RedPacketClaimRecord, 0, len(claims))
for _, c := range claims { for _, c := range claims {

View File

@ -1,9 +1,12 @@
package redpacket package redpacket
import ( import (
"bytes"
"context" "context"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"math/big"
"strings" "strings"
"time" "time"
@ -99,20 +102,22 @@ func (s *redPacketServer) ConfirmWalletBind(ctx context.Context, req *pbredpacke
return nil, errs.ErrArgs.WrapMsg("challenge is expired") return nil, errs.ErrArgs.WrapMsg("challenge is expired")
} }
var verifyErr error
switch challenge.ChainType { switch challenge.ChainType {
case "EVM": case "EVM":
if err := verifyEVMBindSignature(challenge.Message, challenge.WalletAddress, req.Signature); err != nil { verifyErr = verifyEVMBindSignature(challenge.Message, challenge.WalletAddress, req.Signature)
challenge.Status = "FAILED"
challenge.Signature = req.Signature
challenge.UpdatedAt = time.Now()
_ = s.db.UpdateWalletBindingChallenge(ctx, challenge)
return nil, err
}
case "TRON": case "TRON":
return nil, errs.ErrInternalServer.WrapMsg("TRON wallet binding verification is not implemented yet") verifyErr = verifyTRONBindSignature(challenge.Message, challenge.WalletAddress, req.Signature)
default: default:
return nil, errs.ErrArgs.WrapMsg("unsupported chain_type: " + challenge.ChainType) 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() now := time.Now().UTC()
challenge.Status = "VERIFIED" challenge.Status = "VERIFIED"
@ -249,3 +254,96 @@ func verifyEVMBindSignature(message, walletAddress, signature string) error {
func personalSignMessage(message string) string { func personalSignMessage(message string) string {
return fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) 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
}

View File

@ -17,6 +17,7 @@ type RedPacketDatabase interface {
UpdateRedPacketCreated(ctx context.Context, rp *model.RedPacket) error UpdateRedPacketCreated(ctx context.Context, rp *model.RedPacket) error
UpdateRedPacketStatus(ctx context.Context, packetID, status string) error UpdateRedPacketStatus(ctx context.Context, packetID, status string) error
UpdateRedPacketClaimProgress(ctx context.Context, packetID, claimedAmount, status string) error UpdateRedPacketClaimProgress(ctx context.Context, packetID, claimedAmount, status string) error
GetExpiredPendingPackets(ctx context.Context, nowUnix int64) ([]*model.RedPacket, error)
CreateClaimAuth(ctx context.Context, auth *model.RedPacketClaimAuth) error CreateClaimAuth(ctx context.Context, auth *model.RedPacketClaimAuth) error
GetClaimAuth(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error) GetClaimAuth(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error)
@ -28,6 +29,7 @@ type RedPacketDatabase interface {
GetClaimsByPacketID(ctx context.Context, packetID string) ([]*model.RedPacketClaim, error) GetClaimsByPacketID(ctx context.Context, packetID string) ([]*model.RedPacketClaim, error)
SaveRefund(ctx context.Context, refund *model.RedPacketRefund) 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 CreateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error
GetWalletBindingChallenge(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error) GetWalletBindingChallenge(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error)
@ -35,6 +37,8 @@ type RedPacketDatabase interface {
UpsertWalletBinding(ctx context.Context, binding *model.WalletBinding) error UpsertWalletBinding(ctx context.Context, binding *model.WalletBinding) error
GetActiveWalletBinding(ctx context.Context, userID, chainType, walletAddress string) (*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 { type redPacketDatabase struct {
@ -44,6 +48,7 @@ type redPacketDatabase struct {
refund database.RedPacketRefund refund database.RedPacketRefund
challenge database.WalletBindingChallenge challenge database.WalletBindingChallenge
binding database.WalletBinding binding database.WalletBinding
auditLog database.AdminAuditLog
} }
func NewRedPacketDatabase( func NewRedPacketDatabase(
@ -53,6 +58,7 @@ func NewRedPacketDatabase(
refund database.RedPacketRefund, refund database.RedPacketRefund,
challenge database.WalletBindingChallenge, challenge database.WalletBindingChallenge,
binding database.WalletBinding, binding database.WalletBinding,
auditLog database.AdminAuditLog,
) RedPacketDatabase { ) RedPacketDatabase {
return &redPacketDatabase{ return &redPacketDatabase{
rp: rp, rp: rp,
@ -61,6 +67,7 @@ func NewRedPacketDatabase(
refund: refund, refund: refund,
challenge: challenge, challenge: challenge,
binding: binding, binding: binding,
auditLog: auditLog,
} }
} }
@ -120,6 +127,18 @@ func (d *redPacketDatabase) SaveRefund(ctx context.Context, refund *model.RedPac
return d.refund.Save(ctx, refund) 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 { func (d *redPacketDatabase) CreateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error {
return d.challenge.Create(ctx, challenge) return d.challenge.Create(ctx, challenge)
} }

View File

@ -331,6 +331,18 @@ func (m *RedPacketRefundMgo) Save(ctx context.Context, refund *model.RedPacketRe
return err 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 ---- // ---- WalletBindingChallenge ----
type WalletBindingChallengeMgo struct { type WalletBindingChallengeMgo struct {
@ -414,6 +426,24 @@ func NewWalletBindingMongo(db *mongo.Database) (database.WalletBinding, error) {
return &WalletBindingMgo{coll: coll}, nil return &WalletBindingMgo{coll: coll}, nil
} }
// GetExpiredPending returns red packets that have expired but are still in
// "CREATED" status (i.e., not yet refunded or fully claimed).
func (m *RedPacketMgo) GetExpiredPending(ctx context.Context, now int64) ([]*model.RedPacket, error) {
cur, err := m.coll.Find(ctx, bson.M{
"status": "CREATED",
"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 { func (m *WalletBindingMgo) Upsert(ctx context.Context, b *model.WalletBinding) error {
filter := bson.M{ filter := bson.M{
"user_id": b.UserID, "user_id": b.UserID,
@ -454,3 +484,26 @@ func (m *WalletBindingMgo) GetActive(ctx context.Context, userID, chainType, wal
} }
return &b, nil 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
}

View File

@ -13,6 +13,8 @@ type RedPacket interface {
UpdateCreated(ctx context.Context, rp *model.RedPacket) error UpdateCreated(ctx context.Context, rp *model.RedPacket) error
UpdateStatus(ctx context.Context, packetID, status string) error UpdateStatus(ctx context.Context, packetID, status string) error
UpdateClaimProgress(ctx context.Context, packetID, claimedAmount, status string) error UpdateClaimProgress(ctx context.Context, packetID, claimedAmount, status string) error
// GetExpiredPending returns CREATED packets whose expiry_at < now (unix seconds).
GetExpiredPending(ctx context.Context, now int64) ([]*model.RedPacket, error)
} }
type RedPacketClaim interface { type RedPacketClaim interface {
@ -30,6 +32,11 @@ type RedPacketClaimAuth interface {
type RedPacketRefund interface { type RedPacketRefund interface {
Save(ctx context.Context, refund *model.RedPacketRefund) error 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 { type WalletBindingChallenge interface {

View File

@ -1,6 +1,10 @@
package model package model
import "time" import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type RedPacket struct { type RedPacket struct {
BizID string `bson:"biz_id"` BizID string `bson:"biz_id"`
@ -89,3 +93,14 @@ type WalletBinding struct {
CreatedAt time.Time `bson:"created_at"` CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_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"`
}

@ -1 +1 @@
Subproject commit 34a58a77d26a3c133a4be9ce00affdca8b158ba4 Subproject commit c69f02cf664231e963501889263d4c9963dc3fca