272 lines
10 KiB
Go
272 lines
10 KiB
Go
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)
|
|
}
|
|
}
|