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,83 @@
|
||||
// Package watcher polls TronGrid for incoming USDT and marks paid orders.
|
||||
// It only reads the chain and flips order state — it never holds keys or moves
|
||||
// funds (sweeping is a separate offline step).
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
)
|
||||
|
||||
type Watcher struct {
|
||||
st *store.Store
|
||||
tron tron.Fetcher
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(st *store.Store, f tron.Fetcher, log *slog.Logger) *Watcher {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Watcher{st: st, tron: f, log: log, now: time.Now}
|
||||
}
|
||||
|
||||
// Tick: (1) expire overdue pending orders; (2) for each still-pending order,
|
||||
// look for a confirmed incoming transfer >= the expected amount on its unique
|
||||
// address and mark it paid. Idempotent — a transfer seen twice flips the order
|
||||
// at most once (MarkPaid only affects a still-pending row).
|
||||
func (w *Watcher) Tick(ctx context.Context) error {
|
||||
if n, err := w.st.MarkExpired(ctx, w.now()); err != nil {
|
||||
return err
|
||||
} else if n > 0 {
|
||||
w.log.Info("orders expired", "count", n)
|
||||
}
|
||||
|
||||
pending, err := w.st.ListPending(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, o := range pending {
|
||||
transfers, err := w.tron.IncomingTransfers(ctx, o.Address)
|
||||
if err != nil {
|
||||
// Transient (rate limit / network): log and move on; retried next tick.
|
||||
w.log.Warn("fetch transfers failed", "order", o.OrderNo, "err", err)
|
||||
continue
|
||||
}
|
||||
for _, t := range transfers {
|
||||
if t.Value < o.ExpectAmount {
|
||||
continue
|
||||
}
|
||||
ok, err := w.st.MarkPaid(ctx, o.OrderNo, t.TxID)
|
||||
if err != nil {
|
||||
w.log.Error("mark paid", "order", o.OrderNo, "err", err)
|
||||
break
|
||||
}
|
||||
if ok {
|
||||
w.log.Info("order paid", "order", o.OrderNo, "tx", t.TxID, "value", t.Value, "address", o.Address)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Loop runs Tick every interval until ctx is cancelled.
|
||||
func (w *Watcher) Loop(ctx context.Context, interval time.Duration) {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := w.Tick(ctx); err != nil {
|
||||
w.log.Error("watcher tick", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user