383 lines
13 KiB
Go
383 lines
13 KiB
Go
// 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
|
|
loader ReservationLoader // 冷启动预留重建源(装配期注入,nil=不重建)
|
|
|
|
mu sync.Mutex
|
|
reserved map[string]time.Time // "<address>/<amount>" → 预留到期(链上匹配维度,对齐 Query 的 to==addr)(canonical AmountRecentlyUsed 的进程内等价)
|
|
}
|
|
|
|
// PendingReservation 冷启动重建一笔预留所需的最小信息(中性结构,crypto 不依赖 store)。
|
|
type PendingReservation struct {
|
|
AccountID string // 收款账户(用于解析地址,链上匹配维度)
|
|
AmountMinor int64 // attempt 冻结的 base 金额(不含尾数)
|
|
ProviderRef string // "CRYPTO-<OutTradeNo>-<tail>",用于恢复尾数
|
|
ReservedAt time.Time // 建单时间(= attempt.CreatedAt),冷却窗自此算
|
|
}
|
|
|
|
// ReservationLoader 返回当前仍活跃(pending)的 crypto 预留。装配期由 main 用 OrderStore 实现。
|
|
type ReservationLoader func(ctx context.Context) ([]PendingReservation, error)
|
|
|
|
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 WithReservationLoader(l ReservationLoader) Option { return func(p *Provider) { p.loader = l } }
|
|
func WithNow(f func() time.Time) Option { return func(p *Provider) { p.now = f } }
|
|
|
|
// SetReservationLoader 构造后注入冷启动预留源(装配期 main 在 BuildRegistry 之后调用:
|
|
// loader 依赖 OrderStore,而注册表构造不便传 store)。非并发安全,仅启动期单线程调用。
|
|
func (p *Provider) SetReservationLoader(l ReservationLoader) { p.loader = l }
|
|
|
|
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) {
|
|
// 解析地址(同 Query 逻辑,作为链上匹配维度的真相源)
|
|
addr, err := p.address(accountID)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
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 := addr + "/" + 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)
|
|
}
|
|
|
|
// Warm 冷启动兜底:把仍在冷却窗内的活跃预留灌回内存表,兜住重启丢 map 导致的金额复用误配。
|
|
// 幂等:只加不覆盖更早到期时间;冷却已过的跳过。装配期在起服务前调一次即可。
|
|
func (p *Provider) Warm(ctx context.Context) error {
|
|
if p.loader == nil {
|
|
return nil
|
|
}
|
|
items, err := p.loader(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := p.now()
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
for _, it := range items {
|
|
addr, err := p.address(it.AccountID) // 地址是链上匹配维度真相源
|
|
if err != nil {
|
|
continue
|
|
}
|
|
tail, err := tailFromRef(it.ProviderRef)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
until := it.ReservedAt.Add(amountCooldown)
|
|
if !until.After(now) {
|
|
continue // 冷却已过,金额可安全复用,无需恢复
|
|
}
|
|
key := addr + "/" + strconv.FormatInt(it.AmountMinor+tail, 10)
|
|
if cur, ok := p.reserved[key]; !ok || until.After(cur) {
|
|
p.reserved[key] = until
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ScanOrphans 扫地址近 Since 的确认到账,金额不在"任一 Known 的期望金额集"内 → 孤儿。
|
|
// 期望金额 = known.AmountMinor + tailFromRef(known.ProviderRef);块时须晚于 Since。
|
|
func (p *Provider) ScanOrphans(ctx context.Context, req provider.OrphanScanRequest) ([]provider.OrphanTransfer, error) {
|
|
addr, err := p.address(req.AccountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
expected := make(map[int64]struct{}, len(req.Known))
|
|
for _, k := range req.Known {
|
|
tail, terr := tailFromRef(k.ProviderRef)
|
|
if terr != nil {
|
|
continue // 无尾数的 ref 跳过(不误判为孤儿依据)
|
|
}
|
|
expected[k.AmountMinor+tail] = struct{}{}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
sinceUnix := req.Since.Unix()
|
|
var out []provider.OrphanTransfer
|
|
for _, d := range tr.Data {
|
|
if d.To != addr || d.Type != "Transfer" {
|
|
continue
|
|
}
|
|
blockTs := d.BlockMs / 1000
|
|
if blockTs < sinceUnix {
|
|
continue // 窗外旧款不扫(避免把历史正常单反复报孤儿)
|
|
}
|
|
val, perr := strconv.ParseInt(d.Value, 10, 64)
|
|
if perr != nil {
|
|
continue
|
|
}
|
|
if _, ok := expected[val]; ok {
|
|
continue // 金额有主(匹配某 attempt 期望额)→ 非孤儿
|
|
}
|
|
out = append(out, provider.OrphanTransfer{
|
|
TxID: d.TxID, AmountMinor: val, Currency: "USDT", At: time.Unix(blockTs, 0),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// 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
|
|
}
|