feat: verify manual mark-paid payments

- add on-chain validation before manual mark-paid
- verify recipient address, token contract, amount, confirmations, and tx time
- reject duplicate transaction hashes and non on-chain orders
- add manual payment verification tests for EVM, TRON, and Solana
- change BSC network key to binance
- add display_name to supported_assets while keeping network unchanged
This commit is contained in:
line-6000
2026-05-20 23:38:22 +08:00
parent 864eb5969c
commit 2a36a10e7f
202 changed files with 1947 additions and 255 deletions
+50
View File
@@ -108,6 +108,56 @@ func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders,
return order, err
}
// GetOrderByBlockTransactionIDs fetches the first order whose stored tx id
// matches any equivalent spelling supplied by the caller.
func GetOrderByBlockTransactionIDs(blockIDs []string) (*mdb.Orders, error) {
order := new(mdb.Orders)
seen := make(map[string]struct{}, len(blockIDs))
candidates := make([]string, 0, len(blockIDs))
for _, blockID := range blockIDs {
blockID = strings.TrimSpace(blockID)
if blockID == "" {
continue
}
if _, ok := seen[blockID]; ok {
continue
}
seen[blockID] = struct{}{}
candidates = append(candidates, blockID)
}
if len(candidates) == 0 {
return order, nil
}
err := dao.Mdb.Model(order).Where("block_transaction_id IN ?", candidates).Limit(1).Find(order).Error
return order, err
}
// GetOrderByBlockTransactionIDsCaseInsensitive fetches the first order whose
// stored tx id matches any candidate after ASCII case folding. This is used
// only for hex-based chain tx ids; case-sensitive signatures must keep using
// GetOrderByBlockTransactionIDs.
func GetOrderByBlockTransactionIDsCaseInsensitive(blockIDs []string) (*mdb.Orders, error) {
order := new(mdb.Orders)
seen := make(map[string]struct{}, len(blockIDs))
candidates := make([]string, 0, len(blockIDs))
for _, blockID := range blockIDs {
blockID = strings.ToLower(strings.TrimSpace(blockID))
if blockID == "" {
continue
}
if _, ok := seen[blockID]; ok {
continue
}
seen[blockID] = struct{}{}
candidates = append(candidates, blockID)
}
if len(candidates) == 0 {
return order, nil
}
err := dao.Mdb.Model(order).Where("LOWER(block_transaction_id) IN ?", candidates).Limit(1).Find(order).Error
return order, err
}
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
result := tx.Model(&mdb.Orders{}).
+1 -1
View File
@@ -9,7 +9,7 @@ const (
NetworkTron = "tron"
NetworkSolana = "solana"
NetworkEthereum = "ethereum"
NetworkBsc = "bsc"
NetworkBsc = "binance"
NetworkPolygon = "polygon"
NetworkPlasma = "plasma"
)
+3 -2
View File
@@ -1,8 +1,9 @@
package response
type NetworkTokenSupport struct {
Network string `json:"network" example:"tron"`
Tokens []string `json:"tokens" example:"USDT,USDC"`
Network string `json:"network" example:"tron"`
DisplayName string `json:"display_name" example:"TRON"`
Tokens []string `json:"tokens" example:"USDT,USDC"`
}
type EpayPublicConfig struct {
+832
View File
@@ -0,0 +1,832 @@
package service
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"sync"
"time"
tron "github.com/GMWalletApp/epusdt/crypto"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
"github.com/tidwall/gjson"
)
const manualVerifyRequestTimeout = 15 * time.Second
var erc20TransferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
var (
manualOrderPaymentValidatorMu sync.Mutex
manualOrderPaymentValidator manualOrderPaymentValidatorFunc = validateManualOrderPaymentDefault
)
type manualOrderPaymentValidatorFunc func(*mdb.Orders, string) (string, error)
// ValidateManualOrderPayment verifies that the supplied chain transaction
// really settles the order before an admin manually marks it paid. It returns
// the canonical transaction id that should be persisted for duplicate checks.
func ValidateManualOrderPayment(order *mdb.Orders, blockTransactionID string) (string, error) {
manualOrderPaymentValidatorMu.Lock()
validator := manualOrderPaymentValidator
manualOrderPaymentValidatorMu.Unlock()
return validator(order, blockTransactionID)
}
// SetManualOrderPaymentValidatorForTest swaps the chain verifier in tests so
// route/controller tests don't depend on public RPC availability.
func SetManualOrderPaymentValidatorForTest(fn func(*mdb.Orders, string) (string, error)) func() {
manualOrderPaymentValidatorMu.Lock()
old := manualOrderPaymentValidator
if fn == nil {
manualOrderPaymentValidator = validateManualOrderPaymentDefault
} else {
manualOrderPaymentValidator = fn
}
manualOrderPaymentValidatorMu.Unlock()
return func() {
manualOrderPaymentValidatorMu.Lock()
manualOrderPaymentValidator = old
manualOrderPaymentValidatorMu.Unlock()
}
}
func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID string) (string, error) {
if order == nil || order.ID == 0 {
return "", fmt.Errorf("order not found")
}
txID := strings.TrimSpace(blockTransactionID)
if txID == "" {
return "", fmt.Errorf("block_transaction_id is required")
}
var canonicalTxID string
var err error
switch strings.ToLower(strings.TrimSpace(order.Network)) {
case mdb.NetworkTron:
canonicalTxID, err = validateManualTronPayment(order, txID)
case mdb.NetworkSolana:
canonicalTxID, err = validateManualSolanaPayment(order, txID)
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
canonicalTxID, err = validateManualEvmPayment(order, txID)
default:
return "", fmt.Errorf("unsupported manual payment verification network: %s", order.Network)
}
if err != nil {
return "", err
}
if err = ensureManualBlockTransactionUnused(order, canonicalTxID); err != nil {
return "", err
}
return canonicalTxID, nil
}
func ensureManualBlockTransactionUnused(order *mdb.Orders, canonicalTxID string) error {
candidates := equivalentManualBlockTransactionIDs(order.Network, canonicalTxID)
var existing *mdb.Orders
var err error
if manualBlockTransactionIDIsHex(order.Network) {
existing, err = data.GetOrderByBlockTransactionIDsCaseInsensitive(candidates)
} else {
existing, err = data.GetOrderByBlockTransactionIDs(candidates)
}
if err != nil {
return err
}
if existing.ID > 0 && existing.ID != order.ID {
return constant.OrderBlockAlreadyProcess
}
return nil
}
func manualBlockTransactionIDIsHex(network string) bool {
switch strings.ToLower(strings.TrimSpace(network)) {
case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
return true
default:
return false
}
}
func equivalentManualBlockTransactionIDs(network, canonicalTxID string) []string {
network = strings.ToLower(strings.TrimSpace(network))
canonicalTxID = strings.TrimSpace(canonicalTxID)
seen := make(map[string]struct{})
out := make([]string, 0, 6)
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
out = append(out, value)
}
add(canonicalTxID)
switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X")
body = strings.ToLower(body)
add("0x" + body)
add("0x" + strings.ToUpper(body))
add("0X" + body)
add("0X" + strings.ToUpper(body))
add(body)
add(strings.ToUpper(body))
case mdb.NetworkTron:
body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X")
body = strings.ToLower(body)
add(body)
add(strings.ToUpper(body))
add("0x" + body)
add("0x" + strings.ToUpper(body))
add("0X" + body)
add("0X" + strings.ToUpper(body))
}
return out
}
func validateManualEvmPayment(order *mdb.Orders, txID string) (string, error) {
txHash, canonicalTxID, err := canonicalEvmHash(txID)
if err != nil {
return "", err
}
token, err := data.GetEnabledChainTokenBySymbol(order.Network, order.Token)
if err != nil {
return "", err
}
if token == nil || token.ID == 0 || strings.TrimSpace(token.ContractAddress) == "" {
return "", fmt.Errorf("enabled token contract not configured for %s/%s", order.Network, order.Token)
}
ctx, cancel := context.WithTimeout(context.Background(), manualVerifyRequestTimeout)
defer cancel()
clients, err := dialManualEvmClients(ctx, order.Network)
if err != nil {
return "", err
}
defer closeManualEvmClients(clients)
if err = validateManualEvmPaymentAcrossClients(ctx, clients, order, txHash, token); err != nil {
return "", err
}
return canonicalTxID, nil
}
type evmChainReader interface {
TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error)
HeaderByNumber(context.Context, *big.Int) (*types.Header, error)
}
type manualEvmClient struct {
label string
reader evmChainReader
close func()
}
func closeManualEvmClients(clients []manualEvmClient) {
for _, item := range clients {
if item.close != nil {
item.close()
}
}
}
func validateManualEvmPaymentAcrossClients(ctx context.Context, clients []manualEvmClient, order *mdb.Orders, txHash common.Hash, token *mdb.ChainToken) error {
var verifyErrors []string
for _, item := range clients {
if err := validateManualEvmPaymentWithClient(ctx, item.reader, order, txHash, token); err != nil {
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", item.label, err))
continue
}
return nil
}
if len(verifyErrors) > 0 {
return fmt.Errorf("manual EVM verification failed: %s", strings.Join(verifyErrors, "; "))
}
return fmt.Errorf("no enabled %s WS/HTTP RPC node configured", order.Network)
}
func validateManualEvmPaymentWithClient(ctx context.Context, client evmChainReader, order *mdb.Orders, txHash common.Hash, token *mdb.ChainToken) error {
receipt, err := client.TransactionReceipt(ctx, txHash)
if err != nil {
return fmt.Errorf("fetch transaction receipt: %w", err)
}
if receipt.Status != types.ReceiptStatusSuccessful {
return fmt.Errorf("transaction is not successful")
}
if receipt.BlockNumber == nil {
return fmt.Errorf("transaction receipt missing block number")
}
txHeader, err := client.HeaderByNumber(ctx, receipt.BlockNumber)
if err != nil {
return fmt.Errorf("fetch transaction block header: %w", err)
}
if err = ensureEvmTransactionNotBeforeOrder(txHeader.Time, order); err != nil {
return err
}
if err = ensureEvmConfirmations(ctx, client, order.Network, receipt.BlockNumber); err != nil {
return err
}
contract, err := normalizeEvmAddress(token.ContractAddress)
if err != nil {
return fmt.Errorf("invalid token contract address: %w", err)
}
to, err := normalizeEvmAddress(order.ReceiveAddress)
if err != nil {
return fmt.Errorf("invalid order receive address: %w", err)
}
amountMismatch := false
for _, item := range receipt.Logs {
if item == nil || !strings.EqualFold(item.Address.Hex(), contract.Hex()) {
continue
}
if len(item.Topics) < 3 || item.Topics[0] != erc20TransferEventHash {
continue
}
if !strings.EqualFold(common.BytesToAddress(item.Topics[2].Bytes()).Hex(), to.Hex()) {
continue
}
rawAmount := new(big.Int).SetBytes(item.Data)
if amountMatchesRaw(order.ActualAmount, rawAmount, token.Decimals) {
return nil
}
amountMismatch = true
}
if amountMismatch {
return fmt.Errorf("transaction amount mismatch")
}
return fmt.Errorf("matching token transfer to order address not found")
}
func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClient, error) {
var clients []manualEvmClient
var connectErrors []string
for _, nodeType := range []string{mdb.RpcNodeTypeWs, mdb.RpcNodeTypeHttp} {
node, err := data.SelectRpcNode(network, nodeType)
if err != nil {
return nil, err
}
if node == nil || node.ID == 0 || strings.TrimSpace(node.Url) == "" {
continue
}
rpcURL := strings.TrimSpace(node.Url)
client, err := ethclient.DialContext(ctx, rpcURL)
if err == nil {
clients = append(clients, manualEvmClient{
label: fmt.Sprintf("%s %s", nodeType, rpcURL),
reader: client,
close: client.Close,
})
continue
}
connectErrors = append(connectErrors, fmt.Sprintf("%s %s: %v", nodeType, rpcURL, err))
}
if len(clients) > 0 {
return clients, nil
}
if len(connectErrors) > 0 {
return nil, fmt.Errorf("connect %s RPC failed: %s", network, strings.Join(connectErrors, "; "))
}
return nil, fmt.Errorf("no enabled %s WS/HTTP RPC node configured", network)
}
func ensureEvmTransactionNotBeforeOrder(blockTime uint64, order *mdb.Orders) error {
if order == nil {
return fmt.Errorf("order not found")
}
if int64(blockTime)*1000 < order.CreatedAt.TimestampMilli() {
return fmt.Errorf("transaction predates the order")
}
return nil
}
func ensureEvmConfirmations(ctx context.Context, client evmChainReader, network string, txBlock *big.Int) error {
chain, err := data.GetChainByNetwork(network)
if err != nil {
return err
}
minConfirmations := 1
if chain != nil && chain.MinConfirmations > 0 {
minConfirmations = chain.MinConfirmations
}
header, err := client.HeaderByNumber(ctx, nil)
if err != nil {
return fmt.Errorf("fetch latest block: %w", err)
}
confirmations := new(big.Int).Sub(header.Number, txBlock)
confirmations.Add(confirmations, big.NewInt(1))
if confirmations.Sign() < 0 || confirmations.Cmp(big.NewInt(int64(minConfirmations))) < 0 {
return fmt.Errorf("transaction confirmations %s below required %d", confirmations.String(), minConfirmations)
}
return nil
}
type tronContractParam struct {
TypeURL string `json:"type_url"`
Value json.RawMessage `json:"value"`
}
type manualTronTransaction struct {
TxID string `json:"txID"`
RawData struct {
Contract []struct {
Type string `json:"type"`
Parameter tronContractParam `json:"parameter"`
} `json:"contract"`
Timestamp int64 `json:"timestamp"`
} `json:"raw_data"`
Ret []struct {
ContractRet string `json:"contractRet"`
} `json:"ret"`
}
type manualTronTxInfo struct {
ID string `json:"id"`
BlockNumber int64 `json:"blockNumber"`
BlockTimeStamp int64 `json:"blockTimeStamp"`
ContractResult []string `json:"contractResult"`
Log []manualTronEventLog `json:"log"`
Receipt struct {
Result string `json:"result"`
} `json:"receipt"`
}
type manualTronEventLog struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
}
type manualTronBlock struct {
BlockHeader struct {
RawData struct {
Number int64 `json:"number"`
} `json:"raw_data"`
} `json:"block_header"`
}
type manualTronTransferContractValue struct {
OwnerAddress string `json:"owner_address"`
ToAddress string `json:"to_address"`
Amount int64 `json:"amount"`
}
func validateManualTronPayment(order *mdb.Orders, txID string) (string, error) {
normalizedTxID, err := normalizeTronTxID(txID)
if err != nil {
return "", err
}
baseURL, apiKey, err := ResolveTronNode()
if err != nil {
return "", err
}
var tx manualTronTransaction
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactionbyid", map[string]interface{}{"value": normalizedTxID}, &tx); err != nil {
return "", fmt.Errorf("fetch tron transaction: %w", err)
}
if strings.TrimSpace(tx.TxID) == "" {
return "", fmt.Errorf("transaction not found")
}
if strings.TrimSpace(tx.TxID) != "" && !strings.EqualFold(strings.TrimSpace(tx.TxID), normalizedTxID) {
return "", fmt.Errorf("transaction id mismatch")
}
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
return "", fmt.Errorf("transaction is not successful: %s", tx.Ret[0].ContractRet)
}
var info manualTronTxInfo
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactioninfobyid", map[string]interface{}{"value": normalizedTxID}, &info); err != nil {
return "", fmt.Errorf("fetch tron transaction info: %w", err)
}
if info.BlockNumber <= 0 {
return "", fmt.Errorf("transaction is not confirmed")
}
if info.Receipt.Result != "" && info.Receipt.Result != "SUCCESS" {
return "", fmt.Errorf("transaction is not successful: %s", info.Receipt.Result)
}
if err = ensureTronConfirmations(baseURL, apiKey, order.Network, info.BlockNumber); err != nil {
return "", err
}
if info.BlockTimeStamp <= 0 {
return "", fmt.Errorf("transaction block timestamp missing")
}
if info.BlockTimeStamp < order.CreatedAt.TimestampMilli() {
return "", fmt.Errorf("transaction predates the order")
}
if strings.EqualFold(strings.TrimSpace(order.Token), "TRX") {
if err = validateManualTronNativeTransfer(order, &tx); err != nil {
return "", err
}
return normalizedTxID, nil
}
if err = validateManualTronTRC20Transfer(order, &tx, &info); err != nil {
return "", err
}
return normalizedTxID, nil
}
func validateManualTronNativeTransfer(order *mdb.Orders, tx *manualTronTransaction) error {
if len(tx.RawData.Contract) == 0 || tx.RawData.Contract[0].Type != "TransferContract" {
return fmt.Errorf("transaction is not a TRX transfer")
}
var val manualTronTransferContractValue
if err := json.Unmarshal(tx.RawData.Contract[0].Parameter.Value, &val); err != nil {
return err
}
toAddr, err := tronHexToAddress(val.ToAddress)
if err != nil {
return fmt.Errorf("invalid TRX recipient address: %w", err)
}
if !strings.EqualFold(toAddr, order.ReceiveAddress) {
return fmt.Errorf("transaction recipient mismatch")
}
if !amountMatchesRaw(order.ActualAmount, big.NewInt(val.Amount), 6) {
return fmt.Errorf("transaction amount mismatch")
}
return nil
}
func validateManualTronTRC20Transfer(order *mdb.Orders, tx *manualTronTransaction, info *manualTronTxInfo) error {
token, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, order.Token)
if err != nil {
return err
}
if token == nil || token.ID == 0 || strings.TrimSpace(token.ContractAddress) == "" {
return fmt.Errorf("enabled token contract not configured for tron/%s", order.Token)
}
if len(tx.RawData.Contract) == 0 || tx.RawData.Contract[0].Type != "TriggerSmartContract" {
return fmt.Errorf("transaction is not a TRC20 transfer")
}
contractHex, err := tronAddressToHex(token.ContractAddress)
if err != nil {
return fmt.Errorf("invalid configured TRC20 contract: %w", err)
}
return validateManualTronTRC20TransferEvent(order, info, contractHex, token.Decimals)
}
func validateManualTronTRC20TransferEvent(order *mdb.Orders, info *manualTronTxInfo, contractHex string, decimals int) error {
if info == nil || len(info.Log) == 0 {
return fmt.Errorf("matching TRC20 transfer event to order address not found")
}
toHex, err := tronAddressToHex(order.ReceiveAddress)
if err != nil {
return fmt.Errorf("invalid order receive address: %w", err)
}
transferTopic := strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x")
amountMismatch := false
for _, event := range info.Log {
eventContractHex, err := normalizeTronAddressHex(event.Address)
if err != nil || eventContractHex != contractHex {
continue
}
if len(event.Topics) < 3 || normalizeEventTopic(event.Topics[0]) != transferTopic {
continue
}
eventToHex, err := tronTopicAddressToHex(event.Topics[2])
if err != nil || eventToHex != toHex {
continue
}
rawAmount, err := tronEventDataAmount(event.Data)
if err != nil {
return fmt.Errorf("invalid TRC20 transfer event amount: %w", err)
}
if amountMatchesRaw(order.ActualAmount, rawAmount, decimals) {
return nil
}
amountMismatch = true
}
if amountMismatch {
return fmt.Errorf("transaction amount mismatch")
}
return fmt.Errorf("matching TRC20 transfer event to order address not found")
}
func ensureTronConfirmations(baseURL, apiKey, network string, txBlock int64) error {
var latest manualTronBlock
if err := tronPostJSON(baseURL, apiKey, "/wallet/getnowblock", map[string]interface{}{}, &latest); err != nil {
return fmt.Errorf("fetch latest tron block: %w", err)
}
chain, err := data.GetChainByNetwork(network)
if err != nil {
return err
}
minConfirmations := 1
if chain != nil && chain.MinConfirmations > 0 {
minConfirmations = chain.MinConfirmations
}
confirmations := latest.BlockHeader.RawData.Number - txBlock + 1
if confirmations < int64(minConfirmations) {
return fmt.Errorf("transaction confirmations %d below required %d", confirmations, minConfirmations)
}
return nil
}
func tronPostJSON(baseURL, apiKey, path string, body interface{}, out interface{}) error {
payload, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(baseURL, "/")+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
req.Header.Set("TRON-PRO-API-KEY", apiKey)
}
client := &http.Client{Timeout: manualVerifyRequestTimeout}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw))
}
return json.Unmarshal(raw, out)
}
func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) {
sig = strings.TrimSpace(sig)
txData, err := SolGetTransaction(sig)
if err != nil {
return "", fmt.Errorf("fetch solana transaction: %w", err)
}
if !gjson.GetBytes(txData, "result").Exists() || gjson.GetBytes(txData, "result").Type == gjson.Null {
return "", fmt.Errorf("transaction not found")
}
if err = ensureSolanaConfirmations(order.Network, sig); err != nil {
return "", err
}
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
if err != nil {
return "", err
}
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 != "" {
mintTokens[mint] = &tokens[i]
}
}
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
amountMismatch := false
for _, instruction := range instructions {
transferInfo, parseErr := ParseTransferInfoFromInstruction(instruction, txData)
if parseErr != nil || transferInfo == nil {
continue
}
if !isTransferToAddress(transferInfo, order.ReceiveAddress) {
continue
}
token, amount := resolveSolTokenAndAmount(transferInfo, mintTokens, nativeSolToken)
if !strings.EqualFold(token, order.Token) {
continue
}
if err = ensureSolanaTransferNotBeforeOrder(transferInfo.BlockTime, order); err != nil {
return "", err
}
if amountMatchesFloat(order.ActualAmount, amount) {
return sig, nil
}
amountMismatch = true
}
if amountMismatch {
return "", fmt.Errorf("transaction amount mismatch")
}
return "", fmt.Errorf("matching solana transfer to order address not found")
}
func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) error {
if order == nil {
return fmt.Errorf("order not found")
}
if blockTime <= 0 {
return fmt.Errorf("transaction block time missing")
}
if blockTime*1000 < order.CreatedAt.TimestampMilli() {
return fmt.Errorf("transaction predates the order")
}
return nil
}
func ensureSolanaConfirmations(network, sig string) error {
chain, err := data.GetChainByNetwork(network)
if err != nil {
return err
}
minConfirmations := 1
if chain != nil && chain.MinConfirmations > 0 {
minConfirmations = chain.MinConfirmations
}
if minConfirmations <= 1 {
return nil
}
body, err := SolRetryClient("getSignatureStatuses", []interface{}{
[]string{sig},
map[string]interface{}{"searchTransactionHistory": true},
})
if err != nil {
return fmt.Errorf("fetch solana signature status: %w", err)
}
status := gjson.GetBytes(body, "result.value.0")
if !status.Exists() || status.Type == gjson.Null {
return fmt.Errorf("transaction status not found")
}
if errValue := status.Get("err"); errValue.Exists() && errValue.Type != gjson.Null {
return fmt.Errorf("transaction is not successful: %s", errValue.Raw)
}
if status.Get("confirmationStatus").String() == "finalized" {
return nil
}
confirmations := status.Get("confirmations").Int()
if confirmations < int64(minConfirmations) {
return fmt.Errorf("transaction confirmations %d below required %d", confirmations, minConfirmations)
}
return nil
}
func amountMatchesRaw(expected float64, rawAmount *big.Int, decimals int) bool {
if rawAmount == nil || rawAmount.Sign() <= 0 {
return false
}
if decimals < 0 {
decimals = 0
}
actual := decimal.NewFromBigInt(rawAmount, -int32(decimals))
return roundedAmount(expected).Equal(actual.Round(int32(data.GetAmountPrecision())))
}
func amountMatchesFloat(expected, actual float64) bool {
return roundedAmount(expected).Equal(roundedAmount(actual))
}
func roundedAmount(amount float64) decimal.Decimal {
precision := data.GetAmountPrecision()
return decimal.NewFromFloat(math.MustParsePrecFloat64(amount, precision)).Round(int32(precision))
}
func tronHexToAddress(hexAddr string) (string, error) {
hexAddr, err := normalizeTronAddressHex(hexAddr)
if err != nil {
return "", err
}
raw, err := hex.DecodeString(hexAddr)
if err != nil {
return "", err
}
return tron.EncodeCheck(raw), nil
}
func tronAddressToHex(addr string) (string, error) {
raw, err := tron.DecodeCheck(strings.TrimSpace(addr))
if err != nil {
return "", err
}
if len(raw) != 21 || raw[0] != tron.PrefixMainnet {
return "", fmt.Errorf("invalid tron address")
}
return strings.ToLower(hex.EncodeToString(raw)), nil
}
func normalizeTronAddressHex(hexAddr string) (string, error) {
hexAddr = normalizeTronHexAddress(hexAddr)
raw, err := hex.DecodeString(hexAddr)
if err != nil {
return "", err
}
switch len(raw) {
case 20:
raw = append([]byte{tron.PrefixMainnet}, raw...)
case 21:
if raw[0] != tron.PrefixMainnet {
return "", fmt.Errorf("invalid tron address prefix")
}
default:
return "", fmt.Errorf("invalid address length: %d", len(raw))
}
return strings.ToLower(hex.EncodeToString(raw)), nil
}
func normalizeTronHexAddress(hexAddr string) string {
hexAddr = strings.TrimSpace(hexAddr)
hexAddr = strings.TrimPrefix(hexAddr, "0x")
hexAddr = strings.TrimPrefix(hexAddr, "0X")
return strings.ToLower(hexAddr)
}
func normalizeEventTopic(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "0x")
value = strings.TrimPrefix(value, "0X")
return strings.ToLower(value)
}
func tronTopicAddressToHex(topic string) (string, error) {
topic = normalizeEventTopic(topic)
if len(topic) != 64 {
return "", fmt.Errorf("invalid TRON event address topic length")
}
if _, err := hex.DecodeString(topic); err != nil {
return "", err
}
return normalizeTronAddressHex(topic[24:])
}
func tronEventDataAmount(data string) (*big.Int, error) {
data = normalizeEventTopic(data)
if data == "" {
return nil, fmt.Errorf("empty event data")
}
if len(data)%2 != 0 {
return nil, fmt.Errorf("odd-length event data")
}
raw, err := hex.DecodeString(data)
if err != nil {
return nil, err
}
return new(big.Int).SetBytes(raw), nil
}
func isEvmHash(value string) bool {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "0x")
value = strings.TrimPrefix(value, "0X")
if len(value) != 64 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func canonicalEvmHash(value string) (common.Hash, string, error) {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "0x")
value = strings.TrimPrefix(value, "0X")
if len(value) != 64 {
return common.Hash{}, "", fmt.Errorf("invalid EVM transaction hash")
}
if _, err := hex.DecodeString(value); err != nil {
return common.Hash{}, "", err
}
hash := common.HexToHash("0x" + strings.ToLower(value))
return hash, hash.Hex(), nil
}
func normalizeTronTxID(value string) (string, error) {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "0x")
value = strings.TrimPrefix(value, "0X")
if len(value) != 64 {
return "", fmt.Errorf("invalid TRON transaction id length")
}
if _, err := hex.DecodeString(value); err != nil {
return "", err
}
return strings.ToLower(value), nil
}
func normalizeEvmAddress(value string) (common.Address, error) {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "0x")
value = strings.TrimPrefix(value, "0X")
if len(value) != 40 {
return common.Address{}, fmt.Errorf("invalid address length")
}
if _, err := hex.DecodeString(value); err != nil {
return common.Address{}, err
}
return common.HexToAddress("0x" + value), nil
}
@@ -0,0 +1,431 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
func TestManualVerifyEvmHashAcceptsOptional0x(t *testing.T) {
hash := strings.Repeat("a", 64)
if !isEvmHash(hash) {
t.Fatal("expected bare EVM hash to be valid")
}
if !isEvmHash("0x" + hash) {
t.Fatal("expected 0x-prefixed EVM hash to be valid")
}
if isEvmHash("0x" + strings.Repeat("a", 63)) {
t.Fatal("expected short EVM hash to be invalid")
}
}
func TestManualVerifyNormalizeEvmAddressAcceptsOptional0x(t *testing.T) {
addr := "1111111111111111111111111111111111111111"
want := common.HexToAddress("0x" + addr)
got, err := normalizeEvmAddress(addr)
if err != nil {
t.Fatalf("normalize bare address: %v", err)
}
if got != want {
t.Fatalf("bare address = %s, want %s", got.Hex(), want.Hex())
}
got, err = normalizeEvmAddress("0X" + strings.ToUpper(addr))
if err != nil {
t.Fatalf("normalize prefixed address: %v", err)
}
if got != want {
t.Fatalf("prefixed address = %s, want %s", got.Hex(), want.Hex())
}
}
func TestManualVerifyNormalizeTronAddressHexAcceptsOptionalPrefix(t *testing.T) {
body := "a614f803b6fd780986a42c78ec9c7f77e6ded13c"
want := "41" + body
for _, input := range []string{body, "0x" + body, want, "0X" + strings.ToUpper(want)} {
got, err := normalizeTronAddressHex(input)
if err != nil {
t.Fatalf("normalizeTronAddressHex(%q): %v", input, err)
}
if got != want {
t.Fatalf("normalizeTronAddressHex(%q) = %q, want %q", input, got, want)
}
}
}
func TestManualVerifyNormalizeTronTxIDAcceptsOptional0x(t *testing.T) {
txID := strings.Repeat("a", 64)
got, err := normalizeTronTxID(txID)
if err != nil {
t.Fatalf("normalize bare txid: %v", err)
}
if got != txID {
t.Fatalf("bare txid = %q, want %q", got, txID)
}
got, err = normalizeTronTxID("0X" + strings.ToUpper(txID))
if err != nil {
t.Fatalf("normalize prefixed txid: %v", err)
}
if got != txID {
t.Fatalf("prefixed txid = %q, want %q", got, txID)
}
if _, err = normalizeTronTxID("0x" + strings.Repeat("a", 63)); err == nil {
t.Fatal("expected short txid to fail")
}
}
func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkEthereum,
Type: mdb.RpcNodeTypeHttp,
Url: "http://127.0.0.1:1",
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("create rpc node: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
clients, err := dialManualEvmClients(ctx, mdb.NetworkEthereum)
if err != nil {
t.Fatalf("dialManualEvmClients(): %v", err)
}
defer closeManualEvmClients(clients)
if len(clients) != 1 {
t.Fatalf("client count = %d, want 1", len(clients))
}
if !strings.Contains(clients[0].label, mdb.RpcNodeTypeHttp) {
t.Fatalf("client label = %q, want HTTP node", clients[0].label)
}
}
func TestManualVerifyEvmRejectsTransactionBeforeOrder(t *testing.T) {
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
txTime := uint64(time.Now().Add(-time.Hour).Unix())
if err := ensureEvmTransactionNotBeforeOrder(txTime, order); err == nil {
t.Fatal("expected transaction before order to be rejected")
}
txTime = uint64(time.Now().Add(time.Minute).Unix())
if err := ensureEvmTransactionNotBeforeOrder(txTime, order); err != nil {
t.Fatalf("expected transaction after order to pass: %v", err)
}
}
func TestManualVerifyCanonicalEvmHash(t *testing.T) {
hash := strings.Repeat("a", 64)
_, canonical, err := canonicalEvmHash("0X" + strings.ToUpper(hash))
if err != nil {
t.Fatalf("canonicalEvmHash(): %v", err)
}
if canonical != "0x"+hash {
t.Fatalf("canonical hash = %q, want %q", canonical, "0x"+hash)
}
}
func TestManualVerifyEquivalentBlockIDsCatchLegacyVariants(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
hash := strings.Repeat("b", 64)
mixedHash := "0x" + strings.Repeat("bB", 32)
if err := dao.Mdb.Create(&mdb.Orders{
TradeId: "paid-legacy-hash",
OrderId: "paid-legacy-hash",
Status: mdb.StatusPaySuccess,
Network: mdb.NetworkEthereum,
BlockTransactionId: mixedHash,
}).Error; err != nil {
t.Fatalf("create existing order: %v", err)
}
order := &mdb.Orders{BaseModel: mdb.BaseModel{ID: 999}, Network: mdb.NetworkEthereum}
if err := ensureManualBlockTransactionUnused(order, "0x"+hash); err == nil {
t.Fatal("expected legacy hash variant to be treated as already processed")
}
}
func TestManualVerifyEvmRequestFailureFallsBackToNextClient(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
contract := common.HexToAddress("0x1111111111111111111111111111111111111111")
to := common.HexToAddress("0x2222222222222222222222222222222222222222")
rawAmount := big.NewInt(1230000)
receipt := &types.Receipt{
Status: types.ReceiptStatusSuccessful,
BlockNumber: big.NewInt(10),
Logs: []*types.Log{{
Address: contract,
Topics: []common.Hash{
erc20TransferEventHash,
common.Hash{},
common.BytesToHash(to.Bytes()),
},
Data: common.LeftPadBytes(rawAmount.Bytes(), 32),
}},
}
order := &mdb.Orders{
Network: mdb.NetworkEthereum,
Token: "USDT",
ActualAmount: 1.23,
ReceiveAddress: to.Hex(),
BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
}
token := &mdb.ChainToken{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: contract.Hex(), Decimals: 6}
err := validateManualEvmPaymentAcrossClients(context.Background(), []manualEvmClient{
{label: "ws bad", reader: &fakeEvmReader{receiptErr: errors.New("ws receipt failed")}},
{label: "http ok", reader: &fakeEvmReader{
receipt: receipt,
headers: map[string]*types.Header{
"10": {Number: big.NewInt(10), Time: uint64(time.Now().Unix())},
"latest": {Number: big.NewInt(12), Time: uint64(time.Now().Unix())},
},
}},
}, order, common.HexToHash("0x"+strings.Repeat("c", 64)), token)
if err != nil {
t.Fatalf("validateManualEvmPaymentAcrossClients(): %v", err)
}
}
func TestManualVerifyTronTRC20UsesTransferEvent(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
contractHex := "411111111111111111111111111111111111111111"
recipientHex := "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
contractAddress, err := tronHexToAddress(contractHex)
if err != nil {
t.Fatalf("contract address: %v", err)
}
recipientAddress, err := tronHexToAddress(recipientHex)
if err != nil {
t.Fatalf("recipient address: %v", err)
}
if err = dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkTron,
Symbol: "USDT",
ContractAddress: contractAddress,
Decimals: 6,
Enabled: true,
}).Error; err != nil {
t.Fatalf("create token: %v", err)
}
rawAmount := big.NewInt(1230000)
tx := manualTronTransactionFromCallData(t, contractHex, recipientHex, rawAmount)
info := &manualTronTxInfo{Log: []manualTronEventLog{{
Address: strings.TrimPrefix(contractHex, "41"),
Topics: []string{
"0x" + strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x"),
strings.Repeat("0", 64),
"0x" + strings.Repeat("0", 24) + strings.TrimPrefix(recipientHex, "41"),
},
Data: "0x" + fmt.Sprintf("%064x", rawAmount),
}}}
order := &mdb.Orders{ReceiveAddress: recipientAddress, Token: "USDT", ActualAmount: 1.23}
if err = validateManualTronTRC20Transfer(order, &tx, info); err != nil {
t.Fatalf("validateManualTronTRC20Transfer(): %v", err)
}
info.Log[0].Data = "0x" + fmt.Sprintf("%064x", big.NewInt(1240000))
if err = validateManualTronTRC20Transfer(order, &tx, info); err == nil {
t.Fatal("expected event amount mismatch to fail")
}
}
func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
txID := strings.Repeat("a", 64)
contractHex := "411111111111111111111111111111111111111111"
recipientHex := "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
contractAddress, err := tronHexToAddress(contractHex)
if err != nil {
t.Fatalf("contract address: %v", err)
}
recipientAddress, err := tronHexToAddress(recipientHex)
if err != nil {
t.Fatalf("recipient address: %v", err)
}
rawAmount := big.NewInt(1230000)
blockTimeMs := time.Now().Add(time.Minute).UnixMilli()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]string
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode tron request: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if r.URL.Path != "/wallet/getnowblock" && req["value"] != txID {
t.Errorf("request tx id = %q, want %q", req["value"], txID)
http.Error(w, "bad tx id", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/wallet/gettransactionbyid":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"txID": txID,
"raw_data": map[string]interface{}{
"contract": []map[string]interface{}{{
"type": "TriggerSmartContract",
"parameter": map[string]interface{}{
"value": map[string]interface{}{},
},
}},
},
"ret": []map[string]string{{"contractRet": "SUCCESS"}},
})
case "/wallet/gettransactioninfobyid":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"id": txID,
"blockNumber": 100,
"blockTimeStamp": blockTimeMs,
"receipt": map[string]string{"result": "SUCCESS"},
"log": []map[string]interface{}{{
"address": strings.TrimPrefix(contractHex, "41"),
"topics": []string{
strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x"),
strings.Repeat("0", 64),
"0X" + strings.ToUpper(strings.Repeat("0", 24)+strings.TrimPrefix(recipientHex, "41")),
},
"data": fmt.Sprintf("%064x", rawAmount),
}},
})
case "/wallet/getnowblock":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"block_header": map[string]interface{}{
"raw_data": map[string]interface{}{"number": 110},
},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
if err = dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Type: mdb.RpcNodeTypeHttp,
Url: server.URL,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("create tron rpc node: %v", err)
}
if err = dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkTron,
Symbol: "USDT",
ContractAddress: contractAddress,
Decimals: 6,
Enabled: true,
}).Error; err != nil {
t.Fatalf("create token: %v", err)
}
order := &mdb.Orders{
TradeId: "manual-tron-http-flow",
OrderId: "manual-tron-http-flow",
ActualAmount: 1.23,
ReceiveAddress: recipientAddress,
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
PayProvider: mdb.PaymentProviderOnChain,
}
if err = dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
got, err := ValidateManualOrderPayment(order, "0X"+strings.ToUpper(txID))
if err != nil {
t.Fatalf("ValidateManualOrderPayment(): %v", err)
}
if got != txID {
t.Fatalf("canonical tx id = %q, want %q", got, txID)
}
}
func TestManualVerifySolanaRejectsMissingBlockTime(t *testing.T) {
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
if err := ensureSolanaTransferNotBeforeOrder(0, order); err == nil {
t.Fatal("expected missing block time to fail")
}
if err := ensureSolanaTransferNotBeforeOrder(time.Now().Add(time.Minute).Unix(), order); err != nil {
t.Fatalf("expected future block time to pass: %v", err)
}
}
type fakeEvmReader struct {
receipt *types.Receipt
receiptErr error
headers map[string]*types.Header
headerErr error
}
func (f *fakeEvmReader) TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) {
if f.receiptErr != nil {
return nil, f.receiptErr
}
return f.receipt, nil
}
func (f *fakeEvmReader) HeaderByNumber(_ context.Context, number *big.Int) (*types.Header, error) {
if f.headerErr != nil {
return nil, f.headerErr
}
key := "latest"
if number != nil {
key = number.String()
}
header := f.headers[key]
if header == nil {
return nil, fmt.Errorf("missing header %s", key)
}
return header, nil
}
func manualTronTransactionFromCallData(t *testing.T, contractHex, recipientHex string, amount *big.Int) manualTronTransaction {
t.Helper()
body := strings.TrimPrefix(recipientHex, "41")
raw := fmt.Sprintf(`{
"raw_data": {
"contract": [{
"type": "TriggerSmartContract",
"parameter": {
"value": {
"contract_address": %q,
"data": %q
}
}
}]
}
}`, contractHex, "a9059cbb"+strings.Repeat("0", 24)+body+fmt.Sprintf("%064x", amount))
var tx manualTronTransaction
if err := json.Unmarshal([]byte(raw), &tx); err != nil {
t.Fatalf("unmarshal tron tx: %v", err)
}
return tx
}
+6 -6
View File
@@ -1,23 +1,23 @@
package service
import (
"errors"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
)
var ErrOrder = errors.New("不存在待支付订单或已过期")
// ErrOrder is returned when checkout initialization cannot find the trade id.
var ErrOrder = constant.OrderNotExists
// GetCheckoutCounterByTradeId returns checkout info for a pending order.
// GetCheckoutCounterByTradeId returns checkout initialization data for an existing order.
// It does not decide the payment state; callers should use CheckStatus for that.
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
return nil, err
}
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
if orderInfo.ID <= 0 {
return nil, ErrOrder
}