Files
epusdt/src/model/service/sol_task.go
T
line-6000 8d2ddbd045 feat: add configurable payment amount precision
- add system.amount_precision setting with 2-6 validation
2026-05-11 17:11:43 +08:00

659 lines
20 KiB
Go

package service
import (
"encoding/json"
"errors"
"fmt"
"math/big"
"sort"
"strings"
"sync"
"time"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/gagliardetto/solana-go"
"github.com/go-resty/resty/v2"
"github.com/shopspring/decimal"
"github.com/tidwall/gjson"
)
// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
var gProcessedSignatures sync.Map // sig -> unix timestamp
type TransferInfo struct {
Source string // Source address (for SOL) or source ATA (for SPL tokens)
Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
Mint string // Token mint (e.g. USDT mint, USDC mint, or "SOL" for native transfers)
Amount float64 // Human-readable amount (adjusted for decimals)
RawAmount uint64 // Raw amount from the transaction (before adjusting for decimals)
Decimals *int // Optional decimals from the transaction, if available
BlockTime int64 // Block time of the transfer unit is seconds since epoch
}
// SolCallBack 扫描指定钱包地址的 Solana 链上交易,匹配待支付订单并确认收款。
func SolCallBack(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[SOL][%s] panic recovered: %v", address, err)
}
}()
// Load enabled Solana tokens from chain_tokens. The contract_address
// column holds the mint for SPL tokens; a special entry with
// symbol=SOL and empty contract_address enables native SOL payments.
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
if err != nil {
log.Sugar.Errorf("[SOL][%s] load chain_tokens err=%v", address, err)
return
}
if len(tokens) == 0 {
log.Sugar.Debugf("[SOL][%s] no enabled chain_tokens, skipping", address)
return
}
mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
var nativeSolToken *mdb.ChainToken
for i := range tokens {
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
mint := strings.TrimSpace(tokens[i].ContractAddress)
if sym == "SOL" && mint == "" {
nativeSolToken = &tokens[i]
continue
}
if mint == "" {
continue
}
mintTokens[mint] = &tokens[i]
}
// Clean up old entries from processed cache (older than 1 hour)
cleanupCutoff := time.Now().Add(-1 * time.Hour).Unix()
gProcessedSignatures.Range(func(key, value interface{}) bool {
if ts, ok := value.(int64); ok && ts < cleanupCutoff {
gProcessedSignatures.Delete(key)
}
return true
})
limit := 1000
// 查询钱包地址 + 每个已启用 SPL 代币 ATA 的签名
queryAddrs := []string{address}
for mint := range mintTokens {
ata, err := FindATAAddress(address, mint)
if err != nil {
log.Sugar.Errorf("[SOL][%s] failed to derive ATA for mint %s: %v", address, mint, err)
continue
}
queryAddrs = append(queryAddrs, ata)
}
// 拉取签名并去重
seen := make(map[string]bool)
var result []solSignatureResult
for _, queryAddr := range queryAddrs {
respBody, err := SolGetSignaturesForAddress(queryAddr, limit, "", "")
if err != nil {
log.Sugar.Errorf("[SOL][%s] SolGetSignaturesForAddress(%s) failed: %v", address, queryAddr, err)
continue
}
resultBody := gjson.GetBytes(respBody, "result")
if !resultBody.Exists() || !resultBody.IsArray() {
log.Sugar.Errorf("[SOL][%s] unexpected response format for %s: %s", address, queryAddr, string(respBody))
continue
}
var batch []solSignatureResult
err = json.Unmarshal([]byte(resultBody.Raw), &batch)
if err != nil {
log.Sugar.Errorf("[SOL][%s] failed to unmarshal signatures for %s: %v", address, queryAddr, err)
continue
}
for _, sig := range batch {
if !seen[sig.Signature] {
seen[sig.Signature] = true
result = append(result, sig)
}
}
}
if len(result) == 0 {
log.Sugar.Debugf("[SOL][%s] no transaction signatures found", address)
return
}
// 按 blockTime 降序排列
sort.Slice(result, func(i, j int) bool {
if result[i].BlockTime == nil {
return false
}
if result[j].BlockTime == nil {
return true
}
return *result[i].BlockTime > *result[j].BlockTime
})
// 时间截止线:订单过期时间 + 5 分钟
cutoffTime := time.Now().Add(-config.GetOrderExpirationTimeDuration() - 5*time.Minute).Unix()
log.Sugar.Debugf("[SOL][%s] fetched %d unique signatures from %d addresses, cutoff=%d",
address, len(result), len(queryAddrs), cutoffTime)
// Process each transaction signature
for sigIdx, txSig := range result {
sig := txSig.Signature
// Skip failed transactions
if txSig.Err != nil {
continue
}
// 超过截止时间的旧签名不再处理
if txSig.BlockTime != nil && *txSig.BlockTime < cutoffTime {
log.Sugar.Debugf("[SOL][%s] [%d/%d] sig=%s blockTime=%d before cutoff=%d, stopping scan",
address, sigIdx+1, len(result), sig, *txSig.BlockTime, cutoffTime)
break
}
// 跳过已处理的签名
if _, ok := gProcessedSignatures.Load(sig); ok {
continue
}
log.Sugar.Debugf("[SOL][%s] [%d/%d] processing sig=%s slot=%d", address, sigIdx+1, len(result), sig, txSig.Slot)
txData, err := SolGetTransaction(sig)
if err != nil {
log.Sugar.Debugf("[SOL][%s] sig=%s fetch failed: %v", address, sig, err)
continue
}
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
log.Sugar.Debugf("[SOL][%s] sig=%s has %d instructions", address, sig, len(instructions))
for instrIdx, instruction := range instructions {
programID := instruction.Get("programId").String()
parsedType := instruction.Get("parsed.type").String()
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] programId=%s parsedType=%s", address, sig, instrIdx, programID, parsedType)
transferInfo, err := ParseTransferInfoFromInstruction(instruction, txData)
if err != nil {
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] parse error: %v", address, sig, instrIdx, err)
continue
}
if transferInfo == nil {
continue
}
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] transfer: src=%s dst=%s mint=%s rawAmount=%d amount=%.6f blockTime=%d",
address, sig, instrIdx,
transferInfo.Source, transferInfo.Destination, transferInfo.Mint,
transferInfo.RawAmount, transferInfo.Amount, transferInfo.BlockTime)
if !isTransferToAddress(transferInfo, address) {
continue
}
token, amount := resolveSolTokenAndAmount(transferInfo, mintTokens, nativeSolToken)
if token == "" || amount <= 0 {
continue
}
log.Sugar.Infof("[SOL][%s] sig=%s instr[%d] incoming transfer confirmed: token=%s amount=%.2f -> querying transaction_lock network=solana address=%s token=%s amount=%.2f",
address, sig, instrIdx, token, amount, address, token, amount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount)
if err != nil {
log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err)
continue
}
if tradeID == "" {
log.Sugar.Infof("[SOL][%s] sig=%s no active transaction_lock matched: network=solana address=%s token=%s amount=%.2f (no order or expired)",
address, sig, address, token, amount)
continue
}
log.Sugar.Infof("[SOL][%s] transaction_lock matched: trade_id=%s sig=%s token=%s amount=%.2f",
address, tradeID, sig, token, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err)
continue
}
log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d",
address, tradeID, order.OrderId, order.Status, order.CreatedAt.TimestampMilli())
// blockTime 秒 → 毫秒,与订单创建时间对齐
blockTimestamp := transferInfo.BlockTime * 1000
createTime := order.CreatedAt.TimestampMilli()
log.Sugar.Infof("[SOL][%s] time check: sig=%s block_time_ms=%d order_created_ms=%d diff_ms=%d",
address, sig, blockTimestamp, createTime, blockTimestamp-createTime)
if blockTimestamp < createTime {
log.Sugar.Warnf("[SOL][%s] sig=%s skipped: block_time_ms=%d is %d ms before order created_ms=%d (transaction predates the order)",
address, sig, blockTimestamp, createTime-blockTimestamp, createTime)
continue
}
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: token,
Network: mdb.NetworkSolana,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: sig,
}
log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f",
address, tradeID, sig, token, amount)
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err)
continue
}
log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err)
continue
}
log.Sugar.Infof("[SOL][%s] order marked paid: trade_id=%s sig=%s token=%s amount=%.2f, sending telegram notification",
address, tradeID, sig, token, amount)
sendPaymentNotification(order)
log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig)
}
// 标记已处理
gProcessedSignatures.Store(sig, time.Now().Unix())
}
}
// solSignatureResult getSignaturesForAddress 返回结构
type solSignatureResult struct {
Signature string `json:"signature"`
Slot uint64 `json:"slot"`
Err interface{} `json:"err"`
BlockTime *int64 `json:"blockTime"`
}
func resolveSolanaRpcURL() (string, error) {
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
return "", err
}
if node == nil || node.ID == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
return rpcURL, nil
}
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
client := resty.New()
client.SetRetryCount(5)
client.SetRetryWaitTime(2 * time.Second)
client.SetRetryMaxWaitTime(10 * time.Second)
client.AddRetryCondition(func(r *resty.Response, err error) bool {
if err != nil {
return true
}
if r.StatusCode() >= 429 || r.StatusCode() >= 500 {
return true
}
return false
})
rpcUrl, err := resolveSolanaRpcURL()
if err != nil {
return nil, err
}
resp, err := client.R().
SetHeader("Content-Type", "application/json").
SetBody(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
}).
Post(rpcUrl)
if err != nil {
return nil, err
}
respBody := resp.Body()
return respBody, nil
}
func SolGetSignaturesForAddress(address string, limit int, untilSig string, beforeSig string) ([]byte, error) {
opts := map[string]interface{}{
"commitment": "finalized",
"limit": limit,
}
if untilSig != "" {
opts["until"] = untilSig
}
if beforeSig != "" {
opts["before"] = beforeSig
}
bodyData, err := SolRetryClient("getSignaturesForAddress",
[]interface{}{address, opts})
if err != nil {
return nil, err
}
var result map[string]interface{}
err = json.Unmarshal(bodyData, &result)
if err != nil {
return nil, err
}
_, ok := result["result"].([]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response format: %v", result)
}
return bodyData, nil
}
func SolGetTransaction(sig string) ([]byte, error) {
txData, err := SolRetryClient("getTransaction", []interface{}{
sig,
map[string]interface{}{
"encoding": "jsonParsed",
"commitment": "confirmed",
"maxSupportedTransactionVersion": 0, // suport
},
})
if err != nil {
log.Sugar.Errorf("SolRetryClient failed: %v", err)
return nil, err
}
errField := gjson.GetBytes(txData, "result.meta.err")
if errField.Exists() && errField.Type != gjson.Null {
log.Sugar.Warnf("Transaction failed: %v", errField.String())
return nil, fmt.Errorf("transaction failed: %s", errField.String())
}
return txData, nil
}
// isTransferToAddress 判断转账目标是否为指定钱包地址
func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
// Native SOL transfer - check destination directly
if transfer.Mint == "SOL" {
return strings.EqualFold(transfer.Destination, targetAddress)
}
// Skip Transfer instruction without mint info (use TransferChecked instead)
if transfer.Mint == "" {
return false
}
// SPL Token transfer - check if destination ATA matches
return MatchAtaAddress(targetAddress, transfer.Mint, transfer.Destination)
}
// resolveSolTokenAndAmount identifies the token from a transfer using
// the admin-configured chain_tokens list. mintTokens maps SPL mint
// addresses to their ChainToken config; nativeSolToken is non-nil when a
// native SOL entry exists in chain_tokens. Returns ("", 0) for
// transfers whose mint is not configured or below min_amount.
func resolveSolTokenAndAmount(transfer *TransferInfo, mintTokens map[string]*mdb.ChainToken, nativeSolToken *mdb.ChainToken) (string, float64) {
mint := transfer.Mint
// Native SOL
if mint == "SOL" {
if nativeSolToken == nil {
return "", 0
}
amount := transfer.Amount
if nativeSolToken.MinAmount > 0 && amount < nativeSolToken.MinAmount {
return "", 0
}
return strings.ToUpper(strings.TrimSpace(nativeSolToken.Symbol)), amount
}
cfg, ok := mintTokens[mint]
if !ok || cfg == nil {
return "", 0
}
decimals := cfg.Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
if decimals <= 0 {
decimals = 6
}
amount := ADJustAmount(transfer.RawAmount, decimals)
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
return "", 0
}
return strings.ToUpper(strings.TrimSpace(cfg.Symbol)), amount
}
const (
// Mint token
USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
USDC_Mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
USDT_Decimals = 6
USDC_Decimals = 6
SOL_Decimals = 9
)
const (
// SPL Token instructions
InstructionTransfer = 3
InstructionTransferChecked = 12
)
const (
// System Program
SystemProgramID = "11111111111111111111111111111111"
InstructionSystemTransfer = 2
)
const (
TokenProgramID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
)
// ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留到系统支持的最大匹配精度)
func ADJustAmount(amount uint64, decimals int) float64 {
if amount == 0 {
return 0
}
decimalAmount := decimal.NewFromBigInt(new(big.Int).SetUint64(amount), 0)
// 10^decimals
decimalDivisor := decimal.New(1, int32(decimals))
adjustedAmount := decimalAmount.Div(decimalDivisor)
return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), data.MaxAmountPrecision)
}
func MatchUsdtAtaAddress(address string, ataTo string) bool {
ata, err := FindATAAddress(address, USDT_Mint)
if err != nil {
log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false
}
return strings.EqualFold(ata, ataTo)
}
func MatchUsdcAtaAddress(address string, ataTo string) bool {
ata, err := FindATAAddress(address, USDC_Mint)
if err != nil {
log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false
}
return strings.EqualFold(ata, ataTo)
}
func MatchAtaAddress(address string, mint string, ataTo string) bool {
ata, err := FindATAAddress(address, mint)
if err != nil {
log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false
}
return strings.EqualFold(ata, ataTo)
}
func FindATAAddress(owner, mint string) (string, error) {
ownerPubKey, err := solana.PublicKeyFromBase58(owner)
if err != nil {
return "", fmt.Errorf("invalid owner public key: %w", err)
}
mintPubKey, err := solana.PublicKeyFromBase58(mint)
if err != nil {
return "", fmt.Errorf("invalid mint public key: %w", err)
}
ata, _, err := solana.FindAssociatedTokenAddress(ownerPubKey, mintPubKey)
if err != nil {
return "", fmt.Errorf("find associated token address failed: %w", err)
}
return ata.String(), nil
}
// ParseTransferInfoFromInstruction 从单条指令中解析转账信息,非转账指令返回 nil
func ParseTransferInfoFromInstruction(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
programID := instruction.Get("programId").String()
parsedType := instruction.Get("parsed.type").String()
if programID == SystemProgramID && parsedType == "transfer" {
return parseSystemTransfer(instruction, txData)
}
if programID == TokenProgramID || programID == Token2022ProgramID {
switch parsedType {
case "transfer":
return parseSplTransfer(instruction, txData)
case "transferChecked":
return parseSplTransferChecked(instruction, txData)
}
}
// Skip non-transfer instructions (ComputeBudget, AToken create, etc.)
return nil, nil
}
func parseSystemTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
info := instruction.Get("parsed.info")
source := info.Get("source").String()
destination := info.Get("destination").String()
lamports := info.Get("lamports").Uint()
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
return &TransferInfo{
Source: source,
Destination: destination,
Mint: "SOL",
Amount: ADJustAmount(lamports, SOL_Decimals),
RawAmount: lamports,
BlockTime: blockTime,
}, nil
}
// parseSplTransfer 解析 SPL Token "transfer" 指令,mint 从 postTokenBalances 中查找
func parseSplTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
info := instruction.Get("parsed.info")
source := info.Get("source").String()
destination := info.Get("destination").String()
amountStr := info.Get("amount").String()
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
if !ok {
return nil, fmt.Errorf("invalid amount: %s", amountStr)
}
// Look up mint and decimals from postTokenBalances using the destination ATA
mint, decimals, found := findMintFromTokenBalances(destination, txData)
if !found {
// Try source ATA as fallback
mint, decimals, found = findMintFromTokenBalances(source, txData)
}
if !found {
return nil, fmt.Errorf("could not determine mint for transfer: source=%s dest=%s", source, destination)
}
d := decimals
return &TransferInfo{
Source: source,
Destination: destination,
Mint: mint,
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
RawAmount: rawAmount.Uint64(),
Decimals: &d,
BlockTime: blockTime,
}, nil
}
// parseSplTransferChecked 解析 SPL Token "transferChecked" 指令
func parseSplTransferChecked(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
info := instruction.Get("parsed.info")
source := info.Get("source").String()
destination := info.Get("destination").String()
mint := info.Get("mint").String()
amountStr := info.Get("tokenAmount.amount").String()
decimals := int(info.Get("tokenAmount.decimals").Int())
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
if !ok {
return nil, fmt.Errorf("invalid amount: %s", amountStr)
}
return &TransferInfo{
Source: source,
Destination: destination,
Mint: mint,
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
RawAmount: rawAmount.Uint64(),
Decimals: &decimals,
BlockTime: blockTime,
}, nil
}
// findMintFromTokenBalances 从 postTokenBalances 中查找 ATA 对应的 mint 和 decimals
func findMintFromTokenBalances(ataAddress string, txData []byte) (string, int, bool) {
accountKeys := gjson.GetBytes(txData, "result.transaction.message.accountKeys").Array()
accountIndex := -1
for i, key := range accountKeys {
if key.Get("pubkey").String() == ataAddress {
accountIndex = i
break
}
}
if accountIndex == -1 {
return "", 0, false
}
balances := gjson.GetBytes(txData, "result.meta.postTokenBalances").Array()
for _, balance := range balances {
if int(balance.Get("accountIndex").Int()) == accountIndex {
mint := balance.Get("mint").String()
decimals := int(balance.Get("uiTokenAmount.decimals").Int())
return mint, decimals, true
}
}
return "", 0, false
}