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,85 @@
|
||||
// Command paywatch is the pangolin-pay service: it hands out per-order TRON
|
||||
// receiving addresses (watch-only, derived from an account xpub), watches
|
||||
// TronGrid for confirmed USDT payments, and marks orders paid. It holds NO
|
||||
// private keys — sweeping funds to cold storage is a separate offline step.
|
||||
//
|
||||
// Env:
|
||||
//
|
||||
// PAY_ACCOUNT_XPUB (required) watch-only account xpub, m/44'/195'/0'
|
||||
// PAY_DB SQLite path (default pay.db)
|
||||
// PAY_ADDR HTTP listen addr (default :8090)
|
||||
// PAY_POLL_SECONDS watcher poll interval (default 20)
|
||||
// TRONGRID_BASE TronGrid base URL (default https://api.trongrid.io)
|
||||
// TRONGRID_API_KEY TronGrid API key (recommended)
|
||||
// USDT_CONTRACT TRC20 USDT contract (default mainnet)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/httpapi"
|
||||
"github.com/wangjia/pangolin/pay/internal/pay"
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
"github.com/wangjia/pangolin/pay/internal/watcher"
|
||||
)
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
xpub := os.Getenv("PAY_ACCOUNT_XPUB")
|
||||
if xpub == "" {
|
||||
log.Error("PAY_ACCOUNT_XPUB is required (watch-only account xpub, m/44'/195'/0')")
|
||||
os.Exit(1)
|
||||
}
|
||||
dbPath := env("PAY_DB", "pay.db")
|
||||
addr := env("PAY_ADDR", ":8090")
|
||||
pollSec, _ := strconv.Atoi(env("PAY_POLL_SECONDS", "20"))
|
||||
if pollSec <= 0 {
|
||||
pollSec = 20
|
||||
}
|
||||
|
||||
st, err := store.Open("file:" + dbPath + "?_txlock=immediate")
|
||||
if err != nil {
|
||||
log.Error("open store", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
svc := pay.New(st, pay.Config{AccountXpub: xpub, OrderTTL: 15 * time.Minute})
|
||||
fetcher := tron.NewClient(env("TRONGRID_BASE", ""), env("USDT_CONTRACT", ""), os.Getenv("TRONGRID_API_KEY"))
|
||||
w := watcher.New(st, fetcher, log)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go w.Loop(ctx, time.Duration(pollSec)*time.Second)
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: httpapi.New(svc), ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() {
|
||||
log.Info("pangolin-pay listening", "addr", addr, "poll_seconds", pollSec)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Error("http server", "err", err)
|
||||
stop()
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
sc, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(sc)
|
||||
log.Info("pangolin-pay stopped")
|
||||
}
|
||||
Reference in New Issue
Block a user