Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
github.com/tidwall/gjson v1.18.0
github.com/xssnick/tonutils-go v1.16.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.48.0
gopkg.in/telebot.v3 v3.0.0
Expand Down
2 changes: 2 additions & 0 deletions src/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@ github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHg
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xssnick/tonutils-go v1.16.0 h1:MLYVZiiMB0Q+XudLo7WyjOli4gMuenNmJpBqETqa5DE=
github.com/xssnick/tonutils-go v1.16.0/go.mod h1:CNSM+L0FYufOFhprg8otc9Y/ecFfLrWqFMq00Gh9yk4=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
Expand Down
1 change: 1 addition & 0 deletions src/internal/testutil/testdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func SetupTestDatabases(t testing.TB) func() {
for _, network := range []string{
mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum,
mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma,
mdb.NetworkTon,
} {
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
}
Expand Down
9 changes: 9 additions & 0 deletions src/model/dao/mdb_table_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func seedChains() {
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
{Network: mdb.NetworkTon, DisplayName: "TON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
}
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed chains err=%s\n", err)
Expand Down Expand Up @@ -97,6 +98,9 @@ func seedChainTokens() {
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
// Plasma
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
// TON — USDT Jetton master (Tether-issued, 6 decimals) + native TON
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", Decimals: 6, Enabled: true},
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
}
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
Expand All @@ -113,6 +117,11 @@ func seedRpcNodes() {
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
// TON — the URL is the path to global.config.json; tonutils-go
// fetches it once at startup and connects to the listed lite servers
// over ADNL/TCP. type=http reflects how this URL is fetched, not how
// transactions are then read.
{Network: mdb.NetworkTon, Url: "https://ton-blockchain.github.io/global.config.json", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
}
for _, d := range defaults {
var count int64
Expand Down
86 changes: 86 additions & 0 deletions src/model/dao/mdb_table_init_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package dao

import (
"path/filepath"
"testing"

"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)

// TestSeedTonRowsAreIdempotent confirms two properties that together
// keep the seed safe across restarts:
// 1. seedChains / seedChainTokens / seedRpcNodes actually insert the
// TON rows we added.
// 2. Re-running each seed does not duplicate any row, so admin edits
// after first boot are not overwritten on subsequent boots.
func TestSeedTonRowsAreIdempotent(t *testing.T) {
prev := Mdb
t.Cleanup(func() { Mdb = prev })

dbPath := filepath.Join(t.TempDir(), "seed.db")
db, err := openDB(dbPath, &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
for _, m := range []interface{}{&mdb.Chain{}, &mdb.ChainToken{}, &mdb.RpcNode{}} {
if err := db.AutoMigrate(m); err != nil {
t.Fatalf("migrate %T: %v", m, err)
}
}
Mdb = db

seedChains()
seedChainTokens()
seedRpcNodes()

var chainCount int64
if err := db.Model(&mdb.Chain{}).Where("network = ?", mdb.NetworkTon).Count(&chainCount).Error; err != nil {
t.Fatalf("count chains: %v", err)
}
if chainCount != 1 {
t.Fatalf("TON chains row count = %d, want 1", chainCount)
}

var tokenCount int64
if err := db.Model(&mdb.ChainToken{}).Where("network = ?", mdb.NetworkTon).Count(&tokenCount).Error; err != nil {
t.Fatalf("count chain_tokens: %v", err)
}
// USDT Jetton + native TON.
if tokenCount != 2 {
t.Fatalf("TON chain_tokens count = %d, want 2", tokenCount)
}

var rpcCount int64
if err := db.Model(&mdb.RpcNode{}).Where("network = ?", mdb.NetworkTon).Count(&rpcCount).Error; err != nil {
t.Fatalf("count rpc_nodes: %v", err)
}
if rpcCount != 1 {
t.Fatalf("TON rpc_nodes count = %d, want 1", rpcCount)
}

// Second pass — must be a no-op.
seedChains()
seedChainTokens()
seedRpcNodes()

for label, q := range map[string]*gorm.DB{
"chains": db.Model(&mdb.Chain{}).Where("network = ?", mdb.NetworkTon),
"chain_tokens": db.Model(&mdb.ChainToken{}).Where("network = ?", mdb.NetworkTon),
"rpc_nodes": db.Model(&mdb.RpcNode{}).Where("network = ?", mdb.NetworkTon),
} {
var n int64
if err := q.Count(&n).Error; err != nil {
t.Fatalf("recount %s: %v", label, err)
}
expect := map[string]int64{
"chains": 1,
"chain_tokens": 2,
"rpc_nodes": 1,
}[label]
if n != expect {
t.Fatalf("after second seed, %s count = %d, want %d", label, n, expect)
}
}
}
6 changes: 5 additions & 1 deletion src/model/data/order_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,13 @@ func normalizeLockNetwork(network string) string {

func normalizeLockAddress(network, address string) string {
address = strings.TrimSpace(address)
if isEVMNetwork(normalizeLockNetwork(network)) {
net := normalizeLockNetwork(network)
if isEVMNetwork(net) {
return strings.ToLower(address)
}
if net == mdb.NetworkTon {
return normalizeTonAddress(address)
}
return address
}

Expand Down
33 changes: 32 additions & 1 deletion src/model/data/wallet_address_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
tonaddress "github.com/xssnick/tonutils-go/address"
)

// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
Expand All @@ -26,11 +27,31 @@ func normalizeWalletNetwork(network string) string {
return strings.ToLower(strings.TrimSpace(network))
}

// normalizeTonAddress collapses TON's three surface forms — bounceable
// (EQ…), non-bounceable (UQ…), and raw (0:hex…) — into a single canonical
// bounceable user-friendly string. Same underlying wallet, one storage
// key. Returns the input unchanged if it cannot be parsed so the caller
// surfaces validation errors at the DB layer.
func normalizeTonAddress(addr string) string {
parsed, err := tonaddress.ParseAddr(addr)
if err != nil {
parsed, err = tonaddress.ParseRawAddr(addr)
if err != nil {
return addr
}
}
return parsed.Bounce(true).String()
}

func normalizeWalletAddressByNetwork(network, address string) string {
address = strings.TrimSpace(address)
if isEVMNetwork(normalizeWalletNetwork(network)) {
net := normalizeWalletNetwork(network)
if isEVMNetwork(net) {
return strings.ToLower(address)
}
if net == mdb.NetworkTon {
return normalizeTonAddress(address)
}
return address
}

Expand Down Expand Up @@ -127,6 +148,11 @@ func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, er
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
}
}
if network == mdb.NetworkTon {
for i := range list {
list[i].Address = normalizeTonAddress(list[i].Address)
}
}
return list, err
}

Expand All @@ -150,6 +176,11 @@ func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
}
}
if network == mdb.NetworkTon {
for i := range list {
list[i].Address = normalizeTonAddress(list[i].Address)
}
}
return list, err
}

Expand Down
56 changes: 56 additions & 0 deletions src/model/data/wallet_address_data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
tonaddress "github.com/xssnick/tonutils-go/address"
)

func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
Expand Down Expand Up @@ -81,3 +82,58 @@ func TestAddWalletAddressWithNetworkKeepsOriginalCaseForNonEvm(t *testing.T) {
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
}
}

