From fbd3bd6f36dbbe86b770715bb4ccbf22b1284462 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 14:33:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2):=20crypto=20adapter=20=E2=80=94=20?= =?UTF-8?q?=E7=A7=BB=E6=A4=8D=20pangolin-pay=20=E5=8D=95=E5=9C=B0=E5=9D=80?= =?UTF-8?q?+=E5=94=AF=E4=B8=80=E9=87=91=E9=A2=9D=E6=A8=A1=E5=9E=8B(only=5F?= =?UTF-8?q?confirmed=20=E6=89=AB=E9=93=BE,VerifyCallback=20=E4=B8=8D?= =?UTF-8?q?=E9=80=82=E7=94=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/crypto/crypto.go | 260 ++++++++++++++++++++++++ internal/provider/crypto/crypto_test.go | 143 +++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 internal/provider/crypto/crypto.go create mode 100644 internal/provider/crypto/crypto_test.go diff --git a/internal/provider/crypto/crypto.go b/internal/provider/crypto/crypto.go new file mode 100644 index 0000000..92b0eaa --- /dev/null +++ b/internal/provider/crypto/crypto.go @@ -0,0 +1,260 @@ +// Package crypto ports pangolin-pay's self-hosted USDT-TRC20 receiving model to +// provider.Provider: a single fixed receiving address per account + a unique +// amount per order (base price + a random micro tail in [1,9999], reserved +// against reuse for a cooldown window longer than the payment TTL). Settlement +// is query-only: poll TronGrid (only_confirmed) and match by exact amount + +// block time after order creation. No keys are ever held here; sweeping to cold +// storage is a separate offline step. +// +// Canonical source (logic ported, no import): pangolin repo ref +// origin/worktree-macos-killswitch:pay/ — internal/pay/service.go (allocateAmount), +// internal/watcher/watcher.go (Tick matching), internal/tron/client.go (IncomingTransfers). +// The tail rides in ProviderRef ("CRYPTO--") so Query can +// recompute the expected amount without touching the frozen attempt.AmountMinor. +package crypto + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/money" + "github.com/wangjia/pay/internal/provider" +) + +// USDTContract 主网 TRC20 USDT 合约地址(6 位小数,最小单位=money USDT minor)。 +const USDTContract = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + +const ( + defaultBaseURL = "https://api.trongrid.io" + refPrefix = "CRYPTO-" + orderTTL = 15 * time.Minute // canonical OrderTTL:支付窗 + amountCooldown = 30 * time.Minute // canonical AmountCooldown:金额预留窗,须 > orderTTL(迟到旧款不可能匹配复用金额的新单) + tailMax = 9999 // 唯一金额尾数 ∈ [1,9999] 微USDT,≤0.01 USDT +) + +type Provider struct { + accts *accounts.Registry + baseURL string + http *http.Client + now func() time.Time + + mu sync.Mutex + reserved map[string]time.Time // "/" → 预留到期(canonical AmountRecentlyUsed 的进程内等价) +} + +type Option func(*Provider) + +func WithBaseURL(u string) Option { return func(p *Provider) { p.baseURL = u } } +func WithHTTPClient(c *http.Client) Option { return func(p *Provider) { p.http = c } } + +func New(accts *accounts.Registry, opts ...Option) *Provider { + p := &Provider{ + accts: accts, + baseURL: defaultBaseURL, + http: &http.Client{Timeout: 15 * time.Second}, + now: time.Now, + reserved: map[string]time.Time{}, + } + for _, o := range opts { + o(p) + } + return p +} + +func (p *Provider) Method() string { return "crypto" } + +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderCryptoAddress}, + SupportsRefund: false, + SettleCurrencies: []string{"USDT"}, + Regions: []string{"global"}, + } +} + +func (p *Provider) address(accountID string) (string, error) { + a := p.accts.Credential(accountID, "ADDRESS") + if a == "" { + return "", fmt.Errorf("crypto: 账户 %s 未配置收款地址(env _ADDRESS)", accountID) + } + return a, nil +} + +func (p *Provider) apiKey(accountID string) string { + if k := p.accts.Credential(accountID, "TRONGRID_KEY"); k != "" { + return k + } + return os.Getenv("TRONGRID_API_KEY") +} + +// allocateAmount 移植 canonical pay/service.go:随机尾数 [1,tailMax] + 冷却预留, +// 保证同(地址,金额)在冷却窗内唯一——迟到付款绝不可能匹配到新单。64 次重试。 +func (p *Provider) allocateAmount(accountID string, base int64) (amount, tail int64, err error) { + p.mu.Lock() + defer p.mu.Unlock() + now := p.now() + for k, until := range p.reserved { // 顺手清理过期预留,map 不长胖 + if now.After(until) { + delete(p.reserved, k) + } + } + for attempt := 0; attempt < 64; attempt++ { + t, rerr := randInt(tailMax) // [1, tailMax] + if rerr != nil { + return 0, 0, rerr + } + amt := base + t + key := accountID + "/" + strconv.FormatInt(amt, 10) + if _, used := p.reserved[key]; used { + continue + } + p.reserved[key] = now.Add(amountCooldown) + return amt, t, nil + } + return 0, 0, fmt.Errorf("crypto: 无法分配唯一金额(同价并发单过多?)") +} + +// randInt returns a uniform integer in [1, max](canonical 同名函数原样)。 +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 +} + +// tailFromRef 解析 "CRYPTO--" 的尾数(最后一个 '-' 之后)。 +func tailFromRef(ref string) (int64, error) { + i := strings.LastIndex(ref, "-") + if i < 0 || i == len(ref)-1 { + return 0, fmt.Errorf("crypto: provider_ref 无尾数: %q", ref) + } + return strconv.ParseInt(ref[i+1:], 10, 64) +} + +func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { + if req.Currency != "USDT" { + return nil, fmt.Errorf("crypto: 仅支持 USDT, got %s", req.Currency) + } + addr, err := p.address(req.Account.AccountID) + if err != nil { + return nil, err + } + expected, tail, err := p.allocateAmount(req.Account.AccountID, req.AmountMinor) + if err != nil { + return nil, err + } + display, err := money.Format(expected, "USDT") + if err != nil { + return nil, err + } + exp := p.now().Add(orderTTL) + return &provider.Session{ + ProviderRef: refPrefix + req.OutTradeNo + "-" + strconv.FormatInt(tail, 10), + RenderType: provider.RenderCryptoAddress, + Payload: map[string]any{ + "address": addr, + "amount": display, // 如 "29.997263":用户须付此精确额,唯一金额即订单身份 + "amount_minor": expected, + "currency": "USDT", + "network": "TRC20", + "contract": USDTContract, + }, + ExpiresAt: &exp, + }, nil +} + +// VerifyCallback: 自托管无渠道异步回调(canonical 即 watcher 轮询),入账只走查单兜底。 +func (p *Provider) VerifyCallback(_ context.Context, _ provider.CallbackInput) (*provider.PaidEvent, error) { + return nil, provider.ErrNotSupported +} + +// trc20Resp 对应 TronGrid /v1/accounts/{addr}/transactions/trc20 响应 +// (canonical tron/client.go 同构;contract_address 查询参数已在服务端过滤合约)。 +type trc20Resp struct { + Data []struct { + TxID string `json:"transaction_id"` + To string `json:"to"` + Type string `json:"type"` + Value string `json:"value"` + BlockMs int64 `json:"block_timestamp"` // 毫秒 + } `json:"data"` +} + +// Query 移植 canonical watcher.Tick 的匹配:已确认(only_confirmed)到账中, +// 精确等于期望金额且块时晚于建单的一笔 → succeeded;否则 pending。 +func (p *Provider) Query(ctx context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) { + pending := &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending} + if req.Currency != "USDT" { + return pending, nil + } + tail, err := tailFromRef(req.ProviderRef) + if err != nil { + return nil, err + } + expected := req.AmountMinor + tail + addr, err := p.address(req.AccountID) + if err != nil { + return nil, err + } + + endpoint := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_confirmed=true&contract_address=%s&limit=50", + p.baseURL, url.PathEscape(addr), url.QueryEscape(USDTContract)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + if k := p.apiKey(req.AccountID); k != "" { + httpReq.Header.Set("TRON-PRO-API-KEY", k) + } + resp, err := p.http.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("crypto: TronGrid HTTP %d: %s", resp.StatusCode, body) + } + var tr trc20Resp + if err := json.Unmarshal(body, &tr); err != nil { + return nil, err + } + + createdUnix := req.CreatedAt.Unix() + for _, d := range tr.Data { + if d.To != addr || d.Type != "Transfer" { + continue + } + val, perr := strconv.ParseInt(d.Value, 10, 64) + if perr != nil || val != expected { // 唯一金额精确匹配 + continue + } + blockTs := d.BlockMs / 1000 + if blockTs <= createdUnix { // 块时必须晚于建单:拒迟到旧款(canonical t.BlockTs > o.CreatedAt) + continue + } + paidAt := time.Unix(blockTs, 0) + return &provider.PaidEvent{ + ProviderRef: req.ProviderRef, + Status: provider.PaidSucceeded, + PaidAmountMinor: val, + PaidCurrency: "USDT", + Raw: d.TxID, + PaidAt: &paidAt, + }, nil + } + return pending, nil +} diff --git a/internal/provider/crypto/crypto_test.go b/internal/provider/crypto/crypto_test.go new file mode 100644 index 0000000..0f7ac3b --- /dev/null +++ b/internal/provider/crypto/crypto_test.go @@ -0,0 +1,143 @@ +package crypto_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/provider/crypto" +) + +const addr = "TWreceiveADDRESS0000000000000000000" + +func newProv(t *testing.T, ts *httptest.Server) *crypto.Provider { + t.Setenv("CRY_ADDRESS", addr) + t.Setenv("CRY_TRONGRID_KEY", "test-key") + reg := accounts.New([]config.AccountConfig{ + {AccountID: "cry-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "cry"}, + }) + return crypto.New(reg, crypto.WithBaseURL(ts.URL), crypto.WithHTTPClient(ts.Client())) +} + +// 唯一金额分配(canonical allocateAmount 语义):同价并发单分到不同尾数金额, +// 尾数 ∈ [1,9999] 微USDT,且经 ProviderRef 往返。 +func TestCreateAllocatesUniqueTailedAmount(t *testing.T) { + ts := httptest.NewServer(http.NotFoundHandler()) // Create 不打网 + defer ts.Close() + p := newProv(t, ts) + + mk := func(outNo string) *provider.Session { + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: outNo, AmountMinor: 29990000, Currency: "USDT", + Account: config.AccountConfig{AccountID: "cry-1", CredentialEnvPrefix: "cry"}, + }) + if err != nil { + t.Fatalf("create %s: %v", outNo, err) + } + return sess + } + s1, s2 := mk("PAY-A"), mk("PAY-B") + + if s1.RenderType != provider.RenderCryptoAddress { + t.Fatalf("render_type = %s", s1.RenderType) + } + if s1.Payload["address"] != addr { + t.Fatalf("address = %v want %s", s1.Payload["address"], addr) + } + a1, _ := s1.Payload["amount_minor"].(int64) + a2, _ := s2.Payload["amount_minor"].(int64) + for _, a := range []int64{a1, a2} { + if a <= 29990000 || a > 29990000+9999 { + t.Fatalf("amount_minor = %d 不在 (base, base+9999]", a) + } + } + if a1 == a2 { + t.Fatalf("冷却窗内两单分到同一唯一金额: %d(预留失效)", a1) + } + // 尾数编进 provider_ref,Query 端可复原期望金额 + if want := fmt.Sprintf("CRYPTO-PAY-A-%d", a1-29990000); s1.ProviderRef != want { + t.Fatalf("provider_ref = %s want %s", s1.ProviderRef, want) + } + if s1.ExpiresAt == nil { + t.Fatal("应带 15min 支付窗 ExpiresAt") + } +} + +// canonical watcher 匹配:only_confirmed + 精确金额 + 块时晚于建单。 +func TestQueryMatchesConfirmedExactAmount(t *testing.T) { + const base, tail = int64(29990000), int64(777) + expected := base + tail + created := time.Now().Add(-10 * time.Minute) + req := provider.QueryRequest{ + ProviderRef: "CRYPTO-PAY-A-777", OutTradeNo: "PAY-A", AccountID: "cry-1", + AmountMinor: base, Currency: "USDT", CreatedAt: created, + } + + run := func(value, blockMs int64) (*provider.PaidEvent, url.Values) { + var seen url.Values + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.URL.Query() + if r.Header.Get("TRON-PRO-API-KEY") == "" { + http.Error(w, "no key", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "data": []map[string]any{{ + "transaction_id": "tx1", + "to": addr, + "type": "Transfer", + "value": strconv.FormatInt(value, 10), + "block_timestamp": blockMs, // 毫秒(TronGrid 口径),adapter 内 /1000 成秒 + }}, + }) + })) + defer ts.Close() + p := newProv(t, ts) + ev, err := p.Query(context.Background(), req) + if err != nil { + t.Fatalf("query: %v", err) + } + return ev, seen + } + + // A. 已确认 + 金额精确 + 块时晚于建单 → succeeded + ev, seen := run(expected, time.Now().Add(-2*time.Minute).UnixMilli()) + if ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != expected || ev.PaidCurrency != "USDT" { + t.Fatalf("A: event = %+v", ev) + } + if ev.PaidAt == nil || ev.Raw != "tx1" { + t.Fatalf("A: PaidAt/Raw = %+v", ev) + } + if seen.Get("only_confirmed") != "true" { + t.Fatalf("必须带 only_confirmed=true(canonical 确认标准), query = %v", seen) + } + + // B. 金额差 1 微USDT → pending(唯一金额精确匹配,不误判) + if ev, _ := run(expected-1, time.Now().Add(-2*time.Minute).UnixMilli()); ev.Status != provider.PaidPending { + t.Fatalf("B: 金额不符应 pending, got %+v", ev) + } + + // C. 块时早于建单(迟到旧款/金额复用场景)→ pending + if ev, _ := run(expected, created.Add(-time.Hour).UnixMilli()); ev.Status != provider.PaidPending { + t.Fatalf("C: 块时早于建单应 pending, got %+v", ev) + } +} + +func TestVerifyCallbackNotSupported(t *testing.T) { + ts := httptest.NewServer(http.NotFoundHandler()) + defer ts.Close() + p := newProv(t, ts) + if _, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte("{}")}); err == nil { + t.Fatal("crypto 无异步回调,VerifyCallback 应返回 ErrNotSupported") + } +}