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) } } // 冷启动兜底:装配期注入的 ReservationLoader 在 Warm 时把仍在冷却窗内的 pending // 预留灌回内存表;超冷却窗的旧预留(迟到旧款已不可能匹配)不必恢复。 func TestWarmRebuildsReservationsFromLoader(t *testing.T) { const addr = "TWarmTestAddr000000000000000000000" t.Setenv("CRY_ADDRESS", addr) reg := accounts.New([]config.AccountConfig{ {AccountID: "cry-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "cry"}, }) now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) // 两条 pending:一条在冷却窗内(应恢复),一条建单于 40min 前(> 30min 冷却窗,应跳过)。 loader := func(context.Context) ([]crypto.PendingReservation, error) { return []crypto.PendingReservation{ {AccountID: "cry-1", AmountMinor: 29990000, ProviderRef: "CRYPTO-PAY-A-263", ReservedAt: now.Add(-5 * time.Minute)}, {AccountID: "cry-1", AmountMinor: 29990000, ProviderRef: "CRYPTO-PAY-B-777", ReservedAt: now.Add(-40 * time.Minute)}, }, nil } p := crypto.New(reg, crypto.WithReservationLoader(loader), crypto.WithNow(func() time.Time { return now })) if err := p.Warm(context.Background()); err != nil { t.Fatalf("warm: %v", err) } res := p.GetReserved() inWindow := addr + "/" + strconv.FormatInt(29990000+263, 10) expired := addr + "/" + strconv.FormatInt(29990000+777, 10) if _, ok := res[inWindow]; !ok { t.Fatalf("冷却窗内的预留应恢复, got %v", res) } if _, ok := res[expired]; ok { t.Fatalf("超冷却窗的预留不应恢复, got %v", res) } } // 孤儿到账发现:假 TronGrid 返回两笔确认到账,一笔匹配 known 期望金额(base+tail), // 一笔无主 → 仅后者报孤儿。 func TestScanOrphansFlagsUnmatchedTransfer(t *testing.T) { const addr = "TOrphanScanAddr00000000000000000000" now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) // 假 TronGrid:to=addr 两笔确认到账。29990263 匹配 known(base 29990000 + tail 263);88880000 无主。 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":[ {"transaction_id":"TX-MATCH","to":"` + addr + `","type":"Transfer","value":"29990263","block_timestamp":` + strconv.FormatInt(now.Add(-5*time.Minute).UnixMilli(), 10) + `}, {"transaction_id":"TX-ORPHAN","to":"` + addr + `","type":"Transfer","value":"88880000","block_timestamp":` + strconv.FormatInt(now.Add(-3*time.Minute).UnixMilli(), 10) + `} ]}`)) })) defer ts.Close() t.Setenv("CRY_ADDRESS", addr) reg := accounts.New([]config.AccountConfig{{AccountID: "cry-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "cry"}}) p := crypto.New(reg, crypto.WithBaseURL(ts.URL), crypto.WithHTTPClient(ts.Client()), crypto.WithNow(func() time.Time { return now })) orphans, err := p.ScanOrphans(context.Background(), provider.OrphanScanRequest{ AccountID: "cry-1", Since: now.Add(-time.Hour), Known: []provider.KnownAttempt{{AmountMinor: 29990000, ProviderRef: "CRYPTO-PAY-A-263"}}, // 期望 29990263 }) if err != nil { t.Fatalf("scan: %v", err) } if len(orphans) != 1 || orphans[0].TxID != "TX-ORPHAN" || orphans[0].AmountMinor != 88880000 { t.Fatalf("只应报 1 笔孤儿 TX-ORPHAN, got %+v", orphans) } }