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:
wangjia
2026-07-09 02:57:47 +08:00
parent 4393edf1d7
commit 4bb92209ca
13 changed files with 1007 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
// Package pay is the order service: create a payment (derive a fresh receiving
// address, record a pending order) and look one up.
package pay
import (
"context"
"crypto/rand"
"fmt"
"time"
"github.com/wangjia/pangolin/pay/internal/store"
"github.com/wangjia/pangolin/pay/internal/wallet"
)
type Config struct {
AccountXpub string // watch-only account xpub (m/44'/195'/0')
OrderTTL time.Duration // how long a pending order stays payable
}
type Service struct {
st *store.Store
cfg Config
now func() time.Time
}
func New(st *store.Store, cfg Config) *Service {
if cfg.OrderTTL <= 0 {
cfg.OrderTTL = 15 * time.Minute
}
return &Service{st: st, cfg: cfg, now: time.Now}
}
// CreateOrder assigns a fresh HD receiving address and records a pending order.
// amount is in micro-USDT (1e-6).
func (s *Service) CreateOrder(ctx context.Context, sku string, amount int64) (*store.Order, error) {
if amount <= 0 {
return nil, fmt.Errorf("pay: amount must be positive")
}
if sku == "" {
return nil, fmt.Errorf("pay: sku required")
}
idx, err := s.st.NextAddrIndex(ctx)
if err != nil {
return nil, fmt.Errorf("pay: next addr index: %w", err)
}
addr, err := wallet.AddressFromAccountXpub(s.cfg.AccountXpub, 0, idx)
if err != nil {
return nil, fmt.Errorf("pay: derive address: %w", err)
}
now := s.now()
o := &store.Order{
OrderNo: newOrderNo(now),
SKU: sku,
ExpectAmount: amount,
AddrIndex: idx,
Address: addr,
Status: store.StatusPending,
CreatedAt: now,
ExpiresAt: now.Add(s.cfg.OrderTTL),
}
if err := s.st.CreateOrder(ctx, o); err != nil {
return nil, fmt.Errorf("pay: create order: %w", err)
}
return o, nil
}
func (s *Service) GetOrder(ctx context.Context, orderNo string) (*store.Order, error) {
return s.st.GetOrder(ctx, orderNo)
}
func newOrderNo(t time.Time) string {
var b [6]byte
_, _ = rand.Read(b[:])
return fmt.Sprintf("PAY%s%x", t.UTC().Format("20060102150405"), b)
}
+58
View File
@@ -0,0 +1,58 @@
package pay
import (
"context"
"testing"
"github.com/wangjia/pangolin/pay/internal/store"
)
// Same golden test-mnemonic account xpub as the wallet package. First two
// receiving addresses (index 0,1) are locked so we prove CreateOrder assigns the
// right HD address and advances the cursor.
const testXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd"
func TestCreateOrderDerivesSequentialAddresses(t *testing.T) {
st, err := store.Open(":memory:")
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
svc := New(st, Config{AccountXpub: testXpub})
ctx := context.Background()
o0, err := svc.CreateOrder(ctx, "pro-year", 5_000000)
if err != nil {
t.Fatalf("order0: %v", err)
}
if o0.AddrIndex != 0 || o0.Address != "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH" {
t.Fatalf("order0 addr: idx=%d addr=%s", o0.AddrIndex, o0.Address)
}
if o0.Status != store.StatusPending || o0.ExpiresAt.Before(o0.CreatedAt) {
t.Fatalf("order0 state: %+v", o0)
}
o1, err := svc.CreateOrder(ctx, "pro-month", 500000)
if err != nil {
t.Fatalf("order1: %v", err)
}
if o1.AddrIndex != 1 || o1.Address != "TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK" {
t.Fatalf("order1 addr: idx=%d addr=%s", o1.AddrIndex, o1.Address)
}
if o1.Address == o0.Address {
t.Fatal("addresses must not repeat across orders")
}
}
func TestCreateOrderRejectsBadInput(t *testing.T) {
st, _ := store.Open(":memory:")
t.Cleanup(func() { _ = st.Close() })
svc := New(st, Config{AccountXpub: testXpub})
ctx := context.Background()
if _, err := svc.CreateOrder(ctx, "x", 0); err == nil {
t.Fatal("expected error for non-positive amount")
}
if _, err := svc.CreateOrder(ctx, "", 100); err == nil {
t.Fatal("expected error for empty sku")
}
}