diff --git a/pay/README.md b/pay/README.md index 50ddb66..a9fcf59 100644 --- a/pay/README.md +++ b/pay/README.md @@ -1,8 +1,13 @@ # pangolin-pay -自托管 USDT(TRC20) 收款服务。给每个订单派生一个**唯一收款地址**(watch-only,从账户 -xpub 派生,**不持私钥**),轮询 TronGrid 侦测到账、确认后标记订单已付。归集(把钱扫到冷 -钱包)是**独立的离线步骤**,本服务不碰私钥。 +自托管 USDT(TRC20) 收款服务。**单个固定收款地址 + 每单唯一金额**:所有订单收到同一个地址, +靠**唯一金额**(基准价 + 微尾数,≤0.01 USDT)区分。watcher 轮询 TronGrid,按 +**"到账 tx + 精确金额 + 区块时间晚于建单"** 匹配订单;匹配不到活跃订单的到账(付错/迟到)→ +记 `orphan_payments` 人工对账。归集(把钱扫到冷钱包)是**独立离线步骤**,本服务**不持私钥**。 + +> 收款地址 = 钱包 A 的地址 0(`m/44'/195'/0'/0/0`),配置注入或由 xpub 派生。 +> 前端契约(`POST /order` 返回 address+expect_amount)与地址模型解耦——以后要升多地址/GasFree +> 是纯后端换实现,前端零改动。模型定稿见 `docs/pay-single-address-plan.html`。 > 属 #34「独角数卡 + USDT 收款闭环」的加密货币交易引擎(计划见 > `docs/superpowers/plans/2026-07-09-crypto-tx-engine.md`)。概念见 brain @@ -74,13 +79,19 @@ go run ./cmd/paywatch ## API ``` -POST /order {"sku":"pro-year","amount":5000000} # amount = micro-USDT(1e-6) +POST /order {"user_ref":"buyer123","sku":"pro-year","amount":5000000} # amount = 基准价 micro-USDT(1e-6) → 201 {"order_no","address","expect_amount","status":"pending","expires_at"} + # expect_amount = 基准 + 唯一微尾数;支付页要显示"请付精确金额 expect_amount" + → 409 {"error":"user already has an active order"} # 同一 user_ref 同时只能一个活跃订单 GET /order/{orderNo} → 200 {..., "status":"pending|paid|expired","tx_id"} GET /healthz → 200 ok ``` -门面(独角数卡)下单时调 `POST /order` 拿收款地址;支付页轮询 `GET /order/{id}` 直到 `paid`。 +门面(独角数卡)下单时调 `POST /order`(带 `user_ref`)拿 **address + expect_amount**; +支付页显示"往 address 付**精确的** expect_amount"(可复制),轮询 `GET /order/{id}` 直到 `paid`。 +用户付错金额 → 该到账进 orphan,需人工对账。 + +配置:`PAY_RECEIVE_ADDRESS`(单收款地址)或 `PAY_ACCOUNT_XPUB`(自动派生 index 0)。 ## Phase D —— 归集(气隙签名,`cmd/sweep`) diff --git a/pay/cmd/paywatch/main.go b/pay/cmd/paywatch/main.go index 863f19c..845f0ec 100644 --- a/pay/cmd/paywatch/main.go +++ b/pay/cmd/paywatch/main.go @@ -75,10 +75,21 @@ func main() { log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - xpub := os.Getenv("PAY_ACCOUNT_XPUB") - if xpub == "" { - log.Error("PAY_ACCOUNT_XPUB is required (watch-only account xpub, m/44'/195'/0')") - os.Exit(1) + // Single fixed receiving address: either given directly, or derived as index 0 + // of the watch-only account xpub (= wallet A address 0). + receiveAddr := os.Getenv("PAY_RECEIVE_ADDRESS") + if receiveAddr == "" { + xpub := os.Getenv("PAY_ACCOUNT_XPUB") + if xpub == "" { + log.Error("set PAY_RECEIVE_ADDRESS, or PAY_ACCOUNT_XPUB to derive address 0") + os.Exit(1) + } + a, err := wallet.AddressFromAccountXpub(xpub, 0, 0) + if err != nil { + log.Error("derive receive address from xpub", "err", err) + os.Exit(1) + } + receiveAddr = a } dbPath := env("PAY_DB", "pay.db") addr := env("PAY_ADDR", ":8090") @@ -94,9 +105,10 @@ func main() { } defer func() { _ = st.Close() }() - svc := pay.New(st, pay.Config{AccountXpub: xpub, OrderTTL: 15 * time.Minute}) + svc := pay.New(st, pay.Config{ReceiveAddress: receiveAddr, OrderTTL: 15 * time.Minute}) fetcher := tron.NewClient(env("TRONGRID_BASE", ""), env("USDT_CONTRACT", ""), os.Getenv("TRONGRID_API_KEY")) - w := watcher.New(st, fetcher, log) + w := watcher.New(st, fetcher, receiveAddr, log) + log.Info("receiving address", "address", receiveAddr) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() diff --git a/pay/internal/httpapi/handler.go b/pay/internal/httpapi/handler.go index a85e32c..eb18947 100644 --- a/pay/internal/httpapi/handler.go +++ b/pay/internal/httpapi/handler.go @@ -28,8 +28,9 @@ func New(svc *pay.Service) http.Handler { } type createReq struct { - SKU string `json:"sku"` - Amount int64 `json:"amount"` // micro-USDT (1e-6) + UserRef string `json:"user_ref"` + SKU string `json:"sku"` + Amount int64 `json:"amount"` // base price, micro-USDT (1e-6) } type orderResp struct { @@ -64,7 +65,11 @@ func (h *Handler) createOrder(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } - o, err := h.svc.CreateOrder(r.Context(), req.SKU, req.Amount) + o, err := h.svc.CreateOrder(r.Context(), req.UserRef, req.SKU, req.Amount) + if errors.Is(err, pay.ErrUserHasActiveOrder) { + writeJSON(w, http.StatusConflict, map[string]string{"error": "user already has an active order"}) + return + } if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return diff --git a/pay/internal/httpapi/handler_test.go b/pay/internal/httpapi/handler_test.go index 1fcb3d0..c593ea7 100644 --- a/pay/internal/httpapi/handler_test.go +++ b/pay/internal/httpapi/handler_test.go @@ -11,15 +11,15 @@ import ( "github.com/wangjia/pangolin/pay/internal/store" ) -const testXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd" +const recvAddr = "TRecv00000000000000000000000000000A" -func TestCreateAndGetOrder(t *testing.T) { +func TestCreateGetAndConflict(t *testing.T) { st, _ := store.Open(":memory:") t.Cleanup(func() { _ = st.Close() }) - srv := httptest.NewServer(New(pay.New(st, pay.Config{AccountXpub: testXpub}))) + srv := httptest.NewServer(New(pay.New(st, pay.Config{ReceiveAddress: recvAddr}))) t.Cleanup(srv.Close) - body, _ := json.Marshal(map[string]any{"sku": "pro-year", "amount": 5_000000}) + body, _ := json.Marshal(map[string]any{"user_ref": "u1", "sku": "pro-year", "amount": 5_000000}) resp, err := http.Post(srv.URL+"/order", "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) @@ -30,30 +30,35 @@ func TestCreateAndGetOrder(t *testing.T) { var created orderResp _ = json.NewDecoder(resp.Body).Decode(&created) _ = resp.Body.Close() - if created.Address != "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH" || created.Status != "pending" { + if created.Address != recvAddr || created.Status != "pending" { t.Fatalf("create resp: %+v", created) } + if created.ExpectAmount <= 5_000000 { + t.Fatalf("expect_amount %d should be base+unique tail", created.ExpectAmount) + } 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) + // Same user again -> 409 Conflict. + r3, _ := http.Post(srv.URL+"/order", "application/json", bytes.NewReader(body)) + if r3.StatusCode != http.StatusConflict { + t.Fatalf("want 409 for second active order, 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, _ := http.Get(srv.URL + "/order/NOPE") + if r4.StatusCode != http.StatusNotFound { + t.Fatalf("want 404, got %d", r4.StatusCode) } _ = r4.Body.Close() + + r5, _ := http.Post(srv.URL+"/order", "application/json", bytes.NewReader([]byte(`{"user_ref":"u2","sku":"x","amount":0}`))) + if r5.StatusCode != http.StatusBadRequest { + t.Fatalf("want 400 for bad amount, got %d", r5.StatusCode) + } + _ = r5.Body.Close() } diff --git a/pay/internal/pay/service.go b/pay/internal/pay/service.go index b2ddbfd..e53e69c 100644 --- a/pay/internal/pay/service.go +++ b/pay/internal/pay/service.go @@ -1,22 +1,29 @@ -// Package pay is the order service: create a payment (derive a fresh receiving -// address, record a pending order) and look one up. +// Package pay is the order service: create a payment (single fixed receiving +// address + a unique amount) and look one up. Orders are distinguished by the +// unique amount, so one receiving address serves all of them. package pay import ( "context" "crypto/rand" + "errors" "fmt" + "math/big" "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 + ReceiveAddress string // the single fixed receiving address (wallet A addr 0) + OrderTTL time.Duration // how long a pending order stays payable (default 15m) + TailMax int64 // unique-amount tail range [1,TailMax] micro-USDT (default 9999, <0.01 USDT) + AmountCooldown time.Duration // an amount stays reserved this long against reuse; must exceed OrderTTL (default 30m) } +// ErrUserHasActiveOrder is returned when a user already has a pending order. +var ErrUserHasActiveOrder = errors.New("pay: user already has an active order") + type Service struct { st *store.Store cfg Config @@ -27,33 +34,52 @@ func New(st *store.Store, cfg Config) *Service { if cfg.OrderTTL <= 0 { cfg.OrderTTL = 15 * time.Minute } + if cfg.TailMax <= 0 { + cfg.TailMax = 9999 + } + if cfg.AmountCooldown <= 0 { + cfg.AmountCooldown = 30 * 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") +// CreateOrder records a pending order for userRef at base price priceMicro +// (micro-USDT), assigning a unique amount (base + tail) that no recent order +// shares — so a stale payment can never match a new order. One active order per +// user is enforced. +func (s *Service) CreateOrder(ctx context.Context, userRef, sku string, priceMicro int64) (*store.Order, error) { + if userRef == "" { + return nil, fmt.Errorf("pay: userRef required") } 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) + if priceMicro <= 0 { + return nil, fmt.Errorf("pay: price must be positive") } - addr, err := wallet.AddressFromAccountXpub(s.cfg.AccountXpub, 0, idx) - if err != nil { - return nil, fmt.Errorf("pay: derive address: %w", err) + if s.cfg.ReceiveAddress == "" { + return nil, fmt.Errorf("pay: receive address not configured") } + + // One active order per user. + if _, err := s.st.ActiveOrderByUser(ctx, userRef); err == nil { + return nil, ErrUserHasActiveOrder + } else if !errors.Is(err, store.ErrNotFound) { + return nil, fmt.Errorf("pay: check active order: %w", err) + } + now := s.now() + amount, err := s.allocateAmount(ctx, priceMicro, now) + if err != nil { + return nil, err + } + o := &store.Order{ OrderNo: newOrderNo(now), + UserRef: userRef, SKU: sku, ExpectAmount: amount, - AddrIndex: idx, - Address: addr, + Address: s.cfg.ReceiveAddress, Status: store.StatusPending, CreatedAt: now, ExpiresAt: now.Add(s.cfg.OrderTTL), @@ -64,6 +90,27 @@ func (s *Service) CreateOrder(ctx context.Context, sku string, amount int64) (*s return o, nil } +// allocateAmount picks base+tail such that the amount wasn't used within the +// cooldown window (keeps concurrent + recently-expired amounts distinct). +func (s *Service) allocateAmount(ctx context.Context, base int64, now time.Time) (int64, error) { + since := now.Add(-s.cfg.AmountCooldown).Unix() + for attempt := 0; attempt < 64; attempt++ { + tail, err := randInt(s.cfg.TailMax) // [1, TailMax] + if err != nil { + return 0, err + } + amount := base + tail + used, err := s.st.AmountRecentlyUsed(ctx, amount, since) + if err != nil { + return 0, fmt.Errorf("pay: amount check: %w", err) + } + if !used { + return amount, nil + } + } + return 0, fmt.Errorf("pay: could not allocate a unique amount (too many concurrent orders at this price?)") +} + func (s *Service) GetOrder(ctx context.Context, orderNo string) (*store.Order, error) { return s.st.GetOrder(ctx, orderNo) } @@ -73,3 +120,12 @@ func newOrderNo(t time.Time) string { _, _ = rand.Read(b[:]) return fmt.Sprintf("PAY%s%x", t.UTC().Format("20060102150405"), b) } + +// randInt returns a uniform integer in [1, max]. +func randInt(max int64) (int64, error) { + n, err := rand.Int(rand.Reader, big.NewInt(max)) + if err != nil { + return 0, err + } + return n.Int64() + 1, nil +} diff --git a/pay/internal/pay/service_test.go b/pay/internal/pay/service_test.go index 6c56ea9..2424902 100644 --- a/pay/internal/pay/service_test.go +++ b/pay/internal/pay/service_test.go @@ -2,57 +2,76 @@ package pay import ( "context" + "errors" "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" +const recvAddr = "TRecv00000000000000000000000000000A" -func TestCreateOrderDerivesSequentialAddresses(t *testing.T) { +func newSvc(t *testing.T) *Service { + t.Helper() st, err := store.Open(":memory:") if err != nil { t.Fatalf("store: %v", err) } t.Cleanup(func() { _ = st.Close() }) - svc := New(st, Config{AccountXpub: testXpub}) + return New(st, Config{ReceiveAddress: recvAddr}) +} + +func TestCreateOrderUniqueAmountSameAddress(t *testing.T) { + svc := newSvc(t) 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) + o1, err := svc.CreateOrder(ctx, "u1", "pro-year", 5_000000) 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 != recvAddr { + t.Fatalf("addr %s, want single receive address", o1.Address) } - if o1.Address == o0.Address { - t.Fatal("addresses must not repeat across orders") + if o1.ExpectAmount <= 5_000000 || o1.ExpectAmount > 5_000000+9999 { + t.Fatalf("amount %d not base+tail(<=9999)", o1.ExpectAmount) + } + + o2, err := svc.CreateOrder(ctx, "u2", "pro-year", 5_000000) + if err != nil { + t.Fatalf("order2: %v", err) + } + if o2.ExpectAmount == o1.ExpectAmount { + t.Fatal("amounts must be unique across concurrent orders") + } + if o2.Address != o1.Address { + t.Fatal("single-address model: both orders share the receiving address") + } +} + +func TestCreateOrderOneActivePerUser(t *testing.T) { + svc := newSvc(t) + ctx := context.Background() + if _, err := svc.CreateOrder(ctx, "u1", "pro", 100); err != nil { + t.Fatal(err) + } + _, err := svc.CreateOrder(ctx, "u1", "pro", 100) + if !errors.Is(err, ErrUserHasActiveOrder) { + t.Fatalf("want ErrUserHasActiveOrder, got %v", err) + } + if _, err := svc.CreateOrder(ctx, "u2", "pro", 100); err != nil { + t.Fatalf("different user should be allowed: %v", err) } } func TestCreateOrderRejectsBadInput(t *testing.T) { - st, _ := store.Open(":memory:") - t.Cleanup(func() { _ = st.Close() }) - svc := New(st, Config{AccountXpub: testXpub}) + svc := newSvc(t) 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, "", "pro", 100); err == nil { + t.Fatal("empty userRef should error") } - if _, err := svc.CreateOrder(ctx, "", 100); err == nil { - t.Fatal("expected error for empty sku") + if _, err := svc.CreateOrder(ctx, "u1", "", 100); err == nil { + t.Fatal("empty sku should error") + } + if _, err := svc.CreateOrder(ctx, "u1", "pro", 0); err == nil { + t.Fatal("non-positive price should error") } } diff --git a/pay/internal/store/store.go b/pay/internal/store/store.go index 67e1bc3..901b923 100644 --- a/pay/internal/store/store.go +++ b/pay/internal/store/store.go @@ -1,5 +1,10 @@ -// 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 persists pay orders + orphan payments in SQLite (pure-Go +// modernc driver, no CGO — same choice as the control plane). +// +// Model: single fixed receiving address + a unique amount per order. Orders are +// matched by (amount == expect_amount) and (payment block time > order created), +// so a payment can never be misattributed to a later order that happens to share +// the same address. package store import ( @@ -19,13 +24,14 @@ const ( 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). +// Order is one payment request. Amounts are micro-USDT (1e-6), matching the raw +// integer value of a TRC20 USDT transfer (USDT has 6 decimals). ExpectAmount is +// the *unique* amount (base price + a small unique tail). type Order struct { OrderNo string + UserRef string SKU string ExpectAmount int64 - AddrIndex uint32 Address string Status Status TxID string @@ -42,7 +48,7 @@ func Open(dsn string) (*Store, error) { if err != nil { return nil, err } - db.SetMaxOpenConns(1) // SQLite: serialize writers, avoid "database is locked" + db.SetMaxOpenConns(1) // SQLite: serialize writers s := &Store{db: db} if err := s.migrate(); err != nil { _ = db.Close() @@ -57,9 +63,9 @@ func (s *Store) migrate() error { stmts := []string{ `CREATE TABLE IF NOT EXISTS pay_orders( order_no TEXT PRIMARY KEY, + user_ref TEXT NOT NULL, 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 '', @@ -67,8 +73,16 @@ func (s *Store) migrate() error { 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)`, + `CREATE INDEX IF NOT EXISTS idx_orders_amount_created ON pay_orders(expect_amount, created_at)`, + `CREATE INDEX IF NOT EXISTS idx_orders_user_status ON pay_orders(user_ref, status)`, + `CREATE TABLE IF NOT EXISTS orphan_payments( + tx_id TEXT PRIMARY KEY, + address TEXT NOT NULL, + value INTEGER NOT NULL, + block_ts INTEGER NOT NULL, + created_at INTEGER NOT NULL, + handled INTEGER NOT NULL DEFAULT 0 +)`, } for _, q := range stmts { if _, err := s.db.Exec(q); err != nil { @@ -78,41 +92,20 @@ func (s *Store) migrate() error { 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) + `INSERT INTO pay_orders(order_no,user_ref,sku,expect_amount,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()) + o.OrderNo, o.UserRef, o.SKU, o.ExpectAmount, 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` +const cols = `order_no,user_ref,sku,expect_amount,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 { + if err := sc.Scan(&o.OrderNo, &o.UserRef, &o.SKU, &o.ExpectAmount, &o.Address, &o.Status, &o.TxID, &created, &expires); err != nil { return nil, err } o.CreatedAt = time.Unix(created, 0) @@ -146,8 +139,29 @@ func (s *Store) ListPending(ctx context.Context) ([]*Order, error) { 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. +// ActiveOrderByUser returns the user's pending order, or (nil, ErrNotFound) if +// none — used to enforce "one active order per user". +func (s *Store) ActiveOrderByUser(ctx context.Context, userRef string) (*Order, error) { + row := s.db.QueryRowContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE user_ref=? AND status=? LIMIT 1`, userRef, StatusPending) + o, err := scanOrder(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return o, err +} + +// AmountRecentlyUsed reports whether any order with this expect_amount was +// created at/after sinceUnix — used to keep the unique amount collision-free +// within the late-payment window (so a stale payment can't match a new order). +func (s *Store) AmountRecentlyUsed(ctx context.Context, amount, sinceUnix int64) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM pay_orders WHERE expect_amount=? AND created_at>=?`, amount, sinceUnix).Scan(&n) + return n > 0, err +} + +// MarkPaid transitions pending->paid idempotently (only affects a still-pending +// row). Returns true if this call 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=?`, @@ -170,3 +184,22 @@ func (s *Store) MarkExpired(ctx context.Context, now time.Time) (int64, error) { n, _ := res.RowsAffected() return n, nil } + +// TxHandled reports whether a tx id has already been consumed — either matched +// to an order (pay_orders.tx_id) or recorded as an orphan. Guards idempotency. +func (s *Store) TxHandled(ctx context.Context, txID string) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT (SELECT COUNT(*) FROM pay_orders WHERE tx_id=?) + (SELECT COUNT(*) FROM orphan_payments WHERE tx_id=?)`, + txID, txID).Scan(&n) + return n > 0, err +} + +// RecordOrphan stores a payment that matched no active order (wrong amount / late +// after the address was reused). Idempotent on tx_id. Needs manual reconciliation. +func (s *Store) RecordOrphan(ctx context.Context, txID, address string, value, blockTs int64, now time.Time) error { + _, err := s.db.ExecContext(ctx, + `INSERT OR IGNORE INTO orphan_payments(tx_id,address,value,block_ts,created_at) VALUES(?,?,?,?,?)`, + txID, address, value, blockTs, now.Unix()) + return err +} diff --git a/pay/internal/store/store_test.go b/pay/internal/store/store_test.go index 4e62042..047abff 100644 --- a/pay/internal/store/store_test.go +++ b/pay/internal/store/store_test.go @@ -16,17 +16,10 @@ func openMem(t *testing.T) *Store { 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 mkOrder(no, user string, amount int64, addr string, now time.Time) *Order { + return &Order{ + OrderNo: no, UserRef: user, SKU: "pro", ExpectAmount: amount, Address: addr, + Status: StatusPending, CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute), } } @@ -34,33 +27,88 @@ 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 { + if err := s.CreateOrder(ctx, mkOrder("PAY1", "u1", 5_000017, "TADDR", now)); 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) + if got.UserRef != "u1" || got.ExpectAmount != 5_000017 || got.Status != StatusPending { + t.Fatalf("roundtrip: %+v", got) } - - ok, err := s.MarkPaid(ctx, "PAY1", "tx-abc") + ok, err := s.MarkPaid(ctx, "PAY1", "tx-a") if err != nil || !ok { - t.Fatalf("first MarkPaid ok=%v err=%v (want true,nil)", ok, err) + t.Fatalf("first MarkPaid ok=%v err=%v", ok, err) } - ok2, err := s.MarkPaid(ctx, "PAY1", "tx-dup") + ok2, err := s.MarkPaid(ctx, "PAY1", "tx-b") if err != nil || ok2 { - t.Fatalf("second MarkPaid ok=%v err=%v (want false,nil — idempotent)", ok2, err) + t.Fatalf("second MarkPaid ok=%v err=%v (want false)", 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) + if got.Status != StatusPaid || got.TxID != "tx-a" { + t.Fatalf("after paid: %s / %s", got.Status, got.TxID) + } +} + +func TestActiveOrderByUser(t *testing.T) { + s := openMem(t) + ctx := context.Background() + now := time.Unix(1_700_000_000, 0) + _ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 100, "T", now)) + + o, err := s.ActiveOrderByUser(ctx, "u1") + if err != nil || o.OrderNo != "PAY1" { + t.Fatalf("u1 active: %v %v", o, err) + } + if _, err := s.ActiveOrderByUser(ctx, "u2"); err != ErrNotFound { + t.Fatalf("u2 want ErrNotFound, got %v", err) + } + _, _ = s.MarkPaid(ctx, "PAY1", "tx") + if _, err := s.ActiveOrderByUser(ctx, "u1"); err != ErrNotFound { + t.Fatalf("paid should not be active: %v", err) + } +} + +func TestAmountRecentlyUsed(t *testing.T) { + s := openMem(t) + ctx := context.Background() + now := time.Unix(1_700_000_000, 0) + _ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 5_000017, "T", now)) + since := now.Add(-30 * time.Minute).Unix() + + if used, _ := s.AmountRecentlyUsed(ctx, 5_000017, since); !used { + t.Fatal("5_000017 should be recently used") + } + if used, _ := s.AmountRecentlyUsed(ctx, 5_000018, since); used { + t.Fatal("5_000018 not used") + } + if used, _ := s.AmountRecentlyUsed(ctx, 5_000017, now.Add(time.Minute).Unix()); used { + t.Fatal("outside window should be false") + } +} + +func TestTxHandledAndOrphan(t *testing.T) { + s := openMem(t) + ctx := context.Background() + now := time.Unix(1_700_000_000, 0) + _ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 100, "T", now)) + + if h, _ := s.TxHandled(ctx, "tx-x"); h { + t.Fatal("tx-x should be unhandled") + } + _, _ = s.MarkPaid(ctx, "PAY1", "tx-x") + if h, _ := s.TxHandled(ctx, "tx-x"); !h { + t.Fatal("matched tx should be handled") + } + if err := s.RecordOrphan(ctx, "tx-o", "T", 999, now.Unix(), now); err != nil { + t.Fatalf("orphan: %v", err) + } + if h, _ := s.TxHandled(ctx, "tx-o"); !h { + t.Fatal("orphan tx should be handled") + } + if err := s.RecordOrphan(ctx, "tx-o", "T", 999, now.Unix(), now); err != nil { + t.Fatalf("orphan idempotent: %v", err) } } @@ -68,21 +116,16 @@ 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) + _ = s.CreateOrder(ctx, &Order{OrderNo: "old", UserRef: "u1", SKU: "x", ExpectAmount: 1, Address: "T", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(time.Minute)}) + _ = s.CreateOrder(ctx, &Order{OrderNo: "new", UserRef: "u2", SKU: "x", ExpectAmount: 2, Address: "T", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(time.Hour)}) 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) + t.Fatalf("MarkExpired n=%d err=%v", 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) + o1, _ := s.GetOrder(ctx, "old") + o2, _ := s.GetOrder(ctx, "new") + if o1.Status != StatusExpired || o2.Status != StatusPending { + t.Fatalf("old=%s new=%s", o1.Status, o2.Status) } } diff --git a/pay/internal/tron/client.go b/pay/internal/tron/client.go index e21eb43..4844019 100644 --- a/pay/internal/tron/client.go +++ b/pay/internal/tron/client.go @@ -18,10 +18,13 @@ 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). +// BlockTs is the on-chain block time in **unix seconds** — used to reject a +// payment that arrived before the order it might match was created. type Transfer struct { - TxID string - To string - Value int64 + TxID string + To string + Value int64 + BlockTs int64 } // Fetcher returns confirmed incoming USDT transfers to a given address. @@ -67,10 +70,11 @@ func (c *Client) IncomingTransfers(ctx context.Context, address string) ([]Trans } var body struct { Data []struct { - TransactionID string `json:"transaction_id"` - To string `json:"to"` - Value string `json:"value"` - Type string `json:"type"` + TransactionID string `json:"transaction_id"` + To string `json:"to"` + Value string `json:"value"` + Type string `json:"type"` + BlockTimestamp int64 `json:"block_timestamp"` // milliseconds } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { @@ -85,7 +89,7 @@ func (c *Client) IncomingTransfers(ctx context.Context, address string) ([]Trans 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}) + out = append(out, Transfer{TxID: d.TransactionID, To: d.To, Value: v, BlockTs: d.BlockTimestamp / 1000}) } return out, nil } diff --git a/pay/internal/watcher/watcher.go b/pay/internal/watcher/watcher.go index 2c4e868..56d1f25 100644 --- a/pay/internal/watcher/watcher.go +++ b/pay/internal/watcher/watcher.go @@ -1,6 +1,7 @@ -// 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 polls TronGrid for incoming USDT to the single receiving +// address and matches each confirmed payment to a pending order by exact amount +// + block time. 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 ( @@ -13,23 +14,28 @@ import ( ) type Watcher struct { - st *store.Store - tron tron.Fetcher - log *slog.Logger - now func() time.Time + st *store.Store + tron tron.Fetcher + address string + log *slog.Logger + now func() time.Time } -func New(st *store.Store, f tron.Fetcher, log *slog.Logger) *Watcher { +func New(st *store.Store, f tron.Fetcher, address string, log *slog.Logger) *Watcher { if log == nil { log = slog.Default() } - return &Watcher{st: st, tron: f, log: log, now: time.Now} + return &Watcher{st: st, tron: f, address: address, 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). +// Tick: +// 1. expire overdue pending orders; +// 2. fetch confirmed incoming USDT transfers to the single receiving address; +// 3. match each transfer to a pending order by **exact amount** and **block time +// after the order was created**; a confirmed transfer that matches no active +// order (wrong amount / late after reuse) is recorded as an orphan. +// +// Idempotent: a tx already matched to an order or recorded as orphan is skipped. func (w *Watcher) Tick(ctx context.Context) error { if n, err := w.st.MarkExpired(ctx, w.now()); err != nil { return err @@ -37,31 +43,54 @@ func (w *Watcher) Tick(ctx context.Context) error { w.log.Info("orders expired", "count", n) } + transfers, err := w.tron.IncomingTransfers(ctx, w.address) + if err != nil { + w.log.Warn("fetch transfers failed", "err", err) + return nil // transient (rate limit / network); retried next tick + } + if len(transfers) == 0 { + return nil + } + pending, err := w.st.ListPending(ctx) if err != nil { return err } + // Index pending orders by their unique expect amount. + byAmount := make(map[int64]*store.Order, len(pending)) for _, o := range pending { - transfers, err := w.tron.IncomingTransfers(ctx, o.Address) + byAmount[o.ExpectAmount] = o + } + + for _, t := range transfers { + handled, err := w.st.TxHandled(ctx, t.TxID) 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) + w.log.Error("tx handled check", "tx", t.TxID, "err", err) continue } - for _, t := range transfers { - if t.Value < o.ExpectAmount { - continue - } + if handled { + continue // already matched or already an orphan + } + + if o := byAmount[t.Value]; o != nil && t.BlockTs > o.CreatedAt.Unix() { ok, err := w.st.MarkPaid(ctx, o.OrderNo, t.TxID) if err != nil { w.log.Error("mark paid", "order", o.OrderNo, "err", err) - break + continue } if ok { - w.log.Info("order paid", "order", o.OrderNo, "tx", t.TxID, "value", t.Value, "address", o.Address) + w.log.Info("order paid", "order", o.OrderNo, "tx", t.TxID, "value", t.Value) + delete(byAmount, t.Value) // a second transfer of the same amount can't reuse this order } - break + continue } + + // Confirmed payment matching no active order -> orphan (needs reconciliation). + if err := w.st.RecordOrphan(ctx, t.TxID, w.address, t.Value, t.BlockTs, w.now()); err != nil { + w.log.Error("record orphan", "tx", t.TxID, "err", err) + continue + } + w.log.Warn("orphan payment", "tx", t.TxID, "value", t.Value, "block_ts", t.BlockTs) } return nil } diff --git a/pay/internal/watcher/watcher_test.go b/pay/internal/watcher/watcher_test.go index ad0c97f..050d619 100644 --- a/pay/internal/watcher/watcher_test.go +++ b/pay/internal/watcher/watcher_test.go @@ -9,81 +9,159 @@ import ( "github.com/wangjia/pangolin/pay/internal/tron" ) -type mockFetcher struct{ m map[string][]tron.Transfer } +const recvAddr = "TRecv00000000000000000000000000000A" -func (f *mockFetcher) IncomingTransfers(_ context.Context, addr string) ([]tron.Transfer, error) { - return f.m[addr], nil +type mockFetcher struct{ transfers []tron.Transfer } + +func (f *mockFetcher) IncomingTransfers(_ context.Context, _ string) ([]tron.Transfer, error) { + return f.transfers, nil } -func newPending(t *testing.T, st *store.Store, orderNo, addr string, amount int64, expires time.Time) { +func memStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.Open(":memory:") + if err != nil { + t.Fatalf("store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func seed(t *testing.T, st *store.Store, no string, amount int64, created 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, + OrderNo: no, UserRef: "u", SKU: "pro", ExpectAmount: amount, Address: recvAddr, + Status: store.StatusPending, CreatedAt: created, ExpiresAt: created.Add(time.Hour), } if err := st.CreateOrder(context.Background(), o); err != nil { - t.Fatalf("seed order: %v", err) + t.Fatalf("seed: %v", err) } } -func TestWatcherMarksPaidOnSufficientTransfer(t *testing.T) { - st, _ := store.Open(":memory:") - t.Cleanup(func() { _ = st.Close() }) +func TestWatcherMatchesByAmountAndTime(t *testing.T) { + st := memStore(t) ctx := context.Background() now := time.Unix(1_700_000_100, 0) + created := now.Add(-5 * time.Minute) + seed(t, st, "PAY1", 5_000017, created) + seed(t, st, "PAY2", 5_000018, created) - 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) + fetch := &mockFetcher{transfers: []tron.Transfer{ + {TxID: "tx1", To: recvAddr, Value: 5_000017, BlockTs: created.Add(time.Minute).Unix()}, + }} + w := New(st, fetch, recvAddr, 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) + o1, _ := st.GetOrder(ctx, "PAY1") + if o1.Status != store.StatusPaid || o1.TxID != "tx1" { + t.Fatalf("PAY1 %s/%s", o1.Status, o1.TxID) + } + o2, _ := st.GetOrder(ctx, "PAY2") + if o2.Status != store.StatusPending { + t.Fatalf("PAY2 should stay pending, got %s", o2.Status) + } + + if err := w.Tick(ctx); err != nil { // idempotent + t.Fatalf("tick2: %v", err) + } + o1, _ = st.GetOrder(ctx, "PAY1") + if o1.Status != store.StatusPaid || o1.TxID != "tx1" { + t.Fatal("idempotency broken") + } +} + +func TestWatcherWrongAmountIsOrphan(t *testing.T) { + st := memStore(t) + ctx := context.Background() + now := time.Unix(1_700_000_100, 0) + created := now.Add(-5 * time.Minute) + seed(t, st, "PAY1", 5_000017, created) + + fetch := &mockFetcher{transfers: []tron.Transfer{ + {TxID: "tx-wrong", To: recvAddr, Value: 5_000000, BlockTs: created.Add(time.Minute).Unix()}, + }} + w := New(st, fetch, recvAddr, nil) + w.now = func() time.Time { return now } + _ = w.Tick(ctx) + + o, _ := st.GetOrder(ctx, "PAY1") + if o.Status != store.StatusPending { + t.Fatalf("PAY1 should stay pending, got %s", o.Status) + } + if h, _ := st.TxHandled(ctx, "tx-wrong"); !h { + t.Fatal("wrong-amount payment should be recorded as orphan") + } +} + +func TestWatcherLatePaymentDoesNotMatchNewOrder(t *testing.T) { + // Order1 (amount 5_000017) expired; a NEW order (amount 5_000018) is now active + // on the SAME address. A late payment of the OLD amount must NOT match the new + // order (different amount) -> orphan. + st := memStore(t) + ctx := context.Background() + now := time.Unix(1_700_000_500, 0) + seed(t, st, "PAY2", 5_000018, now.Add(-time.Minute)) + + fetch := &mockFetcher{transfers: []tron.Transfer{ + {TxID: "tx-late", To: recvAddr, Value: 5_000017, BlockTs: now.Unix()}, + }} + w := New(st, fetch, recvAddr, nil) + w.now = func() time.Time { return now } + _ = w.Tick(ctx) + + o2, _ := st.GetOrder(ctx, "PAY2") + if o2.Status != store.StatusPending { + t.Fatalf("PAY2 must not be matched by a wrong-amount late payment, got %s", o2.Status) + } + if h, _ := st.TxHandled(ctx, "tx-late"); !h { + t.Fatal("late payment should be orphan") + } +} + +func TestWatcherIgnoresPaymentBeforeOrder(t *testing.T) { + // A payment whose block time is BEFORE the order was created must not match + // (guards address reuse: prior balance / old tx). + st := memStore(t) + ctx := context.Background() + now := time.Unix(1_700_000_500, 0) + created := now.Add(-2 * time.Minute) + seed(t, st, "PAY1", 5_000017, created) + + fetch := &mockFetcher{transfers: []tron.Transfer{ + {TxID: "tx-old", To: recvAddr, Value: 5_000017, BlockTs: created.Add(-time.Minute).Unix()}, + }} + w := New(st, fetch, recvAddr, nil) + w.now = func() time.Time { return now } + _ = w.Tick(ctx) + + o, _ := st.GetOrder(ctx, "PAY1") + if o.Status != store.StatusPending { + t.Fatalf("payment before order must not match, got %s", o.Status) + } + if h, _ := st.TxHandled(ctx, "tx-old"); !h { + t.Fatal("pre-order payment should be orphan") + } +} + +func TestWatcherExpires(t *testing.T) { + st := memStore(t) + ctx := context.Background() + now := time.Unix(1_700_000_500, 0) + o := &store.Order{ + OrderNo: "OLD", UserRef: "u", SKU: "pro", ExpectAmount: 1, Address: recvAddr, + Status: store.StatusPending, CreatedAt: now.Add(-time.Hour), ExpiresAt: now.Add(-time.Minute), + } + _ = st.CreateOrder(ctx, o) + + w := New(st, &mockFetcher{}, recvAddr, nil) + w.now = func() time.Time { return now } + _ = w.Tick(ctx) + + got, _ := st.GetOrder(ctx, "OLD") + if got.Status != store.StatusExpired { + t.Fatalf("want expired, got %s", got.Status) } }