4bb92209ca
- 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>
84 lines
2.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|