c5949a595a
从"每单唯一 HD 地址"改为"单个固定收款地址 + 每单唯一金额",归集成本 O(订单数)→O(1)。 - store: pay_orders 加 user_ref/expect_amount(唯一金额)/matched_tx_id;新 orphan_payments 表; ActiveOrderByUser(同用户单订单)、AmountRecentlyUsed(迟到窗口内金额不复用)、TxHandled(幂等)、 RecordOrphan。去掉每单派生游标。 - pay: CreateOrder(userRef,sku,priceMicro)——同用户单订单校验 + 分配唯一金额(base+随机微尾数[1,9999]、 cooldown 内不复用),address 恒为收款地址。 - tron: Transfer 加 BlockTs(区块时间秒),取 block_timestamp。 - watcher: 单地址取到账,按"金额==expect && block_ts>建单"匹配 → paid;不匹配的到账 → orphan;幂等。 - httpapi: POST /order 加 user_ref,同用户重复 → 409;main 收款地址=PAY_RECEIVE_ADDRESS 或 xpub index0。 - 测试:唯一金额/同地址、同用户单订单、精确匹配、付错成孤儿、迟到不误配新单、付款早于建单不匹配、 超时、幂等、409,全绿。README 更新为单地址模型+API(user_ref/精确金额/orphan)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
96 lines
2.9 KiB
Go
96 lines
2.9 KiB
Go
// Package tron reads confirmed incoming TRC20 (USDT) transfers from TronGrid.
|
|
// Only reads — the watcher never signs or moves funds (that's offline sweeping).
|
|
package tron
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// USDTContractMainnet is the TRON mainnet USDT (TRC20) contract. 6 decimals.
|
|
// ⚠️ Verify before relying on it in production (Phase-level constant check).
|
|
const USDTContractMainnet = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
|
|
|
// Transfer is one confirmed incoming TRC20 transfer to a watched address.
|
|
// Value is the raw integer amount (micro-USDT, since USDT has 6 decimals).
|
|
// BlockTs is the on-chain block time in **unix seconds** — used to reject a
|
|
// payment that arrived before the order it might match was created.
|
|
type Transfer struct {
|
|
TxID string
|
|
To string
|
|
Value int64
|
|
BlockTs int64
|
|
}
|
|
|
|
// Fetcher returns confirmed incoming USDT transfers to a given address.
|
|
type Fetcher interface {
|
|
IncomingTransfers(ctx context.Context, address string) ([]Transfer, error)
|
|
}
|
|
|
|
// Client talks to the TronGrid HTTP API.
|
|
type Client struct {
|
|
base string
|
|
usdtContract string
|
|
apiKey string
|
|
hc *http.Client
|
|
}
|
|
|
|
func NewClient(base, usdtContract, apiKey string) *Client {
|
|
if base == "" {
|
|
base = "https://api.trongrid.io"
|
|
}
|
|
if usdtContract == "" {
|
|
usdtContract = USDTContractMainnet
|
|
}
|
|
return &Client{base: base, usdtContract: usdtContract, apiKey: apiKey, hc: &http.Client{Timeout: 15 * time.Second}}
|
|
}
|
|
|
|
func (c *Client) IncomingTransfers(ctx context.Context, address string) ([]Transfer, error) {
|
|
u := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_confirmed=true&contract_address=%s&limit=50",
|
|
c.base, url.PathEscape(address), url.QueryEscape(c.usdtContract))
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if c.apiKey != "" {
|
|
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
|
}
|
|
resp, err := c.hc.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("tron: trongrid status %d", resp.StatusCode)
|
|
}
|
|
var body struct {
|
|
Data []struct {
|
|
TransactionID string `json:"transaction_id"`
|
|
To string `json:"to"`
|
|
Value string `json:"value"`
|
|
Type string `json:"type"`
|
|
BlockTimestamp int64 `json:"block_timestamp"` // milliseconds
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
return nil, fmt.Errorf("tron: decode: %w", err)
|
|
}
|
|
out := make([]Transfer, 0, len(body.Data))
|
|
for _, d := range body.Data {
|
|
if d.To != address || d.Type != "Transfer" {
|
|
continue
|
|
}
|
|
v, err := strconv.ParseInt(d.Value, 10, 64)
|
|
if err != nil {
|
|
continue // skip malformed value rather than fail the whole batch
|
|
}
|
|
out = append(out, Transfer{TxID: d.TransactionID, To: d.To, Value: v, BlockTs: d.BlockTimestamp / 1000})
|
|
}
|
|
return out, nil
|
|
}
|