// TestNormalizeTonAddressCollapsesSurfaceForms confirms that the three
// user-facing TON address forms — bounceable (EQ…), non-bounceable
// (UQ…), and raw (workchain:hex) — collapse to one canonical storage
// key so a lock written from a notification matches a wallet entered
// from the admin UI.
func TestNormalizeTonAddressCollapsesSurfaceForms(t *testing.T) {
bounceable := "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
parsed, err := tonaddress.ParseAddr(bounceable)
if err != nil {
t.Fatalf("parse seed bounceable: %v", err)
}
nonBounceable := parsed.Bounce(false).String()
raw := parsed.StringRaw()

canonical := normalizeTonAddress(bounceable)
if canonical != bounceable {
t.Fatalf("bounceable input should round-trip, got %q want %q", canonical, bounceable)
}
if got := normalizeTonAddress(nonBounceable); got != canonical {
t.Fatalf("non-bounceable did not normalize to canonical: got %q want %q", got, canonical)
}
if got := normalizeTonAddress(raw); got != canonical {
t.Fatalf("raw form did not normalize to canonical: got %q want %q", got, canonical)
}
}

func TestAddWalletAddressWithNetworkCanonicalizesTonAddress(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()

bounceable := "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
parsed, err := tonaddress.ParseAddr(bounceable)
if err != nil {
t.Fatalf("parse seed bounceable: %v", err)
}
nonBounceable := parsed.Bounce(false).String()

row, err := AddWalletAddressWithNetwork(mdb.NetworkTon, nonBounceable)
if err != nil {
t.Fatalf("add ton wallet: %v", err)
}
if row.Address != bounceable {
t.Fatalf("stored TON address = %q, want canonical %q", row.Address, bounceable)
}

// Looking up the same wallet by either surface form must hit the row.
loaded, err := GetWalletAddressByNetworkAndAddress(mdb.NetworkTon, bounceable)
if err != nil {
t.Fatalf("load by bounceable: %v", err)
}
if loaded.ID == 0 {
t.Fatal("expected to find TON wallet by bounceable form")
}
}
1 change: 1 addition & 0 deletions src/model/mdb/wallet_address_mdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const (
NetworkBsc = "bsc"
NetworkPolygon = "polygon"
NetworkPlasma = "plasma"
NetworkTon = "ton"
)

