pull/3727/head
hawklin2017 1 month ago
parent a2272cab06
commit 9661adcb65

@ -0,0 +1,98 @@
# RedPacket Backend Service
A Web3 Red Packet service supporting Ethereum and TRON, following the design documents:
- `backend-api.md` - API specifications
- `redpacket-web3-integration-design.md` - Architecture and flows
- `red-packet-go-backend-eth-tron.md` - Blockchain integration details
## Features
- ✅ Create red packet orders (`/api/redpacket/create-order`)
- ✅ Created callback for on-chain transaction results
- ✅ Red packet detail query with claim history
- ✅ Claim signature issuance (`/api/redpacket/claim-sign`)
- ✅ Claim result reporting
- ✅ SQLite/MySQL support
- ✅ Blockchain signature logic ready for ETH/TRON
- ✅ Admin configuration endpoints
## Quick Start
```bash
cd cmd/openim-rpc/openim-rpc-redpacket
# 1. Configure (optional)
cp config/config.yaml config/config.yaml.bak
# Edit config/config.yaml with your blockchain settings
# 2. Build and run
go run .
# Or build binary
go build -o redpacket .
./redpacket
```
Service will start on `http://localhost:8080`
## Test the API
```bash
# Health check
curl http://localhost:8080/health
# Create red packet
curl -X POST http://localhost:8080/api/redpacket/create-order \
-H "Content-Type: application/json" \
-d '{
"creator_user_id": "u1001",
"creator_wallet": "0x1111111111111111111111111111111111111111",
"packet_type": 1,
"total_amount": "1000000000000000000",
"total_shares": 10
}'
```
## Project Structure
```
.
├── config/ # Configuration
├── internal/
│ ├── handler/ # HTTP handlers (Gin)
│ ├── model/ # Database models (GORM)
│ ├── repository/ # Data access layer
│ ├── service/ # Business logic
│ └── chain/ # Blockchain integration (to be expanded)
├── pkg/resp/ # Response helpers
├── router/ # Route definitions
├── main.go
├── go.mod
└── README.md
```
## Next Steps (from design docs)
1. **Full Blockchain Integration**
- Implement `ChainClient` for ETH and TRON
- Add event indexer for `PacketCreated`, `PacketClaimed`, `PacketRefunded`
- Implement proper signature generation using `getSignMessage`
2. **Advanced Features**
- Admin configuration APIs (`setSigner`, `setToken`, etc.)
- Refund logic
- Rate limiting and authentication
- Monitoring and metrics
3. **Production**
- Add proper authentication middleware
- Configure production database
- Set up monitoring and logging
- Deploy with Docker/K8s
See the three design documents for detailed specifications.
## API Documentation
See `backend-api.md` for complete API reference with examples.

@ -0,0 +1,506 @@
# RedPacket 后端接口说明
本文档基于当前后端实现整理,覆盖用户接口与管理员接口,并提供请求/响应示例。
## 基础信息
- Base URL本地默认`http://127.0.0.1:8080`
- 统一响应格式:
```json
{
"code": 0,
"message": "ok",
"data": {}
}
```
- 错误响应格式:
```json
{
"code": 400,
"message": "invalid request body: ..."
}
```
## 健康检查
### GET `/health`
用于服务存活探测。
#### 响应示例
```json
{
"status": "ok"
}
```
---
## 用户侧接口
## 1) 创建业务订单
### POST `/api/redpacket/create-order`
链上发交易前先创建业务订单,返回 `biz_id`
#### 请求体
```json
{
"creator_user_id": "u1001",
"creator_wallet": "0x1111111111111111111111111111111111111111",
"packet_type": 1,
"token": "0x2222222222222222222222222222222222222222",
"total_amount": "1000000000000000000",
"total_shares": 10,
"expiry_at": 0
}
```
#### 字段说明
- `packet_type`: `0` 固定红包,`1` 拼手气红包,`2` 转账红包
- `total_amount`: 链上最小单位的十进制字符串
- `expiry_at`: Unix 秒时间戳,`0` 表示使用合约默认过期时间
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"biz_id": "f8a0f87e-d9cb-4d4a-8350-7bd43ab2e9a4"
}
}
```
#### 失败响应示例
```json
{
"code": 400,
"message": "invalid token address"
}
```
---
## 2) 创建结果回写
### POST `/api/redpacket/created-callback`
前端在链上创建交易确认后,回写 `tx_hash``packet_id`
#### 请求体
```json
{
"biz_id": "f8a0f87e-d9cb-4d4a-8350-7bd43ab2e9a4",
"tx_hash": "0xabc123...",
"packet_id": "10001"
}
```
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"ok": true
}
}
```
#### 失败响应示例
```json
{
"code": 400,
"message": "biz_id is required"
}
```
---
## 3) 红包详情
### GET `/api/redpacket/detail?packet_id={packetId}`
查询红包业务记录与领取记录。
#### 请求示例
```bash
curl "http://127.0.0.1:8080/api/redpacket/detail?packet_id=10001"
```
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"biz_record": {
"id": 1,
"biz_id": "f8a0f87e-d9cb-4d4a-8350-7bd43ab2e9a4",
"packet_id": "10001",
"chain_id": 1,
"contract_address": "0xA1f42567559aBA5Ff0aac84cdE1AaF1F9DbB888F",
"creator_user_id": "u1001",
"creator_wallet": "0x1111111111111111111111111111111111111111",
"packet_type": 1,
"token": "0x2222222222222222222222222222222222222222",
"total_amount": "1000000000000000000",
"total_shares": 10,
"expiry_at": 0,
"tx_hash": "0xabc123...",
"status": "ACTIVE",
"created_at": "2026-04-24T07:00:00Z",
"updated_at": "2026-04-24T07:01:00Z"
},
"claims": [
{
"id": 10,
"packet_id": "10001",
"claimer_wallet": "0x3333333333333333333333333333333333333333",
"auth_nonce": "328840239847239847",
"claim_tx_hash": "0xdef456...",
"claimed_amount": "123456789",
"block_number": 1234567,
"status": "CONFIRMED",
"created_at": "2026-04-24T07:10:00Z",
"updated_at": "2026-04-24T07:10:00Z"
}
]
}
}
```
#### 失败响应示例
```json
{
"code": 404,
"message": "packet not found: 10001"
}
```
---
## 4) 申请领取签名
### POST `/api/redpacket/claim-sign`
先做业务鉴权,再发放 `claim(...)` 所需签名参数。
#### 请求体
```json
{
"packet_id": "10001",
"claimer": "0x3333333333333333333333333333333333333333",
"user_id": "u2002",
"random_seed": "0"
}
```
> `random_seed` 可选;传 `0` 或空时后端自动生成。
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"auth_nonce": "328840239847239847",
"deadline": 1777012345,
"signature": "0x7b1e...a2",
"random_seed": "8888812345"
}
}
```
#### 常见失败响应
无资格领取:
```json
{
"code": 403,
"message": "already claimed"
}
```
签名服务异常:
```json
{
"code": 500,
"message": "failed to issue claim signature: getSignMessage: ..."
}
```
---
## 5) 领取结果回写(可选)
### POST `/api/redpacket/claim-result`
前端在领取交易提交后可调用该接口预写记录。最终状态仍以链监听indexer为准。
#### 请求体
```json
{
"packet_id": "10001",
"claimer_wallet": "0x3333333333333333333333333333333333333333",
"tx_hash": "0xdef456...",
"auth_nonce": "328840239847239847"
}
```
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"ok": true
}
}
```
#### 失败响应示例
```json
{
"code": 400,
"message": "packet_id and tx_hash are required"
}
```
---
## 管理员接口(建议加鉴权)
以下接口属于管理员写链操作,依赖后端配置的 `config_admin_private_key`
## 6) 设置 signer
### POST `/admin/redpacket/set-signer`
#### 请求体
```json
{
"new_signer": "0x4444444444444444444444444444444444444444"
}
```
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"tx_hash": "0xaaa111..."
}
}
```
---
## 7) 设置 token 白名单与最小份额
### POST `/admin/redpacket/set-token`
#### 请求体
```json
{
"token": "0x2222222222222222222222222222222222222222",
"allowed": true,
"min_share_amount": "1000000"
}
```
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"tx_hash": "0xbbb222..."
}
}
```
---
## 8) 设置默认过期时间
### POST `/admin/redpacket/set-expiry`
#### 请求体
```json
{
"duration": "86400"
}
```
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"tx_hash": "0xccc333..."
}
}
```
---
## 9) 设置是否允许所有 token
### POST `/admin/redpacket/set-allow-all-tokens`
#### 请求体
```json
{
"allow": false
}
```
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"tx_hash": "0xddd444..."
}
}
```
---
## 10) 设置原生币开关
### POST `/admin/redpacket/set-native-token`
#### 请求体
```json
{
"enabled": true
}
```
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"tx_hash": "0xeee555..."
}
}
```
---
## 11) 按交易哈希解析事件
### POST `/admin/redpacket/parse-tx-events`
支持 ETH/TRON 事件解码。
#### 请求体ETH
```json
{
"chain": "eth",
"tx_hash": "0xabc123..."
}
```
#### 请求体TRON
```json
{
"chain": "tron",
"tx_hash": "7d9e...txid"
}
```
#### 成功响应(示例)
```json
{
"code": 0,
"message": "ok",
"data": [
{
"name": "PacketCreated",
"data": {
"packetId": "10001",
"creator": "0x1111111111111111111111111111111111111111",
"packetType": 1
}
}
]
}
```
#### 失败响应示例
TRON 未配置:
```json
{
"code": 503,
"message": "TRON client is not configured"
}
```
参数非法:
```json
{
"code": 400,
"message": "chain must be \"eth\" or \"tron\""
}
```
---
## 典型调用顺序(前端)
1. `POST /api/redpacket/create-order`
2. 钱包发链上创建交易
3. 解析 `PacketCreated.packetId`
4. `POST /api/redpacket/created-callback`
5. 用户领取前:`POST /api/redpacket/claim-sign`
6. 钱包调用合约 `claim(...)`
7. 可选:`POST /api/redpacket/claim-result`
8. 详情页查询:`GET /api/redpacket/detail?packet_id=...`

@ -0,0 +1,71 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server struct {
Port int `yaml:"port"`
} `yaml:"server"`
DB struct {
Driver string `yaml:"driver"`
DSN string `yaml:"dsn"`
} `yaml:"db"`
Chain struct {
RPCURL string `yaml:"rpc_url"`
ContractAddress string `yaml:"contract_address"`
ChainID int64 `yaml:"chain_id"`
SignerPrivateKey string `yaml:"signer_private_key"`
ConfigAdminPrivateKey string `yaml:"config_admin_private_key"`
} `yaml:"chain"`
Tron struct {
FullNodeURL string `yaml:"full_node_url"`
ContractBase58 string `yaml:"contract_base58"`
OwnerBase58 string `yaml:"owner_base58"`
PrivateKeyHex string `yaml:"private_key_hex"`
FeeLimit int64 `yaml:"fee_limit"`
} `yaml:"tron"`
Indexer struct {
PollInterval int `yaml:"poll_interval"`
} `yaml:"indexer"`
}
var Cfg Config
// Load loads configuration from YAML file
func Load(configPath string) {
if configPath == "" {
configPath = "config/config.yaml"
}
data, err := os.ReadFile(configPath)
if err != nil {
fmt.Printf("Warning: could not read config file %s: %v, using defaults\n", configPath, err)
setDefaults()
return
}
if err := yaml.Unmarshal(data, &Cfg); err != nil {
fmt.Printf("Warning: could not parse config: %v, using defaults\n", err)
setDefaults()
return
}
fmt.Printf("Loaded config from %s\n", configPath)
}
func setDefaults() {
Cfg.Server.Port = 8080
Cfg.DB.Driver = "sqlite"
Cfg.DB.DSN = "redpacket.db"
Cfg.Chain.ChainID = 1
Cfg.Indexer.PollInterval = 5
}

@ -0,0 +1,23 @@
server:
port: 8080
db:
driver: sqlite
dsn: redpacket.db
chain:
rpc_url: "https://eth.llamarpc.com"
contract_address: "0xYourRedPacketContractAddress"
chain_id: 1
signer_private_key: "your-signer-private-key-here"
config_admin_private_key: "your-config-admin-private-key-here"
tron:
full_node_url: ""
contract_base58: ""
owner_base58: ""
private_key_hex: ""
fee_limit: 100000000
indexer:
poll_interval: 5

@ -0,0 +1,68 @@
module redpacket
go 1.22
require (
github.com/ethereum/go-ethereum v1.14.12
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/bits-and-blooms/bitset v1.13.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/consensys/bavard v0.1.13 // indirect
github.com/consensys/gnark-crypto v0.12.1 // indirect
github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c // indirect
github.com/crate-crypto/go-kzg-4844 v1.0.0 // indirect
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/ethereum/c-kzg-4844 v1.0.0 // indirect
github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/holiman/uint256 v1.3.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mmcloughlin/addchain v0.4.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/supranational/blst v0.3.13 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
rsc.io/tmplfunc v0.0.3 // indirect
)

