mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
feat: add Aptos USDT/USDC payment support
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const defaultAptosFullnodeURL = "https://aptos-rest.publicnode.com/"
|
||||
|
||||
var aptosFullnodeURL = defaultAptosFullnodeURL
|
||||
var aptosFixedNodeLogOnce sync.Once
|
||||
|
||||
func AptosFixedFullnodeURL() string {
|
||||
return strings.TrimSpace(aptosFullnodeURL)
|
||||
}
|
||||
|
||||
func AptosGetLedgerVersion() (int64, error) {
|
||||
body, err := aptosGet("/v1")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
latest := gjson.GetBytes(body, "ledger_version").Int()
|
||||
log.Sugar.Debugf("[APTOS] latest ledger version=%d", latest)
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func AptosGetTransactionByHash(txID string) ([]byte, error) {
|
||||
txID = normalizeAptosTxID(txID)
|
||||
log.Sugar.Infof("[APTOS] fetch transaction by hash tx=%s rpc=%s", txID, aptosFullnodeURL)
|
||||
return aptosGet(fmt.Sprintf("/v1/transactions/by_hash/%s", txID))
|
||||
}
|
||||
|
||||
func AptosGetTransactions(start int64, limit int64) ([]byte, error) {
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
log.Sugar.Debugf("[APTOS] fetch transactions start=%d limit=%d rpc=%s", start, limit, aptosFullnodeURL)
|
||||
return aptosGet(fmt.Sprintf("/v1/transactions?start=%d&limit=%d", start, limit))
|
||||
}
|
||||
|
||||
func ParseAptosTransfers(body []byte, address string, tokens []mdb.ChainToken) ([]MoveObservedTransfer, error) {
|
||||
receive, err := addressutil.NormalizeMoveAddress(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseAptosTransfersForWallets(body, map[string]struct{}{receive: {}}, tokens)
|
||||
}
|
||||
|
||||
func ParseAptosTransfersForWallets(body []byte, wallets map[string]struct{}, tokens []mdb.ChainToken) ([]MoveObservedTransfer, error) {
|
||||
root := gjson.ParseBytes(body)
|
||||
if !root.IsArray() {
|
||||
return nil, fmt.Errorf("unexpected Aptos transactions response")
|
||||
}
|
||||
var out []MoveObservedTransfer
|
||||
for _, tx := range root.Array() {
|
||||
if tx.Get("type").String() != "" && tx.Get("type").String() != "user_transaction" {
|
||||
continue
|
||||
}
|
||||
if !tx.Get("success").Bool() {
|
||||
continue
|
||||
}
|
||||
txID := strings.TrimSpace(tx.Get("hash").String())
|
||||
if txID == "" {
|
||||
continue
|
||||
}
|
||||
blockTimeMs := parseAptosTimestampMs(tx.Get("timestamp"))
|
||||
version := tx.Get("version").Int()
|
||||
txctx := buildAptosTransferContext(tx)
|
||||
events := buildAptosFungibleEvents(tx, txctx, tokens)
|
||||
deposits := make([]aptosFungibleEvent, 0)
|
||||
withdrawals := make(map[string]int)
|
||||
for _, event := range events {
|
||||
if event.token == nil {
|
||||
continue
|
||||
}
|
||||
switch event.action {
|
||||
case "withdraw":
|
||||
withdrawals[aptosTransferMatchKey(event.token.Symbol, event.rawAmount)]++
|
||||
case "deposit":
|
||||
deposits = append(deposits, event)
|
||||
}
|
||||
}
|
||||
usedDeposits := make([]bool, len(deposits))
|
||||
for i, event := range deposits {
|
||||
matchKey := aptosTransferMatchKey(event.token.Symbol, event.rawAmount)
|
||||
if withdrawals[matchKey] <= 0 {
|
||||
continue
|
||||
}
|
||||
if appendAptosObservedTransfer(&out, txID, version, blockTimeMs, event, wallets) {
|
||||
withdrawals[matchKey]--
|
||||
usedDeposits[i] = true
|
||||
} else {
|
||||
logAptosUnwatchedDeposit(txID, version, event, "direct")
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(deposits); i++ {
|
||||
if usedDeposits[i] {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(deposits); j++ {
|
||||
if usedDeposits[j] || !aptosDepositPairCanMatch(deposits[i], deposits[j]) {
|
||||
continue
|
||||
}
|
||||
sum := new(big.Int).Add(deposits[i].rawAmount, deposits[j].rawAmount)
|
||||
matchKey := aptosTransferMatchKey(deposits[i].token.Symbol, sum)
|
||||
if withdrawals[matchKey] <= 0 {
|
||||
continue
|
||||
}
|
||||
appendedFirst := appendAptosObservedTransfer(&out, txID, version, blockTimeMs, deposits[i], wallets)
|
||||
appendedSecond := appendAptosObservedTransfer(&out, txID, version, blockTimeMs, deposits[j], wallets)
|
||||
if !appendedFirst && !appendedSecond {
|
||||
logAptosUnwatchedDeposit(txID, version, deposits[i], "split")
|
||||
logAptosUnwatchedDeposit(txID, version, deposits[j], "split")
|
||||
continue
|
||||
}
|
||||
withdrawals[matchKey]--
|
||||
usedDeposits[i] = true
|
||||
usedDeposits[j] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func appendAptosObservedTransfer(out *[]MoveObservedTransfer, txID string, version int64, blockTimeMs int64, event aptosFungibleEvent, wallets map[string]struct{}) bool {
|
||||
recipient := event.owner
|
||||
if !moveWalletWatched(recipient, wallets) {
|
||||
return false
|
||||
}
|
||||
*out = append(*out, MoveObservedTransfer{
|
||||
Network: mdb.NetworkAptos,
|
||||
ReceiveAddress: recipient,
|
||||
Token: NormalizeAptosPaymentSymbol(event.token.Symbol),
|
||||
RawAmount: event.rawAmount,
|
||||
Decimals: event.token.Decimals,
|
||||
MinAmount: event.token.MinAmount,
|
||||
Amount: rawToFloat(event.rawAmount, event.token.Decimals),
|
||||
TxID: normalizeAptosTxID(txID),
|
||||
BlockTimeMs: blockTimeMs,
|
||||
Version: version,
|
||||
TransferKey: moveAptosTransferKey(version, event.eventIndex, recipient, event.token.Symbol, event.rawAmount),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
func logAptosUnwatchedDeposit(txID string, version int64, event aptosFungibleEvent, matchMode string) {
|
||||
log.Sugar.Debugf(
|
||||
"[APTOS] matched deposit skipped owner_not_watched mode=%s version=%d tx=%s token=%s amount=%.8f raw=%s owner=%s",
|
||||
matchMode,
|
||||
version,
|
||||
normalizeAptosTxID(txID),
|
||||
NormalizeAptosPaymentSymbol(event.token.Symbol),
|
||||
rawToFloat(event.rawAmount, event.token.Decimals),
|
||||
event.rawAmount.String(),
|
||||
event.owner,
|
||||
)
|
||||
}
|
||||
|
||||
func aptosDepositPairCanMatch(first, second aptosFungibleEvent) bool {
|
||||
if first.token == nil || second.token == nil || first.rawAmount == nil || second.rawAmount == nil {
|
||||
return false
|
||||
}
|
||||
if NormalizeAptosPaymentSymbol(first.token.Symbol) != NormalizeAptosPaymentSymbol(second.token.Symbol) {
|
||||
return false
|
||||
}
|
||||
return first.token.ContractAddress == second.token.ContractAddress
|
||||
}
|
||||
|
||||
type aptosFungibleEvent struct {
|
||||
eventIndex int
|
||||
action string
|
||||
owner string
|
||||
rawAmount *big.Int
|
||||
token *mdb.ChainToken
|
||||
}
|
||||
|
||||
func buildAptosFungibleEvents(tx gjson.Result, ctx aptosTransferContext, tokens []mdb.ChainToken) []aptosFungibleEvent {
|
||||
out := make([]aptosFungibleEvent, 0)
|
||||
for eventIndex, event := range tx.Get("events").Array() {
|
||||
action := aptosFungibleEventAction(event)
|
||||
if action == "" {
|
||||
continue
|
||||
}
|
||||
rawAmount, ok := parseSignedBigInt(aptosEventRawAmount(event, ctx))
|
||||
if !ok || rawAmount.Sign() <= 0 {
|
||||
continue
|
||||
}
|
||||
token := resolveAptosPaymentToken(aptosEventAssetID(event, ctx), tokens)
|
||||
if token == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, aptosFungibleEvent{
|
||||
eventIndex: eventIndex,
|
||||
action: action,
|
||||
owner: aptosEventRecipient(event, ctx),
|
||||
rawAmount: rawAmount,
|
||||
token: token,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func aptosFungibleEventAction(event gjson.Result) string {
|
||||
typ := strings.ToLower(strings.TrimSpace(event.Get("type").String()))
|
||||
switch typ {
|
||||
case "0x1::fungible_asset::deposit":
|
||||
return "deposit"
|
||||
case "0x1::fungible_asset::withdraw":
|
||||
return "withdraw"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func aptosTransferMatchKey(symbol string, rawAmount *big.Int) string {
|
||||
raw := ""
|
||||
if rawAmount != nil {
|
||||
raw = rawAmount.String()
|
||||
}
|
||||
return NormalizeAptosPaymentSymbol(symbol) + ":" + raw
|
||||
}
|
||||
|
||||
func resolveAptosPaymentToken(assetID string, tokens []mdb.ChainToken) *mdb.ChainToken {
|
||||
token := resolveMoveToken(mdb.NetworkAptos, assetID, tokens)
|
||||
if token == nil {
|
||||
return nil
|
||||
}
|
||||
switch NormalizeAptosPaymentSymbol(token.Symbol) {
|
||||
case "USDT", "USDC":
|
||||
return token
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeAptosPaymentSymbol(symbol string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(symbol))
|
||||
}
|
||||
|
||||
type aptosTransferContext struct {
|
||||
storeOwners map[string]string
|
||||
storeMetadata map[string]string
|
||||
}
|
||||
|
||||
func buildAptosTransferContext(tx gjson.Result) aptosTransferContext {
|
||||
ctx := aptosTransferContext{
|
||||
storeOwners: make(map[string]string),
|
||||
storeMetadata: make(map[string]string),
|
||||
}
|
||||
for _, change := range tx.Get("changes").Array() {
|
||||
if change.Get("type").String() != "write_resource" {
|
||||
continue
|
||||
}
|
||||
store, err := addressutil.NormalizeMoveAddress(change.Get("address").String())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
changeType := strings.TrimSpace(change.Get("data.type").String())
|
||||
switch changeType {
|
||||
case "0x1::fungible_asset::FungibleStore":
|
||||
if metadata := firstNonEmpty(
|
||||
change.Get("data.data.metadata.inner").String(),
|
||||
change.Get("data.data.metadata").String(),
|
||||
); metadata != "" {
|
||||
ctx.storeMetadata[store] = normalizeAptosObservedAssetID(metadata)
|
||||
}
|
||||
case "0x1::object::ObjectCore":
|
||||
if owner := firstMoveAddress(change.Get("data.data.owner").String()); owner != "" {
|
||||
ctx.storeOwners[store] = owner
|
||||
}
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func aptosEventRecipient(event gjson.Result, ctx aptosTransferContext) string {
|
||||
if store := firstMoveAddress(event.Get("data.store").String()); store != "" {
|
||||
if owner := ctx.storeOwners[store]; owner != "" {
|
||||
return owner
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func aptosEventRawAmount(event gjson.Result, ctx aptosTransferContext) string {
|
||||
return event.Get("data.amount").String()
|
||||
}
|
||||
|
||||
func aptosEventAssetID(event gjson.Result, ctx aptosTransferContext) string {
|
||||
if store := firstMoveAddress(event.Get("data.store").String()); store != "" {
|
||||
if metadata := ctx.storeMetadata[store]; metadata != "" {
|
||||
return metadata
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeAptosObservedAssetID(assetID string) string {
|
||||
return addressutil.NormalizeMoveAssetID(assetID)
|
||||
}
|
||||
|
||||
func parseAptosTimestampMs(value gjson.Result) int64 {
|
||||
raw := strings.TrimSpace(value.String())
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n / 1000
|
||||
}
|
||||
|
||||
func normalizeAptosTxID(txID string) string {
|
||||
txID = strings.ToLower(strings.TrimSpace(txID))
|
||||
if strings.HasPrefix(txID, "0x") {
|
||||
return txID
|
||||
}
|
||||
return "0x" + txID
|
||||
}
|
||||
|
||||
func ValidateManualAptosPayment(order *mdb.Orders, txID string) (string, error) {
|
||||
txID = normalizeAptosTxID(txID)
|
||||
body, err := AptosGetTransactionByHash(txID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch aptos transaction: %w", err)
|
||||
}
|
||||
if err = validateManualAptosTransaction(order, txID, body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return txID, nil
|
||||
}
|
||||
|
||||
func validateManualAptosTransaction(order *mdb.Orders, txID string, body []byte) error {
|
||||
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkAptos)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
transfers, err := ParseAptosTransfers([]byte("["+string(body)+"]"), order.ReceiveAddress, tokens)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
amountMismatch := false
|
||||
for _, transfer := range transfers {
|
||||
if !strings.EqualFold(transfer.TxID, txID) {
|
||||
continue
|
||||
}
|
||||
if err = EnsureAptosTransferConfirmed(transfer.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = EnsureMoveTransferMatchesOrder(order, transfer); err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "amount mismatch") {
|
||||
amountMismatch = true
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if amountMismatch {
|
||||
return fmt.Errorf("transaction amount mismatch")
|
||||
}
|
||||
return fmt.Errorf("matching aptos transfer to order address not found")
|
||||
}
|
||||
|
||||
func aptosGet(path string) (body []byte, err error) {
|
||||
network := mdb.NetworkAptos
|
||||
defer recordMoveRPCResult(network, &err)
|
||||
logAptosFixedNode()
|
||||
endpoint, err := aptosJoinURL(aptosFullnodeURL, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := (&http.Client{Timeout: manualVerifyRequestTimeout}).Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ = io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return nil, fmt.Errorf("aptos rpc HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func aptosJoinURL(base, path string) (string, error) {
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
return "", fmt.Errorf("empty aptos rpc url")
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return strings.TrimRight(base, "/"), nil
|
||||
}
|
||||
path = strings.TrimSpace(path)
|
||||
trimmedBase := strings.TrimRight(base, "/")
|
||||
lowerBase := strings.ToLower(trimmedBase)
|
||||
if strings.HasSuffix(lowerBase, "/v1") && strings.HasPrefix(path, "/v1") {
|
||||
path = strings.TrimPrefix(path, "/v1")
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
}
|
||||
return trimmedBase + path, nil
|
||||
}
|
||||
|
||||
func logAptosFixedNode() {
|
||||
aptosFixedNodeLogOnce.Do(func() {
|
||||
log.Sugar.Infof("[APTOS] using fixed public fullnode url=%s", AptosFixedFullnodeURL())
|
||||
})
|
||||
}
|
||||
|
||||
func EnsureAptosTransferConfirmed(version int64) error {
|
||||
if version <= 0 {
|
||||
return fmt.Errorf("aptos transaction version missing")
|
||||
}
|
||||
latest, err := AptosGetLedgerVersion()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch aptos latest ledger version: %w", err)
|
||||
}
|
||||
return ensureAptosConfirmations(version, latest)
|
||||
}
|
||||
|
||||
func ensureAptosConfirmations(version, latest int64) error {
|
||||
chain, err := data.GetChainByNetwork(mdb.NetworkAptos)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
minConfirmations := int64(1)
|
||||
if chain != nil && chain.MinConfirmations > 0 {
|
||||
minConfirmations = int64(chain.MinConfirmations)
|
||||
}
|
||||
if latest-version+1 < minConfirmations {
|
||||
return fmt.Errorf("aptos transaction confirmations insufficient")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -89,6 +89,8 @@ func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID str
|
||||
canonicalTxID, err = validateManualSolanaPayment(order, txID)
|
||||
case mdb.NetworkTon:
|
||||
canonicalTxID, err = validateManualTonPayment(order, txID)
|
||||
case mdb.NetworkAptos:
|
||||
canonicalTxID, err = ValidateManualAptosPayment(order, txID)
|
||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||
canonicalTxID, err = validateManualEvmPayment(order, txID)
|
||||
default:
|
||||
@@ -123,7 +125,7 @@ func ensureManualBlockTransactionUnused(order *mdb.Orders, canonicalTxID string)
|
||||
|
||||
func manualBlockTransactionIDIsHex(network string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(network)) {
|
||||
case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||
case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkAptos:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -149,7 +151,7 @@ func equivalentManualBlockTransactionIDs(network, canonicalTxID string) []string
|
||||
|
||||
add(canonicalTxID)
|
||||
switch network {
|
||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkAptos:
|
||||
body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X")
|
||||
body = strings.ToLower(body)
|
||||
add("0x" + body)
|
||||
|
||||
@@ -113,6 +113,66 @@ func TestManualVerifyNormalizeTronTxIDAcceptsOptional0x(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManualAptosPayment(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, MinConfirmations: 1}).Error; err != nil {
|
||||
t.Fatalf("create aptos chain: %v", err)
|
||||
}
|
||||
usdc := "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
|
||||
if err := dao.Mdb.Create(&mdb.ChainToken{
|
||||
Network: mdb.NetworkAptos,
|
||||
Symbol: "USDC",
|
||||
ContractAddress: usdc,
|
||||
Decimals: 6,
|
||||
Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create aptos usdc token: %v", err)
|
||||
}
|
||||
|
||||
receive, err := addressutil.NormalizeMoveAddress("0xa")
|
||||
if err != nil {
|
||||
t.Fatalf("normalize aptos receive: %v", err)
|
||||
}
|
||||
store, err := addressutil.NormalizeMoveAddress("0x11")
|
||||
if err != nil {
|
||||
t.Fatalf("normalize aptos store: %v", err)
|
||||
}
|
||||
senderStore, err := addressutil.NormalizeMoveAddress("0x12")
|
||||
if err != nil {
|
||||
t.Fatalf("normalize aptos sender store: %v", err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/v1/transactions/by_hash/"):
|
||||
_, _ = w.Write([]byte(`{"type":"user_transaction","success":true,"hash":"0xabc","version":"101","timestamp":"1700000000123456","payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdc + `"},"` + receive + `","4200000"],"type":"entry_function_payload"},"events":[{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4200000","store":"` + senderStore + `"}},{"type":"0x1::fungible_asset::Deposit","data":{"amount":"4200000","store":"` + store + `"}}],"changes":[{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}]}`))
|
||||
case r.URL.Path == "/v1":
|
||||
_, _ = w.Write([]byte(`{"ledger_version":"101"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
overrideAptosFullnodeURLForTest(t, server.URL)
|
||||
|
||||
order := &mdb.Orders{
|
||||
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(1699999999000))},
|
||||
Network: mdb.NetworkAptos,
|
||||
Token: "USDC",
|
||||
ActualAmount: 4.2,
|
||||
ReceiveAddress: receive,
|
||||
}
|
||||
got, err := ValidateManualAptosPayment(order, "ABC")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateManualAptosPayment(): %v", err)
|
||||
}
|
||||
if got != "0xabc" {
|
||||
t.Fatalf("canonical tx id = %q, want %q", got, "0xabc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualTonTxRefAcceptsCanonicalLTAndHashOnly(t *testing.T) {
|
||||
receiveRaw := "0:ba295e33b3c4c9b5265aa4ead1166a92931ce9abea120a8c5e91044a1257f89c"
|
||||
hash := strings.Repeat("a", 64)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||
"github.com/dromara/carbon/v2"
|
||||
)
|
||||
|
||||
func overrideAptosFullnodeURLForTest(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
oldURL := aptosFullnodeURL
|
||||
aptosFullnodeURL = url
|
||||
aptosFixedNodeLogOnce = sync.Once{}
|
||||
t.Cleanup(func() {
|
||||
aptosFullnodeURL = oldURL
|
||||
aptosFixedNodeLogOnce = sync.Once{}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAptosGetLedgerVersionUsesFixedPublicNode(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
data.ResetRpcFailoverForTest()
|
||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||
|
||||
var fixedCalls int
|
||||
fixed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixedCalls++
|
||||
if r.URL.Path != "/v1" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ledger_version":"123"}`))
|
||||
}))
|
||||
defer fixed.Close()
|
||||
overrideAptosFullnodeURLForTest(t, fixed.URL)
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkAptos, Url: "https://old-aptos-rpc.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeBoth, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed aptos rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
got, err := AptosGetLedgerVersion()
|
||||
if err != nil {
|
||||
t.Fatalf("AptosGetLedgerVersion(): %v", err)
|
||||
}
|
||||
if got != 123 {
|
||||
t.Fatalf("ledger version = %d, want 123", got)
|
||||
}
|
||||
if fixedCalls != 1 {
|
||||
t.Fatalf("fixed node calls = %d, want 1", fixedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManualAptosPaymentUsesFixedPublicNode(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, MinConfirmations: 1}).Error; err != nil {
|
||||
t.Fatalf("create aptos chain: %v", err)
|
||||
}
|
||||
const usdc = "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
|
||||
upsertTestChainToken(t, mdb.ChainToken{
|
||||
Network: mdb.NetworkAptos,
|
||||
Symbol: "USDC",
|
||||
ContractAddress: usdc,
|
||||
Decimals: 6,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
receive, err := addressutil.NormalizeMoveAddress("0xa")
|
||||
if err != nil {
|
||||
t.Fatalf("normalize aptos receive: %v", err)
|
||||
}
|
||||
store, err := addressutil.NormalizeMoveAddress("0x31")
|
||||
if err != nil {
|
||||
t.Fatalf("normalize aptos store: %v", err)
|
||||
}
|
||||
senderStore, err := addressutil.NormalizeMoveAddress("0x32")
|
||||
if err != nil {
|
||||
t.Fatalf("normalize aptos sender store: %v", err)
|
||||
}
|
||||
|
||||
var fixedCalls int
|
||||
fixed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixedCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/v1/transactions/by_hash/"):
|
||||
_, _ = w.Write([]byte(`{"type":"user_transaction","success":true,"hash":"0xabc","version":"101","timestamp":"1700000000123456","payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdc + `"},"` + receive + `","4200000"],"type":"entry_function_payload"},"events":[{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4200000","store":"` + senderStore + `"}},{"type":"0x1::fungible_asset::Deposit","data":{"amount":"4200000","store":"` + store + `"}}],"changes":[{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}]}`))
|
||||
case r.URL.Path == "/v1":
|
||||
_, _ = w.Write([]byte(`{"ledger_version":"101"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer fixed.Close()
|
||||
overrideAptosFullnodeURLForTest(t, fixed.URL)
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkAptos, Url: "https://old-both.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeBoth, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkAptos, Url: "https://old-manual.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed aptos rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
order := &mdb.Orders{
|
||||
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(1699999999000))},
|
||||
Network: mdb.NetworkAptos,
|
||||
Token: "USDC",
|
||||
ActualAmount: 4.2,
|
||||
ReceiveAddress: receive,
|
||||
}
|
||||
got, err := ValidateManualAptosPayment(order, "ABC")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateManualAptosPayment(): %v", err)
|
||||
}
|
||||
if got != "0xabc" {
|
||||
t.Fatalf("canonical tx id = %q, want %q", got, "0xabc")
|
||||
}
|
||||
if fixedCalls != 2 {
|
||||
t.Fatalf("fixed node calls = %d, want 2", fixedCalls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
)
|
||||
|
||||
var gProcessedMoveTx sync.Map // transfer-level key -> unix timestamp
|
||||
|
||||
var processMoveOrder = OrderProcessing
|
||||
|
||||
type MoveObservedTransfer struct {
|
||||
Network string
|
||||
ReceiveAddress string
|
||||
Token string
|
||||
RawAmount *big.Int
|
||||
Decimals int
|
||||
MinAmount float64
|
||||
Amount float64
|
||||
TxID string
|
||||
BlockTimeMs int64
|
||||
Version int64
|
||||
TransferKey string
|
||||
}
|
||||
|
||||
func ProcessMoveObservedTransferResult(transfer MoveObservedTransfer) error {
|
||||
net := moveLogLabel(transfer.Network)
|
||||
cacheKey := MoveTransferCacheKey(transfer)
|
||||
now := time.Now().Unix()
|
||||
if _, loaded := gProcessedMoveTx.LoadOrStore(cacheKey, now); loaded {
|
||||
return nil
|
||||
}
|
||||
clearCache := func() {
|
||||
gProcessedMoveTx.Delete(cacheKey)
|
||||
}
|
||||
if transfer.RawAmount == nil || transfer.RawAmount.Sign() <= 0 || transfer.Amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
if transfer.MinAmount > 0 && transfer.Amount < transfer.MinAmount {
|
||||
log.Sugar.Debugf("[%s-%s][%s] skip below min amount tx=%s amount=%.2f min=%.2f", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, transfer.Amount, transfer.MinAmount)
|
||||
return nil
|
||||
}
|
||||
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(transfer.Network, transfer.ReceiveAddress, transfer.Token, transfer.Amount)
|
||||
if err != nil {
|
||||
clearCache()
|
||||
log.Sugar.Errorf("[%s-%s][%s] lock lookup failed tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, err)
|
||||
return err
|
||||
}
|
||||
if tradeID == "" {
|
||||
raw := ""
|
||||
if transfer.RawAmount != nil {
|
||||
raw = transfer.RawAmount.String()
|
||||
}
|
||||
log.Sugar.Infof("[%s-%s][%s] skip unmatched tx=%s amount=%.8f raw=%s decimals=%d", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, transfer.Amount, raw, transfer.Decimals)
|
||||
return nil
|
||||
}
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
clearCache()
|
||||
log.Sugar.Errorf("[%s-%s][%s] load order trade_id=%s tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
|
||||
return err
|
||||
}
|
||||
if err := EnsureMoveTransferMatchesOrder(order, transfer); err != nil {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip tx=%s trade_id=%s: %v", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, tradeID, err)
|
||||
return nil
|
||||
}
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: transfer.ReceiveAddress,
|
||||
Token: transfer.Token,
|
||||
Network: transfer.Network,
|
||||
TradeId: tradeID,
|
||||
Amount: transfer.Amount,
|
||||
BlockTransactionId: transfer.TxID,
|
||||
}
|
||||
if err = processMoveOrder(req); err != nil {
|
||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||
log.Sugar.Infof("[%s-%s][%s] already resolved trade_id=%s tx=%s reason=%v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
|
||||
return nil
|
||||
}
|
||||
clearCache()
|
||||
log.Sugar.Errorf("[%s-%s][%s] OrderProcessing failed trade_id=%s tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
|
||||
return err
|
||||
}
|
||||
sendPaymentNotification(order)
|
||||
log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s tx=%s", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func MoveTransferCacheKey(transfer MoveObservedTransfer) string {
|
||||
if transfer.TransferKey != "" {
|
||||
return strings.ToLower(strings.TrimSpace(transfer.TransferKey))
|
||||
}
|
||||
receive := strings.ToLower(strings.TrimSpace(transfer.ReceiveAddress))
|
||||
token := strings.ToUpper(strings.TrimSpace(transfer.Token))
|
||||
raw := ""
|
||||
if transfer.RawAmount != nil {
|
||||
raw = transfer.RawAmount.String()
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(transfer.Network)) {
|
||||
case mdb.NetworkAptos:
|
||||
return fmt.Sprintf("aptos:%d:%s:%s:%s", transfer.Version, receive, token, raw)
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(transfer.Network)) + ":" + strings.TrimSpace(transfer.TxID)
|
||||
}
|
||||
}
|
||||
|
||||
func EnsureMoveTransferMatchesOrder(order *mdb.Orders, transfer MoveObservedTransfer) error {
|
||||
if order == nil || order.ID == 0 {
|
||||
return fmt.Errorf("order not found")
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(order.Network)) != strings.ToLower(strings.TrimSpace(transfer.Network)) {
|
||||
return fmt.Errorf("network mismatch")
|
||||
}
|
||||
orderAddr, err := addressutil.NormalizeMoveAddress(order.ReceiveAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order receive address: %w", err)
|
||||
}
|
||||
transferAddr, err := addressutil.NormalizeMoveAddress(transfer.ReceiveAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid transfer receive address: %w", err)
|
||||
}
|
||||
if orderAddr != transferAddr {
|
||||
return fmt.Errorf("transaction recipient mismatch")
|
||||
}
|
||||
if !strings.EqualFold(order.Token, transfer.Token) {
|
||||
return fmt.Errorf("token mismatch")
|
||||
}
|
||||
if transfer.BlockTimeMs <= 0 {
|
||||
return fmt.Errorf("transaction block time missing")
|
||||
}
|
||||
if transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() {
|
||||
return fmt.Errorf("transaction predates the order")
|
||||
}
|
||||
if !amountMatchesRaw(order.ActualAmount, transfer.RawAmount, transfer.Decimals) {
|
||||
return fmt.Errorf("transaction amount mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func moveWalletWatched(address string, wallets map[string]struct{}) bool {
|
||||
if address == "" || len(wallets) == 0 {
|
||||
return false
|
||||
}
|
||||
_, ok := wallets[address]
|
||||
return ok
|
||||
}
|
||||
|
||||
func moveAptosTransferKey(version int64, eventIndex int, receive, token string, raw *big.Int) string {
|
||||
rawText := ""
|
||||
if raw != nil {
|
||||
rawText = raw.String()
|
||||
}
|
||||
return fmt.Sprintf("aptos:%d:%d:%s:%s:%s", version, eventIndex, strings.ToLower(strings.TrimSpace(receive)), strings.ToUpper(strings.TrimSpace(token)), rawText)
|
||||
}
|
||||
|
||||
func resolveMoveToken(network, assetID string, tokens []mdb.ChainToken) *mdb.ChainToken {
|
||||
assetID = addressutil.NormalizeMoveAssetID(assetID)
|
||||
for i := range tokens {
|
||||
contract := strings.TrimSpace(tokens[i].ContractAddress)
|
||||
if contract == "" {
|
||||
continue
|
||||
}
|
||||
if addressutil.MoveAssetMatches(assetID, contract) {
|
||||
return &tokens[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstMoveAddress(values ...string) string {
|
||||
for _, v := range values {
|
||||
if normalized, err := addressutil.NormalizeMoveAddress(v); err == nil {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseSignedBigInt(raw string) (*big.Int, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, false
|
||||
}
|
||||
out, ok := new(big.Int).SetString(raw, 10)
|
||||
return out, ok
|
||||
}
|
||||
|
||||
func rawToFloat(raw *big.Int, decimals int) float64 {
|
||||
if raw == nil {
|
||||
return 0
|
||||
}
|
||||
value, _ := new(big.Rat).SetFrac(new(big.Int).Set(raw), new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil)).Float64()
|
||||
return value
|
||||
}
|
||||
|
||||
func recordMoveRPCResult(network string, errp *error) {
|
||||
if errp != nil && *errp != nil {
|
||||
data.RecordRpcFailure(network)
|
||||
return
|
||||
}
|
||||
data.RecordRpcSuccess(network)
|
||||
}
|
||||
|
||||
func moveLogLabel(network string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(network)) {
|
||||
case mdb.NetworkAptos:
|
||||
return "APTOS"
|
||||
default:
|
||||
return strings.ToUpper(network)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||
"github.com/dromara/carbon/v2"
|
||||
)
|
||||
|
||||
func TestParseAptosTransfersUSDTAndUSDCFungibleEvents(t *testing.T) {
|
||||
receive, _ := addressutil.NormalizeMoveAddress("0xa")
|
||||
receive2, _ := addressutil.NormalizeMoveAddress("0xb")
|
||||
usdc := "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
|
||||
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
|
||||
usdcStore, _ := addressutil.NormalizeMoveAddress("0x12")
|
||||
usdcSenderStore, _ := addressutil.NormalizeMoveAddress("0x13")
|
||||
usdtStore, _ := addressutil.NormalizeMoveAddress("0x14")
|
||||
usdtSenderStore, _ := addressutil.NormalizeMoveAddress("0x15")
|
||||
noOwnerStore, _ := addressutil.NormalizeMoveAddress("0x16")
|
||||
body := []byte(`[
|
||||
{
|
||||
"type":"user_transaction",
|
||||
"success":true,
|
||||
"hash":"abc",
|
||||
"version":"101",
|
||||
"timestamp":"1700000000123456",
|
||||
"events":[
|
||||
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"2500000","store":"` + usdcSenderStore + `"}},
|
||||
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"2500000","store":"` + usdcStore + `"}}
|
||||
],
|
||||
"changes":[
|
||||
{"address":"` + usdcSenderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},
|
||||
{"address":"` + usdcStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},
|
||||
{"address":"` + usdcStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"0xa"}},"type":"write_resource"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type":"user_transaction",
|
||||
"success":true,
|
||||
"hash":"def",
|
||||
"version":"102",
|
||||
"timestamp":"1700000001123456",
|
||||
"payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdt + `"},"` + receive2 + `","3100000"],"type":"entry_function_payload"},
|
||||
"events":[
|
||||
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"3100000","store":"` + usdtSenderStore + `"}},
|
||||
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"3100000","store":"` + usdtStore + `"}}
|
||||
],
|
||||
"changes":[
|
||||
{"address":"` + usdtSenderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||
{"address":"` + usdtStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||
{"address":"` + usdtStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive2 + `"}},"type":"write_resource"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type":"user_transaction",
|
||||
"success":true,
|
||||
"hash":"skip-withdraw-only",
|
||||
"version":"103",
|
||||
"timestamp":"1700000002123456",
|
||||
"events":[
|
||||
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4100000","metadata_address":"` + usdc + `"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type":"user_transaction",
|
||||
"success":true,
|
||||
"hash":"skip-no-owner",
|
||||
"version":"104",
|
||||
"timestamp":"1700000003123456",
|
||||
"events":[
|
||||
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"5100000","store":"` + usdcSenderStore + `"}},
|
||||
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"5100000","store":"` + noOwnerStore + `"}}
|
||||
],
|
||||
"changes":[
|
||||
{"address":"` + noOwnerStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type":"user_transaction",
|
||||
"success":false,
|
||||
"hash":"skip-failed",
|
||||
"events":[
|
||||
{"type":"0x1::fungible_asset::Deposit","data":{"to":"` + receive + `","amount":"1","metadata_address":"` + usdc + `"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type":"user_transaction",
|
||||
"success":true,
|
||||
"hash":"skip-non-store-owner-fallback",
|
||||
"version":"105",
|
||||
"timestamp":"1700000004123456",
|
||||
"events":[
|
||||
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"6100000","metadata_address":"` + usdc + `"}},
|
||||
{"type":"0x1::fungible_asset::Deposit","data":{"to":"` + receive + `","amount":"6100000","metadata_address":"` + usdc + `"}}
|
||||
]
|
||||
}
|
||||
]`)
|
||||
tokens := []mdb.ChainToken{
|
||||
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: usdc, Decimals: 6, Enabled: true, MinAmount: 1},
|
||||
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true, MinAmount: 3},
|
||||
}
|
||||
|
||||
got, err := ParseAptosTransfers(body, receive, tokens)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAptosTransfers: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("transfers len = %d, want 1: %#v", len(got), got)
|
||||
}
|
||||
if got[0].Token != "USDC" || got[0].Amount != 2.5 || got[0].TxID != "0xabc" || got[0].ReceiveAddress != receive {
|
||||
t.Fatalf("usdc transfer = %#v", got[0])
|
||||
}
|
||||
if got[0].TransferKey == "" {
|
||||
t.Fatalf("transfer key should be non-empty: %#v", got[0])
|
||||
}
|
||||
if got[0].MinAmount != 1 {
|
||||
t.Fatalf("usdc min amount = %.2f, want 1", got[0].MinAmount)
|
||||
}
|
||||
|
||||
gotSecondWallet, err := ParseAptosTransfers(body, receive2, tokens)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAptosTransfers second wallet: %v", err)
|
||||
}
|
||||
if len(gotSecondWallet) != 1 || gotSecondWallet[0].Token != "USDT" || gotSecondWallet[0].Amount != 3.1 || gotSecondWallet[0].TxID != "0xdef" {
|
||||
t.Fatalf("usdt transfer = %#v", gotSecondWallet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAptosTransfersSplitDepositsMatchSingleWithdraw(t *testing.T) {
|
||||
receive, _ := addressutil.NormalizeMoveAddress("0xa")
|
||||
receive2, _ := addressutil.NormalizeMoveAddress("0xb")
|
||||
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
|
||||
withdrawStore, _ := addressutil.NormalizeMoveAddress("0x21")
|
||||
depositStore, _ := addressutil.NormalizeMoveAddress("0x22")
|
||||
depositStore2, _ := addressutil.NormalizeMoveAddress("0x23")
|
||||
body := []byte(`[
|
||||
{
|
||||
"type":"user_transaction",
|
||||
"success":true,
|
||||
"hash":"split",
|
||||
"version":"201",
|
||||
"timestamp":"1700000000123456",
|
||||
"events":[
|
||||
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"3000000","store":"` + withdrawStore + `"}},
|
||||
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"1000000","store":"` + depositStore + `"}},
|
||||
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"2000000","store":"` + depositStore2 + `"}}
|
||||
],
|
||||
"changes":[
|
||||
{"address":"` + withdrawStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||
{"address":"` + depositStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||
{"address":"` + depositStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"},
|
||||
{"address":"` + depositStore2 + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||
{"address":"` + depositStore2 + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive2 + `"}},"type":"write_resource"}
|
||||
]
|
||||
}
|
||||
]`)
|
||||
tokens := []mdb.ChainToken{
|
||||
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true},
|
||||
}
|
||||
wallets := map[string]struct{}{receive: {}, receive2: {}}
|
||||
|
||||
got, err := ParseAptosTransfersForWallets(body, wallets, tokens)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAptosTransfersForWallets: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("transfers len = %d, want 2: %#v", len(got), got)
|
||||
}
|
||||
if got[0].Token != "USDT" || got[0].Amount != 1 || got[0].ReceiveAddress != receive || got[0].TxID != "0xsplit" {
|
||||
t.Fatalf("first split transfer = %#v", got[0])
|
||||
}
|
||||
if got[1].Token != "USDT" || got[1].Amount != 2 || got[1].ReceiveAddress != receive2 || got[1].TxID != "0xsplit" {
|
||||
t.Fatalf("second split transfer = %#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAptosTransfersUsesStoreOwnerWhenGuidAccountIsZero(t *testing.T) {
|
||||
receive, _ := addressutil.NormalizeMoveAddress("0xa")
|
||||
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
|
||||
store, _ := addressutil.NormalizeMoveAddress("0x31")
|
||||
senderStore, _ := addressutil.NormalizeMoveAddress("0x32")
|
||||
body := []byte(`[
|
||||
{
|
||||
"type":"user_transaction",
|
||||
"success":true,
|
||||
"hash":"decimal",
|
||||
"version":"301",
|
||||
"timestamp":"1700000000123456",
|
||||
"events":[
|
||||
{"guid":{"creation_number":"0","account_address":"0x0"},"sequence_number":"0","type":"0x1::fungible_asset::Withdraw","data":{"amount":"10000","store":"` + senderStore + `"}},
|
||||
{"guid":{"creation_number":"0","account_address":"0x0"},"sequence_number":"0","type":"0x1::fungible_asset::Deposit","data":{"amount":"10000","store":"` + store + `"}}
|
||||
],
|
||||
"changes":[
|
||||
{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||
{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||
{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}
|
||||
]
|
||||
}
|
||||
]`)
|
||||
tokens := []mdb.ChainToken{
|
||||
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true},
|
||||
}
|
||||
|
||||
got, err := ParseAptosTransfers(body, receive, tokens)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAptosTransfers: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("transfers len = %d, want 1: %#v", len(got), got)
|
||||
}
|
||||
if got[0].ReceiveAddress != receive || got[0].Decimals != 6 || got[0].Amount != 0.01 || got[0].RawAmount.String() != "10000" {
|
||||
t.Fatalf("transfer amount = %#v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMoveTransferMatchesOrder(t *testing.T) {
|
||||
receive, _ := addressutil.NormalizeMoveAddress("0xc")
|
||||
order := &mdb.Orders{
|
||||
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||
Network: mdb.NetworkAptos,
|
||||
Token: "USDC",
|
||||
ActualAmount: 3.5,
|
||||
ReceiveAddress: strings.ToUpper(receive),
|
||||
}
|
||||
transfer := MoveObservedTransfer{
|
||||
Network: mdb.NetworkAptos,
|
||||
ReceiveAddress: receive,
|
||||
Token: "USDC",
|
||||
RawAmount: big.NewInt(3_500_000),
|
||||
Decimals: 6,
|
||||
BlockTimeMs: time.Now().UnixMilli(),
|
||||
}
|
||||
if err := EnsureMoveTransferMatchesOrder(order, transfer); err != nil {
|
||||
t.Fatalf("EnsureMoveTransferMatchesOrder: %v", err)
|
||||
}
|
||||
|
||||
transfer.RawAmount = big.NewInt(3_400_000)
|
||||
if err := EnsureMoveTransferMatchesOrder(order, transfer); err == nil || !strings.Contains(err.Error(), "amount") {
|
||||
t.Fatalf("amount mismatch err = %v, want amount error", err)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,11 @@ func normalizeOrderAddressByNetwork(network, address string) string {
|
||||
return normalized
|
||||
}
|
||||
return address
|
||||
case mdb.NetworkAptos:
|
||||
if normalized, err := addressutil.NormalizeMoveAddress(address); err == nil {
|
||||
return normalized
|
||||
}
|
||||
return address
|
||||
default:
|
||||
return address
|
||||
}
|
||||
@@ -67,6 +72,17 @@ func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) {
|
||||
if tokenRow == nil || tokenRow.ID == 0 {
|
||||
return nil, constant.SupportedAssetNotFound
|
||||
}
|
||||
if tokenRow.Network == mdb.NetworkAptos {
|
||||
switch NormalizeAptosPaymentSymbol(tokenRow.Symbol) {
|
||||
case "USDT", "USDC":
|
||||
default:
|
||||
return nil, constant.SupportedAssetNotFound
|
||||
}
|
||||
}
|
||||
contractAddress := strings.TrimSpace(tokenRow.ContractAddress)
|
||||
if tokenRow.Network == mdb.NetworkAptos && contractAddress == "" {
|
||||
return nil, constant.SupportedAssetNotFound
|
||||
}
|
||||
return tokenRow, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -469,6 +469,33 @@ func TestCreateTransactionRejectsDisabledTonTokenBeforeRate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionRejectsEnabledMisconfiguredAptosUSDT(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true}).Error; err != nil {
|
||||
t.Fatalf("create aptos chain: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.ChainToken{
|
||||
Network: mdb.NetworkAptos,
|
||||
Symbol: "USDT",
|
||||
ContractAddress: "",
|
||||
Decimals: 6,
|
||||
Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create aptos usdt token: %v", err)
|
||||
}
|
||||
|
||||
req := newCreateTransactionRequest("order_aptos_misconfigured_usdt_1", 10)
|
||||
req.Network = mdb.NetworkAptos
|
||||
req.Token = "USDT"
|
||||
|
||||
_, err := CreateTransaction(req, nil)
|
||||
if err != constant.SupportedAssetNotFound {
|
||||
t.Fatalf("create misconfigured Aptos USDT error = %v, want %v", err, constant.SupportedAssetNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionSupportedTonTokenWithoutRateReturnsRateError(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
Reference in New Issue
Block a user