diff --git a/pay/README.md b/pay/README.md new file mode 100644 index 0000000..4335caf --- /dev/null +++ b/pay/README.md @@ -0,0 +1,63 @@ +# pangolin-pay + +自托管 USDT(TRC20) 收款服务。给每个订单派生一个**唯一收款地址**(watch-only,从账户 +xpub 派生,**不持私钥**),轮询 TronGrid 侦测到账、确认后标记订单已付。归集(把钱扫到冷 +钱包)是**独立的离线步骤**,本服务不碰私钥。 + +> 属 #34「独角数卡 + USDT 收款闭环」的加密货币交易引擎(计划见 +> `docs/superpowers/plans/2026-07-09-crypto-tx-engine.md`)。概念见 brain +> `notes/dev/crypto-hd-wallet-basics.html`。 + +## 安全模型(必读) + +- 本服务(热、联网)**只持 account xpub**——能派生收款地址、能查到账,**拿不到任何私钥**。被脱库也转不走钱。 +- 私钥/助记词**冷存**;要动钱(归集)时才在**离线端**用私钥签名(见 seed.go,OFFLINE ONLY)。 +- 密钥(xpub / TronGrid key)走 Bitwarden,不入 git、不写死。 + +## Phase A —— 离线准备钱包(你在断网机器上做) + +1. 断网,用 Ian Coleman `bip39-standalone.html`(或 `bip_utils`)生成**两套** 24 词助记词: + 钱包 A(运营收款)、钱包 B(冷备金库)。Coin=TRX、English。 +2. 取**钱包 A 的 Account Extended Public Key**(`m/44'/195'/0'` 的 xpub)→ 就是本服务的 `PAY_ACCOUNT_XPUB`。 +3. 取**钱包 B 的地址0**(`T...`)→ 归集目标(Phase D 用)。 +4. **交叉核对(关键)**:本仓自带的金标准向量(测试助记词 `abandon…about`)必须与 Ian Coleman + 一致 —— 跑 `go test ./internal/wallet -run TestKnownVector -v`,再在 Ian Coleman 里用同一测试 + 助记词、Coin=TRX 对照前 3 个地址。一致 = 派生实现可信;不一致 = 有 bug,别上线。 +5. 助记词 A/B 分开冷存 + Bitwarden。**只把 xpub_A 交给本服务。** + +## 运行 + +```bash +export PAY_ACCOUNT_XPUB="xpub..." # 钱包 A 的 account xpub(必填) +export TRONGRID_API_KEY="..." # TronGrid key(建议) +export PAY_DB="pay.db" # SQLite 路径(默认 pay.db) +export PAY_ADDR=":8090" # 监听(默认 :8090) +export PAY_POLL_SECONDS="20" # 轮询间隔秒(默认 20) +# USDT_CONTRACT / TRONGRID_BASE 默认主网 +go run ./cmd/paywatch +``` + +## API + +``` +POST /order {"sku":"pro-year","amount":5000000} # amount = micro-USDT(1e-6) + → 201 {"order_no","address","expect_amount","status":"pending","expires_at"} +GET /order/{orderNo} → 200 {..., "status":"pending|paid|expired","tx_id"} +GET /healthz → 200 ok +``` + +门面(独角数卡)下单时调 `POST /order` 拿收款地址;支付页轮询 `GET /order/{id}` 直到 `paid`。 + +## Phase D —— 归集(离线,后续) + +`internal/wallet/seed.go` 提供离线派生私钥的原语(`PrivKeyHexFromMnemonic`)。完整的 sweep +(构造 TRC20 transfer → 离线签名 → 广播)+ gas 处理 + runbook 在后续切片实现。 + +## 测试 + +```bash +go test ./... +``` + +- `wallet`:派生一致性(xpub 路径 == 私钥路径)+ 金标准向量(需 A.4 核对)。 +- `store`/`pay`/`watcher`/`httpapi`:建单/派生地址/侦测到账/幂等/超时/HTTP。 diff --git a/pay/cmd/paywatch/main.go b/pay/cmd/paywatch/main.go new file mode 100644 index 0000000..bff656b --- /dev/null +++ b/pay/cmd/paywatch/main.go @@ -0,0 +1,85 @@ +// Command paywatch is the pangolin-pay service: it hands out per-order TRON +// receiving addresses (watch-only, derived from an account xpub), watches +// TronGrid for confirmed USDT payments, and marks orders paid. It holds NO +// private keys — sweeping funds to cold storage is a separate offline step. +// +// Env: +// +// PAY_ACCOUNT_XPUB (required) watch-only account xpub, m/44'/195'/0' +// PAY_DB SQLite path (default pay.db) +// PAY_ADDR HTTP listen addr (default :8090) +// PAY_POLL_SECONDS watcher poll interval (default 20) +// TRONGRID_BASE TronGrid base URL (default https://api.trongrid.io) +// TRONGRID_API_KEY TronGrid API key (recommended) +// USDT_CONTRACT TRC20 USDT contract (default mainnet) +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/wangjia/pangolin/pay/internal/httpapi" + "github.com/wangjia/pangolin/pay/internal/pay" + "github.com/wangjia/pangolin/pay/internal/store" + "github.com/wangjia/pangolin/pay/internal/tron" + "github.com/wangjia/pangolin/pay/internal/watcher" +) + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +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) + } + dbPath := env("PAY_DB", "pay.db") + addr := env("PAY_ADDR", ":8090") + pollSec, _ := strconv.Atoi(env("PAY_POLL_SECONDS", "20")) + if pollSec <= 0 { + pollSec = 20 + } + + st, err := store.Open("file:" + dbPath + "?_txlock=immediate") + if err != nil { + log.Error("open store", "err", err) + os.Exit(1) + } + defer func() { _ = st.Close() }() + + svc := pay.New(st, pay.Config{AccountXpub: xpub, OrderTTL: 15 * time.Minute}) + fetcher := tron.NewClient(env("TRONGRID_BASE", ""), env("USDT_CONTRACT", ""), os.Getenv("TRONGRID_API_KEY")) + w := watcher.New(st, fetcher, log) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + go w.Loop(ctx, time.Duration(pollSec)*time.Second) + + srv := &http.Server{Addr: addr, Handler: httpapi.New(svc), ReadHeaderTimeout: 10 * time.Second} + go func() { + log.Info("pangolin-pay listening", "addr", addr, "poll_seconds", pollSec) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error("http server", "err", err) + stop() + } + }() + + <-ctx.Done() + sc, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(sc) + log.Info("pangolin-pay stopped") +} diff --git a/pay/go.mod b/pay/go.mod index 76ebf63..3df7d01 100644 --- a/pay/go.mod +++ b/pay/go.mod @@ -7,12 +7,21 @@ require ( github.com/btcsuite/btcd/btcutil v1.2.0 github.com/tyler-smith/go-bip39 v1.1.0 golang.org/x/crypto v0.53.0 + modernc.org/sqlite v1.53.0 ) require ( github.com/btcsuite/btcd/btcec/v2 v2.3.5 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sys v0.46.0 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/pay/go.sum b/pay/go.sum index ba6af72..57e6ef8 100644 --- a/pay/go.sum +++ b/pay/go.sum @@ -11,10 +11,24 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee h1:FPP9HDkBbPyniu+u7FHZg+kKFX1WW0gxOGteJ0h3AJk= github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee/go.mod h1:N6sz6HwJAenJ6d+/xmSl0ikfV05ZrVGmjt1ryy/WOtE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= @@ -23,11 +37,46 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/pay/internal/httpapi/handler.go b/pay/internal/httpapi/handler.go new file mode 100644 index 0000000..a85e32c --- /dev/null +++ b/pay/internal/httpapi/handler.go @@ -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)) +} diff --git a/pay/internal/httpapi/handler_test.go b/pay/internal/httpapi/handler_test.go new file mode 100644 index 0000000..1fcb3d0 --- /dev/null +++ b/pay/internal/httpapi/handler_test.go @@ -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() +} diff --git a/pay/internal/pay/service.go b/pay/internal/pay/service.go new file mode 100644 index 0000000..b2ddbfd --- /dev/null +++ b/pay/internal/pay/service.go @@ -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) +} diff --git a/pay/internal/pay/service_test.go b/pay/internal/pay/service_test.go new file mode 100644 index 0000000..6c56ea9 --- /dev/null +++ b/pay/internal/pay/service_test.go @@ -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") + } +} diff --git a/pay/internal/store/store.go b/pay/internal/store/store.go new file mode 100644 index 0000000..67e1bc3 --- /dev/null +++ b/pay/internal/store/store.go @@ -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 +} diff --git a/pay/internal/store/store_test.go b/pay/internal/store/store_test.go new file mode 100644 index 0000000..4e62042 --- /dev/null +++ b/pay/internal/store/store_test.go @@ -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) + } +} diff --git a/pay/internal/tron/client.go b/pay/internal/tron/client.go new file mode 100644 index 0000000..e21eb43 --- /dev/null +++ b/pay/internal/tron/client.go @@ -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 +} diff --git a/pay/internal/watcher/watcher.go b/pay/internal/watcher/watcher.go new file mode 100644 index 0000000..2c4e868 --- /dev/null +++ b/pay/internal/watcher/watcher.go @@ -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) + } + } + } +} diff --git a/pay/internal/watcher/watcher_test.go b/pay/internal/watcher/watcher_test.go new file mode 100644 index 0000000..ad0c97f --- /dev/null +++ b/pay/internal/watcher/watcher_test.go @@ -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) + } +}