feat(v2): crypto adapter — 移植 pangolin-pay 单地址+唯一金额模型(only_confirmed 扫链,VerifyCallback 不适用)
This commit is contained in:
@@ -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-<OutTradeNo>-<tail>") 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 // "<accountID>/<amount>" → 预留到期(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 <PREFIX>_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-<OutTradeNo>-<tail>" 的尾数(最后一个 '-' 之后)。
|
||||
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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user