diff --git a/src/go.mod b/src/go.mod index bce79674..b2f27e96 100644 --- a/src/go.mod +++ b/src/go.mod @@ -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 diff --git a/src/go.sum b/src/go.sum index e30804d0..314301bf 100644 --- a/src/go.sum +++ b/src/go.sum @@ -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= diff --git a/src/internal/testutil/testdb.go b/src/internal/testutil/testdb.go index 2902e1dd..21cb6b58 100644 --- a/src/internal/testutil/testdb.go +++ b/src/internal/testutil/testdb.go @@ -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}) } diff --git a/src/model/dao/mdb_table_init.go b/src/model/dao/mdb_table_init.go index cd844b80..96e4e5f7 100644 --- a/src/model/dao/mdb_table_init.go +++ b/src/model/dao/mdb_table_init.go @@ -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) @@ -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) @@ -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 diff --git a/src/model/dao/mdb_table_init_test.go b/src/model/dao/mdb_table_init_test.go new file mode 100644 index 00000000..ea250ef6 --- /dev/null +++ b/src/model/dao/mdb_table_init_test.go @@ -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) + } + } +} diff --git a/src/model/data/order_data.go b/src/model/data/order_data.go index 1ce9244c..f365e6f5 100644 --- a/src/model/data/order_data.go +++ b/src/model/data/order_data.go @@ -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 } diff --git a/src/model/data/wallet_address_data.go b/src/model/data/wallet_address_data.go index 0c562b5a..ce973897 100644 --- a/src/model/data/wallet_address_data.go +++ b/src/model/data/wallet_address_data.go @@ -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 添加) @@ -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 } @@ -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 } @@ -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 } diff --git a/src/model/data/wallet_address_data_test.go b/src/model/data/wallet_address_data_test.go index fb9149e9..2ec31cd9 100644 --- a/src/model/data/wallet_address_data_test.go +++ b/src/model/data/wallet_address_data_test.go @@ -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) { @@ -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") + } +} diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go index 23c2feaa..86e3cc4f 100644 --- a/src/model/mdb/wallet_address_mdb.go +++ b/src/model/mdb/wallet_address_mdb.go @@ -12,6 +12,7 @@ const ( NetworkBsc = "bsc" NetworkPolygon = "polygon" NetworkPlasma = "plasma" + NetworkTon = "ton" ) const ( diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index a85866f2..776d0d52 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -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 @@ -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 } diff --git a/src/model/service/order_service_lock_test.go b/src/model/service/order_service_lock_test.go new file mode 100644 index 00000000..9ef50ad6 --- /dev/null +++ b/src/model/service/order_service_lock_test.go @@ -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) + } +} diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go index 86843076..bf1b1b2d 100644 --- a/src/model/service/task_service.go +++ b/src/model/service/task_service.go @@ -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" diff --git a/src/model/service/ton_task.go b/src/model/service/ton_task.go new file mode 100644 index 00000000..130b0de4 --- /dev/null +++ b/src/model/service/ton_task.go @@ -0,0 +1,564 @@ +package service + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "sync" + "time" + + "github.com/GMWalletApp/epusdt/config" + "github.com/GMWalletApp/epusdt/model/data" + "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/model/request" + "github.com/GMWalletApp/epusdt/util/constant" + "github.com/GMWalletApp/epusdt/util/log" + "github.com/GMWalletApp/epusdt/util/math" + "github.com/shopspring/decimal" + tonaddress "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/liteclient" + "github.com/xssnick/tonutils-go/tlb" + "github.com/xssnick/tonutils-go/ton" + "github.com/xssnick/tonutils-go/ton/jetton" + "github.com/xssnick/tonutils-go/tvm/cell" +) + +// Retryable outcomes MUST NOT be cached: caching a transient failure +// turns it into a multi-hour blind spot that outlasts the order. +type tonOutcome int + +const ( + tonOutcomeProcessed tonOutcome = iota + tonOutcomeIrrelevant + tonOutcomeRetry +) + +// Body opcodes we accept as plain wallet-to-wallet transfers. Anything +// else is a contract message whose attached TON is forwarding gas. +const ( + tonOpTextComment uint64 = 0x00000000 + tonOpEncryptedComment uint64 = 0x2167da4b // TEP-1 encrypted comment (Tonkeeper-style) +) + +// Block-time is used ONLY to size auxiliary windows (TTL, cutoff). +// Confirmation depth itself is counted via real master seqno advance. +const ( + tonBlockTimeSeconds = 5 + tonListLimit = 32 + tonRequestTimeout = 30 * time.Second + tonProcessedCacheTTL = 1 * time.Hour + tonNativeDecimals = 9 + tonPendingFloorTTL = int64(3600) // 1h floor for pending TTL + tonPendingTTLSafetyMultiplier = int64(3) + tonScanCutoffMaxExtraSeconds = int64(3600) // 1h cap on scan extension + tonMaxEffectiveMinConfirmations = int(tonScanCutoffMaxExtraSeconds / tonBlockTimeSeconds) +) + +var ( + gTonAPIMu sync.Mutex + gTonAPI ton.APIClientWrapped + gTonAPIConfigURL string + + gProcessedTonTx sync.Map // hex(hash) -> unix ts + gTonPendingConfirm sync.Map // hex(hash) -> tonPendingEntry + gTonJettonWalletMu sync.Mutex + gTonJettonWallet = map[string]*tonaddress.Address{} // owner|master -> jetton wallet +) + +// firstSeenMasterSeqno is a safe upper bound on the tx's actual +// anchor block. We don't estimate a head-start from tx.Now because +// wall-clock and block-time estimates can over-credit. expiresAt is +// per-entry because admin-tunable min_confirmations may need head-room +// past the 1h floor. +type tonPendingEntry struct { + firstSeenMasterSeqno uint32 + expiresAt int64 // unix +} + +// Clamps the admin-configured min_confirmations to a value the listener +// can satisfy within its scan window. <=0 disables the gate. Values +// above the supported max are clamped with a warn so misconfig yields +// degraded depth rather than stranded payments. The lock-TTL extension +// at order creation uses the same clamped value for consistency. +func effectiveTonMinConfirmations(configured int) int { + if configured <= 0 { + return 0 + } + if configured > tonMaxEffectiveMinConfirmations { + log.Sugar.Warnf("[TON] chains.min_confirmations=%d exceeds listener max=%d; clamping", + configured, tonMaxEffectiveMinConfirmations) + return tonMaxEffectiveMinConfirmations + } + return configured +} + +func loadTonMinConfirmations() int { + configured := 1 // defence-in-depth when admin hasn't tuned the seed + if chainRow, err := data.GetChainByNetwork(mdb.NetworkTon); err == nil && chainRow != nil && chainRow.MinConfirmations > 0 { + configured = chainRow.MinConfirmations + } + return effectiveTonMinConfirmations(configured) +} + +// Extra time a TON lock must outlive order_expiration so the +// confirmation gate has room to settle payments arriving near expiry. +func tonLockExpirationBuffer() time.Duration { + return time.Duration(loadTonMinConfirmations()*tonBlockTimeSeconds) * time.Second +} + +// TonCallBack scans a single owner address for inbound payments and +// confirms any matching pending orders. Errors are logged; the next +// tick retries. +func TonCallBack(ownerAddrStr string, wg *sync.WaitGroup) { + defer wg.Done() + defer func() { + if err := recover(); err != nil { + log.Sugar.Errorf("[TON][%s] panic recovered: %v", ownerAddrStr, err) + } + }() + + api, err := ensureTonAPI() + if err != nil { + log.Sugar.Errorf("[TON][%s] init api err=%v", ownerAddrStr, err) + return + } + + ownerAddr, err := tonaddress.ParseAddr(ownerAddrStr) + if err != nil { + log.Sugar.Errorf("[TON][%s] parse owner addr err=%v", ownerAddrStr, err) + return + } + + tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTon) + if err != nil { + log.Sugar.Errorf("[TON][%s] load chain_tokens err=%v", ownerAddrStr, err) + return + } + if len(tokens) == 0 { + log.Sugar.Debugf("[TON][%s] no enabled chain_tokens, skipping", ownerAddrStr) + return + } + + minConfirmations := loadTonMinConfirmations() + + // Partition tokens: symbol=TON with empty contract → native; non-empty + // contract → Jetton master. + var nativeToken *mdb.ChainToken + jettonTokens := make(map[string]*mdb.ChainToken, len(tokens)) + for i := range tokens { + sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol)) + contract := strings.TrimSpace(tokens[i].ContractAddress) + if sym == "TON" && contract == "" { + nativeToken = &tokens[i] + continue + } + if contract == "" { + continue + } + jettonTokens[contract] = &tokens[i] + } + + cleanupTonProcessedCache() + cleanupTonPendingConfirm() + + ctx, cancel := context.WithTimeout(context.Background(), tonRequestTimeout) + defer cancel() + + master, err := api.CurrentMasterchainInfo(ctx) + if err != nil { + log.Sugar.Errorf("[TON][%s] masterchain info err=%v", ownerAddrStr, err) + return + } + + // Jetton wallet address is deterministic per (master, owner); cache. + jettonWalletToToken := make(map[string]*mdb.ChainToken, len(jettonTokens)) + for masterStr, tk := range jettonTokens { + jwAddr, err := resolveJettonWalletAddress(ctx, api, master, ownerAddr, masterStr) + if err != nil { + log.Sugar.Errorf("[TON][%s] resolve jetton wallet master=%s err=%v", ownerAddrStr, masterStr, err) + continue + } + jettonWalletToToken[jwAddr.StringRaw()] = tk + } + + acc, err := api.GetAccount(ctx, master, ownerAddr) + if err != nil { + log.Sugar.Errorf("[TON][%s] get account err=%v", ownerAddrStr, err) + return + } + if !acc.IsActive || acc.LastTxLT == 0 { + log.Sugar.Debugf("[TON][%s] account not active or no transactions yet", ownerAddrStr) + return + } + + // Cutoff covers the gate's max wait so high min_confirmations don't + // age out before settling. + extraCutoff := int64(minConfirmations) * tonBlockTimeSeconds + cutoff := time.Now(). + Add(-config.GetOrderExpirationTimeDuration() - 5*time.Minute - time.Duration(extraCutoff)*time.Second). + Unix() + lt := acc.LastTxLT + txHash := acc.LastTxHash + scanned := 0 + currentMasterSeqno := master.SeqNo + + for lt > 0 && len(txHash) > 0 { + txs, err := api.ListTransactions(ctx, ownerAddr, tonListLimit, lt, txHash) + if err != nil { + if errors.Is(err, ton.ErrNoTransactionsWereFound) { + break + } + log.Sugar.Errorf("[TON][%s] list transactions err=%v", ownerAddrStr, err) + return + } + if len(txs) == 0 { + break + } + + // ListTransactions returns oldest-first; walk newest-first. + for i := len(txs) - 1; i >= 0; i-- { + tx := txs[i] + scanned++ + + if int64(tx.Now) < cutoff { + log.Sugar.Debugf("[TON][%s] tx now=%d before cutoff=%d, stopping scan", ownerAddrStr, tx.Now, cutoff) + return + } + + if len(tx.Hash) == 0 { + log.Sugar.Warnf("[TON][%s] skipping tx with empty hash lt=%d", ownerAddrStr, tx.LT) + continue + } + hashKey := hex.EncodeToString(tx.Hash) + if _, dup := gProcessedTonTx.Load(hashKey); dup { + continue + } + + if !tonConfirmationDepthReached(hashKey, currentMasterSeqno, minConfirmations) { + continue + } + + outcome := processTonTransaction(tx, ownerAddrStr, nativeToken, jettonWalletToToken) + // Only cache terminal outcomes; retryable ones get another tick. + if outcome != tonOutcomeRetry { + gProcessedTonTx.Store(hashKey, time.Now().Unix()) + gTonPendingConfirm.Delete(hashKey) + } + } + + oldest := txs[0] + if oldest.PrevTxLT == 0 || len(oldest.PrevTxHash) == 0 { + break + } + lt = oldest.PrevTxLT + txHash = oldest.PrevTxHash + } + + log.Sugar.Debugf("[TON][%s] scan complete, processed %d transactions", ownerAddrStr, scanned) +} + +// Inspects an incoming internal message and matches it against active +// orders. Confirmation gating is the caller's responsibility. +func processTonTransaction(tx *tlb.Transaction, ownerAddrStr string, + nativeToken *mdb.ChainToken, jettonWalletToToken map[string]*mdb.ChainToken) tonOutcome { + if tx.IO.In == nil || tx.IO.In.MsgType != tlb.MsgTypeInternal { + return tonOutcomeIrrelevant + } + ti := tx.IO.In.AsInternal() + if ti == nil { + return tonOutcomeIrrelevant + } + + // Skip bounced messages (refunds, not income). + if dsc, ok := tx.Description.(tlb.TransactionDescriptionOrdinary); ok && dsc.BouncePhase != nil { + if _, bounced := dsc.BouncePhase.Phase.(tlb.BouncePhaseOk); bounced { + return tonOutcomeIrrelevant + } + } + + txHashHex := hex.EncodeToString(tx.Hash) + blockTime := int64(ti.CreatedAt) + if blockTime <= 0 { + blockTime = int64(tx.Now) + } + + // Jetton transfer: source is our own jetton wallet contract; body + // is a transfer_notification. + if ti.SrcAddr != nil { + if tk := jettonWalletToToken[ti.SrcAddr.StringRaw()]; tk != nil { + var notif jetton.TransferNotification + if err := tlb.LoadFromCell(¬if, ti.Body.BeginParse()); err != nil { + log.Sugar.Warnf("[TON][%s] tx=%s decode jetton transfer err=%v", ownerAddrStr, txHashHex, err) + return tonOutcomeIrrelevant + } + amount := adjustTonAmount(notif.Amount.Nano(), tk.Decimals) + senderStr := "" + if notif.Sender != nil { + senderStr = notif.Sender.String() + } + return matchAndConfirmTonPayment(ownerAddrStr, txHashHex, blockTime, tk, amount, senderStr) + } + } + + // Native TON: require non-zero value AND a plain-transfer body shape. + // Contract messages carrying forwarding TON would otherwise be + // misclassified as payments on amount collision. + if nativeToken == nil { + return tonOutcomeIrrelevant + } + nano := ti.Amount.Nano() + if nano == nil || nano.Sign() <= 0 { + return tonOutcomeIrrelevant + } + if !isPlainTonTransferBody(ti.Body) { + log.Sugar.Debugf("[TON][%s] tx=%s rejecting non-plain body for native classification", ownerAddrStr, txHashHex) + return tonOutcomeIrrelevant + } + amount := adjustTonAmount(nano, tonNativeDecimals) + senderStr := "" + if ti.SrcAddr != nil { + senderStr = ti.SrcAddr.String() + } + return matchAndConfirmTonPayment(ownerAddrStr, txHashHex, blockTime, nativeToken, amount, senderStr) +} + +// Block-count confirmation gate via real master seqno advance. +// First sight records currentMasterSeqno as a safe upper bound on +// the tx's anchor and defers; later sights settle once the live +// master seqno has advanced by min_confirmations beyond that. +func tonConfirmationDepthReached(hashKey string, currentMasterSeqno uint32, minConfirmations int) bool { + if minConfirmations <= 0 { + return true + } + if v, loaded := gTonPendingConfirm.Load(hashKey); loaded { + entry := v.(tonPendingEntry) + if currentMasterSeqno <= entry.firstSeenMasterSeqno { + return false + } + return currentMasterSeqno-entry.firstSeenMasterSeqno >= uint32(minConfirmations) + } + gTonPendingConfirm.Store(hashKey, tonPendingEntry{ + firstSeenMasterSeqno: currentMasterSeqno, + expiresAt: time.Now().Unix() + tonPendingTTLSeconds(minConfirmations), + }) + return false +} + +// Scales with min_confirmations so large admin values have head-room +// to satisfy before eviction; floored at 1h for small values. +func tonPendingTTLSeconds(minConfirmations int) int64 { + scaled := int64(minConfirmations) * tonBlockTimeSeconds * tonPendingTTLSafetyMultiplier + if scaled > tonPendingFloorTTL { + return scaled + } + return tonPendingFloorTTL +} + +func cleanupTonPendingConfirm() { + now := time.Now().Unix() + gTonPendingConfirm.Range(func(k, v interface{}) bool { + if e, ok := v.(tonPendingEntry); ok && now > e.expiresAt { + gTonPendingConfirm.Delete(k) + } + return true + }) +} + +// Plain wallet-to-wallet body: nil/empty, or 32-bit opcode is +// tonOpTextComment / tonOpEncryptedComment. Any other opcode is a +// contract message; treating it as native TON would risk crediting +// a payment from a coincidental amount collision. +func isPlainTonTransferBody(body *cell.Cell) bool { + if body == nil { + return true + } + loader := body.BeginParse() + if loader == nil || loader.BitsLeft() < 32 { + return true + } + op, err := loader.LoadUInt(32) + if err != nil { + return true + } + return op == tonOpTextComment || op == tonOpEncryptedComment +} + +func matchAndConfirmTonPayment(ownerAddrStr, txHashHex string, blockTime int64, + token *mdb.ChainToken, amount float64, sender string) tonOutcome { + symbol := strings.ToUpper(strings.TrimSpace(token.Symbol)) + if amount <= 0 { + return tonOutcomeIrrelevant + } + if token.MinAmount > 0 && amount < token.MinAmount { + return tonOutcomeIrrelevant + } + + log.Sugar.Infof("[TON][%s] tx=%s incoming %s amount=%.6f from=%s -> matching", + ownerAddrStr, txHashHex, symbol, amount, sender) + + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, ownerAddrStr, symbol, amount) + if err != nil { + // Transient DB hiccup — don't poison the dedup cache. + log.Sugar.Errorf("[TON][%s] tx=%s lock lookup err=%v", ownerAddrStr, txHashHex, err) + return tonOutcomeRetry + } + if tradeID == "" { + // Order may still be creating; stay retryable until cutoff. + // Debug-level so an active wallet doesn't flood logs. + log.Sugar.Debugf("[TON][%s] tx=%s no active transaction_lock: token=%s amount=%.6f", + ownerAddrStr, txHashHex, symbol, amount) + return tonOutcomeRetry + } + + order, err := data.GetOrderInfoByTradeId(tradeID) + if err != nil { + log.Sugar.Errorf("[TON][%s] tx=%s load order err=%v", ownerAddrStr, txHashHex, err) + return tonOutcomeRetry + } + + // Reject payments older than the order itself. + createdMs := order.CreatedAt.TimestampMilli() + blockMs := blockTime * 1000 + if blockMs < createdMs { + log.Sugar.Warnf("[TON][%s] tx=%s skipped: block_time_ms=%d before order_created_ms=%d", + ownerAddrStr, txHashHex, blockMs, createdMs) + return tonOutcomeIrrelevant + } + + req := &request.OrderProcessingRequest{ + ReceiveAddress: ownerAddrStr, + Token: symbol, + Network: mdb.NetworkTon, + TradeId: tradeID, + Amount: amount, + BlockTransactionId: txHashHex, + } + if err := OrderProcessing(req); err != nil { + if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { + log.Sugar.Infof("[TON][%s] tx=%s already resolved: trade_id=%s reason=%v", + ownerAddrStr, txHashHex, tradeID, err) + return tonOutcomeProcessed + } + log.Sugar.Errorf("[TON][%s] tx=%s OrderProcessing failed trade_id=%s err=%v", + ownerAddrStr, txHashHex, tradeID, err) + return tonOutcomeRetry + } + log.Sugar.Infof("[TON][%s] order marked paid: trade_id=%s tx=%s token=%s amount=%.6f", + ownerAddrStr, tradeID, txHashHex, symbol, amount) + sendPaymentNotification(order) + return tonOutcomeProcessed +} + +func adjustTonAmount(nano *big.Int, decimals int) float64 { + if nano == nil || nano.Sign() == 0 { + return 0 + } + if decimals <= 0 { + decimals = tonNativeDecimals + } + d := decimal.NewFromBigInt(nano, 0) + divisor := decimal.New(1, int32(decimals)) + adjusted := d.Div(divisor) + return math.MustParsePrecFloat64(adjusted.InexactFloat64(), data.MaxAmountPrecision) +} + +func cleanupTonProcessedCache() { + cutoff := time.Now().Add(-tonProcessedCacheTTL).Unix() + gProcessedTonTx.Range(func(k, v interface{}) bool { + if ts, ok := v.(int64); ok && ts < cutoff { + gProcessedTonTx.Delete(k) + } + return true + }) +} + +// Derivation is deterministic per (master, owner) so we cache across +// ticks to avoid repeated get-method round-trips. +func resolveJettonWalletAddress(ctx context.Context, api ton.APIClientWrapped, + master *ton.BlockIDExt, owner *tonaddress.Address, masterStr string) (*tonaddress.Address, error) { + masterAddr, err := tonaddress.ParseAddr(masterStr) + if err != nil { + if masterAddr, err = tonaddress.ParseRawAddr(masterStr); err != nil { + return nil, fmt.Errorf("parse master addr %q: %w", masterStr, err) + } + } + key := owner.StringRaw() + "|" + masterAddr.StringRaw() + + gTonJettonWalletMu.Lock() + cached, ok := gTonJettonWallet[key] + gTonJettonWalletMu.Unlock() + if ok && cached != nil { + return cached, nil + } + + jc := jetton.NewJettonMasterClient(api, masterAddr) + wallet, err := jc.GetJettonWalletAtBlock(ctx, owner, master) + if err != nil { + return nil, err + } + addr := wallet.Address() + + gTonJettonWalletMu.Lock() + gTonJettonWallet[key] = addr + gTonJettonWalletMu.Unlock() + return addr, nil +} + +func ensureTonAPI() (ton.APIClientWrapped, error) { + cfgURL, err := resolveTonConfigURL() + if err != nil { + return nil, err + } + + gTonAPIMu.Lock() + defer gTonAPIMu.Unlock() + if gTonAPI != nil && gTonAPIConfigURL == cfgURL { + return gTonAPI, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), tonRequestTimeout) + defer cancel() + + cfg, err := liteclient.GetConfigFromUrl(ctx, cfgURL) + if err != nil { + return nil, fmt.Errorf("fetch ton config %q: %w", cfgURL, err) + } + + pool := liteclient.NewConnectionPool() + if err := pool.AddConnectionsFromConfig(ctx, cfg); err != nil { + return nil, fmt.Errorf("connect to lite servers: %w", err) + } + + api := ton.NewAPIClient(pool, ton.ProofCheckPolicyFast).WithRetry() + api.SetTrustedBlockFromConfig(cfg) + + gTonAPI = api + gTonAPIConfigURL = cfgURL + + // Cached jetton wallets reference the old pool; drop them. + gTonJettonWalletMu.Lock() + gTonJettonWallet = map[string]*tonaddress.Address{} + gTonJettonWalletMu.Unlock() + + log.Sugar.Infof("[TON] initialized API client from config %s", cfgURL) + return api, nil +} + +func resolveTonConfigURL() (string, error) { + node, err := data.SelectRpcNode(mdb.NetworkTon, mdb.RpcNodeTypeHttp) + if err != nil { + return "", err + } + if node == nil || node.ID == 0 { + return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes (set the URL of the TON global.config.json)", + mdb.NetworkTon, mdb.RpcNodeTypeHttp) + } + url := strings.TrimSpace(node.Url) + if url == "" { + return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID) + } + return url, nil +} diff --git a/src/model/service/ton_task_test.go b/src/model/service/ton_task_test.go new file mode 100644 index 00000000..623c7944 --- /dev/null +++ b/src/model/service/ton_task_test.go @@ -0,0 +1,203 @@ +package service + +import ( + "testing" + "time" + + "github.com/xssnick/tonutils-go/tvm/cell" +) + +// Pins the classifier that decides whether an internal message is a +// wallet-to-wallet payment or a contract notification. Permissive on +// edge cases (nil/short body) because dropping a real transfer is +// worse than accepting a contract notification we then can't match. +func TestIsPlainTonTransferBodyClassifiesMessageShapes(t *testing.T) { + cases := []struct { + name string + body *cell.Cell + want bool + }{ + { + name: "nil body is a plain transfer (no comment)", + body: nil, + want: true, + }, + { + name: "empty body cell is a plain transfer", + body: cell.BeginCell().EndCell(), + want: true, + }, + { + name: "body shorter than 32 bits is treated as plain (defensive)", + body: cell.BeginCell().MustStoreUInt(0xff, 8).EndCell(), + want: true, + }, + { + name: "opcode 0 (text comment) is a plain transfer", + body: cell.BeginCell().MustStoreUInt(tonOpTextComment, 32).MustStoreStringSnake("thanks for the coffee").EndCell(), + want: true, + }, + { + name: "encrypted comment opcode 0x2167da4b is a plain transfer", + body: cell.BeginCell().MustStoreUInt(tonOpEncryptedComment, 32).MustStoreUInt(0xabcdef, 32).EndCell(), + want: true, + }, + { + name: "non-zero opcode is a contract notification (rejected)", + body: cell.BeginCell().MustStoreUInt(0x7362d09c, 32).EndCell(), // jetton transfer_notification + want: false, + }, + { + name: "another non-zero opcode (NFT ownership_assigned) is rejected", + body: cell.BeginCell().MustStoreUInt(0x05138d91, 32).EndCell(), + want: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isPlainTonTransferBody(tc.body); got != tc.want { + t.Fatalf("isPlainTonTransferBody = %v, want %v", got, tc.want) + } + }) + } +} + +func resetTonPendingConfirm(t *testing.T) { + t.Helper() + gTonPendingConfirm.Range(func(k, _ interface{}) bool { + gTonPendingConfirm.Delete(k) + return true + }) +} + +// Pins the safety property: a tx settles only after master seqno has +// actually advanced by min_confirmations from first sight. Wall-clock +// age never grants depth (which would over-credit on slow blocks or +// clock skew). +func TestTonConfirmationDepthReachedRequiresBlockAdvance(t *testing.T) { + resetTonPendingConfirm(t) + + // minConfirmations <= 0 disables the gate entirely. + if !tonConfirmationDepthReached("any", 1000, 0) { + t.Fatal("min=0 should bypass the gate") + } + if !tonConfirmationDepthReached("any", 1000, -5) { + t.Fatal("negative min should bypass the gate") + } + + // Fresh tx: first observation defers, no head-start possible. + resetTonPendingConfirm(t) + if tonConfirmationDepthReached("tx-A", 100, 1) { + t.Fatal("first observation should defer") + } + // Same seqno on second look — still defers. + if tonConfirmationDepthReached("tx-A", 100, 1) { + t.Fatal("no master advance should keep deferring") + } + // Master advanced by 1 → depth=1 ≥ min=1, allow. + if !tonConfirmationDepthReached("tx-A", 101, 1) { + t.Fatal("depth=1 should satisfy min=1") + } + + // New tx, min=3 — needs 3 master advances after first sight. + resetTonPendingConfirm(t) + if tonConfirmationDepthReached("tx-B", 500, 3) { + t.Fatal("first observation defers") + } + if tonConfirmationDepthReached("tx-B", 501, 3) { + t.Fatal("depth=1 < min=3 should defer") + } + if tonConfirmationDepthReached("tx-B", 502, 3) { + t.Fatal("depth=2 < min=3 should defer") + } + if !tonConfirmationDepthReached("tx-B", 503, 3) { + t.Fatal("depth=3 should satisfy min=3") + } +} + +// Pins no-over-credit: first call MUST return false regardless of +// currentSeqno or min. Depth can only come from observed master +// advances. +func TestTonConfirmationDepthReachedDefersFirstSightAlways(t *testing.T) { + resetTonPendingConfirm(t) + if tonConfirmationDepthReached("never-seen-before", 10_000_000, 1) { + t.Fatal("first sight must defer regardless of current seqno or min") + } + // Stored entry should anchor at the current seqno (no head-start). + v, ok := gTonPendingConfirm.Load("never-seen-before") + if !ok { + t.Fatal("first sight should have stored a pending entry") + } + if got := v.(tonPendingEntry).firstSeenMasterSeqno; got != 10_000_000 { + t.Fatalf("anchor should equal currentMasterSeqno; got %d", got) + } +} + +// TTL has a 1h floor for small min_confirmations and scales linearly +// above that so large admin values get time to satisfy. +func TestTonPendingTTLSecondsScalesAndFloors(t *testing.T) { + cases := []struct { + minConfirmations int + wantMin int64 + }{ + {minConfirmations: 0, wantMin: tonPendingFloorTTL}, + {minConfirmations: 1, wantMin: tonPendingFloorTTL}, + {minConfirmations: 100, wantMin: tonPendingFloorTTL}, + // 1000 blocks * 5s * 3 = 15000s > 3600s floor. + {minConfirmations: 1000, wantMin: 15000}, + // Very high values must scale, not get capped. + {minConfirmations: 10000, wantMin: 150000}, + } + for _, c := range cases { + got := tonPendingTTLSeconds(c.minConfirmations) + if got < c.wantMin { + t.Fatalf("min=%d ttl=%d should be >= %d", c.minConfirmations, got, c.wantMin) + } + } +} + +// Misconfigured min_confirmations clamps to a supported range: +// <=0 disables, oversized values clamp down (not silently broken). +func TestEffectiveTonMinConfirmationsClamps(t *testing.T) { + if got := effectiveTonMinConfirmations(0); got != 0 { + t.Fatalf("0 should disable gate (got %d)", got) + } + if got := effectiveTonMinConfirmations(-3); got != 0 { + t.Fatalf("negative should disable gate (got %d)", got) + } + if got := effectiveTonMinConfirmations(1); got != 1 { + t.Fatalf("normal value passes through (got %d)", got) + } + if got := effectiveTonMinConfirmations(tonMaxEffectiveMinConfirmations); got != tonMaxEffectiveMinConfirmations { + t.Fatalf("max boundary should pass (got %d)", got) + } + if got := effectiveTonMinConfirmations(tonMaxEffectiveMinConfirmations + 1); got != tonMaxEffectiveMinConfirmations { + t.Fatalf("above max should clamp to max (got %d)", got) + } + if got := effectiveTonMinConfirmations(10_000_000); got != tonMaxEffectiveMinConfirmations { + t.Fatalf("very large should clamp to max (got %d)", got) + } +} + +// Per-entry expiry sweep: expired entries go, long-TTL ones survive +// past the old 1h global policy. +func TestCleanupTonPendingConfirmEvictsExpiredEntries(t *testing.T) { + resetTonPendingConfirm(t) + now := time.Now().Unix() + + gTonPendingConfirm.Store("expired", tonPendingEntry{firstSeenMasterSeqno: 1, expiresAt: now - 60}) + gTonPendingConfirm.Store("active", tonPendingEntry{firstSeenMasterSeqno: 1, expiresAt: now + 3600}) + gTonPendingConfirm.Store("long-min-conf", tonPendingEntry{firstSeenMasterSeqno: 1, expiresAt: now + 24*3600}) + + cleanupTonPendingConfirm() + + if _, ok := gTonPendingConfirm.Load("expired"); ok { + t.Fatal("expired entry should have been evicted") + } + if _, ok := gTonPendingConfirm.Load("active"); !ok { + t.Fatal("active entry should survive") + } + if _, ok := gTonPendingConfirm.Load("long-min-conf"); !ok { + t.Fatal("long-TTL entry should survive past the old 1h policy") + } +} diff --git a/src/task/listen.go b/src/task/listen.go index 96d14e7b..0390ced5 100644 --- a/src/task/listen.go +++ b/src/task/listen.go @@ -26,6 +26,15 @@ func Start() { log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)") + // TON polling — lite servers don't expose a transfer-event firehose, + // so we walk each owner's transaction history every tick. + _, err = c.AddJob("@every 5s", ListenTonJob{}) + if err != nil { + log.Sugar.Errorf("[task] Failed to add ListenTonJob: %v", err) + return + } + log.Sugar.Info("[task] ListenTonJob scheduled successfully (@every 5s)") + // RPC node health checks _, err = c.AddJob("@every 30s", RpcHealthJob{}) if err != nil { diff --git a/src/task/listen_ton_job.go b/src/task/listen_ton_job.go new file mode 100644 index 00000000..77234c84 --- /dev/null +++ b/src/task/listen_ton_job.go @@ -0,0 +1,41 @@ +package task + +import ( + "sync" + + "github.com/GMWalletApp/epusdt/model/data" + "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/model/service" + "github.com/GMWalletApp/epusdt/util/log" +) + +type ListenTonJob struct{} + +var gListenTonJobLock sync.Mutex + +func (r ListenTonJob) Run() { + gListenTonJobLock.Lock() + defer gListenTonJobLock.Unlock() + log.Sugar.Debug("[ListenTonJob] Job triggered") + if !data.IsChainEnabled(mdb.NetworkTon) { + log.Sugar.Debug("[ListenTonJob] chain disabled, skipping") + return + } + walletAddresses, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTon) + if err != nil { + log.Sugar.Errorf("[ListenTonJob] failed to get wallet addresses: %v", err) + return + } + if len(walletAddresses) == 0 { + log.Sugar.Debug("[ListenTonJob] no available wallet addresses") + return + } + log.Sugar.Infof("[ListenTonJob] scanning %d wallet addresses", len(walletAddresses)) + var wg sync.WaitGroup + for _, addr := range walletAddresses { + wg.Add(1) + go service.TonCallBack(addr.Address, &wg) + } + wg.Wait() + log.Sugar.Debug("[ListenTonJob] job completed") +}