feat: add Solana payment scanning with USDT/USDC support and wallet management API

- Rewrite Solana scanner: stateless scan with processed-signature cache, query wallet + USDT ATA + USDC ATA addresses to cover all SPL transfers
  - Add network field to transaction_lock (4-tuple unique index) and order flow
  - Add wallet management endpoints (add/list/get/status/delete) with token auth
  - Add network info to Telegram payment notification
  - Add USDC hint to test script GMPay prompt
  - Add API docs and Solana scanning docs
This commit is contained in:
line-6000
2026-04-11 00:10:54 +08:00
parent a7963b335b
commit 3f071e6c01
21 changed files with 1579 additions and 233 deletions
+12 -8
View File
@@ -28,8 +28,10 @@ const (
IncrementalMaximumNumber = 100
)
var gCreateTransactionLock sync.Mutex
var gOrderProcessingLock sync.Mutex
var (
gCreateTransactionLock sync.Mutex
gOrderProcessingLock sync.Mutex
)
// CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
@@ -38,6 +40,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
network := strings.ToLower(strings.TrimSpace(req.Network))
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
if rate <= 0 {
@@ -61,7 +64,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
return nil, constant.OrderAlreadyExists
}
walletAddress, err := data.GetAvailableWalletAddress()
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil {
return nil, err
}
@@ -71,7 +74,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
tradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, token, amount, walletAddress)
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress)
if err != nil {
return nil, err
}
@@ -88,6 +91,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
ActualAmount: availableAmount,
ReceiveAddress: availableAddress,
Token: token,
Network: network,
Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl,
@@ -148,20 +152,20 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
return err
}
if err = data.UnLockTransaction(req.ReceiveAddress, req.Token, req.Amount); err != nil {
if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil {
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
}
return nil
}
// ReserveAvailableWalletAndAmount finds and locks an address+token+amount pair.
func ReserveAvailableWalletAndAmount(tradeID string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
// ReserveAvailableWalletAndAmount finds and locks a network+address+token+amount pair.
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableAddress := ""
availableAmount := amount
tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress {
err := data.LockTransaction(address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
err := data.LockTransaction(network, address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
if err == nil {
return address.Address, nil
}
+8 -3
View File
@@ -18,6 +18,7 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
OrderId: orderID,
Currency: "CNY",
Token: "USDT",
Network: "tron",
Amount: amount,
NotifyUrl: "https://merchant.example/callback",
}
@@ -53,7 +54,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
t.Fatalf("unexpected tokens: %s, %s", resp1.Token, resp2.Token)
}
tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount)
tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount)
if err != nil {
t.Fatalf("get first runtime lock: %v", err)
}
@@ -61,7 +62,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId)
}
tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount)
tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount)
if err != nil {
t.Fatalf("get second runtime lock: %v", err)
}
@@ -86,6 +87,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_1",
@@ -108,7 +110,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId)
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp.ReceiveAddress, resp.Token, resp.ActualAmount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp.ReceiveAddress, resp.Token, resp.ActualAmount)
if err != nil {
t.Fatalf("get runtime lock after processing: %v", err)
}
@@ -133,6 +135,7 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
req := &request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_1",
@@ -180,6 +183,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_expired",
@@ -239,6 +243,7 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
errs <- OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: address,
Token: token,
Network: "tron",
TradeId: tradeID,
Amount: amount,
BlockTransactionId: "shared_block",
+546 -170
View File
@@ -1,22 +1,419 @@
package service
import (
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math/big"
"sort"
"strings"
"sync"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/rpc"
"github.com/go-resty/resty/v2"
"github.com/shopspring/decimal"
"github.com/tidwall/gjson"
)
func SolCallBack(address string) {}
// 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)
}
}()
// 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
// 查询钱包地址 + USDT ATA + USDC ATA 三个地址的签名
queryAddrs := []string{address}
if usdtAta, err := FindATAAddress(address, USDT_Mint); err == nil {
queryAddrs = append(queryAddrs, usdtAta)
} else {
log.Sugar.Errorf("[SOL][%s] failed to derive USDT ATA: %v", address, err)
}
if usdcAta, err := FindATAAddress(address, USDC_Mint); err == nil {
queryAddrs = append(queryAddrs, usdcAta)
} else {
log.Sugar.Errorf("[SOL][%s] failed to derive USDC ATA: %v", address, err)
}
// 拉取签名并去重
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 := getTokenTypeAndAmount(transferInfo)
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"`
}
// 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 := config.GetSolanaRpcUrl()
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)
}
// getTokenTypeAndAmount 根据 mint 识别代币类型并计算可读金额
func getTokenTypeAndAmount(transfer *TransferInfo) (string, float64) {
mint := transfer.Mint
// Native SOL
if mint == "SOL" {
return "SOL", transfer.Amount
}
// SPL Tokens
switch mint {
case USDT_Mint:
decimals := USDT_Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
return "USDT", ADJustAmount(transfer.RawAmount, decimals)
case USDC_Mint:
decimals := USDC_Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
return "USDC", ADJustAmount(transfer.RawAmount, decimals)
default:
// Unsupported token
return "", 0
}
}
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 (
@@ -24,6 +421,49 @@ const (
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 {
fmt.Printf("FindATAAddress failed: %v\n", err)
return false
}
return strings.EqualFold(ata, ataTo)
}
func MatchUsdcAtaAddress(address string, ataTo string) bool {
ata, err := FindATAAddress(address, USDC_Mint)
if err != nil {
fmt.Printf("FindATAAddress failed: %v\n", 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 {
fmt.Printf("FindATAAddress failed: %v\n", err)
return false
}
return strings.EqualFold(ata, ataTo)
}
func FindATAAddress(owner, mint string) (string, error) {
ownerPubKey, err := solana.PublicKeyFromBase58(owner)
if err != nil {
@@ -43,191 +483,127 @@ func FindATAAddress(owner, mint string) (string, error) {
return ata.String(), nil
}
const (
InstructionTransfer = 3
InstructionTransferChecked = 12
)
// ParseTransferInfoFromInstruction 从单条指令中解析转账信息,非转账指令返回 nil
func ParseTransferInfoFromInstruction(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
programID := instruction.Get("programId").String()
parsedType := instruction.Get("parsed.type").String()
type TransferInfo struct {
ProgramID string
Type string
if programID == SystemProgramID && parsedType == "transfer" {
return parseSystemTransfer(instruction, txData)
}
Source string
Destination string
Mint string
Authority string
if programID == TokenProgramID || programID == Token2022ProgramID {
switch parsedType {
case "transfer":
return parseSplTransfer(instruction, txData)
case "transferChecked":
return parseSplTransferChecked(instruction, txData)
}
}
Amount uint64
Decimals *uint8
// Skip non-transfer instructions (ComputeBudget, AToken create, etc.)
return nil, nil
}
func isTokenProgram(programID solana.PublicKey) bool {
p := programID.String()
return p == TokenProgramID || p == Token2022ProgramID
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
}
func DecodeTransfer(data []byte, accountKeys []solana.PublicKey, accountIndexes []uint16, programID solana.PublicKey) (*TransferInfo, error) {
if len(data) < 9 {
return nil, fmt.Errorf("invalid transfer data length: %d", len(data))
// 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)
}
if data[0] != InstructionTransfer {
return nil, fmt.Errorf("not transfer instruction, got: %d", data[0])
// 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)
}
if len(accountIndexes) < 3 {
return nil, fmt.Errorf("transfer accounts not enough: %d", len(accountIndexes))
}
amount := binary.LittleEndian.Uint64(data[1:9])
sourceIdx := accountIndexes[0]
destIdx := accountIndexes[1]
authIdx := accountIndexes[2]
if int(sourceIdx) >= len(accountKeys) || int(destIdx) >= len(accountKeys) || int(authIdx) >= len(accountKeys) {
return nil, errors.New("account index out of range")
}
info := &TransferInfo{
ProgramID: programID.String(),
Type: "transfer",
Source: accountKeys[sourceIdx].String(),
Destination: accountKeys[destIdx].String(),
Authority: accountKeys[authIdx].String(),
Amount: amount,
}
return info, nil
d := decimals
return &TransferInfo{
Source: source,
Destination: destination,
Mint: mint,
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
RawAmount: rawAmount.Uint64(),
Decimals: &d,
BlockTime: blockTime,
}, nil
}
type TransferCheckedInfo struct {
ProgramID string
Type string
// 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()
Source string
Destination string
Mint string
Authority string
Amount uint64
Decimals uint8
}
func DecodeTransferCheck(data []byte, accountKeys []solana.PublicKey, accountIndexes []uint16, programID solana.PublicKey) (*TransferInfo, error) {
if len(data) < 10 {
return nil, fmt.Errorf("invalid transferChecked data length: %d", len(data))
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
if !ok {
return nil, fmt.Errorf("invalid amount: %s", amountStr)
}
if data[0] != InstructionTransferChecked {
return nil, fmt.Errorf("not transferChecked instruction, got: %d", data[0])
}
if len(accountIndexes) < 4 {
return nil, fmt.Errorf("transferChecked accounts not enough: %d", len(accountIndexes))
}
amount := binary.LittleEndian.Uint64(data[1:9])
decimals := uint8(data[9])
sourceIdx := accountIndexes[0]
mintIdx := accountIndexes[1]
destIdx := accountIndexes[2]
authIdx := accountIndexes[3]
if int(sourceIdx) >= len(accountKeys) ||
int(mintIdx) >= len(accountKeys) ||
int(destIdx) >= len(accountKeys) ||
int(authIdx) >= len(accountKeys) {
return nil, errors.New("account index out of range")
}
info := &TransferInfo{
ProgramID: programID.String(),
Type: "transferChecked",
Source: accountKeys[sourceIdx].String(),
Mint: accountKeys[mintIdx].String(),
Destination: accountKeys[destIdx].String(),
Authority: accountKeys[authIdx].String(),
Amount: amount,
return &TransferInfo{
Source: source,
Destination: destination,
Mint: mint,
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
RawAmount: rawAmount.Uint64(),
Decimals: &decimals,
}
return info, nil
BlockTime: blockTime,
}, nil
}
// ParseTransactionTransfers
func ParseTransactionTransfers(ctx context.Context, client *rpc.Client, sig string) ([]*TransferInfo, error) {
signature, err := solana.SignatureFromBase58(sig)
if err != nil {
return nil, fmt.Errorf("invalid signature: %w", err)
}
tx, err := client.GetTransaction(
ctx,
signature,
&rpc.GetTransactionOpts{
Encoding: solana.EncodingBase64,
Commitment: rpc.CommitmentConfirmed,
MaxSupportedTransactionVersion: func(v uint64) *uint64 { return &v }(0),
},
)
if err != nil {
return nil, fmt.Errorf("get transaction failed: %w", err)
}
if tx == nil {
return nil, errors.New("transaction not found")
}
decodedTx, err := tx.Transaction.GetTransaction()
if err != nil {
return nil, fmt.Errorf("decode transaction failed: %w", err)
}
msg := decodedTx.Message
accountKeys := msg.AccountKeys
results := make([]*TransferInfo, 0)
for _, ix := range msg.Instructions {
if int(ix.ProgramIDIndex) >= len(accountKeys) {
continue
}
programID := accountKeys[ix.ProgramIDIndex]
if !isTokenProgram(programID) {
continue
}
data := ix.Data
if len(data) == 0 {
continue
}
switch data[0] {
case InstructionTransfer:
info, err := DecodeTransfer(data, accountKeys, toUint16Slice(ix.Accounts), programID)
if err == nil {
results = append(results, info)
}
case InstructionTransferChecked:
info, err := DecodeTransferCheck(data, accountKeys, toUint16Slice(ix.Accounts), programID)
if err == nil {
results = append(results, info)
}
// 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
}
return results, nil
}
func toUint16Slice(in []uint16) []uint16 {
out := make([]uint16, len(in))
for i, v := range in {
out[i] = uint16(v)
}
return out
}
func DecodeBase64InstructionData(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
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
}
+331 -27
View File
@@ -1,45 +1,349 @@
package service
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/gagliardetto/solana-go/rpc"
"github.com/tidwall/gjson"
)
func TestParseSolTransaction(t *testing.T) {
client := rpc.New("https://api.mainnet-beta.solana.com")
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
txInfo, err := ParseTransactionTransfers(context.Background(), client, sig)
func TestSolClientHealthy(t *testing.T) {
bodyData, err := SolRetryClient("getHealth", nil)
if err != nil {
panic(err)
t.Fatalf("SolRetryClient failed: %v", err)
}
for _, item := range txInfo {
fmt.Println("type =", item.Type)
fmt.Println("program =", item.ProgramID)
fmt.Println("from =", item.Source)
fmt.Println("to =", item.Destination)
fmt.Println("mint =", item.Mint)
fmt.Println("authority =", item.Authority)
fmt.Println("amount =", item.Amount)
if item.Decimals != nil {
fmt.Println("decimals =", *item.Decimals)
}
fmt.Println("-----")
var result map[string]interface{}
err = json.Unmarshal(bodyData, &result)
if err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
status, ok := result["result"].(string)
if !ok {
t.Fatalf("Unexpected response format: %v", result)
}
t.Logf("RPC Health Status: %s", status)
if status != "ok" {
t.Errorf("Expected health status 'ok', got '%s'", status)
}
}
func TestSolClientGetSignaturesForAddress(t *testing.T) {
// Example wallet address (replace with actual test address)
address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
bodyData, err := SolRetryClient("getSignaturesForAddress", []interface{}{address, map[string]interface{}{"commitment": "finalized", "limit": 100}})
if err != nil {
t.Fatalf("SolRetryClient failed: %v", err)
}
var result map[string]interface{}
err = json.Unmarshal(bodyData, &result)
if err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
signatures, ok := result["result"].([]interface{})
if !ok {
t.Fatalf("Unexpected response format: %v", result)
}
t.Logf("Found %d signatures for address %s", len(signatures), address)
}
func TestSolClientGetTransaction(t *testing.T) {
// Example transaction signature (replace with actual test signature)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
txData, err := SolRetryClient("getTransaction", []interface{}{sig, map[string]interface{}{"encoding": "jsonParsed", "commitment": "finalized"}})
if err != nil {
t.Fatalf("SolRetryClient failed: %v", err)
}
fmt.Printf("%v\n", string(txData))
var result map[string]interface{}
err = json.Unmarshal(txData, &result)
if err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
txInfo, ok := result["result"].(map[string]interface{})
if !ok {
t.Fatalf("Unexpected response format: %v", result)
}
t.Logf("Transaction Info for signature %s: %v", sig, txInfo)
}
func TestFindATAAddress(t *testing.T) {
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
ata, err := FindATAAddress(owner, mint)
if err != nil {
panic(err)
tests := []struct {
name string
owner string
mint string
want string
}{
{
name: "RAY token ATA",
owner: "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu",
mint: "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R",
want: "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4",
},
}
fmt.Println("ATA =", ata)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ata, err := FindATAAddress(tt.owner, tt.mint)
if err != nil {
t.Fatalf("FindATAAddress failed: %v", err)
}
t.Logf("Owner: %s", tt.owner)
t.Logf("Mint: %s", tt.mint)
t.Logf("ATA: %s", ata)
if tt.want != "" && ata != tt.want {
t.Errorf("Expected ATA %s, got %s", tt.want, ata)
}
})
}
}
func TestMatchATAAddress(t *testing.T) {
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
expectedATA := "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4"
ok := MatchAtaAddress(owner, mint, expectedATA)
t.Logf("Owner: %s", owner)
t.Logf("Mint: %s", mint)
t.Logf("Expected ATA: %s", expectedATA)
t.Logf("Match result: %v", ok)
if !ok {
t.Error("Expected ATA to match, but it didn't")
}
}
func TestMatchUsdtAtaAddress(t *testing.T) {
// Example wallet address (replace with actual test address)
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
ata, err := FindATAAddress(owner, USDT_Mint)
if err != nil {
t.Fatalf("FindATAAddress failed: %v", err)
}
t.Logf("Owner: %s", owner)
t.Logf("USDT Mint: %s", USDT_Mint)
t.Logf("USDT ATA: %s", ata)
ok := MatchUsdtAtaAddress(owner, ata)
if !ok {
t.Error("Expected USDT ATA to match")
}
}
func TestMatchUsdcAtaAddress(t *testing.T) {
// Example wallet address (replace with actual test address)
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
ata, err := FindATAAddress(owner, USDC_Mint)
if err != nil {
t.Fatalf("FindATAAddress failed: %v", err)
}
t.Logf("Owner: %s", owner)
t.Logf("USDC Mint: %s", USDC_Mint)
t.Logf("USDC ATA: %s", ata)
ok := MatchUsdcAtaAddress(owner, ata)
if !ok {
t.Error("Expected USDC ATA to match")
}
}
func TestAdjustAmount(t *testing.T) {
tests := []struct {
name string
amount uint64
decimals int
want float64
}{
{
name: "USDT amount (6 decimals)",
amount: 123456789,
decimals: 6,
want: 123.46,
},
{
name: "USDC amount (6 decimals)",
amount: 1000000,
decimals: 6,
want: 1.0,
},
{
name: "SOL amount (9 decimals)",
amount: 1000000000,
decimals: 9,
want: 1.0,
},
{
name: "Zero amount",
amount: 0,
decimals: 6,
want: 0,
},
{
name: "Small amount",
amount: 1,
decimals: 6,
want: 0.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
adjusted := ADJustAmount(tt.amount, tt.decimals)
t.Logf("Raw amount: %d, Decimals: %d, Adjusted: %.2f", tt.amount, tt.decimals, adjusted)
if adjusted != tt.want {
t.Errorf("Expected %.2f, got %.2f", tt.want, adjusted)
}
})
}
}
func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
// SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances)
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
txData, err := SolGetTransaction(sig)
if err != nil {
t.Fatalf("SolGetTransaction failed: %v", err)
}
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
var found bool
for _, inst := range instructions {
info, err := ParseTransferInfoFromInstruction(inst, txData)
if err != nil {
t.Logf("parse error (ok to skip): %v", err)
continue
}
if info == nil {
continue
}
found = true
t.Logf("SPL transfer: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
if info.Mint == "" {
t.Error("Expected mint to be resolved from postTokenBalances")
}
if info.RawAmount != 50000 {
t.Errorf("Expected raw amount 50000, got %d", info.RawAmount)
}
if info.BlockTime == 0 {
t.Error("Expected non-zero blockTime")
}
}
if !found {
t.Error("No transfer instruction found in transaction")
}
}
func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
// SPL Token "transferChecked" (has mint and tokenAmount in instruction)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
txData, err := SolGetTransaction(sig)
if err != nil {
t.Fatalf("SolGetTransaction failed: %v", err)
}
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
var found bool
for _, inst := range instructions {
info, err := ParseTransferInfoFromInstruction(inst, txData)
if err != nil {
t.Logf("parse error (ok to skip): %v", err)
continue
}
if info == nil {
continue
}
found = true
t.Logf("TransferChecked: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
if info.Mint != USDT_Mint {
t.Errorf("Expected USDT mint %s, got %s", USDT_Mint, info.Mint)
}
if info.RawAmount != 300000 {
t.Errorf("Expected raw amount 300000, got %d", info.RawAmount)
}
if info.Amount != 0.3 {
t.Errorf("Expected amount 0.3, got %f", info.Amount)
}
if info.BlockTime == 0 {
t.Error("Expected non-zero blockTime")
}
}
if !found {
t.Error("No transferChecked instruction found in transaction")
}
}
func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
// System program SOL transfer
sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ"
txData, err := SolGetTransaction(sig)
if err != nil {
t.Fatalf("SolGetTransaction failed: %v", err)
}
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
transferCount := 0
for _, inst := range instructions {
info, err := ParseTransferInfoFromInstruction(inst, txData)
if err != nil {
t.Logf("parse error (ok to skip): %v", err)
continue
}
if info == nil {
continue
}
transferCount++
t.Logf("System transfer #%d: source=%s dest=%s mint=%s amount=%.9f raw=%d blockTime=%d",
transferCount, info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
if info.Mint != "SOL" {
t.Errorf("Expected mint SOL, got %s", info.Mint)
}
if info.RawAmount == 0 {
t.Error("Expected non-zero raw amount")
}
if info.BlockTime == 0 {
t.Error("Expected non-zero blockTime")
}
}
if transferCount == 0 {
t.Error("No system transfer instruction found")
}
t.Logf("Found %d system transfers", transferCount)
}
+20 -2
View File
@@ -110,7 +110,7 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
}
txID := transfer.Get("txID").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "TRX", amount)
if err != nil {
panic(err)
}
@@ -134,6 +134,7 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "TRX",
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
@@ -212,7 +213,7 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
}
txID := transfer.Get("transaction_id").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "USDT", amount)
if err != nil {
panic(err)
}
@@ -236,6 +237,7 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "USDT",
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
@@ -263,6 +265,7 @@ func sendPaymentNotification(order *mdb.Orders) {
"📋 <b>订单信息</b>\n"+
"├ 交易号:<code>%s</code>\n"+
"├ 订单号:<code>%s</code>\n"+
"├ 网络:<code>%s</code>\n"+
"└ 钱包地址:<code>%s</code>\n\n"+
"⏰ <b>时间信息</b>\n"+
"├ 创建时间:%s\n"+
@@ -273,9 +276,24 @@ func sendPaymentNotification(order *mdb.Orders) {
strings.ToUpper(order.Token),
order.TradeId,
order.OrderId,
networkDisplay(order.Network),
order.ReceiveAddress,
order.CreatedAt.ToDateTimeString(),
carbon.Now().ToDateTimeString(),
)
telegram.SendToBot(msg)
}
func networkDisplay(n string) string {
switch n {
case mdb.NetworkTron:
return "Tron"
case mdb.NetworkSolana:
return "Solana"
default:
if n == "" {
return "Tron"
}
return strings.ToUpper(n)
}
}