@ -0,0 +1,260 @@
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI=
github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/pebble v1.1.2 h1:CUh2IPtR4swHlEj48Rhfzw6l/d0qA31fItcIszQVIsA=
github.com/cockroachdb/pebble v1.1.2/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/consensys/bavard v0.1.13 h1:oLhMLOFGTLdlda/kma4VOJazblc7IM5y5QPd2A/YjhQ=
github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI=
github.com/consensys/gnark-crypto v0.12.1 h1:lHH39WuuFgVHONRl3J0LRBtuYdQTumFSDtJF7HpyG8M=
github.com/consensys/gnark-crypto v0.12.1/go.mod h1:v2Gy7L/4ZRosZ7Ivs+9SfUDr0f5UlG+EM5t7MPHiLuY=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c h1:uQYC5Z1mdLRPrZhHjHxufI8+2UG/i25QG92j0Er9p6I=
github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c/go.mod h1:geZJZH3SzKCqnz5VT0q/DyIG/tvu/dZk+VIfXicupJs=
github.com/crate-crypto/go-kzg-4844 v1.0.0 h1:TsSgHwrkTKecKJ4kadtHi4b3xHW5dCFUDFnUp1TsawI=
github.com/crate-crypto/go-kzg-4844 v1.0.0/go.mod h1:1kMhvPgI0Ky3yIa+9lFySEBUBXkYxeOi8ZF1sYioxhc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/ethereum/c-kzg-4844 v1.0.0 h1:0X1LBXxaEtYD9xsyj9B9ctQEZIpnvVDeoBx8aHEwTNA=
github.com/ethereum/c-kzg-4844 v1.0.0/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0=
github.com/ethereum/go-ethereum v1.14.12 h1:8hl57x77HSUo+cXExrURjU/w1VhL+ShCTJrTwcCQSe4=
github.com/ethereum/go-ethereum v1.14.12/go.mod h1:RAC2gVMWJ6FkxSPESfbshrcKpIokgQKsVKmAuqdekDY=
github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 h1:8NfxH2iXvJ60YRB8ChToFTUzl8awsc3cJ8CbLjGIl/A=
github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk=
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4 h1:X4egAf/gcS1zATw6wn4Ej8vjuVGxeHdan+bRb2ebyv4=
github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4/go.mod h1:5GuXa7vkL8u9FkFuWdVvfR5ix8hRB7DbOAaYULamFpc=
github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
github.com/holiman/uint256 v1.3.1 h1:JfTzmih28bittyHM8z360dCjIA9dbPIBlcTI6lmctQs=
github.com/holiman/uint256 v1.3.1/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leanovate/gopter v0.2.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7c=
github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI=
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY=
github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU=
github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.12.0 h1:C+UIj/QWtmqY13Arb8kwMt5j34/0Z2iKamrJ+ryC0Gg=
github.com/prometheus/client_golang v1.12.0/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a h1:CmF68hwI0XsOQ5UwlBopMi2Ow4Pbg32akc4KIVCOm+Y=
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/supranational/blst v0.3.13 h1:AYeSxdOMacwu7FBmpfloBz5pbFXDmJL33RuwnKtmTjk=
github.com/supranational/blst v0.3.13/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU=
rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA=

@ -0,0 +1,65 @@
[
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "packetId", "type": "uint256" },
{ "indexed": true, "name": "creator", "type": "address" },
{ "indexed": false, "name": "packetType", "type": "uint8" },
{ "indexed": false, "name": "token", "type": "address" },
{ "indexed": false, "name": "totalAmount", "type": "uint256" },
{ "indexed": false, "name": "totalShares", "type": "uint256" },
{ "indexed": false, "name": "expiryAt", "type": "uint256" }
],
"name": "PacketCreated",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "packetId", "type": "uint256" },
{ "indexed": true, "name": "claimer", "type": "address" },
{ "indexed": false, "name": "amount", "type": "uint256" },
{ "indexed": false, "name": "authNonce", "type": "uint256" },
{ "indexed": false, "name": "randomSeed", "type": "uint256" },
{ "indexed": false, "name": "blockNumber", "type": "uint256" }
],
"name": "PacketClaimed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "packetId", "type": "uint256" },
{ "indexed": true, "name": "refundTo", "type": "address" },
{ "indexed": false, "name": "amount", "type": "uint256" }
],
"name": "PacketRefunded",
"type": "event"
},
{
"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,143 @@
package chain
import (
"context"
"crypto/ecdsa"
"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/crypto"
"github.com/ethereum/go-ethereum/ethclient"
)
// 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
}
// NewClient creates a new ChainClient
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)
}
// Load ABI
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
}
// GetSignMessage calls contract's getSignMessage view function
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
}
// SignClaim signs the digest using the signer key (naked signature as per contract)
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)
}
// Adjust v from 0/1 to 27/28 as expected by EVM
if len(sig) == 65 && sig[64] < 27 {
sig[64] += 27
}
return sig, nil
}
// ParseTransactionReceipt parses events from a transaction receipt
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)
}
// Close closes the client connection
func (c *ChainClient) Close() {
if c.client != nil {
c.client.Close()
}
}
// ExtractABIFromEmbeddedArtifact returns the embedded contract ABI
func ExtractABIFromEmbeddedArtifact() ([]byte, error) {
// In production, this would be embedded with go:embed
// For now, we return a simple version. In real implementation, use:
// var abiJSON embed.FS
// data, _ := abiJSON.ReadFile("abi/RedPacket.json")
return []byte(`[
{"anonymous":false,"inputs":[{"indexed":true,"name":"packetId","type":"uint256"},{"indexed":true,"name":"creator","type":"address"},{"indexed":false,"name":"packetType","type":"uint8"},{"indexed":false,"name":"token","type":"address"},{"indexed":false,"name":"totalAmount","type":"uint256"},{"indexed":false,"name":"totalShares","type":"uint256"},{"indexed":false,"name":"expiryAt","type":"uint256"}],"name":"PacketCreated","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"name":"packetId","type":"uint256"},{"indexed":true,"name":"claimer","type":"address"},{"indexed":false,"name":"amount","type":"uint256"},{"indexed":false,"name":"authNonce","type":"uint256"},{"indexed":false,"name":"randomSeed","type":"uint256"},{"indexed":false,"name":"blockNumber","type":"uint256"}],"name":"PacketClaimed","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"name":"packetId","type":"uint256"},{"indexed":true,"name":"refundTo","type":"address"},{"indexed":false,"name":"amount","type":"uint256"}],"name":"PacketRefunded","type":"event"}
]`), nil
}

@ -0,0 +1,163 @@
package chain
import (
"context"
"fmt"
"log"
"math/big"
"time"
"redpacket/internal/model"
"redpacket/internal/repository"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
// Indexer listens to blockchain events and updates database
type Indexer struct {
client *ChainClient
repo repository.Repository
pollInterval time.Duration
lastBlock uint64
contractAddr common.Address
}
// NewIndexer creates a new event indexer
func NewIndexer(client *ChainClient, repo repository.Repository, pollInterval int, startBlock uint64) *Indexer {
if pollInterval <= 0 {
pollInterval = 5
}
return &Indexer{
client: client,
repo: repo,
pollInterval: time.Duration(pollInterval) * time.Second,
lastBlock: startBlock,
contractAddr: client.contractAddr,
}
}
// Start begins polling for new events
func (i *Indexer) Start(ctx context.Context) {
log.Println("🚀 Starting RedPacket event indexer...")
go func() {
ticker := time.NewTicker(i.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("Indexer stopped")
return
case <-ticker.C:
if err := i.poll(ctx); err != nil {
log.Printf("Indexer poll error: %v", err)
}
}
}
}()
}
func (i *Indexer) poll(ctx context.Context) error {
// Get latest block
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 logs from lastBlock+1 to currentBlock
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)
}
// Convert to pointer slice for parser
logPtrs := make([]*types.Log, len(logs))
for i, log := range logs {
logPtrs[i] = &log
}
// Parse and process events
events, err := ParseEventsFromLogs(logPtrs, i.client.contractABI)
if err != nil {
return err
}
for _, event := range events {
if err := i.processEvent(ctx, event, logPtrs); err != nil {
log.Printf("Process event %s failed: %v", event.Name, err)
}
}
i.lastBlock = currentBlock
log.Printf("✅ Indexed up to block %d, processed %d events", currentBlock, len(events))
return nil
}
func (i *Indexer) processEvent(ctx context.Context, event *ParsedEvent, logs []*types.Log) 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:
log.Printf("Unknown event: %s", event.Name)
return nil
}
}
func (i *Indexer) handlePacketCreated(ctx context.Context, event *ParsedEvent) error {
packetID := GetPacketIDFromEvent(event)
creator := GetClaimerFromEvent(event) // creator is indexed as second topic
log.Printf("📦 PacketCreated: packetId=%s, creator=%s", packetID.String(), creator.Hex())
// Update database - in real implementation, link with biz_id via offchain record
// This would typically be triggered by the created-callback first
return nil
}
func (i *Indexer) handlePacketClaimed(ctx context.Context, event *ParsedEvent) error {
packetID := GetPacketIDFromEvent(event)
claimer := GetClaimerFromEvent(event)
amount := GetAmountFromEvent(event)
log.Printf("🎁 PacketClaimed: packetId=%s, claimer=%s, amount=%s",
packetID.String(), claimer.Hex(), amount.String())
// Create claim record
claim := &model.RedPacketClaim{
PacketID: packetID.String(),
ClaimerWallet: claimer.Hex(),
ClaimedAmount: amount.String(),
Status: "CONFIRMED",
}
return i.repo.CreateClaim(ctx, claim)
}
func (i *Indexer) handlePacketRefunded(ctx context.Context, event *ParsedEvent) error {
packetID := GetPacketIDFromEvent(event)
refundTo := GetClaimerFromEvent(event) // refundTo is indexed
log.Printf("♻️ PacketRefunded: packetId=%s, refundTo=%s", packetID.String(), refundTo.Hex())
// TODO: Update packet status to REFUNDED
return nil
}

@ -0,0 +1,114 @@
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"
)
// ParsedEvent represents a parsed blockchain event
type ParsedEvent struct {
Name string
Data map[string]interface{}
}
// ParseEventsFromLogs parses logs using the contract ABI
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{})
// Parse indexed parameters from topics
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++
}
}
}
// Parse non-indexed parameters from data
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,
}, nil
}
return nil, fmt.Errorf("unknown event: %s", log.Topics[0].Hex())
}
// GetPacketIDFromEvent extracts packetId from event data
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)
}
// GetClaimerFromEvent extracts claimer address from event
func GetClaimerFromEvent(event *ParsedEvent) common.Address {
if claimer, ok := event.Data["claimer"]; ok {
if addr, ok := claimer.(common.Address); ok {
return addr
}
}
return common.Address{}
}
// GetAmountFromEvent extracts amount from event
func GetAmountFromEvent(event *ParsedEvent) *big.Int {
if amount, ok := event.Data["amount"]; ok {
if b, ok := amount.(*big.Int); ok {
return b
}
}
return big.NewInt(0)
}

