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,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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user