feat(pay): 收款引擎 —— 建单/派生地址 + TronGrid watcher 侦测到账(#34/34A Phase B.3+C)
- store(SQLite,modernc 纯 Go):pay_orders + addr_cursor(HD 派生游标,地址不复用);
建单/查单/ListPending/MarkPaid(幂等,仅 pending→paid)/MarkExpired。
- pay 服务:CreateOrder 每单 NextAddrIndex→从 xpub watch-only 派生唯一收款地址→写 pending 单(TTL 15min)。
- tron:TronGrid 客户端读已确认 TRC20 到账(only_confirmed + USDT 合约,micro-USDT 整数)。
- watcher:Tick 先过期逾期单,再对每个 pending 单查到账、金额≥期望→MarkPaid;幂等(同 tx 只认一次)、
网络错误跳过下轮重试;Loop 定时轮询。
- httpapi:POST /order、GET /order/{orderNo}、/healthz;cmd/paywatch 用 env 装配 + 优雅退出。
- 测试:store/service/watcher(mock TronGrid)/httpapi 全绿——建单派生地址正确、到账侦测、
欠额不认、幂等、超时过期、404/400。热服务无私钥。
- README:安全模型 + Phase A 离线备钱包步骤 + 运行/API/测试。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
// Package tron reads confirmed incoming TRC20 (USDT) transfers from TronGrid.
|
||||
// Only reads — the watcher never signs or moves funds (that's offline sweeping).
|
||||
package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// USDTContractMainnet is the TRON mainnet USDT (TRC20) contract. 6 decimals.
|
||||
// ⚠️ Verify before relying on it in production (Phase-level constant check).
|
||||
const USDTContractMainnet = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||
|
||||
// Transfer is one confirmed incoming TRC20 transfer to a watched address.
|
||||
// Value is the raw integer amount (micro-USDT, since USDT has 6 decimals).
|
||||
type Transfer struct {
|
||||
TxID string
|
||||
To string
|
||||
Value int64
|
||||
}
|
||||
|
||||
// Fetcher returns confirmed incoming USDT transfers to a given address.
|
||||
type Fetcher interface {
|
||||
IncomingTransfers(ctx context.Context, address string) ([]Transfer, error)
|
||||
}
|
||||
|
||||
// Client talks to the TronGrid HTTP API.
|
||||
type Client struct {
|
||||
base string
|
||||
usdtContract string
|
||||
apiKey string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func NewClient(base, usdtContract, apiKey string) *Client {
|
||||
if base == "" {
|
||||
base = "https://api.trongrid.io"
|
||||
}
|
||||
if usdtContract == "" {
|
||||
usdtContract = USDTContractMainnet
|
||||
}
|
||||
return &Client{base: base, usdtContract: usdtContract, apiKey: apiKey, hc: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *Client) IncomingTransfers(ctx context.Context, address string) ([]Transfer, error) {
|
||||
u := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_confirmed=true&contract_address=%s&limit=50",
|
||||
c.base, url.PathEscape(address), url.QueryEscape(c.usdtContract))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("tron: trongrid status %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data []struct {
|
||||
TransactionID string `json:"transaction_id"`
|
||||
To string `json:"to"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("tron: decode: %w", err)
|
||||
}
|
||||
out := make([]Transfer, 0, len(body.Data))
|
||||
for _, d := range body.Data {
|
||||
if d.To != address || d.Type != "Transfer" {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseInt(d.Value, 10, 64)
|
||||
if err != nil {
|
||||
continue // skip malformed value rather than fail the whole batch
|
||||
}
|
||||
out = append(out, Transfer{TxID: d.TransactionID, To: d.To, Value: v})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user