From 9b397bd45e50736be464babfce6c5608d5b46e52 Mon Sep 17 00:00:00 2001 From: line-6000 <154492442+line-6000@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:47:46 +0800 Subject: [PATCH] feat: support TON and USDT Jetton payments - add TON chain, token, RPC, address normalization, and order matching support - add liteclient scanner with masterchain/shard catch-up and Jetton validation - add TON manual payment verification and focused tests --- src/config/config.go | 8 +- src/config/config_test.go | 29 + src/controller/admin/chain_controller.go | 5 +- src/controller/admin/order_controller.go | 1 + src/controller/admin/rpc_controller.go | 14 +- src/controller/admin/settings_controller.go | 8 +- src/controller/comm/order_controller.go | 12 +- src/controller/comm/pay_controller.go | 1 + src/go.mod | 1 + src/go.sum | 2 + src/internal/testutil/testdb.go | 11 +- src/model/dao/mdb_table_init.go | 5 + src/model/dao/mdb_table_init_test.go | 28 + src/model/data/order_data.go | 9 +- src/model/data/order_data_test.go | 28 + src/model/data/wallet_address_data.go | 36 +- src/model/data/wallet_address_data_test.go | 22 + src/model/mdb/rpc_node.go | 5 +- src/model/mdb/wallet_address_mdb.go | 1 + src/model/request/order_request.go | 4 +- src/model/service/manual_payment_verify.go | 246 ++++++ .../service/manual_payment_verify_test.go | 245 +++++- src/model/service/order_service.go | 61 +- src/model/service/order_service_test.go | 420 ++++++++++ src/model/service/task_service_test.go | 22 +- src/model/service/ton_lite_client.go | 37 + src/model/service/ton_task.go | 262 +++++++ src/model/service/ton_task_test.go | 173 +++++ src/task/listen.go | 1 + src/task/listen_ton.go | 574 ++++++++++++++ src/task/listen_ton_test.go | 717 ++++++++++++++++++ src/telegram/utils.go | 9 + src/util/address/ton.go | 135 ++++ src/util/address/ton_test.go | 56 ++ 34 files changed, 3131 insertions(+), 57 deletions(-) create mode 100644 src/model/service/ton_lite_client.go create mode 100644 src/model/service/ton_task.go create mode 100644 src/model/service/ton_task_test.go create mode 100644 src/task/listen_ton.go create mode 100644 src/task/listen_ton_test.go create mode 100644 src/util/address/ton.go create mode 100644 src/util/address/ton_test.go diff --git a/src/config/config.go b/src/config/config.go index 1db93a0..1bcca9d 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -352,7 +352,13 @@ func GetRateForCoin(coin string, base string) float64 { if coin == "usdt" && base == "usd" { return 1 } - return getRateForCoinFromAPI(coin, base) + if rate := getRateForCoinFromAPI(coin, base); rate > 0 { + return rate + } + if coin == "ton" { + return getRateForCoinFromAPI("toncoin", base) + } + return 0 } func getForcedRateForCoin(coin string, base string) float64 { diff --git a/src/config/config_test.go b/src/config/config_test.go index c4b950f..ffd8c97 100644 --- a/src/config/config_test.go +++ b/src/config/config_test.go @@ -329,6 +329,35 @@ func TestGetRateForCoinUsesAPIWhenForcedPairMissing(t *testing.T) { } } +func TestGetRateForCoinUsesToncoinAPIFallbackForTon(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv("API_RATE_URL", "") + + installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/cny.json" { + t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path) + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"cny":{"toncoin":1.82}}`)), + Request: r, + }, nil + }) + + installSettingsGetter(t, map[string]string{ + "rate.forced_rate_list": `{"cny":{"ton":0}}`, + "rate.api_url": "https://rate.example.test", + }) + + rate := GetRateForCoin("ton", "cny") + if math.Abs(rate-1.82) > 1e-9 { + t.Fatalf("GetRateForCoin(ton, cny) = %v, want 1.82", rate) + } +} + func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) { viper.Reset() t.Cleanup(viper.Reset) diff --git a/src/controller/admin/chain_controller.go b/src/controller/admin/chain_controller.go index aeaca2a..a225a30 100644 --- a/src/controller/admin/chain_controller.go +++ b/src/controller/admin/chain_controller.go @@ -14,8 +14,7 @@ type UpdateChainRequest struct { DisplayName *string `json:"display_name" example:"Tron"` } -// ListChains returns the chains table (tron/ethereum/solana seeded; new -// networks like bsc/polygon inserted manually once supported). +// ListChains returns the chains table for all seeded supported networks. // @Summary List chains // @Description Returns all supported blockchain networks // @Tags Admin Chains @@ -41,7 +40,7 @@ func (c *BaseAdminController) ListChains(ctx echo.Context) error { // @Security AdminJWT // @Accept json // @Produce json -// @Param network path string true "Network name (e.g. tron, ethereum, solana)" +// @Param network path string true "Network name (e.g. ton, tron, ethereum, solana)" // @Param request body admin.UpdateChainRequest true "Chain settings" // @Success 200 {object} response.ApiResponse // @Failure 400 {object} response.ApiResponse diff --git a/src/controller/admin/order_controller.go b/src/controller/admin/order_controller.go index 3bbd128..d3ac5f1 100644 --- a/src/controller/admin/order_controller.go +++ b/src/controller/admin/order_controller.go @@ -132,6 +132,7 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error { // callback + notification fire normally. // @Summary Mark order paid // @Description Manually mark a waiting order as paid (operator补单) +// @Description TON accepts canonical ton:::, lt:hash, or a unique recent hash-only reference for the order receive address. // @Tags Admin Orders // @Security AdminJWT // @Accept json diff --git a/src/controller/admin/rpc_controller.go b/src/controller/admin/rpc_controller.go index 27033dc..61431b1 100644 --- a/src/controller/admin/rpc_controller.go +++ b/src/controller/admin/rpc_controller.go @@ -16,8 +16,8 @@ import ( type CreateRpcNodeRequest struct { Network string `json:"network" validate:"required" example:"tron"` Url string `json:"url" validate:"required" example:"https://api.trongrid.io"` - // 连接类型 http=HTTP请求 ws=WebSocket长连接 - Type string `json:"type" validate:"required|in:http,ws" enums:"http,ws" example:"http"` + // 连接类型 http=HTTP请求 ws=WebSocket长连接 lite=TON liteserver配置 + Type string `json:"type" validate:"required|in:http,ws,lite" enums:"http,ws,lite" example:"http"` Weight int `json:"weight" example:"1"` ApiKey string `json:"api_key" example:""` Enabled *bool `json:"enabled" example:"true"` @@ -187,9 +187,8 @@ func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error { } // HealthCheckRpcNode performs an on-demand probe and writes the result. -// For HTTP endpoints this is a GET to the URL with a short timeout; for -// WS we just attempt a TCP-level check via the same HTTP client (the -// handshake URL resolves the host identically). +// For HTTP/WS/lite endpoints this is a TCP-level check against the configured +// URL host. TON lite rows usually point to a global.config.json HTTPS URL. // @Summary Health check RPC node // @Description Perform an on-demand health probe on an RPC node // @Tags Admin RPC Nodes @@ -253,6 +252,11 @@ func validateRpcNodeURLForType(rawURL string, nodeType string) error { return nil } return constant.RpcNodeWebSocketURLErr + case mdb.RpcNodeTypeLite: + if scheme == "http" || scheme == "https" { + return nil + } + return constant.RpcNodeHTTPURLErr default: return constant.RpcNodeTypeErr } diff --git a/src/controller/admin/settings_controller.go b/src/controller/admin/settings_controller.go index 39130ae..ec7e508 100644 --- a/src/controller/admin/settings_controller.go +++ b/src/controller/admin/settings_controller.go @@ -19,7 +19,7 @@ import ( // Supported groups and keys: // // - group=rate: -// rate.forced_rate_list (json) — override rate map, e.g. {"cny":{"usdt":0.14635}}; base/coin keys are normalized to lowercase +// rate.forced_rate_list (json) — override rate map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase // rate.api_url (string) — external rate API URL used when no positive forced rate exists // rate.adjust_percent (float) — rate adjustment percentage // rate.okx_c2c_enabled (bool) — use OKX C2C rate feed @@ -27,7 +27,7 @@ import ( // - group=epay: // epay.default_token (string) — token for EPAY orders, e.g. "usdt" (default) // epay.default_currency (string) — fiat currency for EPAY orders, e.g. "cny" (default) -// epay.default_network (string) — blockchain network for EPAY orders, e.g. "tron" (default) +// epay.default_network (string) — blockchain network for EPAY orders, e.g. "tron" or "ton" (default "tron") // // - group=okpay: // okpay.enabled (bool) — enable OkPay as a switch-network payment option @@ -93,9 +93,9 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error { // @Summary Upsert settings // @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n. // @Description Supported groups: brand, rate, system, epay, okpay. -// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron"). +// @Description epay group keys: epay.default_token (e.g. "usdt" or "ton"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron" or "ton"). // @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens. -// @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled. +// @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled. // @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported. // @Description system group keys: system.order_expiration_time, system.amount_precision (int, 2-6, default 2). // @Tags Admin Settings diff --git a/src/controller/comm/order_controller.go b/src/controller/comm/order_controller.go index f052d97..1b41c5a 100644 --- a/src/controller/comm/order_controller.go +++ b/src/controller/comm/order_controller.go @@ -32,8 +32,8 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey { // @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)" // @Param order_id formData string false "Merchant order ID" // @Param currency formData string false "Fiat currency (e.g. cny)" -// @Param token formData string false "Crypto token (e.g. usdt)" -// @Param network formData string false "Network (e.g. tron)" +// @Param token formData string false "Crypto token (e.g. TON, USDT)" +// @Param network formData string false "Network (e.g. ton, tron)" // @Param amount formData number false "Amount" // @Param notify_url formData string false "Callback URL" // @Param signature formData string false "MD5 signature" @@ -41,7 +41,7 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey { // @Param name formData string false "Order name" // @Param payment_type formData string false "Payment type" // @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse} -// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10003 no wallet, 10005 no amount channel" +// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel" // @Router /payments/gmpay/v1/order/create-transaction [post] func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) { req := new(request.CreateTransactionRequest) @@ -61,14 +61,14 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) { // SwitchNetwork 切换支付网络,创建或返回子订单 // @Summary Switch payment network // @Description Switch to a different payment target, creating or returning a sub-order. -// @Description Normal values such as tron/solana/ethereum create on-chain child orders. +// @Description Normal values such as ton/tron/solana/ethereum create on-chain child orders. // @Description The special value okpay creates or reuses an OkPay-hosted child order and returns its payment_url. // @Tags Payment // @Accept json // @Produce json // @Param request body request.SwitchNetworkRequest true "Switch network payload" // @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse} -// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10012 cannot switch sub-order, 10013 not waiting payment, 10017/10018/10019 provider errors, 10042 provider order creation failed" +// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10012 cannot switch sub-order, 10013 not waiting payment, 10016 unsupported asset, 10017/10018/10019 provider errors, 10042 provider order creation failed" // @Router /pay/switch-network [post] func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) { req := new(request.SwitchNetworkRequest) @@ -121,7 +121,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) { // @Param sign formData string true "MD5 signature" // @Param sign_type formData string false "Signature type (MD5)" // @Success 302 "Redirect to checkout counter" -// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10003 no wallet, 10005 no amount channel" +// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel" // @Router /payments/epay/v1/order/create-transaction/submit.php [post] // @Router /payments/epay/v1/order/create-transaction/submit.php [get] func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) { diff --git a/src/controller/comm/pay_controller.go b/src/controller/comm/pay_controller.go index c293999..205b825 100644 --- a/src/controller/comm/pay_controller.go +++ b/src/controller/comm/pay_controller.go @@ -54,6 +54,7 @@ func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) { // SubmitTxHash 手动提交交易 hash 补单 // @Summary Submit transaction hash // @Description Submit an on-chain transaction hash from the cashier. The hash is verified against the order and, when valid, the same manual payment processing path used by admin mark-paid is executed. OkPay/provider orders are not supported. +// @Description TON accepts canonical ton:::, lt:hash, or a unique recent hash-only reference for the order receive address. // @Tags Payment // @Accept json // @Produce json diff --git a/src/go.mod b/src/go.mod index bce7967..b2f27e9 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 e30804d..314301b 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 1d73033..6f57876 100644 --- a/src/internal/testutil/testdb.go +++ b/src/internal/testutil/testdb.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/viper" "go.uber.org/zap" "gorm.io/gorm" + "gorm.io/gorm/clause" "gorm.io/gorm/logger" "gorm.io/gorm/schema" ) @@ -65,11 +66,19 @@ func SetupTestDatabases(t testing.TB) func() { // Seed all standard chains as enabled so IsChainEnabled checks pass. for _, network := range []string{ mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum, - mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, + mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkTon, } { mainDB.Create(&mdb.Chain{Network: network, Enabled: true}) } + mainDB.Clauses(clause.OnConflict{DoNothing: true}).Create(&[]mdb.ChainToken{ + {Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true}, + {Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true}, + {Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true}, + {Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true}, + {Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true}, + }) + // Seed two universal api_keys rows. Both usable for EPAY/GMPAY // flows; the numeric PID 1001 row lets legacy tests that submit // `pid=1001` still match. diff --git a/src/model/dao/mdb_table_init.go b/src/model/dao/mdb_table_init.go index 2324db4..0f66a00 100644 --- a/src/model/dao/mdb_table_init.go +++ b/src/model/dao/mdb_table_init.go @@ -64,6 +64,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) @@ -98,6 +99,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 + {Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true}, + {Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, 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) @@ -133,6 +137,7 @@ func defaultRpcNodes() []mdb.RpcNode { {Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, + {Network: mdb.NetworkTon, Url: "https://ton-blockchain.github.io/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkBsc, Url: "https://rpc.epusdt.com/binance", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown}, diff --git a/src/model/dao/mdb_table_init_test.go b/src/model/dao/mdb_table_init_test.go index 0dd6273..28105ee 100644 --- a/src/model/dao/mdb_table_init_test.go +++ b/src/model/dao/mdb_table_init_test.go @@ -41,3 +41,31 @@ func TestDefaultRpcNodesIncludesManualVerifyEpusdtEvmNodes(t *testing.T) { } } } + +func TestDefaultRpcNodesIncludesTonLiteGeneralNode(t *testing.T) { + var got *mdb.RpcNode + nodes := defaultRpcNodes() + for i := range nodes { + node := nodes[i] + if node.Network == mdb.NetworkTon && node.Type == mdb.RpcNodeTypeLite { + got = &node + break + } + } + + if got == nil { + t.Fatal("missing TON lite seed rpc node") + } + if got.Url != "https://ton-blockchain.github.io/global.config.json" { + t.Fatalf("TON lite seed url = %q", got.Url) + } + if got.Purpose != mdb.RpcNodePurposeGeneral { + t.Fatalf("TON lite seed purpose = %q, want %q", got.Purpose, mdb.RpcNodePurposeGeneral) + } + if !got.Enabled { + t.Fatal("TON lite seed enabled = false, want true") + } + if got.Status != mdb.RpcNodeStatusUnknown { + t.Fatalf("TON lite seed status = %q, want %q", got.Status, mdb.RpcNodeStatusUnknown) + } +} diff --git a/src/model/data/order_data.go b/src/model/data/order_data.go index c441bdd..b70423e 100644 --- a/src/model/data/order_data.go +++ b/src/model/data/order_data.go @@ -8,6 +8,7 @@ import ( "github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/request" + addressutil "github.com/GMWalletApp/epusdt/util/address" "github.com/dromara/carbon/v2" "github.com/shopspring/decimal" "gorm.io/gorm" @@ -39,9 +40,15 @@ func normalizeLockNetwork(network string) string { func normalizeLockAddress(network, address string) string { address = strings.TrimSpace(address) - if isEVMNetwork(normalizeLockNetwork(network)) { + network = normalizeLockNetwork(network) + if isEVMNetwork(network) { return strings.ToLower(address) } + if network == mdb.NetworkTon { + if raw, err := addressutil.TonRawAddressKey(address); err == nil { + return raw + } + } return address } diff --git a/src/model/data/order_data_test.go b/src/model/data/order_data_test.go index 3b9c4ea..e658cc7 100644 --- a/src/model/data/order_data_test.go +++ b/src/model/data/order_data_test.go @@ -8,6 +8,7 @@ import ( "github.com/GMWalletApp/epusdt/internal/testutil" "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/xssnick/tonutils-go/address" ) func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) { @@ -156,3 +157,30 @@ func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) { t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID) } } + +func TestTonTransactionLockAddressUsesRawKey(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + bounceable := "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I" + addr := address.MustParseAddr(bounceable) + nonBounce := addr.Bounce(false).String() + + if err := LockTransaction(mdb.NetworkTon, bounceable, "TON", "trade-ton", 1.23, time.Hour); err != nil { + t.Fatalf("lock ton transaction: %v", err) + } + gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, nonBounce, "TON", 1.23) + if err != nil { + t.Fatalf("lookup ton lock: %v", err) + } + if gotTradeID != "trade-ton" { + t.Fatalf("ton lock lookup = %q, want trade-ton", gotTradeID) + } + gotTradeID, err = GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", 1.23) + if err != nil { + t.Fatalf("lookup ton raw lock: %v", err) + } + if gotTradeID != "trade-ton" { + t.Fatalf("ton raw lock lookup = %q, want trade-ton", gotTradeID) + } +} diff --git a/src/model/data/wallet_address_data.go b/src/model/data/wallet_address_data.go index 0c562b5..3ea892a 100644 --- a/src/model/data/wallet_address_data.go +++ b/src/model/data/wallet_address_data.go @@ -5,6 +5,7 @@ import ( "github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/mdb" + addressutil "github.com/GMWalletApp/epusdt/util/address" "github.com/GMWalletApp/epusdt/util/constant" ) @@ -34,10 +35,23 @@ func normalizeWalletAddressByNetwork(network, address string) string { return address } +func normalizeWalletAddressByNetworkE(network, address string) (string, error) { + network = normalizeWalletNetwork(network) + address = strings.TrimSpace(address) + if network == mdb.NetworkTon { + return addressutil.NormalizeTonAddress(address) + } + return normalizeWalletAddressByNetwork(network, address), nil +} + // AddWalletAddressWithNetwork 创建指定网络的钱包地址 func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) { network = normalizeWalletNetwork(network) - address = normalizeWalletAddressByNetwork(network, address) + var err error + address, err = normalizeWalletAddressByNetworkE(network, address) + if err != nil { + return nil, err + } exist, err := GetWalletAddressByNetworkAndAddress(network, address) if err != nil { @@ -75,9 +89,13 @@ func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, e // GetWalletAddressByNetworkAndAddress 通过网络和地址查询 func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) { network = normalizeWalletNetwork(network) - address = normalizeWalletAddressByNetwork(network, address) + var err error + address, err = normalizeWalletAddressByNetworkE(network, address) + if err != nil { + return nil, err + } walletAddress := new(mdb.WalletAddress) - err := dao.Mdb.Model(walletAddress). + err = dao.Mdb.Model(walletAddress). Where("network = ?", network). Where("address = ?", address). Limit(1).Find(walletAddress).Error @@ -126,6 +144,12 @@ func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, er for i := range list { list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address)) } + } else if network == mdb.NetworkTon { + for i := range list { + if normalized, err := addressutil.NormalizeTonAddress(list[i].Address); err == nil { + list[i].Address = normalized + } + } } return list, err } @@ -149,6 +173,12 @@ func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) { for i := range list { list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address)) } + } else if network == mdb.NetworkTon { + for i := range list { + if normalized, err := addressutil.NormalizeTonAddress(list[i].Address); err == nil { + list[i].Address = normalized + } + } } return list, err } diff --git a/src/model/data/wallet_address_data_test.go b/src/model/data/wallet_address_data_test.go index fb9149e..d0c3c4c 100644 --- a/src/model/data/wallet_address_data_test.go +++ b/src/model/data/wallet_address_data_test.go @@ -7,6 +7,8 @@ import ( "github.com/GMWalletApp/epusdt/internal/testutil" "github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/util/constant" + "github.com/xssnick/tonutils-go/address" ) func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) { @@ -81,3 +83,23 @@ func TestAddWalletAddressWithNetworkKeepsOriginalCaseForNonEvm(t *testing.T) { t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress) } } + +func TestAddWalletAddressWithNetworkNormalizesTonAddressVariants(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + bounceable := "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I" + addr := address.MustParseAddr(bounceable) + expected := addr.Bounce(false).String() + + row, err := AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()) + if err != nil { + t.Fatalf("add raw ton wallet: %v", err) + } + if row.Address != expected { + t.Fatalf("stored TON address = %q, want %q", row.Address, expected) + } + if _, err = AddWalletAddressWithNetwork(mdb.NetworkTon, bounceable); err != constant.WalletAddressAlreadyExists { + t.Fatalf("add equivalent ton wallet error = %v, want already exists", err) + } +} diff --git a/src/model/mdb/rpc_node.go b/src/model/mdb/rpc_node.go index 9a46bad..eb01567 100644 --- a/src/model/mdb/rpc_node.go +++ b/src/model/mdb/rpc_node.go @@ -5,6 +5,7 @@ import "github.com/dromara/carbon/v2" const ( RpcNodeTypeHttp = "http" RpcNodeTypeWs = "ws" + RpcNodeTypeLite = "lite" ) const ( @@ -24,8 +25,8 @@ const ( type RpcNode struct { Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"` Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"` - // 连接类型 http=HTTP请求 ws=WebSocket长连接 - Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"` + // 连接类型 http=HTTP请求 ws=WebSocket长连接 lite=TON liteserver配置 + Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws,lite" example:"http"` Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"` ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"` Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"` diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go index d2432ca..6f8e9a5 100644 --- a/src/model/mdb/wallet_address_mdb.go +++ b/src/model/mdb/wallet_address_mdb.go @@ -12,6 +12,7 @@ const ( NetworkBsc = "binance" NetworkPolygon = "polygon" NetworkPlasma = "plasma" + NetworkTon = "ton" ) const ( diff --git a/src/model/request/order_request.go b/src/model/request/order_request.go index 0486cdd..1c09948 100644 --- a/src/model/request/order_request.go +++ b/src/model/request/order_request.go @@ -6,8 +6,8 @@ import "github.com/gookit/validate" type CreateTransactionRequest struct { OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"` Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny - Token string `json:"token" form:"token" validate:"required" example:"usdt"` // 币种 如:usdt - Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:tron + Token string `json:"token" form:"token" validate:"required" example:"usdt"` // 币种 如:usdt、ton + Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:ton、tron Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"` NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"` Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."` diff --git a/src/model/service/manual_payment_verify.go b/src/model/service/manual_payment_verify.go index f6ffa00..33e57d2 100644 --- a/src/model/service/manual_payment_verify.go +++ b/src/model/service/manual_payment_verify.go @@ -16,6 +16,7 @@ import ( tron "github.com/GMWalletApp/epusdt/crypto" "github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/mdb" + addressutil "github.com/GMWalletApp/epusdt/util/address" "github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/math" "github.com/ethereum/go-ethereum/common" @@ -23,6 +24,10 @@ import ( "github.com/ethereum/go-ethereum/ethclient" "github.com/shopspring/decimal" "github.com/tidwall/gjson" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" + "github.com/xssnick/tonutils-go/ton" + "github.com/xssnick/tonutils-go/ton/jetton" ) const manualVerifyRequestTimeout = 15 * time.Second @@ -82,6 +87,8 @@ func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID str canonicalTxID, err = validateManualTronPayment(order, txID) case mdb.NetworkSolana: canonicalTxID, err = validateManualSolanaPayment(order, txID) + case mdb.NetworkTon: + canonicalTxID, err = validateManualTonPayment(order, txID) case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: canonicalTxID, err = validateManualEvmPayment(order, txID) default: @@ -636,6 +643,245 @@ func tronPostJSON(baseURL, apiKey, path string, body interface{}, out interface{ return json.Unmarshal(raw, out) } +type manualTonTxRef struct { + ReceiveRaw string + LT uint64 + HashHex string + HasLT bool +} + +func validateManualTonPayment(order *mdb.Orders, txID string) (string, error) { + ref, err := parseManualTonTxRef(txID) + if err != nil { + return "", err + } + orderAddr, err := addressutil.ParseTonMainnetAddress(order.ReceiveAddress) + if err != nil { + return "", fmt.Errorf("invalid order receive address: %w", err) + } + if ref.ReceiveRaw != "" && ref.ReceiveRaw != orderAddr.Raw { + return "", fmt.Errorf("transaction recipient mismatch") + } + + nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkTon, mdb.RpcNodeTypeLite) + if err != nil { + return "", err + } + if len(nodes) == 0 { + return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTon, mdb.RpcNodeTypeLite) + } + + var verifyErrors []string + for _, node := range nodes { + api, closeFn, err := dialManualTonClient(node) + if err != nil { + verifyErrors = append(verifyErrors, fmt.Sprintf("%s: connect: %v", manualRpcNodeLabel(node), err)) + continue + } + canonicalID, err := validateManualTonPaymentWithAPI(context.Background(), api, order, orderAddr.Address, ref) + closeFn() + if err != nil { + verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err)) + continue + } + return canonicalID, nil + } + if len(verifyErrors) > 0 { + return "", fmt.Errorf("manual TON verification failed: %s", strings.Join(verifyErrors, "; ")) + } + return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTon, mdb.RpcNodeTypeLite) +} + +func parseManualTonTxRef(input string) (manualTonTxRef, error) { + raw := strings.TrimSpace(input) + if raw == "" { + return manualTonTxRef{}, fmt.Errorf("block_transaction_id is required") + } + if strings.HasPrefix(strings.ToLower(raw), "ton:") { + body := raw[4:] + last := strings.LastIndex(body, ":") + if last <= 0 || last == len(body)-1 { + return manualTonTxRef{}, fmt.Errorf("invalid ton canonical transaction id") + } + hashHex, err := normalizeTonTxHashHex(body[last+1:]) + if err != nil { + return manualTonTxRef{}, err + } + left := body[:last] + mid := strings.LastIndex(left, ":") + if mid <= 0 || mid == len(left)-1 { + return manualTonTxRef{}, fmt.Errorf("invalid ton canonical transaction id") + } + lt, err := parseUint64Strict(left[mid+1:]) + if err != nil { + return manualTonTxRef{}, fmt.Errorf("invalid ton transaction lt") + } + receiveRaw, err := addressutil.TonRawAddressKey(left[:mid]) + if err != nil { + return manualTonTxRef{}, fmt.Errorf("invalid ton canonical recipient: %w", err) + } + return manualTonTxRef{ReceiveRaw: receiveRaw, LT: lt, HashHex: hashHex, HasLT: true}, nil + } + if idx := strings.Index(raw, ":"); idx > 0 { + lt, err := parseUint64Strict(raw[:idx]) + if err != nil { + return manualTonTxRef{}, fmt.Errorf("invalid ton transaction lt") + } + hashHex, err := normalizeTonTxHashHex(raw[idx+1:]) + if err != nil { + return manualTonTxRef{}, err + } + return manualTonTxRef{LT: lt, HashHex: hashHex, HasLT: true}, nil + } + hashHex, err := normalizeTonTxHashHex(raw) + if err != nil { + return manualTonTxRef{}, err + } + return manualTonTxRef{HashHex: hashHex}, nil +} + +func normalizeTonTxHashHex(raw string) (string, error) { + hashHex := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(raw, "0x"), "0X"))) + if len(hashHex) != 64 { + return "", fmt.Errorf("invalid ton transaction hash length") + } + if _, err := hex.DecodeString(hashHex); err != nil { + return "", fmt.Errorf("invalid ton transaction hash") + } + return hashHex, nil +} + +func parseUint64Strict(raw string) (uint64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, fmt.Errorf("empty integer") + } + var out uint64 + for _, ch := range raw { + if ch < '0' || ch > '9' { + return 0, fmt.Errorf("invalid integer") + } + out = out*10 + uint64(ch-'0') + } + return out, nil +} + +func dialManualTonClient(node mdb.RpcNode) (ton.APIClientWrapped, func(), error) { + ctx, cancel := context.WithTimeout(context.Background(), manualVerifyRequestTimeout) + defer cancel() + return ConnectTonLiteAPI(ctx, node.Url, manualVerifyRequestTimeout, 3) +} + +func validateManualTonPaymentWithAPI(ctx context.Context, api ton.APIClientWrapped, order *mdb.Orders, receive *address.Address, ref manualTonTxRef) (string, error) { + master, err := api.CurrentMasterchainInfo(ctx) + if err != nil { + return "", fmt.Errorf("fetch current masterchain: %w", err) + } + txs, err := listManualTonCandidateTransactions(ctx, api, master, receive, ref) + if err != nil { + return "", fmt.Errorf("list ton account transactions: %w", err) + } + if len(txs) == 0 { + return "", fmt.Errorf("transaction not found") + } + + state, err := manualTonTokenState(ctx, api, master, receive) + if err != nil { + return "", err + } + return validateManualTonPaymentCandidates(order, receive, ref, txs, state) +} + +func validateManualTonPaymentCandidates(order *mdb.Orders, receive *address.Address, ref manualTonTxRef, txs []*tlb.Transaction, state *manualTonState) (string, error) { + if state == nil { + state = &manualTonState{jettonWallets: make(map[string]mdb.ChainToken)} + } + var matched []*TonObservedTransfer + for _, tx := range txs { + if tx == nil { + continue + } + hashHex := hex.EncodeToString(tx.Hash) + if !strings.EqualFold(hashHex, ref.HashHex) { + continue + } + if ref.HasLT && tx.LT != ref.LT { + continue + } + transfer, err := ParseTonInboundTransfer(tx, receive, state.nativeToken, state.jettonWallets) + if err != nil || transfer == nil { + continue + } + if err = EnsureTonTransferMatchesOrder(order, transfer); err != nil { + return "", err + } + matched = append(matched, transfer) + } + if len(matched) == 0 { + return "", fmt.Errorf("matching ton transfer to order address not found") + } + if len(matched) > 1 && !ref.HasLT { + return "", fmt.Errorf("ton transaction hash matched multiple recent transactions; submit canonical id or lt:hash") + } + receiveRaw := addressutil.TonRawAddressObjectKey(receive) + return TonCanonicalBlockTransactionID(receiveRaw, matched[0].LT, matched[0].TxHashHex), nil +} + +func listManualTonCandidateTransactions(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, receive *address.Address, ref manualTonTxRef) ([]*tlb.Transaction, error) { + waiter := api.WaitForBlock(master.SeqNo) + if ref.HasLT { + hashBytes, err := hex.DecodeString(ref.HashHex) + if err != nil { + return nil, err + } + return waiter.ListTransactions(ctx, receive, 1, ref.LT, hashBytes) + } + + account, err := waiter.GetAccount(ctx, master, receive) + if err != nil { + return nil, fmt.Errorf("fetch ton account state: %w", err) + } + if account == nil || account.LastTxLT == 0 || len(account.LastTxHash) == 0 { + return nil, fmt.Errorf("transaction not found") + } + return waiter.ListTransactions(ctx, receive, 100, account.LastTxLT, account.LastTxHash) +} + +type manualTonState struct { + nativeToken *mdb.ChainToken + jettonWallets map[string]mdb.ChainToken +} + +func manualTonTokenState(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, receive *address.Address) (*manualTonState, error) { + tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTon) + if err != nil { + return nil, err + } + state := &manualTonState{jettonWallets: make(map[string]mdb.ChainToken)} + for i := range tokens { + sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol)) + contract := strings.TrimSpace(tokens[i].ContractAddress) + if sym == TonNativeSymbol && contract == "" { + token := tokens[i] + state.nativeToken = &token + continue + } + if contract == "" { + continue + } + masterAddr, err := addressutil.ParseTonMainnetAddress(contract) + if err != nil { + continue + } + wallet, err := jetton.NewJettonMasterClient(api, masterAddr.Address).GetJettonWalletAtBlock(ctx, receive, master) + if err != nil { + continue + } + state.jettonWallets[addressutil.TonRawAddressObjectKey(wallet.Address())] = tokens[i] + } + return state, nil +} + func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) { sig = strings.TrimSpace(sig) nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkSolana, mdb.RpcNodeTypeHttp) diff --git a/src/model/service/manual_payment_verify_test.go b/src/model/service/manual_payment_verify_test.go index da3a9bb..e49c9fb 100644 --- a/src/model/service/manual_payment_verify_test.go +++ b/src/model/service/manual_payment_verify_test.go @@ -1,7 +1,9 @@ package service import ( + "bytes" "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -16,11 +18,33 @@ import ( "github.com/GMWalletApp/epusdt/internal/testutil" "github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/mdb" + addressutil "github.com/GMWalletApp/epusdt/util/address" "github.com/dromara/carbon/v2" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/xssnick/tonutils-go/address" + "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" + "gorm.io/gorm/clause" ) +func upsertTestChainToken(t *testing.T, token mdb.ChainToken) { + t.Helper() + if err := dao.Mdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "network"}, {Name: "symbol"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "contract_address", + "decimals", + "enabled", + "min_amount", + }), + }).Create(&token).Error; err != nil { + t.Fatalf("upsert token: %v", err) + } +} + func TestManualVerifyEvmHashAcceptsOptional0x(t *testing.T) { hash := strings.Repeat("a", 64) if !isEvmHash(hash) { @@ -89,6 +113,215 @@ func TestManualVerifyNormalizeTronTxIDAcceptsOptional0x(t *testing.T) { } } +func TestParseManualTonTxRefAcceptsCanonicalLTAndHashOnly(t *testing.T) { + receiveRaw := "0:ba295e33b3c4c9b5265aa4ead1166a92931ce9abea120a8c5e91044a1257f89c" + hash := strings.Repeat("a", 64) + + ref, err := parseManualTonTxRef("ton:" + receiveRaw + ":123:" + strings.ToUpper(hash)) + if err != nil { + t.Fatalf("parse canonical TON tx ref: %v", err) + } + if ref.ReceiveRaw != receiveRaw || ref.LT != 123 || ref.HashHex != hash || !ref.HasLT { + t.Fatalf("canonical ref = %#v", ref) + } + + ref, err = parseManualTonTxRef("456:0X" + strings.ToUpper(hash)) + if err != nil { + t.Fatalf("parse lt:hash TON tx ref: %v", err) + } + if ref.ReceiveRaw != "" || ref.LT != 456 || ref.HashHex != hash || !ref.HasLT { + t.Fatalf("lt:hash ref = %#v", ref) + } + + ref, err = parseManualTonTxRef("0X" + strings.ToUpper(hash)) + if err != nil { + t.Fatalf("parse hash-only TON tx ref: %v", err) + } + if ref.ReceiveRaw != "" || ref.LT != 0 || ref.HashHex != hash || ref.HasLT { + t.Fatalf("hash-only ref = %#v", ref) + } +} + +type fakeManualTonAPI struct { + ton.APIClientWrapped + master *ton.BlockIDExt + account *tlb.Account + txs []*tlb.Transaction + getAccountCalls int + listCalls []manualTonListCall +} + +type manualTonListCall struct { + limit uint32 + lt uint64 + hashHex string +} + +func (f *fakeManualTonAPI) CurrentMasterchainInfo(context.Context) (*ton.BlockIDExt, error) { + return f.master, nil +} + +func (f *fakeManualTonAPI) WaitForBlock(uint32) ton.APIClientWrapped { + return f +} + +func (f *fakeManualTonAPI) GetAccount(context.Context, *ton.BlockIDExt, *address.Address) (*tlb.Account, error) { + f.getAccountCalls++ + if f.account == nil { + return &tlb.Account{}, nil + } + return f.account, nil +} + +func (f *fakeManualTonAPI) ListTransactions(_ context.Context, _ *address.Address, limit uint32, lt uint64, txHash []byte) ([]*tlb.Transaction, error) { + f.listCalls = append(f.listCalls, manualTonListCall{ + limit: limit, + lt: lt, + hashHex: hex.EncodeToString(txHash), + }) + return f.txs, nil +} + +func TestValidateManualTonPaymentWithAPINativeTON(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := dao.Mdb.Model(&mdb.ChainToken{}). + Where("network = ? AND symbol = ?", mdb.NetworkTon, "USDT"). + Update("enabled", false).Error; err != nil { + t.Fatalf("disable TON USDT token: %v", err) + } + + receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x66}, 32)).Bounce(false).Testnet(false) + tx := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil) + order := &mdb.Orders{ + BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))}, + Network: mdb.NetworkTon, + Token: "TON", + ActualAmount: 1.23, + ReceiveAddress: receive.Bounce(false).String(), + } + api := &fakeManualTonAPI{ + master: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: -0x8000000000000000, SeqNo: 9}, + txs: []*tlb.Transaction{tx}, + } + ref := manualTonTxRef{LT: tx.LT, HashHex: hex.EncodeToString(tx.Hash), HasLT: true} + + got, err := validateManualTonPaymentWithAPI(context.Background(), api, order, receive, ref) + if err != nil { + t.Fatalf("validateManualTonPaymentWithAPI(): %v", err) + } + if api.getAccountCalls != 0 { + t.Fatalf("exact TON ref fetched account state %d times, want 0", api.getAccountCalls) + } + if len(api.listCalls) != 1 { + t.Fatalf("ListTransactions calls = %#v, want one exact lookup", api.listCalls) + } + if call := api.listCalls[0]; call.limit != 1 || call.lt != tx.LT || call.hashHex != hex.EncodeToString(tx.Hash) { + t.Fatalf("ListTransactions exact call = %#v, want limit=1 lt/hash from ref", call) + } + want := TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash)) + if got != want { + t.Fatalf("canonical id = %q, want %q", got, want) + } +} + +func TestValidateManualTonPaymentCandidatesUSDTJetton(t *testing.T) { + receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x68}, 32)).Bounce(false).Testnet(false) + jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x69}, 32)).Bounce(false).Testnet(false) + body, err := tlb.ToCell(jetton.TransferNotification{ + QueryID: 7, + Amount: tlb.MustFromNano(big.NewInt(1_000_000), 6), + Sender: sender, + ForwardPayload: cell.BeginCell().EndCell(), + }) + if err != nil { + t.Fatalf("build jetton notification body: %v", err) + } + tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body) + order := &mdb.Orders{ + BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))}, + Network: mdb.NetworkTon, + Token: "USDT", + ActualAmount: 1, + ReceiveAddress: receive.Bounce(false).String(), + } + state := &manualTonState{ + jettonWallets: map[string]mdb.ChainToken{ + addressutil.TonRawAddressObjectKey(jettonWallet): { + BaseModel: mdb.BaseModel{ID: 2}, + Network: mdb.NetworkTon, + Symbol: "USDT", + Decimals: 6, + Enabled: true, + }, + }, + } + + got, err := validateManualTonPaymentCandidates(order, receive, manualTonTxRef{ + LT: tx.LT, + HashHex: hex.EncodeToString(tx.Hash), + HasLT: true, + }, []*tlb.Transaction{tx}, state) + if err != nil { + t.Fatalf("validateManualTonPaymentCandidates(): %v", err) + } + want := TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash)) + if got != want { + t.Fatalf("canonical id = %q, want %q", got, want) + } +} + +func TestValidateManualTonPaymentWithAPIRejectsAmbiguousHashOnly(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := dao.Mdb.Model(&mdb.ChainToken{}). + Where("network = ? AND symbol = ?", mdb.NetworkTon, "USDT"). + Update("enabled", false).Error; err != nil { + t.Fatalf("disable TON USDT token: %v", err) + } + + receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x67}, 32)).Bounce(false).Testnet(false) + tx1 := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil) + tx2 := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil) + tx2.LT = tx1.LT + 1 + order := &mdb.Orders{ + BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))}, + Network: mdb.NetworkTon, + Token: "TON", + ActualAmount: 1.23, + ReceiveAddress: receive.Bounce(false).String(), + } + api := &fakeManualTonAPI{ + master: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: -0x8000000000000000, SeqNo: 9}, + account: &tlb.Account{ + LastTxLT: tx2.LT, + LastTxHash: tx2.Hash, + }, + txs: []*tlb.Transaction{tx1, tx2}, + } + + _, err := validateManualTonPaymentWithAPI(context.Background(), api, order, receive, manualTonTxRef{ + HashHex: hex.EncodeToString(tx1.Hash), + }) + if err == nil || !strings.Contains(err.Error(), "matched multiple") { + t.Fatalf("validate ambiguous hash-only error = %v, want multiple-match error", err) + } + if api.getAccountCalls != 1 { + t.Fatalf("hash-only TON ref fetched account state %d times, want 1", api.getAccountCalls) + } + if len(api.listCalls) != 1 { + t.Fatalf("ListTransactions calls = %#v, want one recent lookup", api.listCalls) + } + if call := api.listCalls[0]; call.limit != 100 || call.lt != tx2.LT || call.hashHex != hex.EncodeToString(tx2.Hash) { + t.Fatalf("ListTransactions recent call = %#v, want account last tx cursor", call) + } +} + func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() @@ -369,15 +602,13 @@ func TestManualVerifyTronTRC20UsesTransferEvent(t *testing.T) { if err != nil { t.Fatalf("recipient address: %v", err) } - if err = dao.Mdb.Create(&mdb.ChainToken{ + upsertTestChainToken(t, mdb.ChainToken{ Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: contractAddress, Decimals: 6, Enabled: true, - }).Error; err != nil { - t.Fatalf("create token: %v", err) - } + }) rawAmount := big.NewInt(1230000) tx := manualTronTransactionFromCallData(t, contractHex, recipientHex, rawAmount) @@ -491,15 +722,13 @@ func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) { }).Error; err != nil { t.Fatalf("create tron rpc node: %v", err) } - if err = dao.Mdb.Create(&mdb.ChainToken{ + upsertTestChainToken(t, mdb.ChainToken{ Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: contractAddress, Decimals: 6, Enabled: true, - }).Error; err != nil { - t.Fatalf("create token: %v", err) - } + }) order := &mdb.Orders{ TradeId: "manual-tron-http-flow", OrderId: "manual-tron-http-flow", diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index 33c83a4..df7b6fc 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -14,6 +14,7 @@ import ( "github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/request" "github.com/GMWalletApp/epusdt/model/response" + addressutil "github.com/GMWalletApp/epusdt/util/address" "github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/log" "github.com/GMWalletApp/epusdt/util/math" @@ -48,11 +49,27 @@ func normalizeOrderAddressByNetwork(network, address string) string { switch network { case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: return strings.ToLower(address) + case mdb.NetworkTon: + if normalized, err := addressutil.NormalizeTonAddress(address); err == nil { + return normalized + } + return address default: return address } } +func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) { + tokenRow, err := data.GetEnabledChainTokenBySymbol(network, token) + if err != nil { + return nil, err + } + if tokenRow == nil || tokenRow.ID == 0 { + return nil, constant.SupportedAssetNotFound + } + return tokenRow, nil +} + // CreateTransaction creates a new payment order. func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) { token := strings.ToUpper(strings.TrimSpace(req.Token)) @@ -68,6 +85,21 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey amountPrecision := data.GetAmountPrecision() payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision) + exist, err := data.GetOrderInfoByOrderId(req.OrderId) + if err != nil { + return nil, err + } + if exist.ID > 0 { + return nil, constant.OrderAlreadyExists + } + + if !data.IsChainEnabled(network) { + return nil, constant.ChainNotEnabled + } + if _, err = ensureEnabledOrderAsset(network, token); err != nil { + return nil, err + } + rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency)) if rate <= 0 { return nil, constant.RateAmountErr @@ -82,17 +114,6 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey return nil, constant.PayAmountErr } - exist, err := data.GetOrderInfoByOrderId(req.OrderId) - if err != nil { - return nil, err - } - if exist.ID > 0 { - return nil, constant.OrderAlreadyExists - } - - if !data.IsChainEnabled(network) { - return nil, constant.ChainNotEnabled - } walletAddress, err := data.GetAvailableWalletAddressByNetwork(network) if err != nil { return nil, err @@ -485,7 +506,16 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter return nil, constant.SubOrderLimitExceeded } - // 5. Calculate amount for the new network + // 5. Validate the target chain and asset before any rate lookup so + // disabled/unknown tokens return the stable unsupported-asset error. + if !data.IsChainEnabled(network) { + return nil, constant.ChainNotEnabled + } + if _, err = ensureEnabledOrderAsset(network, token); err != nil { + return nil, err + } + + // 6. Calculate amount for the new network rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency)) if rate <= 0 { return nil, constant.RateAmountErr @@ -496,10 +526,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter return nil, constant.PayAmountErr } - // 6. Find and lock wallet - if !data.IsChainEnabled(network) { - return nil, constant.ChainNotEnabled - } + // 7. Find and lock wallet walletAddress, err := data.GetAvailableWalletAddressByNetwork(network) if err != nil { return nil, err @@ -518,7 +545,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter return nil, constant.NotAvailableAmountErr } - // 7. Create sub-order + // 8. Create sub-order tx := dao.Mdb.Begin() subOrder := &mdb.Orders{ TradeId: subTradeID, diff --git a/src/model/service/order_service_test.go b/src/model/service/order_service_test.go index 8c27096..7eb6a13 100644 --- a/src/model/service/order_service_test.go +++ b/src/model/service/order_service_test.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "fmt" "io" + "math/big" "net/http" "strings" "sync" @@ -18,6 +19,7 @@ import ( "github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/http_client" "github.com/go-resty/resty/v2" + "github.com/xssnick/tonutils-go/address" ) func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest { @@ -281,6 +283,424 @@ func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) { } } +func TestCreateTransactionCreatesTonOrderAndRawLock(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.5}}`, "json"); err != nil { + t.Fatalf("set forced rate: %v", err) + } + addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + wallet, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()) + if err != nil { + t.Fatalf("add ton wallet: %v", err) + } + + req := newCreateTransactionRequest("order_ton_native_1", 10) + req.Network = mdb.NetworkTon + req.Token = "TON" + + resp, err := CreateTransaction(req, nil) + if err != nil { + t.Fatalf("create TON transaction: %v", err) + } + if resp.ReceiveAddress != wallet.Address { + t.Fatalf("receive address = %q, want %q", resp.ReceiveAddress, wallet.Address) + } + if resp.Token != "TON" { + t.Fatalf("token = %q, want TON", resp.Token) + } + if got := fmt.Sprintf("%.2f", resp.ActualAmount); got != "5.00" { + t.Fatalf("actual amount = %s, want 5.00", got) + } + + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", resp.ActualAmount) + if err != nil { + t.Fatalf("lookup ton runtime lock: %v", err) + } + if tradeID != resp.TradeId { + t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId) + } +} + +func TestCreateTransactionCreatesTonOrderWithConfiguredPrecision(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil { + t.Fatalf("set amount precision: %v", err) + } + if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.12345}}`, "json"); err != nil { + t.Fatalf("set forced rate: %v", err) + } + addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil { + t.Fatalf("add ton wallet: %v", err) + } + + req := newCreateTransactionRequest("order_ton_precision_1", 10) + req.Network = mdb.NetworkTon + req.Token = "TON" + resp, err := CreateTransaction(req, nil) + if err != nil { + t.Fatalf("create TON transaction: %v", err) + } + if got := fmt.Sprintf("%.4f", resp.ActualAmount); got != "1.2345" { + t.Fatalf("actual amount = %s, want 1.2345", got) + } + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.Bounce(true).String(), "TON", 1.2345) + if err != nil { + t.Fatalf("lookup ton runtime lock: %v", err) + } + if tradeID != resp.TradeId { + t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId) + } +} + +func TestCreateTransactionRejectsUnsupportedTonToken(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + req := newCreateTransactionRequest("order_ton_gram_1", 10) + req.Network = mdb.NetworkTon + req.Token = "GRAM" + + _, err := CreateTransaction(req, nil) + if err != constant.SupportedAssetNotFound { + t.Fatalf("create unsupported TON token error = %v, want %v", err, constant.SupportedAssetNotFound) + } +} + +func TestCreateTransactionRejectsDisabledTonTokenBeforeRate(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := dao.Mdb.Model(&mdb.ChainToken{}). + Where("network = ? AND symbol = ?", mdb.NetworkTon, "TON"). + Update("enabled", false).Error; err != nil { + t.Fatalf("disable TON token: %v", err) + } + req := newCreateTransactionRequest("order_ton_disabled_1", 10) + req.Network = mdb.NetworkTon + req.Token = "TON" + + _, err := CreateTransaction(req, nil) + if err != constant.SupportedAssetNotFound { + t.Fatalf("create disabled TON token error = %v, want %v", err, constant.SupportedAssetNotFound) + } +} + +func TestCreateTransactionSupportedTonTokenWithoutRateReturnsRateError(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0}}`, "json"); err != nil { + t.Fatalf("set forced rate: %v", err) + } + if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil { + t.Fatalf("clear rate api url: %v", err) + } + req := newCreateTransactionRequest("order_ton_missing_rate_1", 10) + req.Network = mdb.NetworkTon + req.Token = "TON" + + _, err := CreateTransaction(req, nil) + if err != constant.RateAmountErr { + t.Fatalf("create TON without rate error = %v, want %v", err, constant.RateAmountErr) + } +} + +func TestSwitchNetworkCreatesTonSubOrderAndRawLock(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1,"ton":0.5}}`, "json"); err != nil { + t.Fatalf("set forced rate: %v", err) + } + if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil { + t.Fatalf("add tron wallet: %v", err) + } + addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + wallet, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()) + if err != nil { + t.Fatalf("add ton wallet: %v", err) + } + + parentReq := newCreateTransactionRequest("order_switch_ton_1", 10) + parentReq.Network = mdb.NetworkTron + parentResp, err := CreateTransaction(parentReq, nil) + if err != nil { + t.Fatalf("create parent transaction: %v", err) + } + + subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{ + TradeId: parentResp.TradeId, + Token: "TON", + Network: mdb.NetworkTon, + }) + if err != nil { + t.Fatalf("switch to TON: %v", err) + } + if subResp.Network != mdb.NetworkTon || subResp.Token != "TON" { + t.Fatalf("sub order network/token = %s/%s, want ton/TON", subResp.Network, subResp.Token) + } + if subResp.ReceiveAddress != wallet.Address { + t.Fatalf("sub order receive address = %q, want %q", subResp.ReceiveAddress, wallet.Address) + } + if got := fmt.Sprintf("%.2f", subResp.ActualAmount); got != "5.00" { + t.Fatalf("sub order actual amount = %s, want 5.00", got) + } + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", subResp.ActualAmount) + if err != nil { + t.Fatalf("lookup TON sub-order lock: %v", err) + } + if tradeID != subResp.TradeId { + t.Fatalf("TON sub-order lock trade_id = %q, want %q", tradeID, subResp.TradeId) + } +} + +func TestSwitchNetworkRejectsUnsupportedTonToken(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil { + t.Fatalf("set forced rate: %v", err) + } + if _, err := data.AddWalletAddress("TTestTronAddress002"); err != nil { + t.Fatalf("add tron wallet: %v", err) + } + parentResp, err := CreateTransaction(newCreateTransactionRequest("order_switch_gram_1", 10), nil) + if err != nil { + t.Fatalf("create parent transaction: %v", err) + } + + _, err = SwitchNetwork(&request.SwitchNetworkRequest{ + TradeId: parentResp.TradeId, + Token: "GRAM", + Network: mdb.NetworkTon, + }) + if err != constant.SupportedAssetNotFound { + t.Fatalf("switch unsupported TON token error = %v, want %v", err, constant.SupportedAssetNotFound) + } +} + +func TestTryProcessTonTransferMarksOrderPaidAndReleasesRawLock(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.5}}`, "json"); err != nil { + t.Fatalf("set forced rate: %v", err) + } + addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil { + t.Fatalf("add ton wallet: %v", err) + } + req := newCreateTransactionRequest("order_ton_process_1", 10) + req.Network = mdb.NetworkTon + req.Token = "TON" + resp, err := CreateTransaction(req, nil) + if err != nil { + t.Fatalf("create TON transaction: %v", err) + } + + transfer := &TonObservedTransfer{ + ReceiveAddress: resp.ReceiveAddress, + Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}, + RawAmount: big.NewInt(5_000_000_000), + Amount: resp.ActualAmount, + BlockTimeMs: time.Now().Add(time.Second).UnixMilli(), + LT: 100, + TxHashHex: strings.Repeat("1", 64), + BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 100, strings.Repeat("1", 64)), + } + TryProcessTonTransfer(transfer) + + order, err := data.GetOrderInfoByTradeId(resp.TradeId) + if err != nil { + t.Fatalf("reload order: %v", err) + } + if order.Status != mdb.StatusPaySuccess { + t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess) + } + if order.BlockTransactionId != transfer.BlockID { + t.Fatalf("block transaction id = %q, want %q", order.BlockTransactionId, transfer.BlockID) + } + lock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", resp.ActualAmount) + if err != nil { + t.Fatalf("lookup ton lock: %v", err) + } + if lock != "" { + t.Fatalf("TON lock still exists after payment: %s", lock) + } +} + +func TestTryProcessTonJettonTransferMarksUSDTOrderPaid(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil { + t.Fatalf("set forced rate: %v", err) + } + addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil { + t.Fatalf("add ton wallet: %v", err) + } + req := newCreateTransactionRequest("order_ton_usdt_process_1", 10) + req.Network = mdb.NetworkTon + req.Token = "USDT" + resp, err := CreateTransaction(req, nil) + if err != nil { + t.Fatalf("create TON USDT transaction: %v", err) + } + + transfer := &TonObservedTransfer{ + ReceiveAddress: resp.ReceiveAddress, + Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", Decimals: 6, Enabled: true}, + RawAmount: big.NewInt(1_000_000), + Amount: resp.ActualAmount, + BlockTimeMs: time.Now().Add(time.Second).UnixMilli(), + LT: 110, + TxHashHex: strings.Repeat("4", 64), + BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 110, strings.Repeat("4", 64)), + } + TryProcessTonTransfer(transfer) + + order, err := data.GetOrderInfoByTradeId(resp.TradeId) + if err != nil { + t.Fatalf("reload order: %v", err) + } + if order.Status != mdb.StatusPaySuccess { + t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess) + } + if order.BlockTransactionId != transfer.BlockID { + t.Fatalf("block transaction id = %q, want %q", order.BlockTransactionId, transfer.BlockID) + } + lock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "USDT", resp.ActualAmount) + if err != nil { + t.Fatalf("lookup TON USDT lock: %v", err) + } + if lock != "" { + t.Fatalf("TON USDT lock still exists after payment: %s", lock) + } +} + +func TestTryProcessTonTransferSkipsTransfersBeforeOrderCreation(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.5}}`, "json"); err != nil { + t.Fatalf("set forced rate: %v", err) + } + addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil { + t.Fatalf("add ton wallet: %v", err) + } + req := newCreateTransactionRequest("order_ton_old_transfer_1", 10) + req.Network = mdb.NetworkTon + req.Token = "TON" + resp, err := CreateTransaction(req, nil) + if err != nil { + t.Fatalf("create TON transaction: %v", err) + } + order, err := data.GetOrderInfoByTradeId(resp.TradeId) + if err != nil { + t.Fatalf("load order: %v", err) + } + + TryProcessTonTransfer(&TonObservedTransfer{ + ReceiveAddress: resp.ReceiveAddress, + Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}, + RawAmount: big.NewInt(5_000_000_000), + Amount: resp.ActualAmount, + BlockTimeMs: order.CreatedAt.TimestampMilli() - 1, + LT: 101, + TxHashHex: strings.Repeat("2", 64), + BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 101, strings.Repeat("2", 64)), + }) + + order, err = data.GetOrderInfoByTradeId(resp.TradeId) + if err != nil { + t.Fatalf("reload order: %v", err) + } + if order.Status != mdb.StatusWaitPay { + t.Fatalf("order status = %d, want wait-pay", order.Status) + } + if order.BlockTransactionId != "" { + t.Fatalf("old transfer set block transaction id = %q", order.BlockTransactionId) + } +} + +func TestTryProcessTonSubOrderPaysParentAndReleasesLocks(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1,"ton":0.5}}`, "json"); err != nil { + t.Fatalf("set forced rate: %v", err) + } + if _, err := data.AddWalletAddress("TTestTronAddressForTonSub"); err != nil { + t.Fatalf("add tron wallet: %v", err) + } + addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil { + t.Fatalf("add ton wallet: %v", err) + } + + parentReq := newCreateTransactionRequest("order_ton_sub_parent_1", 10) + parentReq.Network = mdb.NetworkTron + parentResp, err := CreateTransaction(parentReq, nil) + if err != nil { + t.Fatalf("create parent order: %v", err) + } + subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{ + TradeId: parentResp.TradeId, + Token: "TON", + Network: mdb.NetworkTon, + }) + if err != nil { + t.Fatalf("switch to TON: %v", err) + } + + TryProcessTonTransfer(&TonObservedTransfer{ + ReceiveAddress: subResp.ReceiveAddress, + Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}, + RawAmount: big.NewInt(5_000_000_000), + Amount: subResp.ActualAmount, + BlockTimeMs: time.Now().Add(time.Second).UnixMilli(), + LT: 102, + TxHashHex: strings.Repeat("3", 64), + BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 102, strings.Repeat("3", 64)), + }) + + parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId) + if err != nil { + t.Fatalf("reload parent order: %v", err) + } + if parent.Status != mdb.StatusPaySuccess { + t.Fatalf("parent status = %d, want paid", parent.Status) + } + sub, err := data.GetOrderInfoByTradeId(subResp.TradeId) + if err != nil { + t.Fatalf("reload sub order: %v", err) + } + if sub.Status != mdb.StatusPaySuccess { + t.Fatalf("sub status = %d, want paid", sub.Status) + } + parentLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, parentResp.ReceiveAddress, parentResp.Token, parentResp.ActualAmount) + if err != nil { + t.Fatalf("lookup parent lock: %v", err) + } + if parentLock != "" { + t.Fatalf("parent lock still exists: %s", parentLock) + } + subLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", subResp.ActualAmount) + if err != nil { + t.Fatalf("lookup sub lock: %v", err) + } + if subLock != "" { + t.Fatalf("TON sub-order lock still exists: %s", subLock) + } +} + func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() diff --git a/src/model/service/task_service_test.go b/src/model/service/task_service_test.go index ab5e4de..cefb084 100644 --- a/src/model/service/task_service_test.go +++ b/src/model/service/task_service_test.go @@ -12,8 +12,24 @@ import ( "github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/notify" "github.com/ethereum/go-ethereum/common" + "gorm.io/gorm/clause" ) +func upsertTaskServiceTestChainToken(t *testing.T, token mdb.ChainToken) { + t.Helper() + if err := dao.Mdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "network"}, {Name: "symbol"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "contract_address", + "decimals", + "enabled", + "min_amount", + }), + }).Create(&token).Error; err != nil { + t.Fatalf("seed chain token: %v", err) + } +} + func TestSendPaymentNotificationUsesLatestOrderUpdatedAt(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() @@ -158,15 +174,13 @@ func TestTryProcessEvmERC20TransferSkipsTransfersBeforeOrderCreation(t *testing. contract := common.HexToAddress("0x3333333333333333333333333333333333333333") receiveAddress := common.HexToAddress("0x4444444444444444444444444444444444444444") - if err := dao.Mdb.Create(&mdb.ChainToken{ + upsertTaskServiceTestChainToken(t, mdb.ChainToken{ Network: mdb.NetworkEthereum, Symbol: tokenSym, ContractAddress: contract.Hex(), Decimals: 6, Enabled: true, - }).Error; err != nil { - t.Fatalf("seed chain token: %v", err) - } + }) order := &mdb.Orders{ TradeId: tradeID, diff --git a/src/model/service/ton_lite_client.go b/src/model/service/ton_lite_client.go new file mode 100644 index 0000000..06f4fa3 --- /dev/null +++ b/src/model/service/ton_lite_client.go @@ -0,0 +1,37 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/xssnick/tonutils-go/liteclient" + "github.com/xssnick/tonutils-go/ton" +) + +// ConnectTonLiteAPI builds a tonutils-go API client from a liteserver global +// config URL. Scanner and manual verification use this same connection setup. +func ConnectTonLiteAPI(ctx context.Context, configURL string, requestTimeout time.Duration, reconnectLimit int) (ton.APIClientWrapped, func(), error) { + configURL = strings.TrimSpace(configURL) + if configURL == "" { + return nil, func() {}, fmt.Errorf("ton lite config url is empty") + } + if reconnectLimit <= 0 { + reconnectLimit = 3 + } + cfg, err := liteclient.GetConfigFromUrl(ctx, configURL) + if err != nil { + return nil, func() {}, err + } + pool := liteclient.NewConnectionPool() + pool.SetOnDisconnect(pool.DefaultReconnect(3*time.Second, reconnectLimit)) + if err = pool.AddConnectionsFromConfig(ctx, cfg); err != nil { + pool.Stop() + return nil, func() {}, err + } + base := ton.NewAPIClient(pool, ton.ProofCheckPolicyFast) + base.SetTrustedBlockFromConfig(cfg) + api := base.WithRetry(2).WithTimeout(requestTimeout).WithLSInfoInErrors() + return api, pool.Stop, nil +} diff --git a/src/model/service/ton_task.go b/src/model/service/ton_task.go new file mode 100644 index 0000000..38a0f1e --- /dev/null +++ b/src/model/service/ton_task.go @@ -0,0 +1,262 @@ +package service + +import ( + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + + "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" + "github.com/GMWalletApp/epusdt/util/math" + "github.com/shopspring/decimal" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" + "github.com/xssnick/tonutils-go/ton/jetton" + "github.com/xssnick/tonutils-go/tvm/cell" +) + +const ( + TonNativeSymbol = "TON" + TonTransferNotificationOpcode = 0x7362d09c +) + +type TonObservedTransfer struct { + ReceiveAddress string + Token mdb.ChainToken + RawAmount *big.Int + Amount float64 + BlockTimeMs int64 + LT uint64 + TxHashHex string + BlockID string + SenderAddress string + JettonWallet string +} + +func TonCanonicalBlockTransactionID(receiveRaw string, lt uint64, txHashHex string) string { + return fmt.Sprintf("ton:%s:%d:%s", strings.ToLower(strings.TrimSpace(receiveRaw)), lt, strings.ToLower(strings.TrimSpace(txHashHex))) +} + +func ParseTonInboundTransfer(tx *tlb.Transaction, receive *address.Address, nativeToken *mdb.ChainToken, jettonWalletTokens map[string]mdb.ChainToken) (*TonObservedTransfer, error) { + if tx == nil || receive == nil || tx.IO.In == nil || tx.IO.In.MsgType != tlb.MsgTypeInternal { + return nil, nil + } + if isTonTransactionBounced(tx) { + return nil, nil + } + in := tx.IO.In.AsInternal() + if in == nil || in.DstAddr == nil || !in.DstAddr.Equals(receive) { + return nil, nil + } + + receiveRaw := addressutil.TonRawAddressObjectKey(receive) + txHashHex := hex.EncodeToString(tx.Hash) + blockID := TonCanonicalBlockTransactionID(receiveRaw, tx.LT, txHashHex) + blockTimeMs := int64(tx.Now) * 1000 + if in.SrcAddr != nil { + srcRaw := addressutil.TonRawAddressObjectKey(in.SrcAddr) + if token, ok := jettonWalletTokens[srcRaw]; ok { + transfer, err := parseTonJettonTransferNotification(in) + if err != nil { + return nil, nil + } + amount := tonRawAmountToFloat(transfer.Amount.Nano(), token.Decimals) + if amount <= 0 { + return nil, nil + } + if token.MinAmount > 0 && amount < token.MinAmount { + return nil, nil + } + return &TonObservedTransfer{ + ReceiveAddress: addressutil.NormalizeTonAddressObject(receive), + Token: token, + RawAmount: transfer.Amount.Nano(), + Amount: amount, + BlockTimeMs: blockTimeMs, + LT: tx.LT, + TxHashHex: txHashHex, + BlockID: blockID, + SenderAddress: addressutil.NormalizeTonAddressObject(transfer.Sender), + JettonWallet: addressutil.NormalizeTonAddressObject(in.SrcAddr), + }, nil + } + } + + if tonPayloadIsTransferNotification(in.Body) { + return nil, nil + } + if nativeToken == nil || nativeToken.ID == 0 { + return nil, nil + } + rawNano := in.Amount.Nano() + if rawNano == nil || rawNano.Sign() <= 0 { + return nil, nil + } + amount := tonRawAmountToFloat(rawNano, nativeToken.Decimals) + if amount <= 0 { + return nil, nil + } + if nativeToken.MinAmount > 0 && amount < nativeToken.MinAmount { + return nil, nil + } + return &TonObservedTransfer{ + ReceiveAddress: addressutil.NormalizeTonAddressObject(receive), + Token: *nativeToken, + RawAmount: rawNano, + Amount: amount, + BlockTimeMs: blockTimeMs, + LT: tx.LT, + TxHashHex: txHashHex, + BlockID: blockID, + }, nil +} + +func TryProcessTonTransfer(transfer *TonObservedTransfer) { + if transfer == nil { + return + } + tokenSym := strings.ToUpper(strings.TrimSpace(transfer.Token.Symbol)) + defer func() { + if err := recover(); err != nil { + log.Sugar.Errorf("[TON-%s][%s] TryProcessTonTransfer panic: %v", tokenSym, transfer.ReceiveAddress, err) + } + }() + + receive := strings.TrimSpace(transfer.ReceiveAddress) + if tokenSym == "" || receive == "" || transfer.Amount <= 0 || transfer.BlockID == "" { + return + } + log.Sugar.Infof("[TON-%s][%s] observed transfer tx=%s lt=%d amount=%.6f", tokenSym, receive, transfer.BlockID, transfer.LT, transfer.Amount) + + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, receive, tokenSym, transfer.Amount) + if err != nil { + log.Sugar.Warnf("[TON-%s][%s] lock lookup: %v", tokenSym, receive, err) + return + } + if tradeID == "" { + log.Sugar.Infof("[TON-%s][%s] skip unmatched transfer tx=%s amount=%.6f", tokenSym, receive, transfer.BlockID, transfer.Amount) + return + } + + order, err := data.GetOrderInfoByTradeId(tradeID) + if err != nil { + log.Sugar.Warnf("[TON-%s][%s] load order: %v", tokenSym, receive, err) + return + } + if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTon { + log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s network=%q", tokenSym, receive, tradeID, order.Network) + return + } + if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym { + log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s token mismatch order=%s", tokenSym, receive, tradeID, order.Token) + return + } + if !tonOrderAddressMatches(order.ReceiveAddress, receive) { + log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s receive address mismatch order=%s", tokenSym, receive, tradeID, order.ReceiveAddress) + return + } + if transfer.BlockTimeMs > 0 && transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() { + log.Sugar.Warnf("[TON-%s][%s] skip tx %s because block time %d is before order create time %d", tokenSym, receive, transfer.BlockID, transfer.BlockTimeMs, order.CreatedAt.TimestampMilli()) + return + } + + req := &request.OrderProcessingRequest{ + ReceiveAddress: order.ReceiveAddress, + Token: tokenSym, + Network: mdb.NetworkTon, + TradeId: tradeID, + Amount: transfer.Amount, + BlockTransactionId: transfer.BlockID, + } + err = OrderProcessing(req) + if err != nil { + if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { + log.Sugar.Infof("[TON-%s][%s] skip resolved transfer trade_id=%s tx=%s err=%v", tokenSym, receive, tradeID, transfer.BlockID, err) + return + } + log.Sugar.Errorf("[TON-%s][%s] OrderProcessing trade_id=%s tx=%s: %v", tokenSym, receive, tradeID, transfer.BlockID, err) + return + } + + sendPaymentNotification(order) + log.Sugar.Infof("[TON-%s][%s] payment processed trade_id=%s tx=%s", tokenSym, receive, tradeID, transfer.BlockID) +} + +func tonOrderAddressMatches(a, b string) bool { + ar, errA := addressutil.TonRawAddressKey(a) + br, errB := addressutil.TonRawAddressKey(b) + return errA == nil && errB == nil && ar == br +} + +func tonRawAmountToFloat(rawAmount *big.Int, decimals int) float64 { + if rawAmount == nil || rawAmount.Sign() <= 0 { + return 0 + } + if decimals < 0 { + decimals = 0 + } + amount := decimal.NewFromBigInt(rawAmount, -int32(decimals)) + return math.MustParsePrecFloat64(amount.InexactFloat64(), data.MaxAmountPrecision) +} + +func parseTonJettonTransferNotification(in *tlb.InternalMessage) (*jetton.TransferNotification, error) { + if in == nil || in.Body == nil { + return nil, fmt.Errorf("missing jetton transfer notification body") + } + var transfer jetton.TransferNotification + if err := tlb.LoadFromCell(&transfer, in.Body.BeginParse()); err != nil { + return nil, err + } + return &transfer, nil +} + +func tonPayloadIsTransferNotification(body *cell.Cell) bool { + if body == nil { + return false + } + op, err := body.BeginParse().LoadUInt(32) + return err == nil && op == TonTransferNotificationOpcode +} + +func isTonTransactionBounced(tx *tlb.Transaction) bool { + if tx == nil { + return false + } + if dsc, ok := tx.Description.(tlb.TransactionDescriptionOrdinary); ok && dsc.BouncePhase != nil { + if _, ok = dsc.BouncePhase.Phase.(tlb.BouncePhaseOk); ok { + return true + } + } + return false +} + +func EnsureTonTransferMatchesOrder(order *mdb.Orders, transfer *TonObservedTransfer) error { + if order == nil || order.ID == 0 { + return fmt.Errorf("order not found") + } + if transfer == nil { + return fmt.Errorf("matching ton transfer to order address not found") + } + if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTon { + return fmt.Errorf("order network mismatch") + } + if !strings.EqualFold(order.Token, transfer.Token.Symbol) { + return fmt.Errorf("transaction token mismatch") + } + if !tonOrderAddressMatches(order.ReceiveAddress, transfer.ReceiveAddress) { + return fmt.Errorf("transaction recipient mismatch") + } + if transfer.BlockTimeMs <= 0 || transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() { + return fmt.Errorf("transaction predates the order") + } + if !amountMatchesRaw(order.ActualAmount, transfer.RawAmount, transfer.Token.Decimals) { + return fmt.Errorf("transaction amount mismatch") + } + return nil +} diff --git a/src/model/service/ton_task_test.go b/src/model/service/ton_task_test.go new file mode 100644 index 0000000..0344087 --- /dev/null +++ b/src/model/service/ton_task_test.go @@ -0,0 +1,173 @@ +package service + +import ( + "bytes" + "math/big" + "strings" + "testing" + "time" + + "github.com/GMWalletApp/epusdt/model/mdb" + addressutil "github.com/GMWalletApp/epusdt/util/address" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" + "github.com/xssnick/tonutils-go/ton/jetton" + "github.com/xssnick/tonutils-go/tvm/cell" +) + +func tonTestInboundTx(t *testing.T, src, dst *address.Address, amount tlb.Coins, body *cell.Cell) *tlb.Transaction { + t.Helper() + return &tlb.Transaction{ + LT: 123, + Now: uint32(time.Now().Unix()), + Hash: bytes.Repeat([]byte{0x11}, 32), + IO: struct { + In *tlb.Message `tlb:"maybe ^"` + Out *tlb.MessagesList `tlb:"maybe ^"` + }{ + In: &tlb.Message{ + MsgType: tlb.MsgTypeInternal, + Msg: &tlb.InternalMessage{ + SrcAddr: src, + DstAddr: dst, + Amount: amount, + Body: body, + }, + }, + }, + } +} + +func TestParseTonInboundTransferNativeTON(t *testing.T) { + receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x22}, 32)).Bounce(false).Testnet(false) + native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true} + + tx := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), cell.BeginCell().EndCell()) + transfer, err := ParseTonInboundTransfer(tx, receive, native, nil) + if err != nil { + t.Fatalf("ParseTonInboundTransfer(): %v", err) + } + if transfer == nil { + t.Fatal("expected native TON transfer") + } + if transfer.Token.Symbol != "TON" { + t.Fatalf("token = %q, want TON", transfer.Token.Symbol) + } + if transfer.Amount != 1.23 { + t.Fatalf("amount = %v, want 1.23", transfer.Amount) + } + if transfer.ReceiveAddress != addressutil.NormalizeTonAddressObject(receive) { + t.Fatalf("receive address = %q", transfer.ReceiveAddress) + } + if !strings.HasPrefix(transfer.BlockID, "ton:"+addressutil.TonRawAddressObjectKey(receive)+":123:") { + t.Fatalf("block id = %q, want canonical TON prefix", transfer.BlockID) + } +} + +func TestParseTonInboundTransferJettonNotification(t *testing.T) { + receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false) + jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x44}, 32)).Bounce(false).Testnet(false) + native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true} + usdt := mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", Decimals: 6, Enabled: true} + + body, err := tlb.ToCell(jetton.TransferNotification{ + QueryID: 7, + Amount: tlb.MustFromNano(big.NewInt(1230000), 6), + Sender: sender, + ForwardPayload: cell.BeginCell().EndCell(), + }) + if err != nil { + t.Fatalf("build jetton notification body: %v", err) + } + + tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body) + transfer, err := ParseTonInboundTransfer(tx, receive, native, map[string]mdb.ChainToken{ + addressutil.TonRawAddressObjectKey(jettonWallet): usdt, + }) + if err != nil { + t.Fatalf("ParseTonInboundTransfer(): %v", err) + } + if transfer == nil { + t.Fatal("expected USDT jetton transfer") + } + if transfer.Token.Symbol != "USDT" { + t.Fatalf("token = %q, want USDT", transfer.Token.Symbol) + } + if transfer.Amount != 1.23 { + t.Fatalf("amount = %v, want 1.23", transfer.Amount) + } + if transfer.SenderAddress != addressutil.NormalizeTonAddressObject(sender) { + t.Fatalf("sender address = %q", transfer.SenderAddress) + } + if transfer.JettonWallet != addressutil.NormalizeTonAddressObject(jettonWallet) { + t.Fatalf("jetton wallet = %q", transfer.JettonWallet) + } +} + +func TestParseTonInboundTransferJettonNotificationWithNoGasComputeSkip(t *testing.T) { + receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false) + jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x44}, 32)).Bounce(false).Testnet(false) + usdt := mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", Decimals: 6, Enabled: true} + + body, err := tlb.ToCell(jetton.TransferNotification{ + QueryID: 7, + Amount: tlb.MustFromNano(big.NewInt(150000), 6), + Sender: sender, + ForwardPayload: cell.BeginCell().EndCell(), + }) + if err != nil { + t.Fatalf("build jetton notification body: %v", err) + } + + tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body) + tx.Description = tlb.TransactionDescriptionOrdinary{ + ComputePhase: tlb.ComputePhase{Phase: tlb.ComputePhaseSkipped{ + Reason: tlb.ComputeSkipReason{Type: tlb.ComputeSkipReasonNoGas}, + }}, + Aborted: true, + } + transfer, err := ParseTonInboundTransfer(tx, receive, nil, map[string]mdb.ChainToken{ + addressutil.TonRawAddressObjectKey(jettonWallet): usdt, + }) + if err != nil { + t.Fatalf("ParseTonInboundTransfer(): %v", err) + } + if transfer == nil { + t.Fatal("expected no-gas jetton notification to be accepted") + } + if transfer.Token.Symbol != "USDT" { + t.Fatalf("token = %q, want USDT", transfer.Token.Symbol) + } + if transfer.Amount != 0.15 { + t.Fatalf("amount = %v, want 0.15", transfer.Amount) + } +} + +func TestParseTonInboundTransferDoesNotCountUntrustedJettonNotificationAsTON(t *testing.T) { + receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false) + untrustedJettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x55}, 32)).Bounce(false).Testnet(false) + native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true} + + body, err := tlb.ToCell(jetton.TransferNotification{ + QueryID: 7, + Amount: tlb.MustFromNano(big.NewInt(1230000), 6), + Sender: sender, + ForwardPayload: cell.BeginCell().EndCell(), + }) + if err != nil { + t.Fatalf("build jetton notification body: %v", err) + } + + tx := tonTestInboundTx(t, untrustedJettonWallet, receive, tlb.FromNanoTONU(1), body) + transfer, err := ParseTonInboundTransfer(tx, receive, native, nil) + if err != nil { + t.Fatalf("ParseTonInboundTransfer(): %v", err) + } + if transfer != nil { + t.Fatalf("untrusted jetton notification produced transfer: %#v", transfer) + } +} diff --git a/src/task/listen.go b/src/task/listen.go index 96d14e7..173b334 100644 --- a/src/task/listen.go +++ b/src/task/listen.go @@ -14,6 +14,7 @@ func Start() { go StartPolygonWebSocketListener() go StartPlasmaWebSocketListener() go StartTronBlockScannerListener() + go StartTonBlockScannerListener() c := cron.New() diff --git a/src/task/listen_ton.go b/src/task/listen_ton.go new file mode 100644 index 0000000..ad196d3 --- /dev/null +++ b/src/task/listen_ton.go @@ -0,0 +1,574 @@ +package task + +import ( + "context" + "encoding/hex" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/GMWalletApp/epusdt/model/data" + "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/model/service" + addressutil "github.com/GMWalletApp/epusdt/util/address" + "github.com/GMWalletApp/epusdt/util/log" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/ton" + "github.com/xssnick/tonutils-go/ton/jetton" +) + +const ( + tonMasterShard = int64(-0x8000000000000000) + tonScannerConnectTimeout = 20 * time.Second + tonScannerRequestTimeout = 15 * time.Second + tonScannerRetryDelay = 3 * time.Second + tonCatchupYield = 250 * time.Millisecond + tonMaxMasterBlocksTick = uint32(50) + tonBlockTxPageSize = uint32(256) +) + +type tonWatchState struct { + wallets map[string]*address.Address + nativeToken *mdb.ChainToken + jettonTokens []mdb.ChainToken +} + +func (s *tonWatchState) hasWalletInWorkchain(workchain int32) bool { + if s == nil { + return false + } + for _, wallet := range s.wallets { + if wallet != nil && wallet.Workchain() == workchain { + return true + } + } + return false +} + +type tonRuntimeCursor struct { + initialized bool + lastMasterSeqNo uint32 + shardSeqNo map[string]uint32 +} + +func (c *tonRuntimeCursor) ensureShardSeqNo() { + if c.shardSeqNo == nil { + c.shardSeqNo = make(map[string]uint32) + } +} + +type tonJettonWalletCache struct { + sync.Mutex + walletByOwnerToken map[string]string +} + +func newTonJettonWalletCache() *tonJettonWalletCache { + return &tonJettonWalletCache{walletByOwnerToken: make(map[string]string)} +} + +// StartTonBlockScannerListener runs a forward-only TON masterchain scanner. +func StartTonBlockScannerListener() { + cache := newTonJettonWalletCache() + cursor := &tonRuntimeCursor{} + for { + chain, interval := tonChainConfig() + if chain == nil || !chain.Enabled { + sleepOrDone(context.Background(), interval) + continue + } + node, ok := resolveTonLiteNode() + if !ok { + sleepOrDone(context.Background(), interval) + continue + } + log.Sugar.Infof("[TON] connecting using lite node %s", data.RpcNodeLogLabel(node)) + api, closeFn, err := connectTonLiteAPI(node) + if err != nil { + log.Sugar.Warnf("[TON] connect lite node %s failed: %v", data.RpcNodeLogLabel(node), err) + recordTonLiteNodeFailure(node, "connect") + sleepOrDone(context.Background(), tonScannerRetryDelay) + continue + } + log.Sugar.Infof("[TON] connected using lite node %s", data.RpcNodeLogLabel(node)) + err = runTonScanner(context.Background(), api, cache, cursor) + closeFn() + if err != nil { + log.Sugar.Warnf("[TON] scanner stopped: %v", err) + recordTonLiteNodeFailure(node, err.Error()) + sleepOrDone(context.Background(), tonScannerRetryDelay) + continue + } + } +} + +func runTonScanner(ctx context.Context, api ton.APIClientWrapped, cache *tonJettonWalletCache, cursor *tonRuntimeCursor) error { + if cursor == nil { + cursor = &tonRuntimeCursor{} + } + watchSignature := "" + for { + chain, interval := tonChainConfig() + if chain == nil || !chain.Enabled { + return nil + } + state, err := loadTonWatchState() + if err != nil { + return err + } + if len(state.wallets) == 0 || (state.nativeToken == nil && len(state.jettonTokens) == 0) { + log.Sugar.Debug("[TON] no enabled wallets or tokens, idling") + if !sleepOrDone(ctx, interval) { + return nil + } + continue + } + if sig := tonWatchStateSignature(state); sig != watchSignature { + watchSignature = sig + log.Sugar.Infof( + "[TON] watching wallets=%d native_ton=%t jetton_tokens=%d wallet_workchains=%s", + len(state.wallets), + state.nativeToken != nil, + len(state.jettonTokens), + tonWalletWorkchainSummary(state), + ) + } + + head, err := api.CurrentMasterchainInfo(ctx) + if err != nil { + data.RecordRpcFailure(mdb.NetworkTon) + return fmt.Errorf("current masterchain info: %w", err) + } + confirmedHeadSeqNo := tonConfirmedMasterSeqNo(head.SeqNo, chain.MinConfirmations) + data.RecordRpcSuccess(mdb.NetworkTon) + data.RecordRpcBlockHeight(mdb.NetworkTon, int64(head.SeqNo)) + + if !cursor.initialized { + if err = initializeTonRuntimeCursor(ctx, api, head, confirmedHeadSeqNo, cursor); err != nil { + data.RecordRpcFailure(mdb.NetworkTon) + return err + } + log.Sugar.Infof( + "[TON] current masterchain head=%d confirmed=%d root=%s file=%s", + head.SeqNo, + confirmedHeadSeqNo, + shortTonHash(head.RootHash), + shortTonHash(head.FileHash), + ) + log.Sugar.Infof("[TON] initialized runtime cursor at masterchain seqno=%d head=%d", cursor.lastMasterSeqNo, head.SeqNo) + if !sleepOrDone(ctx, interval) { + return nil + } + continue + } + if cursor.lastMasterSeqNo >= confirmedHeadSeqNo { + if !sleepOrDone(ctx, interval) { + return nil + } + continue + } + + target := cursor.lastMasterSeqNo + tonMaxMasterBlocksTick + if target > confirmedHeadSeqNo { + target = confirmedHeadSeqNo + } + log.Sugar.Infof("[TON] processing masterchain blocks from=%d to=%d head=%d confirmed=%d", cursor.lastMasterSeqNo+1, target, head.SeqNo, confirmedHeadSeqNo) + for seq := cursor.lastMasterSeqNo + 1; seq <= target; seq++ { + master, err := api.WaitForBlock(seq).LookupBlock(ctx, address.MasterchainID, tonMasterShard, seq) + if err != nil { + data.RecordRpcFailure(mdb.NetworkTon) + return fmt.Errorf("lookup masterchain seqno=%d: %w", seq, err) + } + if err = processTonMasterBlockWithCursor(ctx, api, master, state, cache, cursor); err != nil { + data.RecordRpcFailure(mdb.NetworkTon) + return fmt.Errorf("process masterchain seqno=%d: %w", seq, err) + } + cursor.lastMasterSeqNo = master.SeqNo + data.RecordRpcSuccess(mdb.NetworkTon) + data.RecordRpcBlockHeight(mdb.NetworkTon, int64(seq)) + } + if target < confirmedHeadSeqNo { + if !sleepOrDone(ctx, tonCatchupYield) { + return nil + } + } + } +} + +func tonConfirmedMasterSeqNo(headSeqNo uint32, minConfirmations int) uint32 { + if minConfirmations <= 1 { + return headSeqNo + } + conf := uint32(minConfirmations) + if headSeqNo < conf-1 { + return 0 + } + return headSeqNo - conf + 1 +} + +func initializeTonRuntimeCursor(ctx context.Context, api ton.APIClientWrapped, head *ton.BlockIDExt, confirmedHeadSeqNo uint32, cursor *tonRuntimeCursor) error { + if cursor == nil { + return nil + } + if head == nil { + return fmt.Errorf("current masterchain head is nil") + } + initMaster := head + if confirmedHeadSeqNo > 0 && confirmedHeadSeqNo != head.SeqNo { + var err error + initMaster, err = api.WaitForBlock(confirmedHeadSeqNo).LookupBlock(ctx, address.MasterchainID, tonMasterShard, confirmedHeadSeqNo) + if err != nil { + return fmt.Errorf("lookup initial confirmed masterchain seqno=%d: %w", confirmedHeadSeqNo, err) + } + } + if confirmedHeadSeqNo > 0 { + if err := initializeTonShardCursors(ctx, api, initMaster, cursor); err != nil { + return fmt.Errorf("initialize shard cursors masterchain seqno=%d: %w", initMaster.SeqNo, err) + } + } + cursor.initialized = true + cursor.lastMasterSeqNo = confirmedHeadSeqNo + return nil +} + +func processTonMasterBlock(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache) error { + return processTonMasterBlockWithCursor(ctx, api, master, state, cache, nil) +} + +func processTonMasterBlockWithCursor(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache, cursor *tonRuntimeCursor) error { + if state.hasWalletInWorkchain(address.MasterchainID) { + if err := processTonShardBlock(ctx, api, master, master, state, cache); err != nil { + return fmt.Errorf("process masterchain transactions: %w", err) + } + } + shards, err := api.WaitForBlock(master.SeqNo).GetBlockShardsInfo(ctx, master) + if err != nil { + return err + } + log.Sugar.Debugf( + "[TON] masterchain seqno=%d root=%s file=%s shard_blocks=%d workchains=%s", + master.SeqNo, + shortTonHash(master.RootHash), + shortTonHash(master.FileHash), + len(shards), + tonShardWorkchainSummary(shards), + ) + if cursor != nil { + return processTonShardBlocksWithCursor(ctx, api, master, shards, state, cache, cursor) + } + for _, shard := range shards { + if err = processTonShardBlock(ctx, api, master, shard, state, cache); err != nil { + return err + } + } + return nil +} + +func initializeTonShardCursors(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, cursor *tonRuntimeCursor) error { + if cursor == nil || master == nil { + return nil + } + shards, err := api.WaitForBlock(master.SeqNo).GetBlockShardsInfo(ctx, master) + if err != nil { + return err + } + cursor.ensureShardSeqNo() + for _, shard := range shards { + if shard == nil { + continue + } + cursor.shardSeqNo[tonShardCursorKey(shard)] = shard.SeqNo + } + log.Sugar.Infof( + "[TON] initialized shard cursors masterchain seqno=%d shard_blocks=%d workchains=%s", + master.SeqNo, + len(shards), + tonShardWorkchainSummary(shards), + ) + return nil +} + +func processTonShardBlocksWithCursor(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, shards []*ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache, cursor *tonRuntimeCursor) error { + if cursor == nil { + return nil + } + cursor.ensureShardSeqNo() + for _, shard := range shards { + if shard == nil { + continue + } + key := tonShardCursorKey(shard) + lastSeqNo, ok := cursor.shardSeqNo[key] + if !state.hasWalletInWorkchain(shard.Workchain) { + cursor.shardSeqNo[key] = shard.SeqNo + continue + } + + start := shard.SeqNo + if ok { + if shard.SeqNo <= lastSeqNo { + continue + } + start = lastSeqNo + 1 + } + if shard.SeqNo > start { + log.Sugar.Infof( + "[TON] processing shard blocks master_seqno=%d workchain=%d shard=%x from=%d to=%d", + master.SeqNo, + shard.Workchain, + uint64(shard.Shard), + start, + shard.SeqNo, + ) + } + for seq := start; seq <= shard.SeqNo; seq++ { + shardBlock := shard + if seq != shard.SeqNo { + var err error + shardBlock, err = api.WaitForBlock(master.SeqNo).LookupBlock(ctx, shard.Workchain, shard.Shard, seq) + if err != nil { + return fmt.Errorf("lookup shard block workchain=%d shard=%x seqno=%d: %w", shard.Workchain, uint64(shard.Shard), seq, err) + } + } + if err := processTonShardBlock(ctx, api, master, shardBlock, state, cache); err != nil { + return err + } + cursor.shardSeqNo[key] = seq + } + } + return nil +} + +func processTonShardBlock(ctx context.Context, api ton.APIClientWrapped, master, shard *ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache) error { + log.Sugar.Debugf("[TON] scanning shard master_seqno=%d workchain=%d shard=%d shard_seqno=%d", master.SeqNo, shard.Workchain, shard.Shard, shard.SeqNo) + var after *ton.TransactionID3 + for { + shorts, incomplete, err := api.WaitForBlock(master.SeqNo).GetBlockTransactionsV2(ctx, shard, tonBlockTxPageSize, after) + if err != nil { + return err + } + for i := range shorts { + account := addressutil.TonAddressFromShortAccount(shard.Workchain, shorts[i].Account) + if account == nil { + continue + } + receive, ok := state.wallets[addressutil.TonRawAddressObjectKey(account)] + if !ok { + continue + } + log.Sugar.Debugf("[TON] watched account tx candidate master_seqno=%d workchain=%d shard=%d lt=%d account=%s", master.SeqNo, shard.Workchain, shard.Shard, shorts[i].LT, addressutil.TonRawAddressObjectKey(account)) + tx, err := api.WaitForBlock(master.SeqNo).GetTransaction(ctx, shard, account, shorts[i].LT) + if err != nil { + return err + } + if tx == nil { + return fmt.Errorf("missing full transaction workchain=%d shard=%d seqno=%d lt=%d", shard.Workchain, shard.Shard, shard.SeqNo, shorts[i].LT) + } + jettonWallets, err := cache.jettonWalletTokens(ctx, api, master, receive, state.jettonTokens) + if err != nil { + return err + } + transfer, err := service.ParseTonInboundTransfer(tx, receive, state.nativeToken, jettonWallets) + if err != nil { + log.Sugar.Warnf("[TON] parse inbound tx lt=%d hash=%s: %v", tx.LT, hex.EncodeToString(tx.Hash), err) + continue + } + if transfer == nil { + log.Sugar.Debugf( + "[TON] unsupported watched tx master_seqno=%d workchain=%d shard=%d lt=%d hash=%s account=%s", + master.SeqNo, + shard.Workchain, + shard.Shard, + tx.LT, + hex.EncodeToString(tx.Hash), + addressutil.TonRawAddressObjectKey(account), + ) + continue + } + service.TryProcessTonTransfer(transfer) + } + if !incomplete { + return nil + } + if len(shorts) == 0 { + return fmt.Errorf("incomplete transaction page without cursor progress workchain=%d shard=%d seqno=%d", shard.Workchain, shard.Shard, shard.SeqNo) + } + after = shorts[len(shorts)-1].ID3() + } +} + +func (c *tonJettonWalletCache) jettonWalletTokens(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, owner *address.Address, tokens []mdb.ChainToken) (map[string]mdb.ChainToken, error) { + out := make(map[string]mdb.ChainToken, len(tokens)) + ownerRaw := addressutil.TonRawAddressObjectKey(owner) + for _, token := range tokens { + masterParsed, err := addressutil.ParseTonMainnetAddress(token.ContractAddress) + if err != nil { + return nil, fmt.Errorf("invalid jetton master symbol=%s address=%s: %w", token.Symbol, token.ContractAddress, err) + } + cacheKey := masterParsed.Raw + "|" + ownerRaw + c.Lock() + walletRaw, ok := c.walletByOwnerToken[cacheKey] + c.Unlock() + if !ok { + wallet, err := jetton.NewJettonMasterClient(api, masterParsed.Address).GetJettonWalletAtBlock(ctx, owner, master) + if err != nil { + return nil, fmt.Errorf("derive jetton wallet symbol=%s owner=%s: %w", token.Symbol, ownerRaw, err) + } + walletRaw = addressutil.TonRawAddressObjectKey(wallet.Address()) + c.Lock() + c.walletByOwnerToken[cacheKey] = walletRaw + c.Unlock() + } + out[walletRaw] = token + } + return out, nil +} + +func loadTonWatchState() (*tonWatchState, error) { + walletRows, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTon) + if err != nil { + return nil, err + } + tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTon) + if err != nil { + return nil, err + } + state := &tonWatchState{wallets: make(map[string]*address.Address)} + for _, row := range walletRows { + parsed, err := addressutil.ParseTonMainnetAddress(row.Address) + if err != nil { + log.Sugar.Warnf("[TON] skip invalid wallet address=%s err=%v", row.Address, err) + continue + } + state.wallets[parsed.Raw] = parsed.Address + } + for i := range tokens { + sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol)) + contract := strings.TrimSpace(tokens[i].ContractAddress) + switch { + case sym == service.TonNativeSymbol && contract == "": + token := tokens[i] + state.nativeToken = &token + case contract != "": + state.jettonTokens = append(state.jettonTokens, tokens[i]) + } + } + return state, nil +} + +func tonChainConfig() (*mdb.Chain, time.Duration) { + row, err := data.GetChainByNetwork(mdb.NetworkTon) + if err != nil || row.ID == 0 { + return nil, 5 * time.Second + } + interval := time.Duration(row.ScanIntervalSec) * time.Second + if interval <= 0 { + interval = 5 * time.Second + } + return row, interval +} + +func tonWatchStateSignature(state *tonWatchState) string { + if state == nil { + return "" + } + return fmt.Sprintf("%d|%t|%d|%s", len(state.wallets), state.nativeToken != nil, len(state.jettonTokens), tonWalletWorkchainSummary(state)) +} + +func tonWalletWorkchainSummary(state *tonWatchState) string { + if state == nil || len(state.wallets) == 0 { + return "none" + } + counts := make(map[int32]int) + for _, wallet := range state.wallets { + if wallet == nil { + continue + } + counts[wallet.Workchain()]++ + } + return formatTonWorkchainCounts(counts) +} + +func tonShardWorkchainSummary(shards []*ton.BlockIDExt) string { + if len(shards) == 0 { + return "none" + } + counts := make(map[int32]int) + for _, shard := range shards { + if shard == nil { + continue + } + counts[shard.Workchain]++ + } + return formatTonWorkchainCounts(counts) +} + +func tonShardCursorKey(shard *ton.BlockIDExt) string { + if shard == nil { + return "" + } + return fmt.Sprintf("%d:%d", shard.Workchain, shard.Shard) +} + +func formatTonWorkchainCounts(counts map[int32]int) string { + if len(counts) == 0 { + return "none" + } + workchains := make([]int, 0, len(counts)) + for workchain := range counts { + workchains = append(workchains, int(workchain)) + } + sort.Ints(workchains) + parts := make([]string, 0, len(workchains)) + for _, workchain := range workchains { + parts = append(parts, fmt.Sprintf("%d:%d", workchain, counts[int32(workchain)])) + } + return strings.Join(parts, ",") +} + +func shortTonHash(raw []byte) string { + hash := hex.EncodeToString(raw) + if len(hash) > 16 { + return hash[:16] + } + if hash == "" { + return "-" + } + return hash +} + +func resolveTonLiteNode(excludeIDs ...uint64) (mdb.RpcNode, bool) { + node, err := data.SelectGeneralRpcNode(mdb.NetworkTon, mdb.RpcNodeTypeLite, excludeIDs...) + if err == nil && node != nil && node.ID > 0 { + node.Url = strings.TrimSpace(node.Url) + if node.Url != "" { + return *node, true + } + log.Sugar.Errorf("[TON] rpc_nodes id=%d has empty url", node.ID) + return mdb.RpcNode{}, false + } + if err != nil { + log.Sugar.Errorf("[TON] resolve lite rpc_nodes err=%v", err) + } else { + log.Sugar.Warn("[TON] no enabled lite RPC node configured in rpc_nodes") + } + return mdb.RpcNode{}, false +} + +func connectTonLiteAPI(node mdb.RpcNode) (ton.APIClientWrapped, func(), error) { + ctx, cancel := context.WithTimeout(context.Background(), tonScannerConnectTimeout) + defer cancel() + return service.ConnectTonLiteAPI(ctx, node.Url, tonScannerRequestTimeout, 5) +} + +func recordTonLiteNodeFailure(node mdb.RpcNode, reason string) { + data.RecordRpcFailure(mdb.NetworkTon) + failures, cooling := data.RecordRpcNodeFailure(node.ID) + nodeLabel := data.RpcNodeLogLabel(node) + if cooling { + log.Sugar.Warnf("[TON] lite node reached fail threshold (%s), node=%s failures=%d/%d", reason, nodeLabel, failures, data.RpcFailoverThreshold) + return + } + log.Sugar.Warnf("[TON] lite node failed (%s), node=%s failures=%d/%d", reason, nodeLabel, failures, data.RpcFailoverThreshold) +} diff --git a/src/task/listen_ton_test.go b/src/task/listen_ton_test.go new file mode 100644 index 0000000..2b49fec --- /dev/null +++ b/src/task/listen_ton_test.go @@ -0,0 +1,717 @@ +package task + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "math/big" + "testing" + "time" + + "github.com/GMWalletApp/epusdt/internal/testutil" + "github.com/GMWalletApp/epusdt/model/dao" + "github.com/GMWalletApp/epusdt/model/data" + "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/model/request" + "github.com/GMWalletApp/epusdt/model/service" + addressutil "github.com/GMWalletApp/epusdt/util/address" + "github.com/xssnick/tonutils-go/address" + "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" +) + +type fakeTonShardAPI struct { + ton.APIClientWrapped + shorts []ton.TransactionShortInfo + shortsByShardSeq map[uint32][]ton.TransactionShortInfo + incomplete bool + shards []*ton.BlockIDExt + shardsErr error + lookupBlocks map[uint32]*ton.BlockIDExt + lookupSeqs []uint32 + txByLT map[uint64]*tlb.Transaction + fetchedLTs []uint64 + fetchedAddrs []string + fetchedShardSeqNos []uint32 +} + +type fakeTonScannerAPI struct { + ton.APIClientWrapped + head *ton.BlockIDExt + shards []*ton.BlockIDExt + shardsErr error + lookups []uint32 + cancel context.CancelFunc + cancelAtLookupSeq uint32 +} + +func (f *fakeTonScannerAPI) CurrentMasterchainInfo(context.Context) (*ton.BlockIDExt, error) { + return f.head, nil +} + +func (f *fakeTonScannerAPI) WaitForBlock(uint32) ton.APIClientWrapped { + return f +} + +func (f *fakeTonScannerAPI) LookupBlock(_ context.Context, workchain int32, shard int64, seqno uint32) (*ton.BlockIDExt, error) { + f.lookups = append(f.lookups, seqno) + if f.cancel != nil && f.cancelAtLookupSeq == seqno { + f.cancel() + } + return &ton.BlockIDExt{ + Workchain: workchain, + Shard: shard, + SeqNo: seqno, + RootHash: bytes.Repeat([]byte{byte(seqno)}, 32), + FileHash: bytes.Repeat([]byte{byte(seqno + 1)}, 32), + }, nil +} + +func (f *fakeTonScannerAPI) GetBlockShardsInfo(context.Context, *ton.BlockIDExt) ([]*ton.BlockIDExt, error) { + if f.shardsErr != nil { + return nil, f.shardsErr + } + return f.shards, nil +} + +func (f *fakeTonShardAPI) WaitForBlock(uint32) ton.APIClientWrapped { + return f +} + +func (f *fakeTonShardAPI) GetBlockTransactionsV2(_ context.Context, block *ton.BlockIDExt, _ uint32, _ ...*ton.TransactionID3) ([]ton.TransactionShortInfo, bool, error) { + if block != nil { + f.fetchedShardSeqNos = append(f.fetchedShardSeqNos, block.SeqNo) + } + return f.blockShorts(block), f.incomplete, nil +} + +func (f *fakeTonShardAPI) blockShorts(block *ton.BlockIDExt) []ton.TransactionShortInfo { + if block != nil && f.shortsByShardSeq != nil { + if shorts, ok := f.shortsByShardSeq[block.SeqNo]; ok { + return shorts + } + } + return f.shorts +} + +func (f *fakeTonShardAPI) GetBlockShardsInfo(context.Context, *ton.BlockIDExt) ([]*ton.BlockIDExt, error) { + if f.shardsErr != nil { + return nil, f.shardsErr + } + return f.shards, nil +} + +func (f *fakeTonShardAPI) LookupBlock(_ context.Context, workchain int32, shard int64, seqno uint32) (*ton.BlockIDExt, error) { + f.lookupSeqs = append(f.lookupSeqs, seqno) + if f.lookupBlocks != nil { + if block, ok := f.lookupBlocks[seqno]; ok { + return block, nil + } + } + return &ton.BlockIDExt{Workchain: workchain, Shard: shard, SeqNo: seqno}, nil +} + +func (f *fakeTonShardAPI) GetTransaction(_ context.Context, _ *ton.BlockIDExt, addr *address.Address, lt uint64) (*tlb.Transaction, error) { + f.fetchedLTs = append(f.fetchedLTs, lt) + f.fetchedAddrs = append(f.fetchedAddrs, addressutil.TonRawAddressObjectKey(addr)) + tx := f.txByLT[lt] + if tx == nil { + return nil, fmt.Errorf("missing tx lt=%d", lt) + } + return tx, nil +} + +func seedTonScannerWallet(t *testing.T) { + t.Helper() + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"); err != nil { + t.Fatalf("seed TON wallet: %v", err) + } +} + +func TestResolveTonLiteNodeSkipsCoolingPrimary(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + data.ResetRpcFailoverForTest() + t.Cleanup(data.ResetRpcFailoverForTest) + + rows := []mdb.RpcNode{ + {Network: mdb.NetworkTon, Url: " https://primary-ton.example.com/global.config.json ", Type: mdb.RpcNodeTypeLite, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk}, + {Network: mdb.NetworkTon, Url: "https://backup-ton.example.com/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk}, + {Network: mdb.NetworkTon, Url: "https://manual-ton.example.com/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1000, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk}, + {Network: mdb.NetworkTon, Url: "https://http-ton.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 2000, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk}, + } + if err := dao.Mdb.Create(&rows).Error; err != nil { + t.Fatalf("seed rpc_nodes: %v", err) + } + + primary, ok := resolveTonLiteNode() + if !ok { + t.Fatalf("resolveTonLiteNode() ok=false, want true") + } + if primary.Url != "https://primary-ton.example.com/global.config.json" { + t.Fatalf("primary node = %#v, want trimmed primary lite node", primary) + } + for i := 0; i < data.RpcFailoverThreshold; i++ { + data.RecordRpcNodeFailure(primary.ID) + } + + got, ok := resolveTonLiteNode() + if !ok { + t.Fatalf("resolveTonLiteNode() after cooldown ok=false, want true") + } + if got.Url != "https://backup-ton.example.com/global.config.json" { + t.Fatalf("resolveTonLiteNode() after cooldown = %#v, want backup lite node", got) + } +} + +func TestRunTonScannerInitializesCursorAtCurrentHead(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + seedTonScannerWallet(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + api := &fakeTonScannerAPI{head: &ton.BlockIDExt{ + Workchain: address.MasterchainID, + Shard: tonMasterShard, + SeqNo: 77, + RootHash: bytes.Repeat([]byte{0x77}, 32), + FileHash: bytes.Repeat([]byte{0x78}, 32), + }} + cursor := &tonRuntimeCursor{} + if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil { + t.Fatalf("runTonScanner(): %v", err) + } + if !cursor.initialized { + t.Fatal("cursor not initialized") + } + if cursor.lastMasterSeqNo != 77 { + t.Fatalf("cursor seqno = %d, want 77", cursor.lastMasterSeqNo) + } + if len(api.lookups) != 0 { + t.Fatalf("initial cursor should not backfill, lookups=%#v", api.lookups) + } +} + +func TestRunTonScannerIdleStatesPreserveInitializedCursor(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) + }{ + { + name: "chain disabled", + setup: func(t *testing.T) { + t.Helper() + seedTonScannerWallet(t) + if err := data.UpdateChainFields(mdb.NetworkTon, map[string]interface{}{"enabled": false}); err != nil { + t.Fatalf("disable TON chain: %v", err) + } + }, + }, + { + name: "no enabled wallets", + setup: func(t *testing.T) { + t.Helper() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + tt.setup(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + api := &fakeTonScannerAPI{ + head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11}, + } + cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 10} + if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil { + t.Fatalf("runTonScanner(): %v", err) + } + if !cursor.initialized || cursor.lastMasterSeqNo != 10 { + t.Fatalf("cursor changed during idle state: initialized=%v seqno=%d, want initialized=true seqno=10", cursor.initialized, cursor.lastMasterSeqNo) + } + }) + } +} + +func TestRunTonScannerDoesNotAdvanceCursorOnMasterProcessingFailure(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + seedTonScannerWallet(t) + + api := &fakeTonScannerAPI{ + head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11}, + shardsErr: errors.New("shard fetch failed"), + } + cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 10} + err := runTonScanner(context.Background(), api, newTonJettonWalletCache(), cursor) + if err == nil { + t.Fatal("expected scanner failure") + } + if cursor.lastMasterSeqNo != 10 { + t.Fatalf("cursor advanced after failed block: %d, want 10", cursor.lastMasterSeqNo) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + api = &fakeTonScannerAPI{ + head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11}, + shards: nil, + } + if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil { + t.Fatalf("runTonScanner retry: %v", err) + } + if len(api.lookups) != 1 || api.lookups[0] != 11 { + t.Fatalf("retry lookups = %#v, want [11]", api.lookups) + } + if cursor.lastMasterSeqNo != 11 { + t.Fatalf("cursor seqno after retry = %d, want 11", cursor.lastMasterSeqNo) + } +} + +func TestRunTonScannerDoesNotMarkCursorInitializedOnInitialShardFailure(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + seedTonScannerWallet(t) + + api := &fakeTonScannerAPI{ + head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11}, + shardsErr: errors.New("initial shard fetch failed"), + } + cursor := &tonRuntimeCursor{} + err := runTonScanner(context.Background(), api, newTonJettonWalletCache(), cursor) + if err == nil { + t.Fatal("expected scanner failure") + } + if cursor.initialized { + t.Fatal("cursor was marked initialized after failed initial shard cursor setup") + } + if cursor.lastMasterSeqNo != 0 { + t.Fatalf("cursor master seqno = %d, want 0", cursor.lastMasterSeqNo) + } +} + +func TestRunTonScannerRespectsMinConfirmations(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + seedTonScannerWallet(t) + if err := data.UpdateChainFields(mdb.NetworkTon, map[string]interface{}{"min_confirmations": 3}); err != nil { + t.Fatalf("set TON min confirmations: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + api := &fakeTonScannerAPI{ + head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 12}, + } + cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 10} + if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil { + t.Fatalf("runTonScanner(): %v", err) + } + if len(api.lookups) != 0 { + t.Fatalf("scanner processed unconfirmed blocks, lookups=%#v", api.lookups) + } + if cursor.lastMasterSeqNo != 10 { + t.Fatalf("cursor seqno = %d, want 10", cursor.lastMasterSeqNo) + } +} + +func TestRunTonScannerCatchupProcessesAtMostFiftyMasterBlocksPerTick(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + seedTonScannerWallet(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + api := &fakeTonScannerAPI{ + head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 100}, + shards: nil, + } + cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 1} + if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil { + t.Fatalf("runTonScanner(): %v", err) + } + if len(api.lookups) != int(tonMaxMasterBlocksTick) { + t.Fatalf("lookup count = %d, want %d", len(api.lookups), tonMaxMasterBlocksTick) + } + if cursor.lastMasterSeqNo != 51 { + t.Fatalf("cursor seqno = %d, want 51", cursor.lastMasterSeqNo) + } +} + +func TestRunTonScannerCatchupContinuesUntilConfirmedHead(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + seedTonScannerWallet(t) + + ctx, cancel := context.WithCancel(context.Background()) + api := &fakeTonScannerAPI{ + head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 60}, + shards: nil, + cancel: cancel, + cancelAtLookupSeq: 60, + } + cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 1} + if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil { + t.Fatalf("runTonScanner(): %v", err) + } + if len(api.lookups) != 59 { + t.Fatalf("lookup count = %d, want 59", len(api.lookups)) + } + if api.lookups[0] != 2 || api.lookups[len(api.lookups)-1] != 60 { + t.Fatalf("lookups = %#v, want continuous 2..60", api.lookups) + } + for i, seq := range api.lookups { + want := uint32(i + 2) + if seq != want { + t.Fatalf("lookup[%d] = %d, want %d", i, seq, want) + } + } + if cursor.lastMasterSeqNo != 60 { + t.Fatalf("cursor seqno = %d, want 60", cursor.lastMasterSeqNo) + } +} + +func TestProcessTonShardBlockFetchesOnlyWatchedWalletTransactions(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + unwatched := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x77}, 32)).Bounce(false).Testnet(false) + sender := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false) + + tx := &tlb.Transaction{ + LT: 2, + Now: uint32(time.Now().Unix()), + Hash: bytes.Repeat([]byte{0x99}, 32), + IO: struct { + In *tlb.Message `tlb:"maybe ^"` + Out *tlb.MessagesList `tlb:"maybe ^"` + }{ + In: &tlb.Message{ + MsgType: tlb.MsgTypeInternal, + Msg: &tlb.InternalMessage{ + SrcAddr: sender, + DstAddr: receive, + Amount: tlb.MustFromTON("1"), + Body: cell.BeginCell().EndCell(), + }, + }, + }, + } + + api := &fakeTonShardAPI{ + shorts: []ton.TransactionShortInfo{ + {Account: unwatched.Data(), LT: 1, Hash: bytes.Repeat([]byte{0x01}, 32)}, + {Account: receive.Data(), LT: 2, Hash: bytes.Repeat([]byte{0x02}, 32)}, + }, + txByLT: map[uint64]*tlb.Transaction{2: tx}, + } + state := &tonWatchState{ + wallets: map[string]*address.Address{ + addressutil.TonRawAddressObjectKey(receive): receive, + }, + nativeToken: &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: service.TonNativeSymbol, Decimals: 9, Enabled: true}, + } + + master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10} + shard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: 0, SeqNo: 9} + err := processTonShardBlock(context.Background(), api, master, shard, state, newTonJettonWalletCache()) + if err != nil { + t.Fatalf("processTonShardBlock(): %v", err) + } + if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != 2 { + t.Fatalf("fetched LTs = %#v, want [2]", api.fetchedLTs) + } + if len(api.fetchedAddrs) != 1 || api.fetchedAddrs[0] != addressutil.TonRawAddressObjectKey(receive) { + t.Fatalf("fetched addresses = %#v, want watched receive address", api.fetchedAddrs) + } +} + +func TestProcessTonMasterBlockWithCursorScansSkippedShardBlocks(t *testing.T) { + receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + sender := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false) + txHash := bytes.Repeat([]byte{0x42}, 32) + tx := &tlb.Transaction{ + LT: 88, + Now: uint32(time.Now().Unix()), + Hash: txHash, + IO: struct { + In *tlb.Message `tlb:"maybe ^"` + Out *tlb.MessagesList `tlb:"maybe ^"` + }{ + In: &tlb.Message{ + MsgType: tlb.MsgTypeInternal, + Msg: &tlb.InternalMessage{ + SrcAddr: sender, + DstAddr: receive, + Amount: tlb.MustFromTON("1"), + Body: cell.BeginCell().EndCell(), + }, + }, + }, + } + + currentShard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: tonMasterShard, SeqNo: 89} + skippedShard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: tonMasterShard, SeqNo: 88} + api := &fakeTonShardAPI{ + shards: []*ton.BlockIDExt{currentShard}, + lookupBlocks: map[uint32]*ton.BlockIDExt{88: skippedShard}, + shortsByShardSeq: map[uint32][]ton.TransactionShortInfo{ + 88: {{Account: receive.Data(), LT: tx.LT, Hash: txHash}}, + 89: nil, + }, + txByLT: map[uint64]*tlb.Transaction{tx.LT: tx}, + } + state := &tonWatchState{ + wallets: map[string]*address.Address{ + addressutil.TonRawAddressObjectKey(receive): receive, + }, + } + cursorKey := tonShardCursorKey(currentShard) + cursor := &tonRuntimeCursor{ + initialized: true, + lastMasterSeqNo: 10, + shardSeqNo: map[string]uint32{cursorKey: 87}, + } + + master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10} + err := processTonMasterBlockWithCursor(context.Background(), api, master, state, newTonJettonWalletCache(), cursor) + if err != nil { + t.Fatalf("processTonMasterBlockWithCursor(): %v", err) + } + if len(api.lookupSeqs) != 1 || api.lookupSeqs[0] != 88 { + t.Fatalf("lookup shard seqs = %#v, want [88]", api.lookupSeqs) + } + if len(api.fetchedShardSeqNos) != 2 || api.fetchedShardSeqNos[0] != 88 || api.fetchedShardSeqNos[1] != 89 { + t.Fatalf("fetched shard seqs = %#v, want [88 89]", api.fetchedShardSeqNos) + } + if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != tx.LT { + t.Fatalf("fetched LTs = %#v, want [%d]", api.fetchedLTs, tx.LT) + } + if cursor.shardSeqNo[cursorKey] != 89 { + t.Fatalf("shard cursor = %d, want 89", cursor.shardSeqNo[cursorKey]) + } +} + +func TestProcessTonShardBlockMatchesJettonUSDTOrder(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil { + t.Fatalf("set forced rate: %v", err) + } + receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, receive.StringRaw()); err != nil { + t.Fatalf("add ton wallet: %v", err) + } + resp, err := service.CreateTransaction(&request.CreateTransactionRequest{ + OrderId: "order_ton_usdt_shard_1", + Currency: "CNY", + Token: "USDT", + Network: mdb.NetworkTon, + Amount: 10, + NotifyUrl: "https://93.184.216.34/callback", + }, nil) + if err != nil { + t.Fatalf("create TON USDT order: %v", err) + } + + sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x31}, 32)).Bounce(false).Testnet(false) + jettonWallet := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x44}, 32)).Bounce(false).Testnet(false) + body, err := tlb.ToCell(jetton.TransferNotification{ + QueryID: 7, + Amount: tlb.MustFromNano(big.NewInt(1_000_000), 6), + Sender: sender, + ForwardPayload: cell.BeginCell().EndCell(), + }) + if err != nil { + t.Fatalf("build jetton notification body: %v", err) + } + tx := &tlb.Transaction{ + LT: 22, + Now: uint32(time.Now().Add(time.Second).Unix()), + Hash: bytes.Repeat([]byte{0x42}, 32), + IO: struct { + In *tlb.Message `tlb:"maybe ^"` + Out *tlb.MessagesList `tlb:"maybe ^"` + }{ + In: &tlb.Message{ + MsgType: tlb.MsgTypeInternal, + Msg: &tlb.InternalMessage{ + SrcAddr: jettonWallet, + DstAddr: receive, + Amount: tlb.FromNanoTONU(1), + Body: body, + }, + }, + }, + } + + usdtMaster, err := addressutil.ParseTonMainnetAddress("0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe") + if err != nil { + t.Fatalf("parse USDT jetton master: %v", err) + } + cache := newTonJettonWalletCache() + ownerRaw := addressutil.TonRawAddressObjectKey(receive) + cache.walletByOwnerToken[usdtMaster.Raw+"|"+ownerRaw] = addressutil.TonRawAddressObjectKey(jettonWallet) + + api := &fakeTonShardAPI{ + shorts: []ton.TransactionShortInfo{ + {Account: receive.Data(), LT: tx.LT, Hash: tx.Hash}, + }, + txByLT: map[uint64]*tlb.Transaction{tx.LT: tx}, + } + state := &tonWatchState{ + wallets: map[string]*address.Address{ + ownerRaw: receive, + }, + jettonTokens: []mdb.ChainToken{ + {BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: usdtMaster.Raw, Decimals: 6, Enabled: true}, + }, + } + master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10} + shard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: 0, SeqNo: 9} + if err := processTonShardBlock(context.Background(), api, master, shard, state, cache); err != nil { + t.Fatalf("processTonShardBlock(): %v", err) + } + + order, err := data.GetOrderInfoByTradeId(resp.TradeId) + if err != nil { + t.Fatalf("reload order: %v", err) + } + if order.Status != mdb.StatusPaySuccess { + t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess) + } + wantBlockID := service.TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash)) + if order.BlockTransactionId != wantBlockID { + t.Fatalf("block transaction id = %q, want %q", order.BlockTransactionId, wantBlockID) + } + lock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, receive.StringRaw(), "USDT", resp.ActualAmount) + if err != nil { + t.Fatalf("lookup TON USDT lock: %v", err) + } + if lock != "" { + t.Fatalf("TON USDT lock still exists after shard processing: %s", lock) + } +} + +func TestProcessTonMasterBlockScansMasterchainWalletTransactions(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + masterchainID := address.MasterchainID + masterchainByte := byte(int8(masterchainID)) + receive := address.NewAddress(0, masterchainByte, bytes.Repeat([]byte{0x66}, 32)).Bounce(false).Testnet(false) + sender := address.NewAddress(0, masterchainByte, bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false) + tx := &tlb.Transaction{ + LT: 2, + Now: uint32(time.Now().Unix()), + Hash: bytes.Repeat([]byte{0x99}, 32), + IO: struct { + In *tlb.Message `tlb:"maybe ^"` + Out *tlb.MessagesList `tlb:"maybe ^"` + }{ + In: &tlb.Message{ + MsgType: tlb.MsgTypeInternal, + Msg: &tlb.InternalMessage{ + SrcAddr: sender, + DstAddr: receive, + Amount: tlb.MustFromTON("1"), + Body: cell.BeginCell().EndCell(), + }, + }, + }, + } + api := &fakeTonShardAPI{ + shorts: []ton.TransactionShortInfo{ + {Account: receive.Data(), LT: 2, Hash: bytes.Repeat([]byte{0x02}, 32)}, + }, + txByLT: map[uint64]*tlb.Transaction{2: tx}, + } + state := &tonWatchState{ + wallets: map[string]*address.Address{ + addressutil.TonRawAddressObjectKey(receive): receive, + }, + nativeToken: &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: service.TonNativeSymbol, Decimals: 9, Enabled: true}, + } + + master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10} + if err := processTonMasterBlock(context.Background(), api, master, state, newTonJettonWalletCache()); err != nil { + t.Fatalf("processTonMasterBlock(): %v", err) + } + if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != 2 { + t.Fatalf("fetched LTs = %#v, want [2]", api.fetchedLTs) + } + if len(api.fetchedAddrs) != 1 || api.fetchedAddrs[0] != addressutil.TonRawAddressObjectKey(receive) { + t.Fatalf("fetched addresses = %#v, want masterchain receive address", api.fetchedAddrs) + } +} + +func TestProcessTonShardBlockFailsOnIncompleteEmptyPage(t *testing.T) { + api := &fakeTonShardAPI{incomplete: true} + state := &tonWatchState{wallets: map[string]*address.Address{}} + master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10} + shard := &ton.BlockIDExt{Workchain: 0, Shard: 0, SeqNo: 9} + + err := processTonShardBlock(context.Background(), api, master, shard, state, newTonJettonWalletCache()) + if err == nil { + t.Fatal("expected incomplete empty page error") + } +} + +func TestProcessTonShardBlockFailsWhenJettonWalletsCannotBeResolved(t *testing.T) { + receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I") + sender := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false) + tx := &tlb.Transaction{ + LT: 2, + Now: uint32(time.Now().Unix()), + Hash: bytes.Repeat([]byte{0x99}, 32), + IO: struct { + In *tlb.Message `tlb:"maybe ^"` + Out *tlb.MessagesList `tlb:"maybe ^"` + }{ + In: &tlb.Message{ + MsgType: tlb.MsgTypeInternal, + Msg: &tlb.InternalMessage{ + SrcAddr: sender, + DstAddr: receive, + Amount: tlb.MustFromTON("1"), + Body: cell.BeginCell().EndCell(), + }, + }, + }, + } + api := &fakeTonShardAPI{ + shorts: []ton.TransactionShortInfo{ + {Account: receive.Data(), LT: 2, Hash: bytes.Repeat([]byte{0x02}, 32)}, + }, + txByLT: map[uint64]*tlb.Transaction{2: tx}, + } + state := &tonWatchState{ + wallets: map[string]*address.Address{ + addressutil.TonRawAddressObjectKey(receive): receive, + }, + jettonTokens: []mdb.ChainToken{ + {BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "not-a-ton-address", Decimals: 6, Enabled: true}, + }, + } + + master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10} + shard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: 0, SeqNo: 9} + err := processTonShardBlock(context.Background(), api, master, shard, state, newTonJettonWalletCache()) + if err == nil { + t.Fatal("expected jetton wallet resolution failure") + } + if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != 2 { + t.Fatalf("fetched LTs = %#v, want [2]", api.fetchedLTs) + } +} diff --git a/src/telegram/utils.go b/src/telegram/utils.go index 2c94a52..6284113 100644 --- a/src/telegram/utils.go +++ b/src/telegram/utils.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/GMWalletApp/epusdt/model/mdb" + addressutil "github.com/GMWalletApp/epusdt/util/address" "github.com/btcsuite/btcutil/base58" "github.com/gagliardetto/solana-go" ) @@ -53,6 +54,9 @@ func isValidAddressByNetwork(network, addr string) bool { return isValidTronAddress(addr) case mdb.NetworkSolana: return isValidSolanaAddress(addr) + case mdb.NetworkTon: + _, err := addressutil.ParseTonMainnetAddress(addr) + return err == nil default: // 其余 EVM 链统一使用 0x 地址校验 return isValidEthereumAddress(addr) @@ -64,6 +68,11 @@ func normalizeWalletAddressByNetwork(network, addr string) string { switch strings.ToLower(strings.TrimSpace(network)) { case mdb.NetworkTron, mdb.NetworkSolana: return addr + case mdb.NetworkTon: + if normalized, err := addressutil.NormalizeTonAddress(addr); err == nil { + return normalized + } + return addr default: return strings.ToLower(addr) } diff --git a/src/util/address/ton.go b/src/util/address/ton.go new file mode 100644 index 0000000..bbf78d3 --- /dev/null +++ b/src/util/address/ton.go @@ -0,0 +1,135 @@ +package addressutil + +import ( + "encoding/base64" + "encoding/binary" + "fmt" + "strings" + + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/crc16" +) + +// TonParsedAddress carries the canonical display and raw forms used by epusdt. +type TonParsedAddress struct { + Address *address.Address + Display string + Raw string +} + +// ParseTonMainnetAddress accepts raw and user-friendly TON addresses and rejects +// user-friendly testnet-only addresses. Raw addresses do not contain a testnet +// flag, so they are treated as mainnet addresses by definition. +func ParseTonMainnetAddress(input string) (*TonParsedAddress, error) { + raw := strings.TrimSpace(input) + if raw == "" { + return nil, fmt.Errorf("ton address is empty") + } + + var ( + addr *address.Address + err error + checkFlags bool + ) + if strings.Contains(raw, ":") { + addr, err = address.ParseRawAddr(raw) + } else { + addr, err = parseUserFriendly(raw) + checkFlags = true + } + if err != nil { + return nil, err + } + if addr == nil || addr.Type() != address.StdAddress || addr.IsAddrNone() { + return nil, fmt.Errorf("unsupported ton address type") + } + if checkFlags && addr.IsTestnetOnly() { + return nil, fmt.Errorf("testnet-only ton address is not supported") + } + + mainnet := addr.Bounce(false).Testnet(false) + return &TonParsedAddress{ + Address: mainnet, + Display: mainnet.String(), + Raw: strings.ToLower(mainnet.StringRaw()), + }, nil +} + +// NormalizeTonAddress returns the canonical user-friendly address stored in DB. +func NormalizeTonAddress(input string) (string, error) { + parsed, err := ParseTonMainnetAddress(input) + if err != nil { + return "", err + } + return parsed.Display, nil +} + +// TonRawAddressKey returns the workchain:hash key used for comparisons and locks. +func TonRawAddressKey(input string) (string, error) { + parsed, err := ParseTonMainnetAddress(input) + if err != nil { + return "", err + } + return parsed.Raw, nil +} + +// NormalizeTonAddressObject returns the canonical user-friendly form for a parsed +// address object. +func NormalizeTonAddressObject(addr *address.Address) string { + if addr == nil { + return "" + } + return addr.Bounce(false).Testnet(false).String() +} + +// TonRawAddressObjectKey returns the raw workchain:hash key for a parsed address. +func TonRawAddressObjectKey(addr *address.Address) string { + if addr == nil { + return "" + } + return strings.ToLower(addr.Bounce(false).Testnet(false).StringRaw()) +} + +// TonAddressFromShortAccount builds a standard TON address from a shard +// transaction account id returned by liteserver block transaction lists. +func TonAddressFromShortAccount(workchain int32, account []byte) *address.Address { + if len(account) != 32 { + return nil + } + return address.NewAddress(0, byte(workchain), append([]byte(nil), account...)).Bounce(false).Testnet(false) +} + +func parseUserFriendly(input string) (*address.Address, error) { + if addr, err := address.ParseAddr(input); err == nil { + return addr, nil + } + for _, enc := range []*base64.Encoding{ + base64.RawStdEncoding, + base64.StdEncoding, + base64.URLEncoding, + } { + data, err := enc.DecodeString(input) + if err != nil { + continue + } + addr, err := addressFromFriendlyBytes(data) + if err == nil { + return addr, nil + } + } + return nil, fmt.Errorf("invalid ton address") +} + +func addressFromFriendlyBytes(data []byte) (*address.Address, error) { + if len(data) != 36 { + return nil, fmt.Errorf("incorrect ton address data length") + } + want := binary.BigEndian.Uint16(data[34:]) + if crc16.ChecksumXMODEM(data[:34]) != want { + return nil, fmt.Errorf("invalid ton address checksum") + } + if len(data[2:34]) != 32 { + return nil, fmt.Errorf("incorrect ton address hash length") + } + return address.NewAddress(data[0], data[1], append([]byte(nil), data[2:34]...)), nil +} diff --git a/src/util/address/ton_test.go b/src/util/address/ton_test.go new file mode 100644 index 0000000..f1b725d --- /dev/null +++ b/src/util/address/ton_test.go @@ -0,0 +1,56 @@ +package addressutil + +import ( + "encoding/base64" + "strings" + "testing" + + "github.com/xssnick/tonutils-go/address" +) + +const testTonBounceable = "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I" + +func TestParseTonMainnetAddressNormalizesSupportedFormats(t *testing.T) { + base := address.MustParseAddr(testTonBounceable) + raw := base.StringRaw() + nonBounce := base.Bounce(false).String() + rawBytes, err := base64.RawURLEncoding.DecodeString(testTonBounceable) + if err != nil { + t.Fatalf("decode fixture: %v", err) + } + stdBase64 := base64.RawStdEncoding.EncodeToString(rawBytes) + + for _, input := range []string{testTonBounceable, nonBounce, raw, stdBase64} { + parsed, err := ParseTonMainnetAddress(input) + if err != nil { + t.Fatalf("ParseTonMainnetAddress(%q): %v", input, err) + } + if parsed.Display != nonBounce { + t.Fatalf("display for %q = %q, want %q", input, parsed.Display, nonBounce) + } + if parsed.Raw != strings.ToLower(raw) { + t.Fatalf("raw for %q = %q, want %q", input, parsed.Raw, strings.ToLower(raw)) + } + } +} + +func TestParseTonMainnetAddressRejectsTestnetOnly(t *testing.T) { + testnet := address.MustParseAddr(testTonBounceable).Testnet(true).String() + if _, err := ParseTonMainnetAddress(testnet); err == nil { + t.Fatal("expected testnet-only address to be rejected") + } +} + +func TestTonAddressFromShortAccount(t *testing.T) { + base := address.MustParseAddr(testTonBounceable) + got := TonAddressFromShortAccount(base.Workchain(), base.Data()) + if got == nil { + t.Fatal("TonAddressFromShortAccount() returned nil") + } + if TonRawAddressObjectKey(got) != TonRawAddressObjectKey(base) { + t.Fatalf("raw address = %q, want %q", TonRawAddressObjectKey(got), TonRawAddressObjectKey(base)) + } + if got = TonAddressFromShortAccount(base.Workchain(), base.Data()[:31]); got != nil { + t.Fatalf("TonAddressFromShortAccount() with short account = %#v, want nil", got) + } +}