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,保留 2 位小数) 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) // Round to 2 decimal places return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), 2) } 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 }