mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 02:06:16 +00:00
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
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
NetworkBsc = "binance"
|
||||
NetworkPolygon = "polygon"
|
||||
NetworkPlasma = "plasma"
|
||||
NetworkTon = "ton"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -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..."`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ func Start() {
|
||||
go StartPolygonWebSocketListener()
|
||||
go StartPlasmaWebSocketListener()
|
||||
go StartTronBlockScannerListener()
|
||||
go StartTonBlockScannerListener()
|
||||
|
||||
c := cron.New()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user