Files
pangolin/pay/internal/watcher/watcher.go
T
wangjia 4bb92209ca 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>
2026-07-09 02:57:47 +08:00

84 lines
2.1 KiB
Go

// 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)
}
}
}
}