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") } } // 预留键按地址而非 accountID 维度:防止共享地址的不同账户在同金额上碰撞。 // 两个 accountID 配置相同 ADDRESS,分别创建订单 → 验证预留键格式为 "address/amount", // 而非 "accountID/amount"。 func TestReservationKeyByAddress(t *testing.T) { const sharedAddr = "TSHARED_ADDRESS_FOR_TEST" t.Setenv("ACC1_ADDRESS", sharedAddr) t.Setenv("ACC1_TRONGRID_KEY", "key1") t.Setenv("ACC2_ADDRESS", sharedAddr) t.Setenv("ACC2_TRONGRID_KEY", "key2") ts := httptest.NewServer(http.NotFoundHandler()) defer ts.Close() reg := accounts.New([]config.AccountConfig{ {AccountID: "acc-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "acc1"}, {AccountID: "acc-2", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "acc2"}, }) p := crypto.New(reg, crypto.WithBaseURL(ts.URL), crypto.WithHTTPClient(ts.Client())) base := int64(50000000) // 为账户 1 创建订单 sess1, err1 := p.Create(context.Background(), provider.CreateRequest{ OutTradeNo: "ORDER-1", AmountMinor: base, Currency: "USDT", Account: config.AccountConfig{AccountID: "acc-1", CredentialEnvPrefix: "acc1"}, }) if err1 != nil { t.Fatalf("acc-1 create: %v", err1) } amt1 := sess1.Payload["amount_minor"].(int64) // 为账户 2 创建订单(同 base,共享地址) sess2, err2 := p.Create(context.Background(), provider.CreateRequest{ OutTradeNo: "ORDER-2", AmountMinor: base, Currency: "USDT", Account: config.AccountConfig{AccountID: "acc-2", CredentialEnvPrefix: "acc2"}, }) if err2 != nil { t.Fatalf("acc-2 create: %v", err2) } amt2 := sess2.Payload["amount_minor"].(int64) // 白盒验证:检查预留键格式(应为 "address/amount" 而非 "accountID/amount") reserved := p.GetReserved() // 新代码:键应为 "address/amount" expectedKey1 := sharedAddr + "/" + strconv.FormatInt(amt1, 10) expectedKey2 := sharedAddr + "/" + strconv.FormatInt(amt2, 10) if _, found := reserved[expectedKey1]; !found { t.Fatalf("预留键应为 %q(address/amount 格式), 实际键: %v", expectedKey1, reserved) } if _, found := reserved[expectedKey2]; !found { t.Fatalf("预留键应为 %q(address/amount 格式), 实际键: %v", expectedKey2, reserved) } // 两个账户应分配不同金额(防止链上同地址同金额碰撞) if amt1 == amt2 { t.Fatalf("共享地址的两个账户不应分配相同金额: %d", amt1) } }