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,86 @@
|
||||
// Package httpapi exposes the order endpoints a storefront (e.g. 独角数卡) calls:
|
||||
// create a payment (get a receiving address) and poll its status.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/pay"
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
type Handler struct{ svc *pay.Service }
|
||||
|
||||
// New wires the routes (Go 1.22 method+wildcard patterns).
|
||||
func New(svc *pay.Service) http.Handler {
|
||||
h := &Handler{svc: svc}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /order", h.createOrder)
|
||||
mux.HandleFunc("GET /order/{orderNo}", h.getOrder)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
type createReq struct {
|
||||
SKU string `json:"sku"`
|
||||
Amount int64 `json:"amount"` // micro-USDT (1e-6)
|
||||
}
|
||||
|
||||
type orderResp struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Address string `json:"address"`
|
||||
ExpectAmount int64 `json:"expect_amount"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
TxID string `json:"tx_id,omitempty"`
|
||||
}
|
||||
|
||||
func toResp(o *store.Order) orderResp {
|
||||
return orderResp{
|
||||
OrderNo: o.OrderNo,
|
||||
Address: o.Address,
|
||||
ExpectAmount: o.ExpectAmount,
|
||||
Status: string(o.Status),
|
||||
ExpiresAt: o.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
TxID: o.TxID,
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (h *Handler) createOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req createReq
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
o, err := h.svc.CreateOrder(r.Context(), req.SKU, req.Amount)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toResp(o))
|
||||
}
|
||||
|
||||
func (h *Handler) getOrder(w http.ResponseWriter, r *http.Request) {
|
||||
o, err := h.svc.GetOrder(r.Context(), r.PathValue("orderNo"))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toResp(o))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/pay"
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
const testXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd"
|
||||
|
||||
func TestCreateAndGetOrder(t *testing.T) {
|
||||
st, _ := store.Open(":memory:")
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
srv := httptest.NewServer(New(pay.New(st, pay.Config{AccountXpub: testXpub})))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"sku": "pro-year", "amount": 5_000000})
|
||||
resp, err := http.Post(srv.URL+"/order", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create status %d", resp.StatusCode)
|
||||
}
|
||||
var created orderResp
|
||||
_ = json.NewDecoder(resp.Body).Decode(&created)
|
||||
_ = resp.Body.Close()
|
||||
if created.Address != "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH" || created.Status != "pending" {
|
||||
t.Fatalf("create resp: %+v", created)
|
||||
}
|
||||
|
||||
r2, _ := http.Get(srv.URL + "/order/" + created.OrderNo)
|
||||
if r2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get status %d", r2.StatusCode)
|
||||
}
|
||||
var got orderResp
|
||||
_ = json.NewDecoder(r2.Body).Decode(&got)
|
||||
_ = r2.Body.Close()
|
||||
if got.OrderNo != created.OrderNo || got.Address != created.Address {
|
||||
t.Fatalf("get mismatch: %+v vs %+v", got, created)
|
||||
}
|
||||
|
||||
r3, _ := http.Get(srv.URL + "/order/NOPE")
|
||||
if r3.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", r3.StatusCode)
|
||||
}
|
||||
_ = r3.Body.Close()
|
||||
|
||||
r4, _ := http.Post(srv.URL+"/order", "application/json", bytes.NewReader([]byte(`{"sku":"x","amount":0}`)))
|
||||
if r4.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for bad amount, got %d", r4.StatusCode)
|
||||
}
|
||||
_ = r4.Body.Close()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package store persists pay orders + the HD address-derivation cursor in
|
||||
// SQLite (pure-Go modernc driver, no CGO — same choice as the control plane).
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusPaid Status = "paid"
|
||||
StatusExpired Status = "expired"
|
||||
)
|
||||
|
||||
// Order is one payment request. Amounts are in micro-USDT (1e-6), matching the
|
||||
// raw integer value of a TRC20 USDT transfer (USDT has 6 decimals).
|
||||
type Order struct {
|
||||
OrderNo string
|
||||
SKU string
|
||||
ExpectAmount int64
|
||||
AddrIndex uint32
|
||||
Address string
|
||||
Status Status
|
||||
TxID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var ErrNotFound = errors.New("store: order not found")
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func Open(dsn string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite: serialize writers, avoid "database is locked"
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS pay_orders(
|
||||
order_no TEXT PRIMARY KEY,
|
||||
sku TEXT NOT NULL,
|
||||
expect_amount INTEGER NOT NULL,
|
||||
addr_index INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
tx_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_orders_status ON pay_orders(status)`,
|
||||
`CREATE TABLE IF NOT EXISTS addr_cursor(id INTEGER PRIMARY KEY CHECK(id=1), next_index INTEGER NOT NULL)`,
|
||||
`INSERT OR IGNORE INTO addr_cursor(id, next_index) VALUES(1, 0)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NextAddrIndex atomically returns the current HD index and advances the cursor.
|
||||
// Addresses are never reused (avoids an old payment landing on a recycled slot).
|
||||
func (s *Store) NextAddrIndex(ctx context.Context) (uint32, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var idx uint32
|
||||
if err := tx.QueryRowContext(ctx, `SELECT next_index FROM addr_cursor WHERE id=1`).Scan(&idx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE addr_cursor SET next_index=? WHERE id=1`, idx+1); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateOrder(ctx context.Context, o *Order) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO pay_orders(order_no,sku,expect_amount,addr_index,address,status,created_at,expires_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`,
|
||||
o.OrderNo, o.SKU, o.ExpectAmount, o.AddrIndex, o.Address, o.Status, o.CreatedAt.Unix(), o.ExpiresAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
const cols = `order_no,sku,expect_amount,addr_index,address,status,tx_id,created_at,expires_at`
|
||||
|
||||
func scanOrder(sc interface{ Scan(...any) error }) (*Order, error) {
|
||||
o := &Order{}
|
||||
var created, expires int64
|
||||
if err := sc.Scan(&o.OrderNo, &o.SKU, &o.ExpectAmount, &o.AddrIndex, &o.Address, &o.Status, &o.TxID, &created, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.CreatedAt = time.Unix(created, 0)
|
||||
o.ExpiresAt = time.Unix(expires, 0)
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOrder(ctx context.Context, orderNo string) (*Order, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE order_no=?`, orderNo)
|
||||
o, err := scanOrder(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return o, err
|
||||
}
|
||||
|
||||
func (s *Store) ListPending(ctx context.Context) ([]*Order, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE status=?`, StatusPending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []*Order
|
||||
for rows.Next() {
|
||||
o, err := scanOrder(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkPaid transitions pending->paid, idempotently (only affects a still-pending
|
||||
// row). Returns true if this call was the one that flipped it.
|
||||
func (s *Store) MarkPaid(ctx context.Context, orderNo, txID string) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE pay_orders SET status=?, tx_id=? WHERE order_no=? AND status=?`,
|
||||
StatusPaid, txID, orderNo, StatusPending)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// MarkExpired flips pending->expired for orders past their deadline.
|
||||
func (s *Store) MarkExpired(ctx context.Context, now time.Time) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE pay_orders SET status=? WHERE status=? AND expires_at < ?`,
|
||||
StatusExpired, StatusPending, now.Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openMem(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestNextAddrIndexMonotonic(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
for want := uint32(0); want < 5; want++ {
|
||||
got, err := s.NextAddrIndex(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("next: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("index got %d want %d", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderRoundtripAndMarkPaidIdempotent(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
o := &Order{
|
||||
OrderNo: "PAY1", SKU: "pro-year", ExpectAmount: 5_000000, AddrIndex: 0,
|
||||
Address: "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", Status: StatusPending,
|
||||
CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute),
|
||||
}
|
||||
if err := s.CreateOrder(ctx, o); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, err := s.GetOrder(ctx, "PAY1")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.SKU != "pro-year" || got.ExpectAmount != 5_000000 || got.Status != StatusPending {
|
||||
t.Fatalf("roundtrip mismatch: %+v", got)
|
||||
}
|
||||
|
||||
ok, err := s.MarkPaid(ctx, "PAY1", "tx-abc")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("first MarkPaid ok=%v err=%v (want true,nil)", ok, err)
|
||||
}
|
||||
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-dup")
|
||||
if err != nil || ok2 {
|
||||
t.Fatalf("second MarkPaid ok=%v err=%v (want false,nil — idempotent)", ok2, err)
|
||||
}
|
||||
got, _ = s.GetOrder(ctx, "PAY1")
|
||||
if got.Status != StatusPaid || got.TxID != "tx-abc" {
|
||||
t.Fatalf("after paid: status=%s tx=%s (want paid,tx-abc)", got.Status, got.TxID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkExpired(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
past := &Order{OrderNo: "old", SKU: "x", ExpectAmount: 1, Address: "T1", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Minute)}
|
||||
future := &Order{OrderNo: "new", SKU: "x", ExpectAmount: 1, Address: "T2", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Hour)}
|
||||
_ = s.CreateOrder(ctx, past)
|
||||
_ = s.CreateOrder(ctx, future)
|
||||
|
||||
n, err := s.MarkExpired(ctx, base.Add(10*time.Minute))
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("MarkExpired n=%d err=%v (want 1)", n, err)
|
||||
}
|
||||
oldO, _ := s.GetOrder(ctx, "old")
|
||||
newO, _ := s.GetOrder(ctx, "new")
|
||||
if oldO.Status != StatusExpired {
|
||||
t.Fatalf("old should be expired, got %s", oldO.Status)
|
||||
}
|
||||
if newO.Status != StatusPending {
|
||||
t.Fatalf("new should still be pending, got %s", newO.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package tron reads confirmed incoming TRC20 (USDT) transfers from TronGrid.
|
||||
// Only reads — the watcher never signs or moves funds (that's offline sweeping).
|
||||
package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// USDTContractMainnet is the TRON mainnet USDT (TRC20) contract. 6 decimals.
|
||||
// ⚠️ Verify before relying on it in production (Phase-level constant check).
|
||||
const USDTContractMainnet = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||
|
||||
// Transfer is one confirmed incoming TRC20 transfer to a watched address.
|
||||
// Value is the raw integer amount (micro-USDT, since USDT has 6 decimals).
|
||||
type Transfer struct {
|
||||
TxID string
|
||||
To string
|
||||
Value int64
|
||||
}
|
||||
|
||||
// Fetcher returns confirmed incoming USDT transfers to a given address.
|
||||
type Fetcher interface {
|
||||
IncomingTransfers(ctx context.Context, address string) ([]Transfer, error)
|
||||
}
|
||||
|
||||
// Client talks to the TronGrid HTTP API.
|
||||
type Client struct {
|
||||
base string
|
||||
usdtContract string
|
||||
apiKey string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func NewClient(base, usdtContract, apiKey string) *Client {
|
||||
if base == "" {
|
||||
base = "https://api.trongrid.io"
|
||||
}
|
||||
if usdtContract == "" {
|
||||
usdtContract = USDTContractMainnet
|
||||
}
|
||||
return &Client{base: base, usdtContract: usdtContract, apiKey: apiKey, hc: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *Client) IncomingTransfers(ctx context.Context, address string) ([]Transfer, error) {
|
||||
u := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_confirmed=true&contract_address=%s&limit=50",
|
||||
c.base, url.PathEscape(address), url.QueryEscape(c.usdtContract))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("tron: trongrid status %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data []struct {
|
||||
TransactionID string `json:"transaction_id"`
|
||||
To string `json:"to"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("tron: decode: %w", err)
|
||||
}
|
||||
out := make([]Transfer, 0, len(body.Data))
|
||||
for _, d := range body.Data {
|
||||
if d.To != address || d.Type != "Transfer" {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseInt(d.Value, 10, 64)
|
||||
if err != nil {
|
||||
continue // skip malformed value rather than fail the whole batch
|
||||
}
|
||||
out = append(out, Transfer{TxID: d.TransactionID, To: d.To, Value: v})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user