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>
90 lines
2.8 KiB
Go
90 lines
2.8 KiB
Go
package watcher
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/wangjia/pangolin/pay/internal/store"
|
|
"github.com/wangjia/pangolin/pay/internal/tron"
|
|
)
|
|
|
|
type mockFetcher struct{ m map[string][]tron.Transfer }
|
|
|
|
func (f *mockFetcher) IncomingTransfers(_ context.Context, addr string) ([]tron.Transfer, error) {
|
|
return f.m[addr], nil
|
|
}
|
|
|
|
func newPending(t *testing.T, st *store.Store, orderNo, addr string, amount int64, expires time.Time) {
|
|
t.Helper()
|
|
o := &store.Order{
|
|
OrderNo: orderNo, SKU: "pro", ExpectAmount: amount, Address: addr,
|
|
Status: store.StatusPending, CreatedAt: time.Unix(1_700_000_000, 0), ExpiresAt: expires,
|
|
}
|
|
if err := st.CreateOrder(context.Background(), o); err != nil {
|
|
t.Fatalf("seed order: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWatcherMarksPaidOnSufficientTransfer(t *testing.T) {
|
|
st, _ := store.Open(":memory:")
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
ctx := context.Background()
|
|
now := time.Unix(1_700_000_100, 0)
|
|
|
|
newPending(t, st, "PAY1", "TADDR1", 5_000000, now.Add(time.Hour))
|
|
fetch := &mockFetcher{m: map[string][]tron.Transfer{}}
|
|
w := New(st, fetch, nil)
|
|
w.now = func() time.Time { return now }
|
|
|
|
// No transfer yet -> stays pending.
|
|
if err := w.Tick(ctx); err != nil {
|
|
t.Fatalf("tick1: %v", err)
|
|
}
|
|
if o, _ := st.GetOrder(ctx, "PAY1"); o.Status != store.StatusPending {
|
|
t.Fatalf("want pending, got %s", o.Status)
|
|
}
|
|
|
|
// Underpayment -> still pending.
|
|
fetch.m["TADDR1"] = []tron.Transfer{{TxID: "tx-under", To: "TADDR1", Value: 4_000000}}
|
|
_ = w.Tick(ctx)
|
|
if o, _ := st.GetOrder(ctx, "PAY1"); o.Status != store.StatusPending {
|
|
t.Fatalf("underpay should stay pending, got %s", o.Status)
|
|
}
|
|
|
|
// Sufficient payment -> paid, tx recorded.
|
|
fetch.m["TADDR1"] = []tron.Transfer{{TxID: "tx-ok", To: "TADDR1", Value: 5_000000}}
|
|
_ = w.Tick(ctx)
|
|
o, _ := st.GetOrder(ctx, "PAY1")
|
|
if o.Status != store.StatusPaid || o.TxID != "tx-ok" {
|
|
t.Fatalf("want paid/tx-ok, got %s/%s", o.Status, o.TxID)
|
|
}
|
|
|
|
// Idempotent: another tick with same transfer doesn't error or flip anything.
|
|
if err := w.Tick(ctx); err != nil {
|
|
t.Fatalf("idempotent tick: %v", err)
|
|
}
|
|
o, _ = st.GetOrder(ctx, "PAY1")
|
|
if o.Status != store.StatusPaid || o.TxID != "tx-ok" {
|
|
t.Fatalf("idempotency broken: %s/%s", o.Status, o.TxID)
|
|
}
|
|
}
|
|
|
|
func TestWatcherExpiresOverdue(t *testing.T) {
|
|
st, _ := store.Open(":memory:")
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
ctx := context.Background()
|
|
now := time.Unix(1_700_000_100, 0)
|
|
|
|
newPending(t, st, "OLD", "TADDR2", 1_000000, now.Add(-time.Minute)) // already overdue
|
|
w := New(st, &mockFetcher{m: map[string][]tron.Transfer{}}, nil)
|
|
w.now = func() time.Time { return now }
|
|
|
|
if err := w.Tick(ctx); err != nil {
|
|
t.Fatalf("tick: %v", err)
|
|
}
|
|
if o, _ := st.GetOrder(ctx, "OLD"); o.Status != store.StatusExpired {
|
|
t.Fatalf("want expired, got %s", o.Status)
|
|
}
|
|
}
|