const (
Expand Down
14 changes: 13 additions & 1 deletion src/model/service/order_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ const (
IncrementalMaximumNumber = 100
)

// Lock TTL = order_expiration + (TON only) the confirmation gate's
// max wait, so a payment arriving near order expiry still has the
// lock valid by the time the gate lets it through.
func lockExpirationForNetwork(network string) time.Duration {
base := config.GetOrderExpirationTimeDuration()
if strings.ToLower(strings.TrimSpace(network)) == mdb.NetworkTon {
base += tonLockExpirationBuffer()
}
return base
}

var (
gCreateTransactionLock sync.Mutex
gOrderProcessingLock sync.Mutex
Expand Down Expand Up @@ -281,11 +292,12 @@ func ReserveAvailableWalletAndAmount(tradeID string, network string, token strin
availableAddress := ""
availableAmount := amount
amountPrecision := data.GetAmountPrecision()
lockExpiration := lockExpirationForNetwork(network)

tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress {
normalizedAddress := normalizeOrderAddressByNetwork(network, address.Address)
err := data.LockTransaction(network, normalizedAddress, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
err := data.LockTransaction(network, normalizedAddress, token, tradeID, targetAmount, lockExpiration)
if err == nil {
return normalizedAddress, nil
}
Expand Down
53 changes: 53 additions & 0 deletions src/model/service/order_service_lock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package service

import (
"testing"
"time"

"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)

// Symmetric to the listener gate: TON locks outlive the gate's max
// wait by construction. Other networks keep the base TTL.
func TestLockExpirationForNetworkAddsTonBuffer(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()

base := 10 * time.Minute
// SetupTestDatabases sets order_expiration_time=10 via viper.

// Non-TON network: no buffer.
if got := lockExpirationForNetwork(mdb.NetworkTron); got != base {
t.Fatalf("non-TON network should not buffer: got %v want %v", got, base)
}

// TON with no chain row in DB (or 0 min_confirmations) falls
// back to the loader's default of 1, so buffer = 1 * 5s.
if got := lockExpirationForNetwork(mdb.NetworkTon); got != base+5*time.Second {
t.Fatalf("TON with default min should add 1*5s: got %v want %v", got, base+5*time.Second)
}

// Bump TON's min_confirmations to 3 → buffer = 15s.
if err := dao.Mdb.Model(&mdb.Chain{}).
Where("network = ?", mdb.NetworkTon).
Update("min_confirmations", 3).Error; err != nil {
t.Fatalf("update min_confirmations: %v", err)
}
if got := lockExpirationForNetwork(mdb.NetworkTon); got != base+15*time.Second {
t.Fatalf("TON min=3 should add 15s: got %v want %v", got, base+15*time.Second)
}

// Misconfigured huge value — clamped, NOT 10000*5s.
if err := dao.Mdb.Model(&mdb.Chain{}).
Where("network = ?", mdb.NetworkTon).
Update("min_confirmations", 10_000_000).Error; err != nil {
t.Fatalf("update min_confirmations: %v", err)
}
got := lockExpirationForNetwork(mdb.NetworkTon)
wantMax := base + time.Duration(tonMaxEffectiveMinConfirmations*tonBlockTimeSeconds)*time.Second
if got != wantMax {
t.Fatalf("oversized min_confirmations should clamp: got %v want %v", got, wantMax)
}
}
2 changes: 2 additions & 0 deletions src/model/service/task_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ func networkDisplay(n string) string {
return "Polygon"
case mdb.NetworkPlasma:
return "Plasma"
case mdb.NetworkTon:
return "TON"
default:
if n == "" {
return "Tron"
Expand Down
Loading