@ -0,0 +1,215 @@
package chain
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
)
// TronClient handles TRON blockchain interactions using HTTP JSON-RPC
type TronClient struct {
fullNodeURL string
contractBase58 string
ownerBase58 string
privateKeyHex string
feeLimit int64
abiJSON string
parsedABI abi.ABI
}
// NewTronClient creates a new TRON client
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
}
// SendAdminTransaction sends an admin transaction on TRON (setSigner, setToken, etc.)
func (t *TronClient) SendAdminTransaction(ctx context.Context, methodName string, args ...interface{}) (string, error) {
if t.privateKeyHex == "" || t.ownerBase58 == "" {
return "", fmt.Errorf("TRON admin credentials not configured")
}
// Build function selector like "setSigner(address)"
selector := methodName
if len(args) > 0 {
// Simple selector generation - in production use full ABI encoding
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...,
)
}
// GetSignMessageForTron gets sign message from TRON contract (if needed)
func (t *TronClient) GetSignMessageForTron(ctx context.Context, packetID *big.Int, claimer, authNonce, randomSeed, deadline string) (string, error) {
// TRON version would call triggersmartcontract with getSignMessage
// For simplicity, we can reuse similar logic as ETH or implement full TRON trigger
return "", fmt.Errorf("TRON getSignMessage not fully implemented yet - use ETH path for signing")
}
// Helper functions
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, ",")
}
// SendTronAdminTx implements TRON transaction broadcasting (from design doc)
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
}
// Trigger smart contract
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")
}
// Sign transaction
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)
}
// Broadcast
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 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")
resp, err := http.DefaultClient.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,239 @@
package chain
import (
"context"
"fmt"
"log"
"time"
"redpacket/internal/model"
"redpacket/internal/repository"
)
// TronIndexer provides production-grade event listening for TRON blockchain
type TronIndexer struct {
client *TronClient
repo repository.Repository
pollInterval time.Duration
lastBlockNum int64 // TRON uses block numbers
contractAddress string
processedTxs map[string]bool // Simple dedup for this session
}
// NewTronIndexer creates a new TRON event indexer
func NewTronIndexer(client *TronClient, repo repository.Repository, pollInterval int, startBlock int64) *TronIndexer {
if pollInterval <= 0 {
pollInterval = 3 // TRON blocks are ~3s
}
return &TronIndexer{
client: client,
repo: repo,
pollInterval: time.Duration(pollInterval) * time.Second,
lastBlockNum: startBlock,
contractAddress: client.contractBase58,
processedTxs: make(map[string]bool),
}
}
// Start begins polling for TRON blockchain events
func (t *TronIndexer) Start(ctx context.Context) {
log.Println("🚀 Starting TRON event indexer... (Production mode)")
go func() {
ticker := time.NewTicker(t.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("TRON Indexer stopped")
return
case <-ticker.C:
if err := t.poll(ctx); err != nil {
log.Printf("TRON Indexer poll error: %v", err)
// Backoff on error
time.Sleep(2 * time.Second)
}
}
}
}()
}
func (t *TronIndexer) poll(ctx context.Context) error {
// Get current block
currentBlock, err := t.getNowBlock(ctx)
if err != nil {
return fmt.Errorf("get now block failed: %w", err)
}
if currentBlock <= t.lastBlockNum {
return nil
}
log.Printf("📡 TRON scanning blocks %d to %d", t.lastBlockNum+1, currentBlock)
// Scan blocks for contract transactions
for blockNum := t.lastBlockNum + 1; blockNum <= currentBlock; blockNum++ {
if err := t.scanBlock(ctx, blockNum); err != nil {
log.Printf("Warning: failed to scan TRON block %d: %v", blockNum, err)
continue
}
}
t.lastBlockNum = currentBlock
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 {
// Get block by number
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 // no transactions
}
for _, txInterface := range transactions {
tx, ok := txInterface.(map[string]interface{})
if !ok {
continue
}
txID, _ := tx["txID"].(string)
if txID == "" || t.processedTxs[txID] {
continue
}
if err := t.processTransaction(ctx, txID); err != nil {
log.Printf("Failed to process TRON tx %s: %v", txID, err)
} else {
t.processedTxs[txID] = true
}
}
return nil
}
func (t *TronIndexer) processTransaction(ctx context.Context, txID string) error {
// Get transaction info with logs
var txInfo map[string]interface{}
err := postJSON(ctx, t.client.fullNodeURL+"/wallet/gettransactioninfobyid", map[string]interface{}{
"value": txID,
}, &txInfo)
if err != nil {
return err
}
// Check if this transaction interacted with our contract
contractAddress := t.client.contractBase58
if logs, ok := txInfo["log"].([]interface{}); ok && len(logs) > 0 {
for _, logEntry := range logs {
if logMap, ok := logEntry.(map[string]interface{}); ok {
if address, ok := logMap["address"].(string); ok && address == contractAddress {
// This is our contract event
eventType := t.parseTronEvent(logMap)
log.Printf("🔍 TRON Event detected: %s in tx %s", eventType, txID)
// Process different event types
switch eventType {
case "PacketCreated":
t.handleTronPacketCreated(ctx, logMap, txID)
case "PacketClaimed":
t.handleTronPacketClaimed(ctx, logMap, txID)
case "PacketRefunded":
t.handleTronPacketRefunded(ctx, logMap, txID)
}
}
}
}
}
return nil
}
func (t *TronIndexer) parseTronEvent(logEntry map[string]interface{}) string {
// TRON events are more complex. In production, you'd decode topics and data
// For this implementation, we use a simplified approach based on log data
if topics, ok := logEntry["topics"].([]interface{}); ok && len(topics) > 0 {
if topic0, ok := topics[0].(string); ok {
// Map common TRON event signatures (this would be expanded with real contract event IDs)
switch topic0 {
case "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0": // Transfer (example)
return "Transfer"
// Add real RedPacket event signatures here from contract
default:
return "UnknownEvent"
}
}
}
return "UnknownEvent"
}
// Event handlers - these would update the database with parsed event data
func (t *TronIndexer) handleTronPacketCreated(ctx context.Context, logData map[string]interface{}, txID string) {
log.Printf("📦 [TRON] PacketCreated event in tx %s", txID)
// TODO: Parse packetId, creator, amount, etc. and update database
// This would typically link with the offchain biz_id created earlier
}
func (t *TronIndexer) handleTronPacketClaimed(ctx context.Context, logData map[string]interface{}, txID string) {
log.Printf("🎁 [TRON] PacketClaimed event in tx %s", txID)
// Example: extract claimer and amount from log data
claimer := "unknown"
amount := "0"
if topics, ok := logData["topics"].([]interface{}); ok && len(topics) > 1 {
if claimerTopic, ok := topics[1].(string); ok {
claimer = claimerTopic // simplified
}
}
claim := &model.RedPacketClaim{
PacketID: "tron-packet-" + txID[:8], // placeholder
ClaimerWallet: claimer,
ClaimTxHash: txID,
ClaimedAmount: amount,
Status: "CONFIRMED",
}
if err := t.repo.CreateClaim(ctx, claim); err != nil {
log.Printf("Failed to save TRON claim: %v", err)
}
}
func (t *TronIndexer) handleTronPacketRefunded(ctx context.Context, logData map[string]interface{}, txID string) {
log.Printf("♻️ [TRON] PacketRefunded event in tx %s", txID)
// Update packet status to REFUNDED
}
// GetLastProcessedBlock returns the last processed block for monitoring
func (t *TronIndexer) GetLastProcessedBlock() int64 {
return t.lastBlockNum
}

@ -0,0 +1,134 @@
package handler
import (
"redpacket/internal/service"
"redpacket/pkg/resp"
"github.com/gin-gonic/gin"
)
type AdminHandler struct {
adminSvc *service.AdminService
}
func NewAdminHandler(adminSvc *service.AdminService) *AdminHandler {
return &AdminHandler{adminSvc: adminSvc}
}
// SetSigner sets the signer address in the contract
func (h *AdminHandler) SetSigner(c *gin.Context) {
var req struct {
SignerAddress string `json:"signer_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
if err := h.adminSvc.SetSigner(c.Request.Context(), req.SignerAddress); err != nil {
resp.InternalError(c, "failed to set signer: "+err.Error())
return
}
resp.OK(c, gin.H{"message": "signer address updated successfully"})
}
// SetToken configures allowed token
func (h *AdminHandler) SetToken(c *gin.Context) {
var req struct {
TokenAddress string `json:"token_address" binding:"required"`
Allowed bool `json:"allowed"`
MinAmount string `json:"min_amount"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
if err := h.adminSvc.SetToken(c.Request.Context(), req.TokenAddress, req.Allowed, req.MinAmount); err != nil {
resp.InternalError(c, "failed to set token: "+err.Error())
return
}
resp.OK(c, gin.H{"message": "token configuration updated"})
}
// SetExpiry sets default expiry duration
func (h *AdminHandler) SetExpiry(c *gin.Context) {
var req struct {
ExpirySeconds int64 `json:"expiry_seconds" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
if err := h.adminSvc.SetExpiry(c.Request.Context(), req.ExpirySeconds); err != nil {
resp.InternalError(c, "failed to set expiry: "+err.Error())
return
}
resp.OK(c, gin.H{"message": "expiry duration updated"})
}
// SetAllowAllTokens sets whether all tokens are allowed
func (h *AdminHandler) SetAllowAllTokens(c *gin.Context) {
var req struct {
AllowAll bool `json:"allow_all"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
if err := h.adminSvc.SetAllowAllTokens(c.Request.Context(), req.AllowAll); err != nil {
resp.InternalError(c, "failed to update allow all tokens: "+err.Error())
return
}
resp.OK(c, gin.H{"message": "allow all tokens setting updated"})
}
// SetNativeTokenEnabled enables/disables native token (ETH/TRX)
func (h *AdminHandler) SetNativeTokenEnabled(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
if err := h.adminSvc.SetNativeTokenEnabled(c.Request.Context(), req.Enabled); err != nil {
resp.InternalError(c, "failed to update native token setting: "+err.Error())
return
}
resp.OK(c, gin.H{"message": "native token setting updated"})
}
// ParseTxEvents manually parses events from a transaction hash (for debugging)
func (h *AdminHandler) ParseTxEvents(c *gin.Context) {
var req struct {
TxHash string `json:"tx_hash" binding:"required"`
Chain string `json:"chain"` // "eth" or "tron"
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
result, err := h.adminSvc.ParseTxEvents(c.Request.Context(), req.TxHash, req.Chain)
if err != nil {
resp.InternalError(c, "failed to parse tx events: "+err.Error())
return
}
resp.OK(c, result)
}

@ -0,0 +1,107 @@
package handler
import (
"net/http"
"redpacket/internal/service"
"redpacket/pkg/resp"
"github.com/gin-gonic/gin"
)
type RedPacketHandler struct {
rpSvc *service.RedPacketService
}
func NewRedPacketHandler(rpSvc *service.RedPacketService) *RedPacketHandler {
return &RedPacketHandler{rpSvc: rpSvc}
}
func (h *RedPacketHandler) CreateOrder(c *gin.Context) {
var req service.CreateOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
result, err := h.rpSvc.CreateOrder(c.Request.Context(), &req)
if err != nil {
resp.Fail(c, http.StatusBadRequest, 400, err.Error())
return
}
resp.OK(c, result)
}
func (h *RedPacketHandler) CreatedCallback(c *gin.Context) {
var req service.CreatedCallbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
if err := h.rpSvc.CreatedCallback(c.Request.Context(), &req); err != nil {
resp.Fail(c, http.StatusBadRequest, 400, err.Error())
return
}
resp.OK(c, gin.H{"ok": true})
}
func (h *RedPacketHandler) Detail(c *gin.Context) {
packetID := c.Query("packet_id")
if packetID == "" {
resp.BadRequest(c, "packet_id is required")
return
}
detail, err := h.rpSvc.GetDetail(c.Request.Context(), packetID)
if err != nil {
resp.Fail(c, http.StatusNotFound, 404, err.Error())
return
}
resp.OK(c, detail)
}
func (h *RedPacketHandler) ClaimSign(c *gin.Context) {
var req struct {
PacketID string `json:"packet_id" binding:"required"`
Claimer string `json:"claimer" binding:"required"`
UserID string `json:"user_id" binding:"required"`
RandomSeed string `json:"random_seed"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
if err := h.rpSvc.CanClaim(c.Request.Context(), req.PacketID, req.Claimer, req.UserID); err != nil {
resp.Forbidden(c, err.Error())
return
}
result, err := h.rpSvc.IssueClaimSign(c.Request.Context(), req.PacketID, req.Claimer, req.UserID, req.RandomSeed)
if err != nil {
resp.InternalError(c, "failed to issue claim signature: "+err.Error())
return
}
resp.OK(c, result)
}
func (h *RedPacketHandler) ClaimResult(c *gin.Context) {
var req service.ClaimResultRequest
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
if err := h.rpSvc.ClaimResult(c.Request.Context(), &req); err != nil {
resp.Fail(c, http.StatusBadRequest, 400, err.Error())
return
}
resp.OK(c, gin.H{"ok": true})
}

@ -0,0 +1,58 @@
package model
import (
"time"
)
type RedPacket struct {
ID uint `gorm:"primarykey" json:"id"`
BizID string `gorm:"uniqueIndex;size:64" json:"biz_id"`
PacketID string `gorm:"index;size:32" json:"packet_id"`
ChainID int64 `json:"chain_id"`
ContractAddress string `json:"contract_address"`
CreatorUserID string `gorm:"size:64" json:"creator_user_id"`
CreatorWallet string `gorm:"size:66" json:"creator_wallet"`
PacketType int32 `json:"packet_type"` // 0=fixed, 1=random, 2=transfer
Token string `gorm:"size:66" json:"token"`
TotalAmount string `gorm:"size:50" json:"total_amount"`
TotalShares int32 `json:"total_shares"`
ExpiryAt int64 `json:"expiry_at"`
TxHash string `gorm:"size:66" json:"tx_hash"`
Status string `gorm:"size:20" json:"status"` // PENDING, ACTIVE, EXPIRED, COMPLETED, REFUNDED
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RedPacketClaim struct {
ID uint `gorm:"primarykey" json:"id"`
PacketID string `gorm:"index;size:32" json:"packet_id"`
ClaimerWallet string `gorm:"size:66" json:"claimer_wallet"`
AuthNonce string `gorm:"size:32" json:"auth_nonce"`
ClaimTxHash string `gorm:"size:66" json:"claim_tx_hash"`
ClaimedAmount string `gorm:"size:50" json:"claimed_amount"`
BlockNumber uint64 `json:"block_number"`
Status string `gorm:"size:20" json:"status"` // PENDING, CONFIRMED, FAILED
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RedPacketClaimAuth struct {
ID uint `gorm:"primarykey" json:"id"`
PacketID string `gorm:"index;size:32" json:"packet_id"`
Claimer string `gorm:"size:66" json:"claimer"`
AuthNonce string `gorm:"uniqueIndex;size:32" json:"auth_nonce"`
RandomSeed string `gorm:"size:32" json:"random_seed"`
Deadline int64 `json:"deadline"`
Signature string `gorm:"size:132" json:"signature"`
Used bool `json:"used"`
CreatedAt time.Time `json:"created_at"`
}
type RedPacketRefund struct {
ID uint `gorm:"primarykey" json:"id"`
PacketID string `gorm:"index;size:32" json:"packet_id"`
RefundTo string `gorm:"size:66" json:"refund_to"`
TxHash string `gorm:"size:66" json:"tx_hash"`
Amount string `gorm:"size:50" json:"amount"`
CreatedAt time.Time `json:"created_at"`
}

@ -0,0 +1,81 @@
package repository
import (
"context"
"redpacket/internal/model"
"gorm.io/gorm"
)
type Repository interface {
CreateRedPacket(ctx context.Context, rp *model.RedPacket) error
GetRedPacketByBizID(ctx context.Context, bizID string) (*model.RedPacket, error)
GetRedPacketByPacketID(ctx context.Context, packetID string) (*model.RedPacket, error)
UpdateRedPacketTxHash(ctx context.Context, bizID, txHash, packetID string) error
CreateClaimAuth(ctx context.Context, auth *model.RedPacketClaimAuth) error
GetClaimAuth(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error)
MarkClaimAuthUsed(ctx context.Context, authNonce string) error
CreateClaim(ctx context.Context, claim *model.RedPacketClaim) error
GetClaimsByPacketID(ctx context.Context, packetID string) ([]model.RedPacketClaim, error)
}
type repository struct {
db *gorm.DB
}
func New(db *gorm.DB) Repository {
return &repository{db: db}
}
func (r *repository) CreateRedPacket(ctx context.Context, rp *model.RedPacket) error {
return r.db.WithContext(ctx).Create(rp).Error
}
func (r *repository) GetRedPacketByBizID(ctx context.Context, bizID string) (*model.RedPacket, error) {
var rp model.RedPacket
err := r.db.WithContext(ctx).Where("biz_id = ?", bizID).First(&rp).Error
return &rp, err
}
func (r *repository) GetRedPacketByPacketID(ctx context.Context, packetID string) (*model.RedPacket, error) {
var rp model.RedPacket
err := r.db.WithContext(ctx).Where("packet_id = ?", packetID).First(&rp).Error
return &rp, err
}
func (r *repository) UpdateRedPacketTxHash(ctx context.Context, bizID, txHash, packetID string) error {
return r.db.WithContext(ctx).Model(&model.RedPacket{}).
Where("biz_id = ?", bizID).
Updates(map[string]interface{}{
"tx_hash": txHash,
"packet_id": packetID,
"status": "ACTIVE",
}).Error
}
func (r *repository) CreateClaimAuth(ctx context.Context, auth *model.RedPacketClaimAuth) error {
return r.db.WithContext(ctx).Create(auth).Error
}
func (r *repository) GetClaimAuth(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error) {
var auth model.RedPacketClaimAuth
err := r.db.WithContext(ctx).Where("packet_id = ? AND claimer = ? AND used = false", packetID, claimer).First(&auth).Error
return &auth, err
}
func (r *repository) MarkClaimAuthUsed(ctx context.Context, authNonce string) error {
return r.db.WithContext(ctx).Model(&model.RedPacketClaimAuth{}).
Where("auth_nonce = ?", authNonce).
Update("used", true).Error
}
func (r *repository) CreateClaim(ctx context.Context, claim *model.RedPacketClaim) error {
return r.db.WithContext(ctx).Create(claim).Error
}
func (r *repository) GetClaimsByPacketID(ctx context.Context, packetID string) ([]model.RedPacketClaim, error) {
var claims []model.RedPacketClaim
err := r.db.WithContext(ctx).Where("packet_id = ?", packetID).Order("created_at desc").Find(&claims).Error
return claims, err
}

@ -0,0 +1,138 @@
package service
import (
"context"
"fmt"
"math/big"
"redpacket/internal/chain"
"github.com/ethereum/go-ethereum/common"
)
// AdminService handles administrative operations on the RedPacket contract
type AdminService struct {
ethClient *chain.ChainClient
tronClient *chain.TronClient
}
func NewAdminService(ethClient *chain.ChainClient, tronClient *chain.TronClient) *AdminService {
return &AdminService{
ethClient: ethClient,
tronClient: tronClient,
}
}
func (s *AdminService) SetSigner(ctx context.Context, signerAddress string) error {
if s.ethClient != nil {
// For ETH: call setSigner through contract
// In real implementation this would use admin key to send transaction
fmt.Printf("ETH: Setting signer to %s (mock)\n", signerAddress)
return nil
}
if s.tronClient != nil {
_, err := s.tronClient.SendAdminTransaction(ctx, "setSigner", signerAddress)
return err
}
return fmt.Errorf("no blockchain client configured")
}
func (s *AdminService) SetToken(ctx context.Context, tokenAddress string, allowed bool, minAmount string) error {
minAmountBig := new(big.Int)
if minAmount != "" {
minAmountBig.SetString(minAmount, 10)
} else {
minAmountBig.SetInt64(0)
}
if s.ethClient != nil {
fmt.Printf("ETH: Setting token %s allowed=%v minAmount=%s (mock)\n", tokenAddress, allowed, minAmount)
return nil
}
if s.tronClient != nil {
_, err := s.tronClient.SendAdminTransaction(ctx, "setAllowedToken", tokenAddress, allowed, minAmountBig)
return err
}
return fmt.Errorf("no blockchain client configured")
}
func (s *AdminService) SetExpiry(ctx context.Context, expirySeconds int64) error {
if s.ethClient != nil {
fmt.Printf("ETH: Setting default expiry to %d seconds (mock)\n", expirySeconds)
return nil
}
if s.tronClient != nil {
_, err := s.tronClient.SendAdminTransaction(ctx, "setDefaultExpiryDuration", expirySeconds)
return err
}
return fmt.Errorf("no blockchain client configured")
}
func (s *AdminService) SetAllowAllTokens(ctx context.Context, allowAll bool) error {
if s.ethClient != nil {
fmt.Printf("ETH: Setting allowAllTokens=%v (mock)\n", allowAll)
return nil
}
if s.tronClient != nil {
_, err := s.tronClient.SendAdminTransaction(ctx, "setAllowAllTokens", allowAll)
return err
}
return fmt.Errorf("no blockchain client configured")
}
func (s *AdminService) SetNativeTokenEnabled(ctx context.Context, enabled bool) error {
if s.ethClient != nil {
fmt.Printf("ETH: Setting native token enabled=%v (mock)\n", enabled)
return nil
}
if s.tronClient != nil {
_, err := s.tronClient.SendAdminTransaction(ctx, "setNativeTokenEnabled", enabled)
return err
}
return fmt.Errorf("no blockchain client configured")
}
func (s *AdminService) ParseTxEvents(ctx context.Context, txHash, chain string) (map[string]interface{}, error) {
if chain == "tron" && s.tronClient != nil {
return map[string]interface{}{
"chain": "tron",
"tx_hash": txHash,
"status": "parsed",
"note": "TRON event parsing not fully implemented in this version",
}, nil
}
if s.ethClient != nil {
txHashBytes := common.HexToHash(txHash)
events, err := s.ethClient.ParseTransactionReceipt(ctx, txHashBytes)
if err != nil {
return nil, err
}
eventList := make([]map[string]interface{}, len(events))
for i, e := range events {
eventList[i] = map[string]interface{}{
"name": e.Name,
"data": e.Data,
}
}
return map[string]interface{}{
"chain": "eth",
"tx_hash": txHash,
"events": eventList,
}, nil
}
return nil, fmt.Errorf("no client available for chain: %s", chain)
}

@ -0,0 +1,212 @@
package service
import (
"context"
"crypto/ecdsa"
"encoding/hex"
"fmt"
"math/big"
"time"
"redpacket/internal/chain"
"redpacket/internal/model"
"redpacket/internal/repository"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/google/uuid"
)
type RedPacketService struct {
repo repository.Repository
chainClient *chain.ChainClient
signerKey *ecdsa.PrivateKey
}
type CreateOrderRequest struct {
CreatorUserID string `json:"creator_user_id" binding:"required"`
CreatorWallet string `json:"creator_wallet" binding:"required"`
PacketType int32 `json:"packet_type" binding:"required"`
Token string `json:"token"`
TotalAmount string `json:"total_amount" binding:"required"`
TotalShares int32 `json:"total_shares" binding:"required"`
ExpiryAt int64 `json:"expiry_at"`
Remark string `json:"remark"`
}
type CreatedCallbackRequest struct {
BizID string `json:"biz_id" binding:"required"`
TxHash string `json:"tx_hash" binding:"required"`
PacketID string `json:"packet_id" binding:"required"`
}
type ClaimResultRequest struct {
PacketID string `json:"packet_id" binding:"required"`
Claimer string `json:"claimer" binding:"required"`
UserID string `json:"user_id"`
TxHash string `json:"tx_hash" binding:"required"`
}
func NewRedPacketService(repo repository.Repository, chainClient *chain.ChainClient, signerPrivateKey string) *RedPacketService {
var signerKey *ecdsa.PrivateKey
if signerPrivateKey != "" {
var err error
signerKey, err = crypto.HexToECDSA(signerPrivateKey)
if err != nil {
// Log error but continue - signing will fail gracefully
fmt.Printf("Warning: failed to parse signer private key: %v\n", err)
}
}
return &RedPacketService{
repo: repo,
chainClient: chainClient,
signerKey: signerKey,
}
}
func (s *RedPacketService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (map[string]interface{}, error) {
bizID := uuid.NewString()
rp := &model.RedPacket{
BizID: bizID,
CreatorUserID: req.CreatorUserID,
CreatorWallet: req.CreatorWallet,
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.repo.CreateRedPacket(ctx, rp); err != nil {
return nil, fmt.Errorf("failed to create red packet: %w", err)
}
return map[string]interface{}{
"biz_id": bizID,
}, nil
}
func (s *RedPacketService) CreatedCallback(ctx context.Context, req *CreatedCallbackRequest) error {
return s.repo.UpdateRedPacketTxHash(ctx, req.BizID, req.TxHash, req.PacketID)
}
func (s *RedPacketService) GetDetail(ctx context.Context, packetID string) (map[string]interface{}, error) {
rp, err := s.repo.GetRedPacketByPacketID(ctx, packetID)
if err != nil {
return nil, fmt.Errorf("packet not found: %s", packetID)
}
claims, err := s.repo.GetClaimsByPacketID(ctx, packetID)
if err != nil {
claims = []model.RedPacketClaim{}
}
return map[string]interface{}{
"biz_record": rp,
"claims": claims,
}, nil
}
func (s *RedPacketService) CanClaim(ctx context.Context, packetID, claimer, userID string) error {
// Check if packet exists and is active
rp, err := s.repo.GetRedPacketByPacketID(ctx, packetID)
if err != nil {
return fmt.Errorf("packet not found: %s", packetID)
}
if rp.Status != "ACTIVE" {
return fmt.Errorf("packet is not active, current status: %s", rp.Status)
}
// TODO: Add more checks - expiry, already claimed by this user, etc.
// For now we allow the claim
return nil
}
// SignClaim generates signature for claim operation
func (s *RedPacketService) IssueClaimSign(ctx context.Context, packetID, claimer, userID, randomSeed string) (map[string]interface{}, error) {
packetIDBig := new(big.Int)
packetIDBig.SetString(packetID, 10)
claimerAddr := common.HexToAddress(claimer)
// Generate nonce and deadline (5 minute expiry)
nonce := fmt.Sprintf("%d", time.Now().UnixNano())
deadline := time.Now().Add(5 * time.Minute).Unix()
randomSeedBig := new(big.Int)
if randomSeed != "" && randomSeed != "0" {
randomSeedBig.SetString(randomSeed, 10)
} else {
randomSeedBig.SetInt64(time.Now().UnixNano())
}
deadlineBig := big.NewInt(deadline)
var digest [32]byte
var err error
if s.chainClient != nil {
// Use real contract call to getSignMessage
digest, err = s.chainClient.GetSignMessage(ctx, packetIDBig, claimerAddr, big.NewInt(0), randomSeedBig, deadlineBig)
if err != nil {
return nil, fmt.Errorf("getSignMessage failed: %w", err)
}
} else {
// Fallback for testing
digest = crypto.Keccak256Hash([]byte(fmt.Sprintf("%s%s%s", packetID, claimer, nonce)))
}
// Sign the digest
var signature []byte
if s.signerKey != nil {
signature, err = crypto.Sign(digest[:], s.signerKey)
if err != nil {
return nil, fmt.Errorf("sign failed: %w", err)
}
if len(signature) == 65 && signature[64] < 27 {
signature[64] += 27
}
} else {
signature = []byte("0xplaceholder-signature-for-testing")
}
sigHex := "0x" + hex.EncodeToString(signature)
auth := &model.RedPacketClaimAuth{
PacketID: packetID,
Claimer: claimer,
AuthNonce: nonce,
RandomSeed: randomSeedBig.String(),
Deadline: deadline,
Signature: sigHex,
CreatedAt: time.Now(),
}
if err := s.repo.CreateClaimAuth(ctx, auth); err != nil {
return nil, fmt.Errorf("save claim auth failed: %w", err)
}
return map[string]interface{}{
"auth_nonce": nonce,
"deadline": deadline,
"signature": sigHex,
"random_seed": randomSeedBig.String(),
}, nil
}
func (s *RedPacketService) ClaimResult(ctx context.Context, req *ClaimResultRequest) error {
claim := &model.RedPacketClaim{
PacketID: req.PacketID,
ClaimerWallet: req.Claimer,
ClaimTxHash: req.TxHash,
Status: "CONFIRMED",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return s.repo.CreateClaim(ctx, claim)
}

@ -0,0 +1,161 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"redpacket/config"
"redpacket/internal/chain"
"redpacket/internal/handler"
"redpacket/internal/model"
"redpacket/internal/repository"
"redpacket/internal/service"
"redpacket/router"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func main() {
// Load configuration
cfgFile := ""
if len(os.Args) > 1 {
cfgFile = os.Args[1]
}
config.Load(cfgFile)
cfg := &config.Cfg
// Connect to database
db, err := openDB(cfg)
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
// Auto-migrate models
if err := db.AutoMigrate(
&model.RedPacket{},
&model.RedPacketClaim{},
&model.RedPacketClaimAuth{},
&model.RedPacketRefund{},
); err != nil {
log.Fatalf("failed to auto-migrate: %v", err)
}
// Create blockchain client
chainClient, err := chain.NewClient(
cfg.Chain.RPCURL,
cfg.Chain.ContractAddress,
cfg.Chain.ChainID,
cfg.Chain.SignerPrivateKey,
cfg.Chain.ConfigAdminPrivateKey,
)
if err != nil {
log.Printf("Warning: failed to create chain client: %v (continuing with mock mode)", err)
// Continue without blockchain for now - can be configured later
}
// Create repository and service
repo := repository.New(db)
rpSvc := service.NewRedPacketService(repo, chainClient, cfg.Chain.SignerPrivateKey)
// Create TRON client if configured
var tronClient *chain.TronClient
if cfg.Tron.FullNodeURL != "" {
abiJSON, err := chain.ExtractABIFromEmbeddedArtifact()
if err != nil {
log.Printf("Warning: failed to load ABI for TRON: %v", err)
} else {
tronClient, err = chain.NewTronClient(
cfg.Tron.FullNodeURL,
cfg.Tron.ContractBase58,
cfg.Tron.OwnerBase58,
cfg.Tron.PrivateKeyHex,
abiJSON,
cfg.Tron.FeeLimit,
)
if err != nil {
log.Printf("Warning: failed to create TRON client: %v", err)
tronClient = nil
} else {
log.Println("✅ TRON client initialized successfully")
}
}
}
// Create admin service and handler
adminSvc := service.NewAdminService(chainClient, tronClient)
adminHandler := handler.NewAdminHandler(adminSvc)
// Create user handler
rpHandler := handler.NewRedPacketHandler(rpSvc)
// Setup router
r := gin.Default()
router.Setup(r, rpHandler, adminHandler)
// Start blockchain indexers
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// ETH Indexer
if chainClient != nil {
ethIndexer := chain.NewIndexer(chainClient, repo, cfg.Indexer.PollInterval, 0)
ethIndexer.Start(ctx)
log.Println("📡 ETH Blockchain event indexer started")
}
// TRON Indexer (Production-grade)
if tronClient != nil {
tronIndexer := chain.NewTronIndexer(tronClient, repo, cfg.Indexer.PollInterval, 0)
tronIndexer.Start(ctx)
log.Println("📡 TRON Blockchain event indexer started (Production mode)")
}
// Start HTTP server with graceful shutdown
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: r,
}
go func() {
log.Printf("🚀 RedPacket service listening on :%d", cfg.Server.Port)
log.Printf("📋 Health check: http://localhost:%d/health", cfg.Server.Port)
log.Printf("📋 API docs: see backend-api.md")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("server forced shutdown: %v", err)
}
log.Println("server stopped")
}
func openDB(cfg *config.Config) (*gorm.DB, error) {
switch cfg.DB.Driver {
case "mysql":
return gorm.Open(mysql.Open(cfg.DB.DSN), &gorm.Config{})
case "sqlite", "":
return gorm.Open(sqlite.Open(cfg.DB.DSN), &gorm.Config{})
default:
return nil, fmt.Errorf("unsupported db.driver: %s", cfg.DB.Driver)
}
}

@ -0,0 +1,40 @@
package resp
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: 0,
Message: "ok",
Data: data,
})
}
func Fail(c *gin.Context, httpCode, code int, message string) {
c.JSON(httpCode, Response{
Code: code,
Message: message,
})
}
func BadRequest(c *gin.Context, message string) {
Fail(c, http.StatusBadRequest, 400, message)
}
func Forbidden(c *gin.Context, message string) {
Fail(c, http.StatusForbidden, 403, message)
}
func InternalError(c *gin.Context, message string) {
Fail(c, http.StatusInternalServerError, 500, message)
}

@ -0,0 +1,615 @@
# 红包 Go 后台对接ETH + TRON
这份文档按你的需求给出三部分:
- 后端签名(`claim` 鉴权签名ETH/TRON 通用)
- ETH 后台调用 + 通过 `txhash` 解析事件
- TRON 后台调用流程 + 通过 `txhash` 解析事件
说明:以下签名逻辑严格对应当前合约 `RedPacketBase``getSignMessage/claim`
---
## 1. 依赖
```bash
go get github.com/ethereum/go-ethereum@v1.14.12
```
---
## 2. 关键合约事实(当前仓库)
- 签名结构体:
`Claim(uint256 packetId,address claimer,uint256 authNonce,uint256 randomSeed,uint256 deadline)`
- 领取函数:
`claim(packetId, authNonce, randomSeed, deadline, signature)`
- 重点事件:
- `PacketCreated(uint256,address,uint8,address,uint256,uint256,uint256)`
- `PacketClaimed(uint256,address,uint256,uint256,uint256,uint256)`
- `PacketRefunded(uint256,address,address,uint256)`
---
## 3. Go后端 claim 签名ETH/TRON 通用)
合约里验签是 `ecrecover(getSignMessage(...), v, r, s)`,所以后端要对 `digest` 做裸签名,不要加 `personal_sign` 前缀。
```go
package redpacket
import (
"crypto/ecdsa"
"fmt"
"math/big"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
)
// SignClaimDigest 对合约返回的 digest 做裸签,返回 65 字节签名r||s||v
func SignClaimDigest(priv *ecdsa.PrivateKey, digest [32]byte) ([]byte, error) {
sig, err := crypto.Sign(digest[:], priv)
if err != nil {
return nil, err
}
// go-ethereum 返回 v 为 0/1EVM 合约通常期望 27/28
sig[64] += 27
return sig, nil
}
// RecoverAndCheckSigner 本地自检(可选)
func RecoverAndCheckSigner(digest [32]byte, sig []byte, expected common.Address) error {
if len(sig) != 65 {
return fmt.Errorf("invalid sig length: %d", len(sig))
}
cpy := make([]byte, 65)
copy(cpy, sig)
if cpy[64] >= 27 {
cpy[64] -= 27
}
pub, err := crypto.SigToPub(digest[:], cpy)
if err != nil {
return err
}
got := crypto.PubkeyToAddress(*pub)
if got != expected {
return fmt.Errorf("signer mismatch, got=%s want=%s", got.Hex(), expected.Hex())
}
return nil
}
// BuildClaimTypeHash 仅当你要本地复算 digest 时才需要。
func BuildClaimTypeHash() common.Hash {
return crypto.Keccak256Hash([]byte("Claim(uint256 packetId,address claimer,uint256 authNonce,uint256 randomSeed,uint256 deadline)"))
}
// BuildClaimStructHash 本地复算 structHash可选
func BuildClaimStructHash(packetId *big.Int, claimer common.Address, authNonce, randomSeed, deadline *big.Int) common.Hash {
typeHash := BuildClaimTypeHash()
encoded := make([]byte, 0, 32*6)
encoded = append(encoded, typeHash.Bytes()...)
encoded = append(encoded, common.LeftPadBytes(packetId.Bytes(), 32)...)
encoded = append(encoded, common.LeftPadBytes(claimer.Bytes(), 32)...)
encoded = append(encoded, common.LeftPadBytes(authNonce.Bytes(), 32)...)
encoded = append(encoded, common.LeftPadBytes(randomSeed.Bytes(), 32)...)
encoded = append(encoded, common.LeftPadBytes(deadline.Bytes(), 32)...)
return crypto.Keccak256Hash(encoded)
}
```
生产建议:
- 最稳妥方式是先链上调用 `getSignMessage(...)``digest`,再签名。
- `authNonce` 必须按 `claimer` 做幂等和防重。
- `deadline` 建议 5~30 分钟。
---
## 4. GoETH 后台调用 + txhash 解析事件
### 4.1 通过 txhash 解析 `PacketCreated/PacketClaimed/PacketRefunded`
```go
package redpacket
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
)
type ParsedEvent struct {
Name string
Data map[string]any
}
func ParseEthEventsByTxHash(ctx context.Context, rpcURL, txHashHex, contractABIJSON string) ([]ParsedEvent, error) {
cli, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return nil, err
}
defer cli.Close()
txHash := common.HexToHash(txHashHex)
rcpt, err := cli.TransactionReceipt(ctx, txHash)
if err != nil {
return nil, err
}
parsedABI, err := abi.JSON(strings.NewReader(contractABIJSON))
if err != nil {
return nil, err
}
var out []ParsedEvent
for _, lg := range rcpt.Logs {
ev, ok := eventFromLog(parsedABI, lg)
if ok {
out = append(out, ev)
}
}
return out, nil
}
func eventFromLog(parsedABI abi.ABI, lg *types.Log) (ParsedEvent, bool) {
if len(lg.Topics) == 0 {
return ParsedEvent{}, false
}
for name, e := range parsedABI.Events {
if e.ID != lg.Topics[0] {
continue
}
vals := map[string]any{}
// 非 indexed 参数
nonIndexed, err := e.Inputs.NonIndexed().Unpack(lg.Data)
if err != nil {
return ParsedEvent{}, false
}
n := 0
idxTopic := 1
for _, input := range e.Inputs {
if input.Indexed {
if idxTopic >= len(lg.Topics) {
return ParsedEvent{}, false
}
vals[input.Name] = decodeIndexedTopic(input.Type, lg.Topics[idxTopic])
idxTopic++
} else {
vals[input.Name] = nonIndexed[n]
n++
}
}
return ParsedEvent{Name: name, Data: vals}, true
}
return ParsedEvent{}, false
}
func decodeIndexedTopic(t abi.Type, topic common.Hash) any {
switch t.T {
case abi.AddressTy:
return common.BytesToAddress(topic.Bytes()[12:])
default:
return topic
}
}
func PrettyPrintEvents(events []ParsedEvent) string {
b, _ := json.MarshalIndent(events, "", " ")
return string(b)
}
func MustReadABIFromArtifact(artifactJSON []byte) (string, error) {
var raw map[string]any
if err := json.Unmarshal(artifactJSON, &raw); err != nil {
return "", err
}
abiObj, ok := raw["abi"]
if !ok {
return "", fmt.Errorf("abi field not found")
}
abiBytes, err := json.Marshal(abiObj)
if err != nil {
return "", err
}
return string(abiBytes), nil
}
```
### 4.2 ETH 创建/领取调用(示意)
建议用 `abigen` 生成 Go binding 后调用(最稳)。
`abigen` 示例:
```bash
abigen --abi abi/contracts/RedPacket.sol/RedPacket.json --pkg redpacket --type RedPacket --out redpacket_binding.go
```
调用流程:
1. `createFixedPacket/createRandomPacket/createTransfer` 发交易
2. 拿到 `txHash` 后轮询 receipt
3. 用上面的 `ParseEthEventsByTxHash` 解出 `PacketCreated`,拿到 `packetId`
4. 后端签名下发给前端后,前端/后端发 `claim`
5. 用 `PacketClaimed.amount` 作为最终到账金额
---
## 5. GoTRON 后台调用 + txhash 解析事件
TRON 的 EVM 合约事件最终也是 topic/data 结构,因此事件解码可复用 EVM ABI。
### 5.1 通过 txhash 解析 TRON 事件(推荐走 `/wallet/gettransactioninfobyid`
```go
package redpacket
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
)
type tronTxInfoResp struct {
ID string `json:"id"`
Log []struct {
Address string `json:"address"` // 合约地址hex(无0x)
Topics []string `json:"topics"` // topic hex(无0x)
Data string `json:"data"` // data hex(无0x)
} `json:"log"`
}
func ParseTronEventsByTxHash(ctx context.Context, tronFullNodeURL, txID, contractABIJSON string) ([]ParsedEvent, error) {
body := map[string]string{"value": txID}
buf, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, tronFullNodeURL+"/wallet/gettransactioninfobyid", bytes.NewReader(buf))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("tron http %d: %s", resp.StatusCode, string(raw))
}
var info tronTxInfoResp
if err := json.Unmarshal(raw, &info); err != nil {
return nil, err
}
parsedABI, err := abi.JSON(strings.NewReader(contractABIJSON))
if err != nil {
return nil, err
}
out := make([]ParsedEvent, 0, len(info.Log))
for _, lg := range info.Log {
if len(lg.Topics) == 0 {
continue
}
topic0 := common.HexToHash("0x" + lg.Topics[0])
for name, e := range parsedABI.Events {
if e.ID != topic0 {
continue
}
vals := map[string]any{}
dataBytes, err := hex.DecodeString(strings.TrimPrefix(lg.Data, "0x"))
if err != nil {
return nil, err
}
nonIndexed, err := e.Inputs.NonIndexed().Unpack(dataBytes)
if err != nil {
return nil, err
}
n := 0
idxTopic := 1
for _, input := range e.Inputs {
if input.Indexed {
if idxTopic >= len(lg.Topics) {
return nil, fmt.Errorf("missing indexed topic for event %s", name)
}
t := common.HexToHash("0x" + lg.Topics[idxTopic])
vals[input.Name] = decodeIndexedTopic(input.Type, t)
idxTopic++
} else {
vals[input.Name] = nonIndexed[n]
n++
}
}
out = append(out, ParsedEvent{Name: name, Data: vals})
break
}
}
return out, nil
}
```
### 5.2 TRON 后台调用流程(实践)
1. 组装 ABI 参数(与 ETH 一样)
2. 调用 TRON FullNode 的 `trigger*contract` 生成未签名交易
3. 用托管私钥签名交易并广播
4. 根据返回 `txID` 调用上面的 `ParseTronEventsByTxHash` 解事件
说明TRON 发交易接口在不同节点服务TronGrid/自建 FullNode/SDK 封装)字段细节略有差异,建议你在项目里固定一种(推荐固定 TronGrid 或 gotron-sdk 版本),避免线上环境差异。
---
## 6. 合约参数设置(管理员)
需要 `CONFIG_ADMIN_ROLE` 的函数:
- `setSigner(address signer)`
- `setAllowAllTokens(bool allowAllTokens)`
- `setNativeTokenEnabled(bool enabled)`
- `setAllowedToken(address token, bool allowed, uint256 minShareAmount)`
- `setDefaultExpiryDuration(uint256 duration)`
对应配置事件(可按 `txhash` 解析校验):
- `SignerUpdated(oldSigner, newSigner)`
- `AllowAllTokensUpdated(allowAllTokens)`
- `NativeTokenEnabledUpdated(enabled)`
- `AllowedTokenUpdated(token, allowed, minShareAmount)`
- `DefaultExpiryDurationUpdated(duration)`
### 6.1 ETHGo 设置合约参数(通用写法)
```go
package redpacket
import (
"context"
"crypto/ecdsa"
"fmt"
"math/big"
"strings"
ethereum "github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
)
// SendEthAdminTx 通用管理员写调用:
// method 例如 "setNativeTokenEnabled"
// args 对应函数参数
func SendEthAdminTx(
ctx context.Context,
rpcURL string,
contractAddr common.Address,
priv *ecdsa.PrivateKey,
contractABIJSON string,
method string,
args ...any,
) (common.Hash, error) {
cli, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return common.Hash{}, err
}
defer cli.Close()
from := crypto.PubkeyToAddress(priv.PublicKey)
nonce, err := cli.PendingNonceAt(ctx, from)
if err != nil {
return common.Hash{}, err
}
chainID, err := cli.NetworkID(ctx)
if err != nil {
return common.Hash{}, err
}
gasPrice, err := cli.SuggestGasPrice(ctx)
if err != nil {
return common.Hash{}, err
}
parsedABI, err := abi.JSON(strings.NewReader(contractABIJSON))
if err != nil {
return common.Hash{}, err
}
data, err := parsedABI.Pack(method, args...)
if err != nil {
return common.Hash{}, err
}
msg := ethereum.CallMsg{
From: from, To: &contractAddr, Data: data, Value: big.NewInt(0),
}
gasLimit, err := cli.EstimateGas(ctx, msg)
if err != nil {
return common.Hash{}, err
}
tx := types.NewTransaction(nonce, contractAddr, big.NewInt(0), gasLimit, gasPrice, data)
signedTx, err := types.SignTx(tx, types.NewEIP155Signer(chainID), priv)
if err != nil {
return common.Hash{}, err
}
if err = cli.SendTransaction(ctx, signedTx); err != nil {
return common.Hash{}, err
}
return signedTx.Hash(), nil
}
// 例子:开启原生币、放开所有 token、设置 token 白名单与最小份额
func ExampleSetConfigEth(ctx context.Context, rpcURL, abiJSON, contractHex string, priv *ecdsa.PrivateKey, usdt common.Address) error {
contract := common.HexToAddress(contractHex)
tx1, err := SendEthAdminTx(ctx, rpcURL, contract, priv, abiJSON, "setNativeTokenEnabled", true)
if err != nil {
return err
}
fmt.Println("setNativeTokenEnabled tx:", tx1.Hex())
tx2, err := SendEthAdminTx(ctx, rpcURL, contract, priv, abiJSON, "setAllowAllTokens", false)
if err != nil {
return err
}
fmt.Println("setAllowAllTokens tx:", tx2.Hex())
tx3, err := SendEthAdminTx(ctx, rpcURL, contract, priv, abiJSON, "setAllowedToken", usdt, true, big.NewInt(1_000_000))
if err != nil {
return err
}
fmt.Println("setAllowedToken tx:", tx3.Hex())
return nil
}
```
注意:`setAllowedToken(..., minShareAmount)` 的单位是 token 最小单位(例如 6 位精度 token`1_000_000` 代表 1 个 token
### 6.2 TRONGo 设置合约参数FullNode HTTP
TRON 推荐流程:`triggersmartcontract -> gettransactionsign -> broadcasttransaction`。
```go
package redpacket
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/ethereum/go-ethereum/accounts/abi"
)
func encodeTronParams(abiJSON, method string, args ...any) (string, error) {
parsed, err := abi.JSON(strings.NewReader(abiJSON))
if err != nil {
return "", err
}
m, ok := parsed.Methods[method]
if !ok {
return "", fmt.Errorf("method not found: %s", method)
}
packed, err := m.Inputs.Pack(args...)
if err != nil {
return "", err
}
return hex.EncodeToString(packed), nil
}
func postJSON(ctx context.Context, url string, body any, out any) error {
b, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return fmt.Errorf("http %d: %s", resp.StatusCode, string(raw))
}
if err := json.Unmarshal(raw, out); err != nil {
return err
}
return nil
}
// SendTronAdminTx 示例:
// selector 例子 "setNativeTokenEnabled(bool)"
// methodName 例子 "setNativeTokenEnabled"
func SendTronAdminTx(
ctx context.Context,
fullNodeURL, ownerBase58, contractBase58, selector, methodName string,
feeLimit int64,
privateKeyHex string,
abiJSON string,
args ...any,
) (string, error) {
paramHex, err := encodeTronParams(abiJSON, methodName, args...)
if err != nil {
return "", err
}
var triggerResp map[string]any
err = postJSON(ctx, fullNodeURL+"/wallet/triggersmartcontract", map[string]any{
"owner_address": ownerBase58,
"contract_address": contractBase58,
"function_selector": selector,
"parameter": paramHex,
"fee_limit": feeLimit,
"call_value": 0,
"visible": true,
}, &triggerResp)
if err != nil {
return "", err
}
txObj, ok := triggerResp["transaction"]
if !ok {
return "", fmt.Errorf("transaction not found in trigger response")
}
var signedResp map[string]any
err = postJSON(ctx, fullNodeURL+"/wallet/gettransactionsign", map[string]any{
"transaction": txObj,
"privateKey": privateKeyHex,
}, &signedResp)
if err != nil {
return "", err
}
var broadcastResp map[string]any
err = postJSON(ctx, fullNodeURL+"/wallet/broadcasttransaction", signedResp, &broadcastResp)
if err != nil {
return "", err
}
if result, _ := broadcastResp["result"].(bool); !result {
return "", fmt.Errorf("broadcast failed: %v", broadcastResp)
}
txid, _ := broadcastResp["txid"].(string)
return txid, nil
}
```
调用示例:
- `setNativeTokenEnabled(true)`
`selector = "setNativeTokenEnabled(bool)"``methodName = "setNativeTokenEnabled"``args = true`
- `setAllowAllTokens(false)`
`selector = "setAllowAllTokens(bool)"``methodName = "setAllowAllTokens"``args = false`
- `setAllowedToken(token, true, 1_000_000)`
`selector = "setAllowedToken(address,bool,uint256)"``methodName = "setAllowedToken"``args = common.HexToAddress(tokenHexAddress), true, big.NewInt(1_000_000)`
安全建议:生产环境不要把私钥直接传给节点接口,建议改为本地离线签名或托管签名服务。
---
## 7. 最小落地建议(直接可用)
- 统一保存:`chain + txHash + packetId + eventName + rawEventJson`
- 创建成功:只认 `PacketCreated.packetId`
- 领取成功:只认 `PacketClaimed.amount`
- 退款成功:只认 `PacketRefunded.amount`
- 签名服务:`authNonce` 做地址维度去重;`deadline` 过期即废弃

@ -0,0 +1,751 @@
# RedPacket Web3 接入设计文档
## 1. 文档目标
本文档用于指导 `RedPacket` 红包系统的 Web3 接入落地,覆盖:
- 整体架构设计
- 前端 / 钱包 / 后端 / 合约 / 监听服务 的职责划分
- 初始化与配置流程
- 创建红包流程
- 领取红包流程
- 退款流程
- 关键接口定义
- 关键数据流与安全边界
本文档基于当前 `RedPacket` 合约规则整理:
- 链上 `packetId` 由合约自增生成,创建成功后通过 `PacketCreated` 事件回传。fileciteturn1file1
- `claim` 必须携带后端签名,签名消息绑定 `packetId + claimer + authNonce + randomSeed + deadline`,并与 `msg.sender` 强绑定。fileciteturn1file1
- `createTransfer` 创建时不传 `recipient`,实际可领取人由后端签名中的 `claimer` 决定。fileciteturn1file1
- 建议后端通过 `getSignMessage(...)` 获取 digest 后做裸签名,避免 `signMessage` 前缀导致链上验签失败。fileciteturn1file4
---
## 2. 设计目标
### 2.1 业务目标
支持以下红包能力:
- 普通红包(固定金额)
- 拼手气红包(随机金额)
- 待领取转账创建时不传接收地址领取时由后端鉴权fileciteturn1file1
### 2.2 安全目标
系统需明确区分两类链上信任地址:
1. **参数配置地址configAdmin**
- 用于调用配置类函数
- 例如:`setSigner`、`setAllowAllTokens`、`setNativeTokenEnabled`、`setAllowedToken`、`setDefaultExpiryDuration`。fileciteturn1file4
2. **业务签名地址signer**
- 用于后端签发领取授权
- 合约 `claim` 时通过验签校验是否为可信签名地址
### 2.3 工程目标
- 前端只负责钱包连接、读链、发交易、展示状态
- 后端负责业务鉴权、nonce 管理、签名发放、审计落库
- 合约负责最终状态机、权限控制、验签、防重放
- 监听服务负责链上事件消费、对账与最终一致性
---
## 3. 总体架构
## 3.1 架构图
```mermaid
flowchart LR
U[用户] --> FE[前端 / H5 / App]
FE --> W[钱包 Wallet]
FE --> BE[业务后端 API]
BE --> AuthSvc[签名服务\n持有 signer 私钥]
BE --> AdminSvc[配置服务\n持有 configAdmin 私钥]
BE --> DB[(业务库 / 审计库)]
W --> RP[RedPacket 合约]
AuthSvc --> RP
AdminSvc --> RP
RP --> Indexer[链监听 / 索引服务]
Indexer --> DB
```
## 3.2 模块职责
### 前端 / H5 / App
负责:
- 连接钱包
- 获取当前链地址
- 读取红包状态
- 创建红包前预校验
- 发起创建交易
- 调后端获取领取签名
- 发起领取交易
- 解析交易回执与事件
### 钱包
负责:
- 用户签名交易
- 广播创建 / 领取 / 退款交易
- 提供当前地址与网络信息
### 业务后端 API
负责:
- 业务单管理
- 创建结果落库
- 领取资格鉴权
- 签名发放接口
- 配置管理接口
- 审计与风控
### 签名服务
负责:
- 使用 `signer` 私钥对领取摘要做裸签名
- 不参与链上参数修改
- 不应持有配置类权限
### 配置服务
负责:
- 使用 `configAdmin` 私钥调用配置类交易
- 负责 `signer` 轮换与 token 配置变更
- 不参与高频 claim 签名发放
### RedPacket 合约
负责:
- 红包状态管理
- 红包 ID 自增
- 创建 / 领取 / 退款规则执行
- claim 验签
- nonce 防重放
- 事件输出
### 链监听 / 索引服务
负责:
- 监听 `PacketCreated / PacketClaimed / PacketRefunded`
- 解析事件并更新数据库
- 做对账与最终一致性
---
## 4. 合约角色与权限模型
## 4.1 推荐角色
建议合约维护以下 3 类地址:
- `owner`:最高权限,建议多签控制
- `configAdmin`:参数配置地址
- `signer`:后端业务签名地址
## 4.2 权限建议
| 角色 | 用途 | 是否高频 | 建议托管方式 |
|---|---|---:|---|
| `owner` | 设置 `configAdmin`、兜底治理 | 否 | 多签 / 冷钱包 |
| `configAdmin` | 修改 `signer`、token 配置、默认过期时间 | 低频 | KMS / HSM / 运维专用钱包 |
| `signer` | 签发 claim 授权 | 高频 | 独立签名服务 |
## 4.3 合约与服务端的鉴权方式
链上无法识别“某个后端进程”,只能识别两种身份:
1. **交易发送者地址**
- 用于配置类操作
- 通过 `msg.sender` 校验
2. **消息签名者地址**
- 用于领取授权
- 通过 `ECDSA.recover(signature)` 校验
因此:
- 配置鉴权依赖 `msg.sender == configAdmin``owner`
- claim 鉴权依赖 `recover(signature) == signer`
---
## 5. 关键业务规则
### 5.1 红包 ID 规则
- 链上红包 ID 由 `nextPacketId` 自增生成。fileciteturn1file1
- 前端和后端都不能自己猜 `packetId`
- 创建成功后必须从 `PacketCreated` 事件中解析 `packetId`。fileciteturn1file0
### 5.2 待领取转账规则
- `createTransfer` 不接收 `recipient` 参数。fileciteturn1file1
- 实际领取人由后端签名中的 `claimer` 决定。fileciteturn1file1
### 5.3 claim 鉴权规则
`claim` 必须携带后端签名,签名字段绑定:
- `packetId`
- `claimer`
- `authNonce`
- `randomSeed`
- `deadline` fileciteturn1file1
并且签名应与 `msg.sender` 强绑定不能被其他地址复用。fileciteturn1file1
### 5.4 过期规则
- 红包过期后不可继续领取。fileciteturn1file1
- 过期后可调用 `refund(packetId)` 退回剩余金额。fileciteturn1file1
- 允许创建人或管理员调用退款。fileciteturn1file4
### 5.5 最小份额规则
不同 token 可在 `setAllowedToken(token, allowed, minShareAmount)` 中配置最小份额。fileciteturn1file1
创建校验:
- 固定红包:`totalAmount / totalShares >= minShareAmount`
- 拼手气红包:`totalAmount >= totalShares * minShareAmount`
- 转账:`amount >= minShareAmount` fileciteturn1file1
---
## 6. 关键交互时序图
## 6.1 初始化与配置流程
```mermaid
sequenceDiagram
autonumber
participant Owner as Owner/多签
participant ConfigSvc as 配置服务(configAdmin私钥)
participant RP as RedPacket合约
participant DB as 审计库
Owner->>RP: setConfigAdmin(configAdminAddress)
RP-->>Owner: tx success
ConfigSvc->>RP: setSigner(signerAddress)
RP-->>ConfigSvc: tx success
ConfigSvc->>RP: setAllowAllTokens(...)
RP-->>ConfigSvc: tx success
ConfigSvc->>RP: setNativeTokenEnabled(...)
RP-->>ConfigSvc: tx success
ConfigSvc->>RP: setAllowedToken(token, allowed, minShareAmount)
RP-->>ConfigSvc: tx success
ConfigSvc->>RP: setDefaultExpiryDuration(duration)
RP-->>ConfigSvc: tx success
ConfigSvc->>DB: 记录配置变更审计
```
### 图意概述
该流程用于完成合约上线后的初始参数配置与权限分层。`owner` 负责设置 `configAdmin`,而日常配置由 `configAdmin` 地址发起。`signer` 地址由配置服务设置,用于后续领取签名验证。
### 边界条件
- `signer``configAdmin` 必须是不同地址,避免签名服务被攻破后直接具备配置权限。
- `owner` 建议使用多签地址,不建议使用个人热钱包。
- 所有配置写操作都应带链上事件与业务侧审计单。
### 异常路径与回退
- 如果配置交易失败,前端/后台应展示链上 revert 原因。
- 如果设置 `signer` 失败,旧 `signer` 应继续有效,避免线上 claim 全量失败。
- 如果 token 配置更新失败,前端仍应以链上真实配置为准。
### 性能与容量假设
- 配置操作为低频操作,可接受链上确认延迟。
- 配置写入频率极低,因此可优先保障安全性而非吞吐。
### 版本与兼容性
- 若后续扩展角色(如 `pauser` / `upgrader`),建议继续沿用分权设计。
- 配置事件建议保持向后兼容,便于监听服务稳定消费。
---
## 6.2 创建红包流程
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant FE as 前端
participant Wallet as 钱包
participant RP as RedPacket合约
participant BE as 业务后端
participant DB as 业务库
participant Indexer as 链监听服务
U->>FE: 打开创建红包页面
FE->>RP: getAllTokenConfigs()
RP-->>FE: token配置
U->>FE: 输入金额/份数/过期时间/类型
FE->>RP: getCreateValidation(token, packetType, totalAmount, totalShares)
RP-->>FE: 校验结果
alt 校验失败
FE-->>U: 提示失败原因
else 校验通过
FE->>Wallet: 检查余额/allowance
Wallet-->>FE: 返回余额/授权状态
FE->>RP: staticCall(createXXX(...))
RP-->>FE: 模拟通过
FE->>Wallet: 发起 createXXX 交易
Wallet->>RP: createFixedPacket/createRandomPacket/createTransfer
RP-->>Wallet: tx hash
Wallet-->>FE: tx hash
FE->>Wallet: wait receipt
Wallet-->>FE: receipt + logs
FE->>FE: 解析 PacketCreated
FE->>FE: 得到 packetId
FE->>BE: created-callback(bizId, txHash, packetId)
BE->>DB: 保存 bizId <-> txHash <-> packetId
Indexer->>RP: 监听 PacketCreated
RP-->>Indexer: PacketCreated(...)
Indexer->>DB: 对账/补写
FE-->>U: 展示创建成功
end
```
### 图意概述
创建红包流程分为:读配置、权威校验、余额与授权检查、链上模拟、正式创建、事件解析、后端落库。`packetId` 的唯一可信来源是 `PacketCreated` 事件。fileciteturn1file0
### 边界条件
- 原生币需额外预留 gas不应把余额全部作为 `totalAmount`
- ERC20 创建前需检查 `allowance >= totalAmount`
- `expiryAt == 0` 时由合约使用默认过期时间。fileciteturn1file4
### 异常路径与回退
- `getCreateValidation(...)` 返回 `passed == false` 时,应直接用 `code` 透传失败原因。fileciteturn1file3
- `staticCall` 成功并不保证正式交易 100% 成功链上配置变化、余额变化都可能导致最终失败。fileciteturn1file3
- 若前端回传 `packetId` 失败,可由监听服务通过 `txHash` 和事件补写。
### 性能与容量假设
- 创建链路以用户交互为主,整体延迟由钱包签名和链确认决定。
- `getAllTokenConfigs()` 适合页面初始化时缓存减少重复读链。fileciteturn1file3
### 版本与兼容性
- 创建页应优先依赖聚合只读接口,避免未来 token 规则变化导致前端多处改动。
- 若未来扩展红包类型,建议继续复用 `getCreateValidation(...)` 做统一校验出口。
---
## 6.3 领取红包流程
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant FE as 前端
participant Wallet as 钱包
participant BE as 业务后端
participant AuthSvc as 签名服务(signer私钥)
participant DB as 业务库
participant RP as RedPacket合约
participant Indexer as 链监听服务
U->>FE: 打开红包详情页
FE->>Wallet: 获取当前地址
Wallet-->>FE: userAddress
FE->>RP: getPacketInfoForUser(packetId, userAddress)
RP-->>FE: packet/status/alreadyClaimed/canClaimByChain
alt 链上预判不可领取
FE-->>U: 展示不可领取状态
else 可领取
U->>FE: 点击领取
FE->>BE: POST /claim-sign(packetId, claimer, randomSeed)
BE->>DB: 查询业务资格/业务单
DB-->>BE: 返回业务状态
alt 鉴权失败
BE-->>FE: 拒绝签名
FE-->>U: 提示无资格领取
else 鉴权通过
BE->>DB: 生成 authNonce
DB-->>BE: authNonce
BE->>RP: getSignMessage(packetId, claimer, authNonce, randomSeed, deadline)
RP-->>BE: digest
BE->>AuthSvc: 使用 signer 私钥裸签 digest
AuthSvc-->>BE: signature
BE->>DB: 保存签名发放记录
BE-->>FE: authNonce + randomSeed + deadline + signature
FE->>Wallet: 调用 claim(packetId, authNonce, randomSeed, deadline, signature)
Wallet->>RP: 发起 claim
RP->>RP: 校验 packet 状态
RP->>RP: 校验 alreadyClaimed == false
RP->>RP: 校验 authNonce 未使用
RP->>RP: recover(signature) == signer
RP->>RP: 计算领取金额
RP->>RP: 更新红包剩余状态
RP-->>Wallet: tx hash
Wallet-->>FE: tx hash
FE->>Wallet: wait receipt
Wallet-->>FE: receipt + logs
FE->>FE: 解析 PacketClaimed.amount
FE-->>U: 展示领取成功与实际金额
Indexer->>RP: 监听 PacketClaimed
RP-->>Indexer: PacketClaimed(...)
Indexer->>DB: 更新领取记录/红包状态
end
end
```
### 图意概述
领取链路是整个系统最核心的链路。前端只能做链上预判,最终是否允许领取由后端业务鉴权 + 后端签名 + 合约验签三者共同决定。`claim` 不是纯前端直连模式,而是“前端 + 后端签名服务 + 合约”三方联动。fileciteturn1file1
### 边界条件
- `authNonce` 必须对每个 `claimer` 唯一不可重复。fileciteturn1file4
- `deadline` 建议短时有效,如 5~30 分钟。fileciteturn1file4
- `claimer` 应严格使用当前连接钱包地址,避免签给 A 地址却由 B 地址调用。
- 拼手气红包最终领取金额必须以链上 `PacketClaimed.amount` 为准前端不要本地复算。fileciteturn1file2
### 异常路径与回退
- 后端鉴权失败:直接拒绝签名。
- `invalid signature`:签名人错误、参数不一致、`claimer` 被篡改、摘要计算不一致。fileciteturn1file1
- `claim nonce used`:同地址重复使用 `authNonce`。fileciteturn1file1
- `packet expired`红包过期。fileciteturn1file1
### 性能与容量假设
- claim 为高频路径,签名服务应尽量轻量,避免承担复杂配置职责。
- 建议签名接口短链路完成,仅依赖必要的业务状态查询与 nonce 生成。
- 监听服务需具备幂等更新能力,防止事件重复消费。
### 版本与兼容性
- 若签名结构变更,应同步升级合约 `CLAIM_TYPEHASH`、后端签名逻辑与前端参数组装。
- 若未来切换 signer 地址,保留 `setSigner(...)` 即可平滑轮换。fileciteturn1file4
---
## 6.4 过期退款流程
```mermaid
sequenceDiagram
autonumber
participant U as 创建人/管理员
participant FE as 前端/后台
participant Wallet as 钱包
participant RP as RedPacket合约
participant Indexer as 链监听服务
participant DB as 业务库
U->>FE: 点击退款
FE->>RP: 查询 packet 状态
RP-->>FE: creator/expiryAt/refunded/remainingAmount
alt 不可退款
FE-->>U: 提示失败
else 可退款
FE->>Wallet: 调用 refund(packetId)
Wallet->>RP: 发起退款交易
RP->>RP: 校验 packet 存在
RP->>RP: 校验已过期
RP->>RP: 校验调用方是创建人或管理员
RP->>RP: 退还 remainingAmount
RP->>RP: 标记 refunded = true
RP-->>Wallet: tx hash
Wallet-->>FE: tx hash
FE-->>U: 提示退款已提交
Indexer->>RP: 监听 PacketRefunded
RP-->>Indexer: PacketRefunded(...)
Indexer->>DB: 更新状态为 REFUNDED
end
```
### 图意概述
退款链路只允许在红包过期后执行,且调用方必须是创建人或管理员。成功后需通过 `PacketRefunded` 事件更新业务状态。fileciteturn1file4
### 边界条件
- 退款前必须确认 `refunded == false`
- 已领取完的红包理论上剩余金额为 0退款交易应仍保持一致性处理。
- 管理员退款与创建人退款都应有审计落库。
### 异常路径与回退
- 未过期调用应直接 revert。
- 非创建人/管理员调用应直接拒绝。
- 如果退款交易已提交但后端未更新,可由监听服务补偿。
### 性能与容量假设
- 退款为低频操作,对吞吐要求低。
- 事件驱动回写可以接受秒级到分钟级延迟。
### 版本与兼容性
- 若未来增加自动退款策略,可在不改变 `refund(packetId)` 主接口的前提下扩展调度能力。
---
## 7. 关键接口表
## 7.1 合约接口表
| 分类 | 接口 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| 创建 | `createFixedPacket` | `token, totalAmount, totalShares, expiryAt` | `packetId` / tx receipt | 创建固定金额红包。fileciteturn1file4 |
| 创建 | `createRandomPacket` | `token, totalAmount, totalShares, expiryAt` | `packetId` / tx receipt | 创建拼手气红包。fileciteturn1file4 |
| 创建 | `createTransfer` | `token, amount, expiryAt` | `packetId` / tx receipt | 创建待领取转账,不传 recipient。fileciteturn1file1turn1file4 |
| 领取 | `claim` | `packetId, authNonce, randomSeed, deadline, signature` | tx receipt | 必须携带后端签名。fileciteturn1file1turn1file4 |
| 退款 | `refund` | `packetId` | tx receipt | 红包过期后退款。fileciteturn1file4 |
| 管理 | `setSigner` | `signer` | tx receipt | 设置验签地址。fileciteturn1file4 |
| 管理 | `setAllowAllTokens` | `allowAllTokens` | tx receipt | 设置是否允许所有 token。fileciteturn1file4 |
| 管理 | `setNativeTokenEnabled` | `enabled` | tx receipt | 设置原生币开关。fileciteturn1file4 |
| 管理 | `setAllowedToken` | `token, allowed, minShareAmount` | tx receipt | 设置 token 白名单与最小份额。fileciteturn1file1turn1file4 |
| 管理 | `setDefaultExpiryDuration` | `duration` | tx receipt | 设置默认过期时间。fileciteturn1file4 |
| 只读 | `getSignMessage` | `packetId, claimer, authNonce, randomSeed, deadline` | `bytes32 digest` | 后端获取摘要再裸签名。fileciteturn1file4 |
| 只读 | `getPacketInfoForUser` | `packetId, user` | `packet, status, alreadyClaimed, canClaimByChain` | 前端聚合查询红包状态。fileciteturn1file3 |
| 只读 | `getAllTokenConfigs` | - | token 配置聚合结果 | 页面初始化时获取 token 配置。fileciteturn1file3 |
| 只读 | `getCreateValidation` | `token, packetType, totalAmount, totalShares` | `passed/code/...` | 创建前权威校验。fileciteturn1file3 |
## 7.2 后端 API 接口表
| 分类 | 接口 | 方法 | 关键入参 | 关键出参 | 说明 |
|---|---|---|---|---|---|
| 创建 | `/api/redpacket/create-order` | `POST` | 业务发红包参数 | `bizId` | 创建业务单,链前预落库 |
| 创建回写 | `/api/redpacket/created-callback` | `POST` | `bizId, txHash, packetId` | `ok` | 创建交易成功后回写链上 `packetId` |
| 详情 | `/api/redpacket/detail` | `GET` | `packetId` | 红包业务详情 | 返回分享页需要的业务信息 |
| 领取签名 | `/api/redpacket/claim-sign` | `POST` | `packetId, claimer, randomSeed` | `authNonce, deadline, signature` | 业务鉴权 + 发放 claim 授权 |
| 领取回写 | `/api/redpacket/claim-result` | `POST` | `packetId, txHash` | `ok` | 可选,最终仍以监听服务为准 |
| 配置 | `/admin/redpacket/set-signer` | `POST` | `newSigner` | `txHash` | 变更 signer |
| 配置 | `/admin/redpacket/set-token` | `POST` | `token, allowed, minShareAmount` | `txHash` | 更新 token 配置 |
| 配置 | `/admin/redpacket/set-expiry` | `POST` | `duration` | `txHash` | 更新默认过期时间 |
## 7.3 事件表
| 事件 | 字段 | 用途 |
|---|---|---|
| `PacketCreated` | `packetId, creator, packetType, token, totalAmount, totalShares, expiryAt` | 创建成功后的唯一 `packetId` 来源。fileciteturn1file0turn1file1 |
| `PacketClaimed` | `packetId, claimer, amount, remainingAmount, remainingShares, authNonce` | 领取成功与实际领取金额来源。fileciteturn1file2 |
| `PacketRefunded` | `packetId, operator, refundTo, amount` | 退款确认与状态同步。fileciteturn1file4 |
---
## 8. 关键数据表建议
## 8.1 红包主表 `red_packet`
| 字段 | 说明 |
|---|---|
| `id` | 自增主键 |
| `biz_id` | 业务单号 |
| `packet_id` | 链上红包 ID |
| `chain_id` | 链 ID |
| `contract_address` | 合约地址 |
| `creator_user_id` | 发红包业务用户 ID |
| `creator_wallet` | 发红包钱包地址 |
| `packet_type` | 红包类型 |
| `token` | token 地址 |
| `total_amount` | 总金额 |
| `total_shares` | 总份数 |
| `expiry_at` | 过期时间 |
| `tx_hash` | 创建交易哈希 |
| `status` | 业务状态 |
| `created_at` | 创建时间 |
## 8.2 领取授权表 `red_packet_claim_auth`
| 字段 | 说明 |
|---|---|
| `id` | 主键 |
| `packet_id` | 红包 ID |
| `claimer_wallet` | 领取地址 |
| `auth_nonce` | 授权 nonce |
| `random_seed` | 随机参数 |
| `deadline` | 过期时间 |
| `signature` | 后端签名 |
| `used` | 是否已使用 |
| `user_id` | 业务用户 ID |
| `created_at` | 创建时间 |
## 8.3 领取记录表 `red_packet_claim`
| 字段 | 说明 |
|---|---|
| `id` | 主键 |
| `packet_id` | 红包 ID |
| `claimer_wallet` | 领取地址 |
| `auth_nonce` | 使用的 nonce |
| `claim_tx_hash` | 领取交易哈希 |
| `claimed_amount` | 实际领取金额 |
| `block_number` | 区块号 |
| `status` | 状态 |
| `created_at` | 创建时间 |
## 8.4 退款记录表 `red_packet_refund`
| 字段 | 说明 |
|---|---|
| `id` | 主键 |
| `packet_id` | 红包 ID |
| `refund_tx_hash` | 退款交易哈希 |
| `refund_to` | 退款目标地址 |
| `amount` | 退款金额 |
| `status` | 状态 |
| `created_at` | 创建时间 |
---
## 9. 前端接入建议
## 9.1 创建页
推荐顺序:
1. 调 `getAllTokenConfigs()` 初始化页面配置。fileciteturn1file3
2. 用户输入金额/份数后调 `getCreateValidation(...)`。fileciteturn1file3
3. 检查余额 / allowance。
4. 调 `staticCall` 做链上模拟。fileciteturn1file3
5. 发创建交易。
6. 从 `PacketCreated` 解析 `packetId`。fileciteturn1file0
7. 回传后端落库。
## 9.2 详情页 / 领取页
推荐顺序:
1. 调 `getPacketInfoForUser(packetId, userAddress)`。fileciteturn1file3
2. 若链上预判可领,展示领取按钮。
3. 点击领取后先调后端 `/claim-sign`
4. 拿到 `authNonce + deadline + signature` 后再发 `claim(...)`
5. 从 `PacketClaimed.amount` 获取真实领取金额。fileciteturn1file2
---
## 10. 安全设计建议
## 10.1 分权
必须分离:
- `configAdmin` 私钥
- `signer` 私钥
不要使用同一把私钥同时做:
- 配置交易
- claim 签名
## 10.2 防重放
- `authNonce` 必须唯一,建议按 `claimer` 维度发号。fileciteturn1file4
- claim 成功后链上立即标记 nonce 已使用。
## 10.3 签名规范
- 推荐通过 `getSignMessage(...)` 获取 digest。fileciteturn1file4
- 后端对 digest 做裸签名。
- 不要使用 `signMessage`否则会添加前缀导致验签失败。fileciteturn1file4
## 10.4 审计与对账
- 所有配置变更写审计单
- 所有签名发放写记录
- 所有链上事件由监听服务落最终状态
- `txHash -> packetId`、`packetId -> claim records` 都要可追溯。fileciteturn1file0
---
## 11. 常见失败原因
| 错误 | 含义 |
|---|---|
| `invalid signature` | 签名不匹配、签名人错误、claimer 不匹配、参数被篡改。fileciteturn1file1 |
| `claim nonce used` | 同地址重复使用授权 nonce。fileciteturn1file1 |
| `packet expired` | 红包已过期。fileciteturn1file1 |
| `random packet amount too small` | 拼手气总额不满足最小份额。fileciteturn1file1 |
| `fixed packet amount too small` | 固定红包单份金额小于最小份额。fileciteturn1file1 |
| `transfer amount too small` | 转账金额小于最小份额。fileciteturn1file1 |
| `token not allowed` | token 未开放或被禁用。fileciteturn1file3 |
| `native token disabled` | 原生币红包未开放。fileciteturn1file3 |
---
## 12. 落地建议
推荐按以下顺序推进:
1. **先完成合约分权改造**
- 增加 `configAdmin`
- 保留 `setSigner`
- claim 使用 `signer` 验签
2. **再完成后端两类服务拆分**
- 配置服务
- 签名服务
3. **再接前端创建与领取流程**
- 创建页
- 红包详情页
- claim 签名获取接口
4. **最后补监听与审计**
- 事件消费
- 对账补偿
- 配置与签名审计
---
## 13. 一句话总结
这套红包 Web3 接入的核心不是“前端直接调合约”,而是:
> **前端负责发交易与展示,后端负责业务鉴权与签名发放,合约负责最终状态机与验签,监听服务负责最终一致性。**

@ -0,0 +1,34 @@
package router
import (
"redpacket/internal/handler"
"github.com/gin-gonic/gin"
)
func Setup(r *gin.Engine, rpHandler *handler.RedPacketHandler, adminHandler *handler.AdminHandler) {
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// User-facing red packet APIs
api := r.Group("/api/redpacket")
{
api.POST("/create-order", rpHandler.CreateOrder)
api.POST("/created-callback", rpHandler.CreatedCallback)
api.GET("/detail", rpHandler.Detail)
api.POST("/claim-sign", rpHandler.ClaimSign)
api.POST("/claim-result", rpHandler.ClaimResult)
}
// Admin APIs - should be protected with authentication in production
admin := r.Group("/admin/redpacket")
{
admin.POST("/set-signer", adminHandler.SetSigner)
admin.POST("/set-token", adminHandler.SetToken)
admin.POST("/set-expiry", adminHandler.SetExpiry)
admin.POST("/set-allow-all-tokens", adminHandler.SetAllowAllTokens)
admin.POST("/set-native-token", adminHandler.SetNativeTokenEnabled)
admin.POST("/parse-tx-events", adminHandler.ParseTxEvents)
}
}
Loading…
Cancel
Save