diff --git a/src/.env.example b/src/.env.example index dbaf8fa..b595454 100644 --- a/src/.env.example +++ b/src/.env.example @@ -61,3 +61,11 @@ order_notice_max_retry=0 forced_usdt_rate= api_rate_url= tron_grid_api_key= +solana_rpc_url= + +ethereum_ws_url=wss://ethereum.publicnode.com + + +# epay +epay_pid= +epay_key= diff --git a/src/.gitignore b/src/.gitignore index 1a07445..41a26cd 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,2 +1,3 @@ .DS_Store dist/ +*.log \ No newline at end of file diff --git a/src/config/config.go b/src/config/config.go index fb221d1..fa96b18 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -328,3 +328,23 @@ func GetCallbackRetryBaseDuration() time.Duration { } return time.Duration(seconds) * time.Second } + +func GetSolanaRpcUrl() string { + rpcUrl := viper.GetString("solana_rpc_url") + if rpcUrl == "" { + return "https://api.mainnet-beta.solana.com" + } + return rpcUrl +} + +func GetEthereumWsUrl() string { + return strings.TrimSpace(viper.GetString("ethereum_ws_url")) +} + +func GetEpayPid() int { + return viper.GetInt("epay_pid") +} + +func GetEpayKey() string { + return viper.GetString("epay_key") +} diff --git a/src/controller/comm/order_controller.go b/src/controller/comm/order_controller.go index f745711..4c58c4d 100644 --- a/src/controller/comm/order_controller.go +++ b/src/controller/comm/order_controller.go @@ -1,6 +1,10 @@ package comm import ( + "encoding/json" + "fmt" + "log" + "github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/service" "github.com/assimon/luuu/util/constant" @@ -22,3 +26,53 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) { } return c.SucJson(ctx, resp) } + +// SwitchNetwork 切换支付网络,创建或返回子订单 +func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) { + req := new(request.SwitchNetworkRequest) + if err = ctx.Bind(req); err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + if err = c.ValidateStruct(ctx, req); err != nil { + return c.FailJson(ctx, err) + } + resp, err := service.SwitchNetwork(req) + if err != nil { + return c.FailJson(ctx, err) + } + + jsonBytes, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return c.FailJson(ctx, err) + } + + fmt.Printf("switch network response: \n%s", string(jsonBytes)) + + return c.SucJson(ctx, resp) +} + +func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) { + req := new(request.CreateTransactionRequest) + if err = ctx.Bind(req); err != nil { + log.Println("bind request error:", err) + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + if err = c.ValidateStruct(ctx, req); err != nil { + log.Println("validate request error:", err) + return c.FailJson(ctx, err) + } + resp, err := service.CreateTransaction(req) + if err != nil { + log.Println("create transaction error:", err) + return c.FailJson(ctx, err) + } + + fmt.Printf("create transaction response: %+v\n", resp) + + tradeID := resp.TradeId + + ctx.Redirect(302, "/pay/checkout-counter/"+tradeID) + + return nil + +} diff --git a/src/controller/comm/pay_controller.go b/src/controller/comm/pay_controller.go index 1bc9ebd..66e6850 100644 --- a/src/controller/comm/pay_controller.go +++ b/src/controller/comm/pay_controller.go @@ -1,6 +1,8 @@ package comm import ( + "encoding/json" + "fmt" "html/template" "net/http" "path/filepath" @@ -30,7 +32,13 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) { if err != nil { return ctx.String(http.StatusOK, err.Error()) } - resp.Network = "TRON" + + jsonByte, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return ctx.String(http.StatusOK, err.Error()) + } + fmt.Printf("%v\n", string(jsonByte)) + return tmpl.Execute(ctx.Response(), resp) } diff --git a/src/controller/comm/wallet_controller.go b/src/controller/comm/wallet_controller.go new file mode 100644 index 0000000..71ca5a7 --- /dev/null +++ b/src/controller/comm/wallet_controller.go @@ -0,0 +1,98 @@ +package comm + +import ( + "strconv" + + "github.com/assimon/luuu/model/data" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/util/constant" + "github.com/labstack/echo/v4" +) + +type addWalletRequest struct { + Network string `json:"network" validate:"required"` + Address string `json:"address" validate:"required"` +} + +type changeStatusRequest struct { + Status int `json:"status" validate:"required|in:1,2"` +} + +// AddWallet 添加钱包地址 +func (c *BaseCommController) AddWallet(ctx echo.Context) error { + req := new(addWalletRequest) + if err := ctx.Bind(req); err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + if err := c.ValidateStruct(ctx, req); err != nil { + return c.FailJson(ctx, err) + } + wallet, err := data.AddWalletAddressWithNetwork(req.Network, req.Address) + if err != nil { + return c.FailJson(ctx, err) + } + return c.SucJson(ctx, wallet) +} + +// ListWallets 获取钱包列表 +func (c *BaseCommController) ListWallets(ctx echo.Context) error { + network := ctx.QueryParam("network") + var wallets []mdb.WalletAddress + var err error + if network != "" { + wallets, err = data.GetAllWalletAddressByNetwork(network) + } else { + wallets, err = data.GetAllWalletAddress() + } + if err != nil { + return c.FailJson(ctx, err) + } + return c.SucJson(ctx, wallets) +} + +// GetWallet 获取单个钱包 +func (c *BaseCommController) GetWallet(ctx echo.Context) error { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + wallet, err := data.GetWalletAddressById(id) + if err != nil { + return c.FailJson(ctx, err) + } + if wallet.ID <= 0 { + return c.FailJson(ctx, constant.NotAvailableWalletAddress) + } + return c.SucJson(ctx, wallet) +} + +// ChangeWalletStatus 启用/禁用钱包 +func (c *BaseCommController) ChangeWalletStatus(ctx echo.Context) error { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + req := new(changeStatusRequest) + if err := ctx.Bind(req); err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + if err := c.ValidateStruct(ctx, req); err != nil { + return c.FailJson(ctx, err) + } + if err := data.ChangeWalletAddressStatus(id, req.Status); err != nil { + return c.FailJson(ctx, err) + } + return c.SucJson(ctx, nil) +} + +// DeleteWallet 删除钱包 +func (c *BaseCommController) DeleteWallet(ctx echo.Context) error { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + if err := data.DeleteWalletAddressById(id); err != nil { + return c.FailJson(ctx, err) + } + return c.SucJson(ctx, nil) +} diff --git a/src/go.mod b/src/go.mod index 97cd864..9bb4db4 100644 --- a/src/go.mod +++ b/src/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/btcsuite/btcutil v1.0.2 github.com/dromara/carbon/v2 v2.6.15 + github.com/ethereum/go-ethereum v1.15.11 github.com/gagliardetto/solana-go v1.16.0 github.com/go-resty/resty/v2 v2.11.0 github.com/gookit/color v1.5.0 @@ -32,19 +33,32 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect - github.com/benbjohnson/clock v1.3.5 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/StackExchange/wmi v1.2.1 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect github.com/blendle/zapdriver v1.3.1 // indirect + github.com/consensys/bavard v0.1.27 // indirect + github.com/consensys/gnark-crypto v0.16.0 // indirect + github.com/crate-crypto/go-eth-kzg v1.3.0 // indirect + github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.0 // indirect + github.com/ethereum/go-verkle v0.2.2 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gagliardetto/binary v0.8.0 // indirect github.com/gagliardetto/treeout v0.1.4 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/gookit/filter v1.1.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/holiman/uint256 v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect @@ -59,6 +73,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mmcloughlin/addchain v0.4.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect @@ -67,30 +82,34 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/supranational/blst v0.3.14 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.1 // indirect github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect go.mongodb.org/mongo-driver v1.17.3 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/ratelimit v0.3.1 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.14.0 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.47.0 // indirect + rsc.io/tmplfunc v0.0.3 // indirect ) diff --git a/src/go.sum b/src/go.sum index 24b5a7c..e8a5339 100644 --- a/src/go.sum +++ b/src/go.sum @@ -1,13 +1,21 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= -github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= +github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= -github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= @@ -20,15 +28,52 @@ github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVa github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.2 h1:CUh2IPtR4swHlEj48Rhfzw6l/d0qA31fItcIszQVIsA= +github.com/cockroachdb/pebble v1.1.2/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/bavard v0.1.27 h1:j6hKUrGAy/H+gpNrpLU3I26n1yc+VMGmd6ID5+gAhOs= +github.com/consensys/bavard v0.1.27/go.mod h1:k/zVjHHC4B+PQy1Pg7fgvG3ALicQw540Crag8qx+dZs= +github.com/consensys/gnark-crypto v0.16.0 h1:8Dl4eYmUWK9WmlP1Bj6je688gBRJCJbT8Mw4KoTAawo= +github.com/consensys/gnark-crypto v0.16.0/go.mod h1:Ke3j06ndtPTVvo++PhGNgvm+lgpLvzbcE2MqljY7diU= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/crate-crypto/go-eth-kzg v1.3.0 h1:05GrhASN9kDAidaFJOda6A4BEvgvuXbazXg/0E3OOdI= +github.com/crate-crypto/go-eth-kzg v1.3.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= +github.com/crate-crypto/go-kzg-4844 v1.1.0 h1:EN/u9k2TF6OWSHrCCDBBU6GLNMq88OspHHlMnHfoyU4= +github.com/crate-crypto/go-kzg-4844 v1.1.0/go.mod h1:JolLjpSff1tCCJKaJx4psrlEdlXuJEC996PL3tTAFks= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/dromara/carbon/v2 v2.6.15 h1:3HuC3XcWczIHUTbg/f0CSVydtKEdM+P0GM1sdsbwXmI= github.com/dromara/carbon/v2 v2.6.15/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ethereum/c-kzg-4844/v2 v2.1.0 h1:gQropX9YFBhl3g4HYhwE70zq3IHFRgbbNPw0Shwzf5w= +github.com/ethereum/c-kzg-4844/v2 v2.1.0/go.mod h1:TC48kOKjJKPbN7C++qIgt0TJzZ70QznYR7Ob+WXl57E= +github.com/ethereum/go-ethereum v1.15.11 h1:JK73WKeu0WC0O1eyX+mdQAVHUV+UR1a9VB/domDngBU= +github.com/ethereum/go-ethereum v1.15.11/go.mod h1:mf8YiHIb0GR4x4TipcvBUPxJLw1mFdmxzoDi11sDRoI= +github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= +github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -44,6 +89,11 @@ github.com/gagliardetto/solana-go v1.16.0 h1:lRPn/NxVmxzXw+vQ3AxH33jQIvj8avx2CKV github.com/gagliardetto/solana-go v1.16.0/go.mod h1:2n7osXNoDeUhq1r1lOgCMVkl90yYUVrV9FHGINBWPHU= github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= @@ -55,14 +105,25 @@ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9 github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= +github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gookit/color v1.3.8/go.mod h1:R3ogXq2B9rTbXoSHJ1HyUVAZ3poOJHpd9nQmyGZsfvQ= @@ -76,9 +137,21 @@ github.com/gookit/goutil v0.4.6 h1:LSATnmIyR0rV4BMj3XyaKFYtX/HbRURnPVHt6Zb5ABs= github.com/gookit/goutil v0.4.6/go.mod h1:pq1eTibwb2wN96jrci0xy7xogWzzo9CihOQJEAvz4yQ= github.com/gookit/validate v1.3.1 h1:YoxBxG5H+WucKjfEo8X+QvYOzED7ue3Mfpq6jtpIbYs= github.com/gookit/validate v1.3.1/go.mod h1:mzcr3U5XtAlQVAgbW0wbmDGiuefU/MNuDkXFMBNb1ko= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4 h1:X4egAf/gcS1zATw6wn4Ej8vjuVGxeHdan+bRb2ebyv4= +github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4/go.mod h1:5GuXa7vkL8u9FkFuWdVvfR5ix8hRB7DbOAaYULamFpc= +github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -87,6 +160,8 @@ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= @@ -108,10 +183,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo/v4 v4.6.0 h1:vsYEeeYy077cB5yMpgI+ubA7iVRZEtrzHhcvRhd27gA= github.com/labstack/echo/v4 v4.6.0/go.mod h1:RnjgMWNDB9g/HucVWhQYNQP9PvbYf6adqftqryo7s9k= github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/libtnb/sqlite v1.0.1 h1:H4kdU3LBOjdxfQy8s02/wG1rNmp8GMiqzgel0axewtI= github.com/libtnb/sqlite v1.0.1/go.mod h1:zuagCP0nvIJwr4e0kdzq9Yc2Pw5HlVOoLT6ZmzmjOes= @@ -129,12 +208,23 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY= +github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU= +github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -150,22 +240,47 @@ github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4 github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.12.0 h1:C+UIj/QWtmqY13Arb8kwMt5j34/0Z2iKamrJ+ryC0Gg= +github.com/prometheus/client_golang v1.12.0/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a h1:CmF68hwI0XsOQ5UwlBopMi2Ow4Pbg32akc4KIVCOm+Y= +github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= +github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= +github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= @@ -173,6 +288,8 @@ github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/shengdoushi/base58 v1.0.0 h1:tGe4o6TmdXFJWoI31VoSWvuaKxf0Px3gqa3sUWhAxBs= github.com/shengdoushi/base58 v1.0.0/go.mod h1:m5uIILfzcKMw6238iWAhP4l3s5+uXyF3+bJKUNhAL9I= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -202,6 +319,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/supranational/blst v0.3.14 h1:xNMoHRJOTwMn63ip6qoWJ2Ymgvj7E2b9jY2FAwY+qRo= +github.com/supranational/blst v0.3.14/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -212,6 +333,12 @@ github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= @@ -219,14 +346,14 @@ github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52 github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= 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/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= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= @@ -234,8 +361,6 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= -go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= @@ -252,6 +377,8 @@ golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -284,6 +411,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -298,9 +426,11 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -336,15 +466,16 @@ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= -gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/telebot.v3 v3.0.0 h1:UgHIiE/RdjoDi6nf4xACM7PU3TqiPVV9vvTydCEnrTo= gopkg.in/telebot.v3 v3.0.0/go.mod h1:7rExV8/0mDDNu9epSrDm/8j22KLaActH1Tbee6YjzWg= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= @@ -394,3 +525,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU= +rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA= diff --git a/src/middleware/check_token.go b/src/middleware/check_token.go new file mode 100644 index 0000000..a679736 --- /dev/null +++ b/src/middleware/check_token.go @@ -0,0 +1,24 @@ +package middleware + +import ( + "github.com/assimon/luuu/config" + "github.com/assimon/luuu/util/constant" + "github.com/labstack/echo/v4" +) + +// CheckApiToken validates the Authorization header against the configured api_auth_token. +// Use this for management APIs (GET/POST) where body-based signature is not practical. +func CheckApiToken() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(ctx echo.Context) error { + token := ctx.Request().Header.Get("Authorization") + if token == "" { + token = ctx.QueryParam("api_token") + } + if token == "" || token != config.GetApiAuthToken() { + return constant.SignatureErr + } + return next(ctx) + } + } +} diff --git a/src/model/dao/runtime_sqlite.go b/src/model/dao/runtime_sqlite.go index 08a942b..291a518 100644 --- a/src/model/dao/runtime_sqlite.go +++ b/src/model/dao/runtime_sqlite.go @@ -41,6 +41,9 @@ func RuntimeInit() error { if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_token_amount_uindex").Error; err != nil { return err } + if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_address_token_amount_uindex").Error; err != nil { + return err + } if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil { color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err) return err diff --git a/src/model/data/order_data.go b/src/model/data/order_data.go index daa03c8..430264b 100644 --- a/src/model/data/order_data.go +++ b/src/model/data/order_data.go @@ -108,11 +108,103 @@ func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, e return result.RowsAffected > 0, result.Error } -// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by address, token and amount. -func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) { +// CountActiveSubOrders counts sub-orders with status=WaitPay under a parent. +func CountActiveSubOrders(parentTradeId string) (int64, error) { + var count int64 + err := dao.Mdb.Model(&mdb.Orders{}). + Where("parent_trade_id = ?", parentTradeId). + Where("status = ?", mdb.StatusWaitPay). + Count(&count).Error + return count, err +} + +// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent. +func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) { + order := new(mdb.Orders) + err := dao.Mdb.Model(order). + Where("parent_trade_id = ?", parentTradeId). + Where("token = ?", token). + Where("network = ?", network). + Where("status = ?", mdb.StatusWaitPay). + Limit(1). + Find(order).Error + return order, err +} + +// GetSiblingSubOrders returns active sub-orders under the same parent, excluding the given trade_id. +func GetSiblingSubOrders(parentTradeId string, excludeTradeId string) ([]mdb.Orders, error) { + var orders []mdb.Orders + err := dao.Mdb.Model(&mdb.Orders{}). + Where("parent_trade_id = ?", parentTradeId). + Where("trade_id != ?", excludeTradeId). + Where("status = ?", mdb.StatusWaitPay). + Find(&orders).Error + return orders, err +} + +// MarkParentOrderSuccess updates the parent order with the sub-order's payment details. +// Token and network are NOT overwritten — the parent keeps its original values. +func MarkParentOrderSuccess(parentTradeId string, sub *mdb.Orders) (bool, error) { + result := dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", parentTradeId). + Where("status = ?", mdb.StatusWaitPay). + Updates(map[string]interface{}{ + "status": mdb.StatusPaySuccess, + "block_transaction_id": sub.BlockTransactionId, + "callback_confirm": mdb.CallBackConfirmNo, + "actual_amount": sub.ActualAmount, + "receive_address": sub.ReceiveAddress, + }) + return result.RowsAffected > 0, result.Error +} + +// MarkOrderSelected sets is_selected=true for the given trade_id. +func MarkOrderSelected(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Update("is_selected", true).Error +} + +// RefreshOrderExpiration resets created_at to now so the expiration timer restarts. +// Called on the parent order when a sub-order is created or returned. +func RefreshOrderExpiration(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Update("created_at", time.Now()).Error +} + +// ResetCallbackConfirmOk sets callback_confirm back to Ok. +// Prevents the callback worker from retrying a sub-order with an empty notify_url. +func ResetCallbackConfirmOk(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Update("callback_confirm", mdb.CallBackConfirmOk).Error +} + +// GetActiveSubOrders returns all active sub-orders under a parent. +func GetActiveSubOrders(parentTradeId string) ([]mdb.Orders, error) { + var orders []mdb.Orders + err := dao.Mdb.Model(&mdb.Orders{}). + Where("parent_trade_id = ?", parentTradeId). + Where("status = ?", mdb.StatusWaitPay). + Find(&orders).Error + return orders, err +} + +// ExpireOrderByTradeId marks a single order as expired if still waiting. +func ExpireOrderByTradeId(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Where("status = ?", mdb.StatusWaitPay). + Update("status", mdb.StatusExpired).Error +} + +// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount. +func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) { scaledAmount, _ := normalizeLockAmount(amount) var lock mdb.TransactionLock err := dao.RuntimeDB.Model(&mdb.TransactionLock{}). + Where("network = ?", network). Where("address = ?", address). Where("token = ?", normalizeLockToken(token)). Where("amount_scaled = ?", scaledAmount). @@ -128,12 +220,13 @@ func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, am return lock.TradeId, nil } -// LockTransaction reserves an address+token+amount pair in sqlite until expiration. -func LockTransaction(address, token, tradeID string, amount float64, expirationTime time.Duration) error { +// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration. +func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error { scaledAmount, amountText := normalizeLockAmount(amount) normalizedToken := normalizeLockToken(token) now := time.Now() lock := &mdb.TransactionLock{ + Network: network, Address: address, Token: normalizedToken, AmountScaled: scaledAmount, @@ -143,7 +236,8 @@ func LockTransaction(address, token, tradeID string, amount float64, expirationT } return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("address = ?", address). + if err := tx.Where("network = ?", network). + Where("address = ?", address). Where("token = ?", normalizedToken). Where("amount_scaled = ?", scaledAmount). Where("expires_at <= ?", now). @@ -165,10 +259,11 @@ func LockTransaction(address, token, tradeID string, amount float64, expirationT }) } -// UnLockTransaction releases the reservation for address+token+amount. -func UnLockTransaction(address string, token string, amount float64) error { +// UnLockTransaction releases the reservation for network+address+token+amount. +func UnLockTransaction(network string, address string, token string, amount float64) error { scaledAmount, _ := normalizeLockAmount(amount) return dao.RuntimeDB. + Where("network = ?", network). Where("address = ?", address). Where("token = ?", normalizeLockToken(token)). Where("amount_scaled = ?", scaledAmount). diff --git a/src/model/data/wallet_address_data.go b/src/model/data/wallet_address_data.go index 40d9b11..36d230c 100644 --- a/src/model/data/wallet_address_data.go +++ b/src/model/data/wallet_address_data.go @@ -1,14 +1,23 @@ package data import ( + "strings" + "github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/util/constant" ) -// AddWalletAddress 创建钱包 +// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加) func AddWalletAddress(address string) (*mdb.WalletAddress, error) { - exist, err := GetWalletAddressByToken(address) + return AddWalletAddressWithNetwork(mdb.NetworkTron, address) +} + +// AddWalletAddressWithNetwork 创建指定网络的钱包地址 +func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) { + network = strings.ToLower(strings.TrimSpace(network)) + address = strings.TrimSpace(address) + exist, err := GetWalletAddressByNetworkAndAddress(network, address) if err != nil { return nil, err } @@ -16,6 +25,7 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) { return nil, constant.WalletAddressAlreadyExists } walletAddress := &mdb.WalletAddress{ + Network: network, Address: address, Status: mdb.TokenStatusEnable, } @@ -23,7 +33,17 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) { return walletAddress, err } -// GetWalletAddressByToken 通过钱包地址获取address +// GetWalletAddressByNetworkAndAddress 通过网络和地址查询 +func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) { + walletAddress := new(mdb.WalletAddress) + err := dao.Mdb.Model(walletAddress). + Where("network = ?", network). + Where("address = ?", address). + Limit(1).Find(walletAddress).Error + return walletAddress, err +} + +// GetWalletAddressByToken 通过钱包地址获取address (兼容旧接口) func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) { walletAddress := new(mdb.WalletAddress) err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error @@ -50,6 +70,16 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) { return WalletAddressList, err } +// GetAvailableWalletAddressByNetwork 获得指定网络的所有可用钱包地址 +func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) { + var list []mdb.WalletAddress + err := dao.Mdb.Model(list). + Where("status = ?", mdb.TokenStatusEnable). + Where("network = ?", network). + Find(&list).Error + return list, err +} + // GetAllWalletAddress 获得所有钱包地址 func GetAllWalletAddress() ([]mdb.WalletAddress, error) { var WalletAddressList []mdb.WalletAddress @@ -57,6 +87,13 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) { return WalletAddressList, err } +// GetAllWalletAddressByNetwork 获得指定网络的所有钱包地址 +func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) { + var list []mdb.WalletAddress + err := dao.Mdb.Model(list).Where("network = ?", network).Find(&list).Error + return list, err +} + // ChangeWalletAddressStatus 启用禁用钱包 func ChangeWalletAddressStatus(id uint64, status int) error { err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Update("status", status).Error diff --git a/src/model/mdb/orders_mdb.go b/src/model/mdb/orders_mdb.go index 5d262f5..9350b2c 100644 --- a/src/model/mdb/orders_mdb.go +++ b/src/model/mdb/orders_mdb.go @@ -8,9 +8,14 @@ const ( CallBackConfirmNo = 2 ) +const ( + PaymentTypeEpay = "Epay" +) + type Orders struct { TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"` - OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` + OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` // the order id is generated by client, and will notify client when order is paid + ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"` BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` Amount float64 `gorm:"column:amount" json:"amount"` Currency string `gorm:"column:currency" json:"currency"` @@ -21,8 +26,11 @@ type Orders struct { Status int `gorm:"column:status;default:1" json:"status"` NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` + Name string `gorm:"column:name" json:"name"` CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` + IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected"` + PaymentType string `gorm:"column:payment_type" json:"payment_type"` BaseModel } diff --git a/src/model/mdb/transaction_lock_mdb.go b/src/model/mdb/transaction_lock_mdb.go index d94266e..0088199 100644 --- a/src/model/mdb/transaction_lock_mdb.go +++ b/src/model/mdb/transaction_lock_mdb.go @@ -4,9 +4,10 @@ import "time" type TransactionLock struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - Address string `gorm:"column:address;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:1" json:"address"` - Token string `gorm:"column:token;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:2" json:"token"` - AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:3" json:"amount_scaled"` + Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"` + Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"` + Token string `gorm:"column:token;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:3" json:"token"` + AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:4" json:"amount_scaled"` AmountText string `gorm:"column:amount_text" json:"amount_text"` TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"` ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"` diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go index f58bbf7..cdbb194 100644 --- a/src/model/mdb/wallet_address_mdb.go +++ b/src/model/mdb/wallet_address_mdb.go @@ -5,6 +5,12 @@ const ( TokenStatusDisable = 2 ) +const ( + NetworkTron = "tron" + NetworkSolana = "solana" + NetworkEthereum = "ethereum" +) + type WalletAddress struct { Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network"` Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address"` diff --git a/src/model/request/order_request.go b/src/model/request/order_request.go index f861088..b42e38e 100644 --- a/src/model/request/order_request.go +++ b/src/model/request/order_request.go @@ -12,6 +12,8 @@ type CreateTransactionRequest struct { NotifyUrl string `json:"notify_url" validate:"required"` Signature string `json:"signature" validate:"required"` RedirectUrl string `json:"redirect_url"` + Name string `json:"name"` + PaymentType string `json:"payment_type"` } func (r CreateTransactionRequest) Translates() map[string]string { @@ -36,3 +38,18 @@ type OrderProcessingRequest struct { TradeId string BlockTransactionId string } + +// SwitchNetworkRequest 切换支付网络 +type SwitchNetworkRequest struct { + TradeId string `json:"trade_id" validate:"required"` + Token string `json:"token" validate:"required"` + Network string `json:"network" validate:"required"` +} + +func (r SwitchNetworkRequest) Translates() map[string]string { + return validate.MS{ + "TradeId": "订单号", + "Token": "币种", + "Network": "网络", + } +} diff --git a/src/model/response/order_response.go b/src/model/response/order_response.go index a04902f..729ab7d 100644 --- a/src/model/response/order_response.go +++ b/src/model/response/order_response.go @@ -25,3 +25,16 @@ type OrderNotifyResponse struct { Signature string `json:"signature"` // 签名 Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期 } + +// OrderNotifyResponseEpay epay订单异步回调结构体 +type OrderNotifyResponseEpay struct { + PID int `json:"pid"` // 商户ID + TradeNo string `json:"trade_no"` // 平台订单号 + OutTradeNo string `json:"out_trade_no"` // 商户订单号 + Type string `json:"type"` // 订单类型 + Name string `json:"name"` // 商品名称 + Money string `json:"money"` // 订单金额,保留4位小数 + Sign string `json:"sign"` // 签名 + SignType string `json:"sign_type"` // 签名类型 // MD5 + TradeStatus string `json:"trade_status"` // 订单状态 // only has "TRADE_SUCCESS" for now +} diff --git a/src/model/response/pay_response.go b/src/model/response/pay_response.go index 6db0429..190194e 100644 --- a/src/model/response/pay_response.go +++ b/src/model/response/pay_response.go @@ -11,6 +11,7 @@ type CheckoutCounterResponse struct { ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳 RedirectUrl string `json:"redirect_url"` CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳 + IsSelected bool `json:"is_selected"` } type CheckStatusResponse struct { diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index 3b81773..27aced1 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -28,8 +28,10 @@ const ( IncrementalMaximumNumber = 100 ) -var gCreateTransactionLock sync.Mutex -var gOrderProcessingLock sync.Mutex +var ( + gCreateTransactionLock sync.Mutex + gOrderProcessingLock sync.Mutex +) // CreateTransaction creates a new payment order. func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) { @@ -38,6 +40,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT token := strings.ToUpper(strings.TrimSpace(req.Token)) currency := strings.ToUpper(strings.TrimSpace(req.Currency)) + network := strings.ToLower(strings.TrimSpace(req.Network)) payAmount := math.MustParsePrecFloat64(req.Amount, 2) rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency)) if rate <= 0 { @@ -61,7 +64,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT return nil, constant.OrderAlreadyExists } - walletAddress, err := data.GetAvailableWalletAddress() + walletAddress, err := data.GetAvailableWalletAddressByNetwork(network) if err != nil { return nil, err } @@ -71,7 +74,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT tradeID := GenerateCode() amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2) - availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, token, amount, walletAddress) + availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress) if err != nil { return nil, err } @@ -88,9 +91,12 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT ActualAmount: availableAmount, ReceiveAddress: availableAddress, Token: token, + Network: network, Status: mdb.StatusWaitPay, NotifyUrl: req.NotifyUrl, RedirectUrl: req.RedirectUrl, + Name: req.Name, + PaymentType: req.PaymentType, } if err = data.CreateOrderWithTransaction(tx, order); err != nil { tx.Rollback() @@ -148,20 +154,83 @@ func OrderProcessing(req *request.OrderProcessingRequest) error { return err } - if err = data.UnLockTransaction(req.ReceiveAddress, req.Token, req.Amount); err != nil { + if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil { log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err) } + + // Load order to check parent-child relationship + order, err := data.GetOrderInfoByTradeId(req.TradeId) + if err != nil { + return nil + } + + // Parent order paid directly: expire all sub-orders and release their locks + if order.ParentTradeId == "" { + subs, subErr := data.GetActiveSubOrders(order.TradeId) + if subErr != nil { + log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr) + return nil + } + for _, sub := range subs { + if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil { + log.Sugar.Warnf("[order] expire sub-order failed, trade_id=%s, err=%v", sub.TradeId, err) + } + if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil { + log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err) + } + } + return nil + } + + // Sub-order should not trigger its own callback (notify_url is empty). + // OrderSuccessWithTransaction unconditionally sets callback_confirm=No, + // reset it here to prevent the callback worker from retrying an empty URL. + if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil { + log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err) + } + + parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId) + if err != nil { + log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err) + return nil + } + + // Mark parent as paid with sub-order's payment details + if _, err = data.MarkParentOrderSuccess(parent.TradeId, order); err != nil { + log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, err) + } + + // Release parent's own wallet lock + if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil { + log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err) + } + + // Expire sibling sub-orders and release their locks + siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId) + if err != nil { + log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err) + return nil + } + for _, sib := range siblings { + if err = data.ExpireOrderByTradeId(sib.TradeId); err != nil { + log.Sugar.Warnf("[order] expire sibling failed, trade_id=%s, err=%v", sib.TradeId, err) + } + if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil { + log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err) + } + } + return nil } -// ReserveAvailableWalletAndAmount finds and locks an address+token+amount pair. -func ReserveAvailableWalletAndAmount(tradeID string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { +// ReserveAvailableWalletAndAmount finds and locks a network+address+token+amount pair. +func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { availableAddress := "" availableAmount := amount tryLockWalletFunc := func(targetAmount float64) (string, error) { for _, address := range walletAddress { - err := data.LockTransaction(address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration()) + err := data.LockTransaction(network, address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration()) if err == nil { return address.Address, nil } @@ -208,3 +277,141 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { } return order, nil } + +const MaxSubOrders = 2 + +// SwitchNetwork creates or returns an existing sub-order for a different token+network. +func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) { + gCreateTransactionLock.Lock() + defer gCreateTransactionLock.Unlock() + + token := strings.ToUpper(strings.TrimSpace(req.Token)) + network := strings.ToLower(strings.TrimSpace(req.Network)) + + // 1. Load parent order + parent, err := data.GetOrderInfoByTradeId(req.TradeId) + if err != nil { + return nil, err + } + if parent.ID <= 0 { + return nil, constant.OrderNotExists + } + if parent.ParentTradeId != "" { + return nil, constant.CannotSwitchSubOrder + } + if parent.Status != mdb.StatusWaitPay { + return nil, constant.OrderNotWaitPay + } + + // 2. Same token+network as parent → mark selected and return + if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) { + _ = data.MarkOrderSelected(parent.TradeId) + parent.IsSelected = true + return buildCheckoutResponse(parent), nil + } + + // 3. Existing active sub-order for this token+network → return it + existing, err := data.GetSubOrderByTokenNetwork(parent.TradeId, token, network) + if err != nil { + return nil, err + } + if existing.ID > 0 { + _ = data.MarkOrderSelected(parent.TradeId) + _ = data.MarkOrderSelected(existing.TradeId) + _ = data.RefreshOrderExpiration(parent.TradeId) + existing.IsSelected = true + return buildCheckoutResponse(existing), nil + } + + // 4. Check sub-order limit + count, err := data.CountActiveSubOrders(parent.TradeId) + if err != nil { + return nil, err + } + if count >= MaxSubOrders { + return nil, constant.SubOrderLimitExceeded + } + + // 5. Calculate amount for the new network + rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency)) + if rate <= 0 { + return nil, constant.RateAmountErr + } + decimalPayAmount := decimal.NewFromFloat(parent.Amount) + decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate)) + if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 { + return nil, constant.PayAmountErr + } + + // 6. Find and lock wallet + walletAddress, err := data.GetAvailableWalletAddressByNetwork(network) + if err != nil { + return nil, err + } + if len(walletAddress) <= 0 { + return nil, constant.NotAvailableWalletAddress + } + + subTradeID := GenerateCode() + amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2) + availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress) + if err != nil { + return nil, err + } + if availableAddress == "" { + return nil, constant.NotAvailableAmountErr + } + + // 7. Create sub-order + tx := dao.Mdb.Begin() + subOrder := &mdb.Orders{ + TradeId: subTradeID, + OrderId: subTradeID, // sub-order uses its own trade_id as order_id (unique constraint) + ParentTradeId: parent.TradeId, + Amount: parent.Amount, + Currency: parent.Currency, + ActualAmount: availableAmount, + ReceiveAddress: availableAddress, + Token: token, + Network: network, + Status: mdb.StatusWaitPay, + IsSelected: true, + NotifyUrl: "", + RedirectUrl: parent.RedirectUrl, + Name: parent.Name, + CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order + PaymentType: parent.PaymentType, + } + if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil { + tx.Rollback() + _ = data.UnLockTransactionByTradeId(subTradeID) + return nil, err + } + if err = tx.Commit().Error; err != nil { + tx.Rollback() + _ = data.UnLockTransactionByTradeId(subTradeID) + return nil, err + } + + // Mark parent as selected and refresh its expiration to match the sub-order + _ = data.MarkOrderSelected(parent.TradeId) + _ = data.RefreshOrderExpiration(parent.TradeId) + + return buildCheckoutResponse(subOrder), nil +} + +func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse { + return &response.CheckoutCounterResponse{ + TradeId: order.TradeId, + Amount: order.Amount, + ActualAmount: order.ActualAmount, + Token: order.Token, + Currency: order.Currency, + ReceiveAddress: order.ReceiveAddress, + Network: order.Network, + ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(), + RedirectUrl: order.RedirectUrl, + CreatedAt: order.CreatedAt.TimestampMilli(), + IsSelected: order.IsSelected, + } +} diff --git a/src/model/service/order_service_test.go b/src/model/service/order_service_test.go index 3aad2e0..99d73b3 100644 --- a/src/model/service/order_service_test.go +++ b/src/model/service/order_service_test.go @@ -18,6 +18,7 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create OrderId: orderID, Currency: "CNY", Token: "USDT", + Network: "tron", Amount: amount, NotifyUrl: "https://merchant.example/callback", } @@ -53,7 +54,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) { t.Fatalf("unexpected tokens: %s, %s", resp1.Token, resp2.Token) } - tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount) + tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount) if err != nil { t.Fatalf("get first runtime lock: %v", err) } @@ -61,7 +62,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) { t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId) } - tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount) + tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount) if err != nil { t.Fatalf("get second runtime lock: %v", err) } @@ -86,6 +87,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) { err = OrderProcessing(&request.OrderProcessingRequest{ ReceiveAddress: resp.ReceiveAddress, Token: resp.Token, + Network: "tron", TradeId: resp.TradeId, Amount: resp.ActualAmount, BlockTransactionId: "block_1", @@ -108,7 +110,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) { t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId) } - tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp.ReceiveAddress, resp.Token, resp.ActualAmount) + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp.ReceiveAddress, resp.Token, resp.ActualAmount) if err != nil { t.Fatalf("get runtime lock after processing: %v", err) } @@ -133,6 +135,7 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) { req := &request.OrderProcessingRequest{ ReceiveAddress: resp.ReceiveAddress, Token: resp.Token, + Network: "tron", TradeId: resp.TradeId, Amount: resp.ActualAmount, BlockTransactionId: "block_1", @@ -180,6 +183,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) { err = OrderProcessing(&request.OrderProcessingRequest{ ReceiveAddress: resp.ReceiveAddress, Token: resp.Token, + Network: "tron", TradeId: resp.TradeId, Amount: resp.ActualAmount, BlockTransactionId: "block_expired", @@ -239,6 +243,7 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) { errs <- OrderProcessing(&request.OrderProcessingRequest{ ReceiveAddress: address, Token: token, + Network: "tron", TradeId: tradeID, Amount: amount, BlockTransactionId: "shared_block", diff --git a/src/model/service/pay_service.go b/src/model/service/pay_service.go index c1e54bf..011707d 100644 --- a/src/model/service/pay_service.go +++ b/src/model/service/pay_service.go @@ -32,6 +32,7 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(), RedirectUrl: orderInfo.RedirectUrl, CreatedAt: orderInfo.CreatedAt.TimestampMilli(), + IsSelected: orderInfo.IsSelected, } return resp, nil } diff --git a/src/model/service/sol_task.go b/src/model/service/sol_task.go index 24c2e8f..06731f9 100644 --- a/src/model/service/sol_task.go +++ b/src/model/service/sol_task.go @@ -1,22 +1,419 @@ package service import ( - "context" - "encoding/base64" - "encoding/binary" + "encoding/json" "errors" "fmt" + "math/big" + "sort" + "strings" + "sync" + "time" + "github.com/assimon/luuu/config" + "github.com/assimon/luuu/model/data" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/model/request" + "github.com/assimon/luuu/util/constant" + "github.com/assimon/luuu/util/log" + "github.com/assimon/luuu/util/math" "github.com/gagliardetto/solana-go" - "github.com/gagliardetto/solana-go/rpc" + "github.com/go-resty/resty/v2" + "github.com/shopspring/decimal" + "github.com/tidwall/gjson" ) -func SolCallBack(address string) {} +// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction +var gProcessedSignatures sync.Map // sig -> unix timestamp + +type TransferInfo struct { + Source string // Source address (for SOL) or source ATA (for SPL tokens) + Destination string // Destination address (for SOL) or destination ATA (for SPL tokens) + Mint string // Token mint (e.g. USDT mint, USDC mint, or "SOL" for native transfers) + Amount float64 // Human-readable amount (adjusted for decimals) + RawAmount uint64 // Raw amount from the transaction (before adjusting for decimals) + Decimals *int // Optional decimals from the transaction, if available + BlockTime int64 // Block time of the transfer unit is seconds since epoch +} + +// SolCallBack 扫描指定钱包地址的 Solana 链上交易,匹配待支付订单并确认收款。 +func SolCallBack(address string, wg *sync.WaitGroup) { + defer wg.Done() + defer func() { + if err := recover(); err != nil { + log.Sugar.Errorf("[SOL][%s] panic recovered: %v", address, err) + } + }() + + // Clean up old entries from processed cache (older than 1 hour) + cleanupCutoff := time.Now().Add(-1 * time.Hour).Unix() + gProcessedSignatures.Range(func(key, value interface{}) bool { + if ts, ok := value.(int64); ok && ts < cleanupCutoff { + gProcessedSignatures.Delete(key) + } + return true + }) + + limit := 1000 + + // 查询钱包地址 + USDT ATA + USDC ATA 三个地址的签名 + queryAddrs := []string{address} + if usdtAta, err := FindATAAddress(address, USDT_Mint); err == nil { + queryAddrs = append(queryAddrs, usdtAta) + } else { + log.Sugar.Errorf("[SOL][%s] failed to derive USDT ATA: %v", address, err) + } + if usdcAta, err := FindATAAddress(address, USDC_Mint); err == nil { + queryAddrs = append(queryAddrs, usdcAta) + } else { + log.Sugar.Errorf("[SOL][%s] failed to derive USDC ATA: %v", address, err) + } + + // 拉取签名并去重 + seen := make(map[string]bool) + var result []solSignatureResult + for _, queryAddr := range queryAddrs { + respBody, err := SolGetSignaturesForAddress(queryAddr, limit, "", "") + if err != nil { + log.Sugar.Errorf("[SOL][%s] SolGetSignaturesForAddress(%s) failed: %v", address, queryAddr, err) + continue + } + + resultBody := gjson.GetBytes(respBody, "result") + if !resultBody.Exists() || !resultBody.IsArray() { + log.Sugar.Errorf("[SOL][%s] unexpected response format for %s: %s", address, queryAddr, string(respBody)) + continue + } + + var batch []solSignatureResult + err = json.Unmarshal([]byte(resultBody.Raw), &batch) + if err != nil { + log.Sugar.Errorf("[SOL][%s] failed to unmarshal signatures for %s: %v", address, queryAddr, err) + continue + } + + for _, sig := range batch { + if !seen[sig.Signature] { + seen[sig.Signature] = true + result = append(result, sig) + } + } + } + + if len(result) == 0 { + log.Sugar.Debugf("[SOL][%s] no transaction signatures found", address) + return + } + + // 按 blockTime 降序排列 + sort.Slice(result, func(i, j int) bool { + if result[i].BlockTime == nil { + return false + } + if result[j].BlockTime == nil { + return true + } + return *result[i].BlockTime > *result[j].BlockTime + }) + + // 时间截止线:订单过期时间 + 5 分钟 + cutoffTime := time.Now().Add(-config.GetOrderExpirationTimeDuration() - 5*time.Minute).Unix() + + log.Sugar.Debugf("[SOL][%s] fetched %d unique signatures from %d addresses, cutoff=%d", + address, len(result), len(queryAddrs), cutoffTime) + + // Process each transaction signature + for sigIdx, txSig := range result { + sig := txSig.Signature + + // Skip failed transactions + if txSig.Err != nil { + continue + } + + // 超过截止时间的旧签名不再处理 + if txSig.BlockTime != nil && *txSig.BlockTime < cutoffTime { + log.Sugar.Debugf("[SOL][%s] [%d/%d] sig=%s blockTime=%d before cutoff=%d, stopping scan", + address, sigIdx+1, len(result), sig, *txSig.BlockTime, cutoffTime) + break + } + + // 跳过已处理的签名 + if _, ok := gProcessedSignatures.Load(sig); ok { + continue + } + + log.Sugar.Debugf("[SOL][%s] [%d/%d] processing sig=%s slot=%d", address, sigIdx+1, len(result), sig, txSig.Slot) + + txData, err := SolGetTransaction(sig) + if err != nil { + log.Sugar.Debugf("[SOL][%s] sig=%s fetch failed: %v", address, sig, err) + continue + } + + instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array() + log.Sugar.Debugf("[SOL][%s] sig=%s has %d instructions", address, sig, len(instructions)) + + for instrIdx, instruction := range instructions { + programID := instruction.Get("programId").String() + parsedType := instruction.Get("parsed.type").String() + log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] programId=%s parsedType=%s", address, sig, instrIdx, programID, parsedType) + + transferInfo, err := ParseTransferInfoFromInstruction(instruction, txData) + if err != nil { + log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] parse error: %v", address, sig, instrIdx, err) + continue + } + if transferInfo == nil { + continue + } + + log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] transfer: src=%s dst=%s mint=%s rawAmount=%d amount=%.6f blockTime=%d", + address, sig, instrIdx, + transferInfo.Source, transferInfo.Destination, transferInfo.Mint, + transferInfo.RawAmount, transferInfo.Amount, transferInfo.BlockTime) + + if !isTransferToAddress(transferInfo, address) { + continue + } + + token, amount := getTokenTypeAndAmount(transferInfo) + if token == "" || amount <= 0 { + continue + } + + log.Sugar.Infof("[SOL][%s] sig=%s instr[%d] incoming transfer confirmed: token=%s amount=%.2f -> querying transaction_lock network=solana address=%s token=%s amount=%.2f", + address, sig, instrIdx, token, amount, address, token, amount) + + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount) + if err != nil { + log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err) + continue + } + if tradeID == "" { + log.Sugar.Infof("[SOL][%s] sig=%s no active transaction_lock matched: network=solana address=%s token=%s amount=%.2f (no order or expired)", + address, sig, address, token, amount) + continue + } + log.Sugar.Infof("[SOL][%s] transaction_lock matched: trade_id=%s sig=%s token=%s amount=%.2f", + address, tradeID, sig, token, amount) + + order, err := data.GetOrderInfoByTradeId(tradeID) + if err != nil { + log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err) + continue + } + log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d", + address, tradeID, order.OrderId, order.Status, order.CreatedAt.TimestampMilli()) + + // blockTime 秒 → 毫秒,与订单创建时间对齐 + blockTimestamp := transferInfo.BlockTime * 1000 + createTime := order.CreatedAt.TimestampMilli() + log.Sugar.Infof("[SOL][%s] time check: sig=%s block_time_ms=%d order_created_ms=%d diff_ms=%d", + address, sig, blockTimestamp, createTime, blockTimestamp-createTime) + if blockTimestamp < createTime { + log.Sugar.Warnf("[SOL][%s] sig=%s skipped: block_time_ms=%d is %d ms before order created_ms=%d (transaction predates the order)", + address, sig, blockTimestamp, createTime-blockTimestamp, createTime) + continue + } + + req := &request.OrderProcessingRequest{ + ReceiveAddress: address, + Token: token, + Network: mdb.NetworkSolana, + TradeId: tradeID, + Amount: amount, + BlockTransactionId: sig, + } + log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f", + address, tradeID, sig, token, amount) + err = OrderProcessing(req) + if err != nil { + if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { + log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err) + continue + } + log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err) + continue + } + + log.Sugar.Infof("[SOL][%s] order marked paid: trade_id=%s sig=%s token=%s amount=%.2f, sending telegram notification", + address, tradeID, sig, token, amount) + sendPaymentNotification(order) + log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig) + } + + // 标记已处理 + gProcessedSignatures.Store(sig, time.Now().Unix()) + } +} + +// solSignatureResult getSignaturesForAddress 返回结构 +type solSignatureResult struct { + Signature string `json:"signature"` + Slot uint64 `json:"slot"` + Err interface{} `json:"err"` + BlockTime *int64 `json:"blockTime"` +} + +// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试 +func SolRetryClient(method string, params []interface{}) ([]byte, error) { + client := resty.New() + client.SetRetryCount(5) + client.SetRetryWaitTime(2 * time.Second) + client.SetRetryMaxWaitTime(10 * time.Second) + client.AddRetryCondition(func(r *resty.Response, err error) bool { + if err != nil { + return true + } + if r.StatusCode() >= 429 || r.StatusCode() >= 500 { + return true + } + return false + }) + + rpcUrl := config.GetSolanaRpcUrl() + + resp, err := client.R(). + SetHeader("Content-Type", "application/json"). + SetBody(map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }). + Post(rpcUrl) + if err != nil { + return nil, err + } + + respBody := resp.Body() + + return respBody, nil +} + +func SolGetSignaturesForAddress(address string, limit int, untilSig string, beforeSig string) ([]byte, error) { + opts := map[string]interface{}{ + "commitment": "finalized", + "limit": limit, + } + if untilSig != "" { + opts["until"] = untilSig + } + if beforeSig != "" { + opts["before"] = beforeSig + } + + bodyData, err := SolRetryClient("getSignaturesForAddress", + []interface{}{address, opts}) + if err != nil { + return nil, err + } + + var result map[string]interface{} + err = json.Unmarshal(bodyData, &result) + if err != nil { + return nil, err + } + + _, ok := result["result"].([]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected response format: %v", result) + } + + return bodyData, nil +} + +func SolGetTransaction(sig string) ([]byte, error) { + txData, err := SolRetryClient("getTransaction", []interface{}{ + sig, + map[string]interface{}{ + "encoding": "jsonParsed", + "commitment": "confirmed", + "maxSupportedTransactionVersion": 0, // suport + }, + }) + if err != nil { + log.Sugar.Errorf("SolRetryClient failed: %v", err) + return nil, err + } + + errField := gjson.GetBytes(txData, "result.meta.err") + if errField.Exists() && errField.Type != gjson.Null { + log.Sugar.Warnf("Transaction failed: %v", errField.String()) + return nil, fmt.Errorf("transaction failed: %s", errField.String()) + } + + return txData, nil +} + +// isTransferToAddress 判断转账目标是否为指定钱包地址 +func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool { + // Native SOL transfer - check destination directly + if transfer.Mint == "SOL" { + return strings.EqualFold(transfer.Destination, targetAddress) + } + + // Skip Transfer instruction without mint info (use TransferChecked instead) + if transfer.Mint == "" { + return false + } + + // SPL Token transfer - check if destination ATA matches + return MatchAtaAddress(targetAddress, transfer.Mint, transfer.Destination) +} + +// getTokenTypeAndAmount 根据 mint 识别代币类型并计算可读金额 +func getTokenTypeAndAmount(transfer *TransferInfo) (string, float64) { + mint := transfer.Mint + + // Native SOL + if mint == "SOL" { + return "SOL", transfer.Amount + } + + // SPL Tokens + switch mint { + case USDT_Mint: + decimals := USDT_Decimals + if transfer.Decimals != nil { + decimals = int(*transfer.Decimals) + } + return "USDT", ADJustAmount(transfer.RawAmount, decimals) + + case USDC_Mint: + decimals := USDC_Decimals + if transfer.Decimals != nil { + decimals = int(*transfer.Decimals) + } + return "USDC", ADJustAmount(transfer.RawAmount, decimals) + + default: + // Unsupported token + return "", 0 + } +} const ( // Mint token USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" USDC_Mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + + USDT_Decimals = 6 + USDC_Decimals = 6 + SOL_Decimals = 9 +) + +const ( + // SPL Token instructions + InstructionTransfer = 3 + InstructionTransferChecked = 12 +) + +const ( + // System Program + SystemProgramID = "11111111111111111111111111111111" + InstructionSystemTransfer = 2 ) const ( @@ -24,6 +421,49 @@ const ( Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" ) +// ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留 2 位小数) +func ADJustAmount(amount uint64, decimals int) float64 { + if amount == 0 { + return 0 + } + decimalAmount := decimal.NewFromBigInt(new(big.Int).SetUint64(amount), 0) + // 10^decimals + decimalDivisor := decimal.New(1, int32(decimals)) + adjustedAmount := decimalAmount.Div(decimalDivisor) + // Round to 2 decimal places + return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), 2) +} + +func MatchUsdtAtaAddress(address string, ataTo string) bool { + ata, err := FindATAAddress(address, USDT_Mint) + if err != nil { + fmt.Printf("FindATAAddress failed: %v\n", err) + return false + } + + return strings.EqualFold(ata, ataTo) +} + +func MatchUsdcAtaAddress(address string, ataTo string) bool { + ata, err := FindATAAddress(address, USDC_Mint) + if err != nil { + fmt.Printf("FindATAAddress failed: %v\n", err) + return false + } + + return strings.EqualFold(ata, ataTo) +} + +func MatchAtaAddress(address string, mint string, ataTo string) bool { + ata, err := FindATAAddress(address, mint) + if err != nil { + fmt.Printf("FindATAAddress failed: %v\n", err) + return false + } + + return strings.EqualFold(ata, ataTo) +} + func FindATAAddress(owner, mint string) (string, error) { ownerPubKey, err := solana.PublicKeyFromBase58(owner) if err != nil { @@ -43,191 +483,127 @@ func FindATAAddress(owner, mint string) (string, error) { return ata.String(), nil } -const ( - InstructionTransfer = 3 - InstructionTransferChecked = 12 -) +// ParseTransferInfoFromInstruction 从单条指令中解析转账信息,非转账指令返回 nil +func ParseTransferInfoFromInstruction(instruction gjson.Result, txData []byte) (*TransferInfo, error) { + programID := instruction.Get("programId").String() + parsedType := instruction.Get("parsed.type").String() -type TransferInfo struct { - ProgramID string - Type string + if programID == SystemProgramID && parsedType == "transfer" { + return parseSystemTransfer(instruction, txData) + } - Source string - Destination string - Mint string - Authority string + if programID == TokenProgramID || programID == Token2022ProgramID { + switch parsedType { + case "transfer": + return parseSplTransfer(instruction, txData) + case "transferChecked": + return parseSplTransferChecked(instruction, txData) + } + } - Amount uint64 - Decimals *uint8 + // Skip non-transfer instructions (ComputeBudget, AToken create, etc.) + return nil, nil } -func isTokenProgram(programID solana.PublicKey) bool { - p := programID.String() - return p == TokenProgramID || p == Token2022ProgramID +func parseSystemTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) { + info := instruction.Get("parsed.info") + source := info.Get("source").String() + destination := info.Get("destination").String() + lamports := info.Get("lamports").Uint() + blockTime := gjson.GetBytes(txData, "result.blockTime").Int() + + return &TransferInfo{ + Source: source, + Destination: destination, + Mint: "SOL", + Amount: ADJustAmount(lamports, SOL_Decimals), + RawAmount: lamports, + BlockTime: blockTime, + }, nil } -func DecodeTransfer(data []byte, accountKeys []solana.PublicKey, accountIndexes []uint16, programID solana.PublicKey) (*TransferInfo, error) { - if len(data) < 9 { - return nil, fmt.Errorf("invalid transfer data length: %d", len(data)) +// parseSplTransfer 解析 SPL Token "transfer" 指令,mint 从 postTokenBalances 中查找 +func parseSplTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) { + info := instruction.Get("parsed.info") + source := info.Get("source").String() + destination := info.Get("destination").String() + amountStr := info.Get("amount").String() + blockTime := gjson.GetBytes(txData, "result.blockTime").Int() + + rawAmount, ok := new(big.Int).SetString(amountStr, 10) + if !ok { + return nil, fmt.Errorf("invalid amount: %s", amountStr) } - if data[0] != InstructionTransfer { - return nil, fmt.Errorf("not transfer instruction, got: %d", data[0]) + // Look up mint and decimals from postTokenBalances using the destination ATA + mint, decimals, found := findMintFromTokenBalances(destination, txData) + if !found { + // Try source ATA as fallback + mint, decimals, found = findMintFromTokenBalances(source, txData) + } + if !found { + return nil, fmt.Errorf("could not determine mint for transfer: source=%s dest=%s", source, destination) } - if len(accountIndexes) < 3 { - return nil, fmt.Errorf("transfer accounts not enough: %d", len(accountIndexes)) - } - - amount := binary.LittleEndian.Uint64(data[1:9]) - - sourceIdx := accountIndexes[0] - destIdx := accountIndexes[1] - authIdx := accountIndexes[2] - - if int(sourceIdx) >= len(accountKeys) || int(destIdx) >= len(accountKeys) || int(authIdx) >= len(accountKeys) { - return nil, errors.New("account index out of range") - } - - info := &TransferInfo{ - ProgramID: programID.String(), - Type: "transfer", - Source: accountKeys[sourceIdx].String(), - Destination: accountKeys[destIdx].String(), - Authority: accountKeys[authIdx].String(), - Amount: amount, - } - - return info, nil + d := decimals + return &TransferInfo{ + Source: source, + Destination: destination, + Mint: mint, + Amount: ADJustAmount(rawAmount.Uint64(), decimals), + RawAmount: rawAmount.Uint64(), + Decimals: &d, + BlockTime: blockTime, + }, nil } -type TransferCheckedInfo struct { - ProgramID string - Type string +// parseSplTransferChecked 解析 SPL Token "transferChecked" 指令 +func parseSplTransferChecked(instruction gjson.Result, txData []byte) (*TransferInfo, error) { + info := instruction.Get("parsed.info") + source := info.Get("source").String() + destination := info.Get("destination").String() + mint := info.Get("mint").String() + amountStr := info.Get("tokenAmount.amount").String() + decimals := int(info.Get("tokenAmount.decimals").Int()) + blockTime := gjson.GetBytes(txData, "result.blockTime").Int() - Source string - Destination string - Mint string - Authority string - - Amount uint64 - Decimals uint8 -} - -func DecodeTransferCheck(data []byte, accountKeys []solana.PublicKey, accountIndexes []uint16, programID solana.PublicKey) (*TransferInfo, error) { - if len(data) < 10 { - return nil, fmt.Errorf("invalid transferChecked data length: %d", len(data)) + rawAmount, ok := new(big.Int).SetString(amountStr, 10) + if !ok { + return nil, fmt.Errorf("invalid amount: %s", amountStr) } - if data[0] != InstructionTransferChecked { - return nil, fmt.Errorf("not transferChecked instruction, got: %d", data[0]) - } - - if len(accountIndexes) < 4 { - return nil, fmt.Errorf("transferChecked accounts not enough: %d", len(accountIndexes)) - } - - amount := binary.LittleEndian.Uint64(data[1:9]) - decimals := uint8(data[9]) - - sourceIdx := accountIndexes[0] - mintIdx := accountIndexes[1] - destIdx := accountIndexes[2] - authIdx := accountIndexes[3] - - if int(sourceIdx) >= len(accountKeys) || - int(mintIdx) >= len(accountKeys) || - int(destIdx) >= len(accountKeys) || - int(authIdx) >= len(accountKeys) { - return nil, errors.New("account index out of range") - } - - info := &TransferInfo{ - ProgramID: programID.String(), - Type: "transferChecked", - Source: accountKeys[sourceIdx].String(), - Mint: accountKeys[mintIdx].String(), - Destination: accountKeys[destIdx].String(), - Authority: accountKeys[authIdx].String(), - Amount: amount, + return &TransferInfo{ + Source: source, + Destination: destination, + Mint: mint, + Amount: ADJustAmount(rawAmount.Uint64(), decimals), + RawAmount: rawAmount.Uint64(), Decimals: &decimals, - } - - return info, nil + BlockTime: blockTime, + }, nil } -// ParseTransactionTransfers -func ParseTransactionTransfers(ctx context.Context, client *rpc.Client, sig string) ([]*TransferInfo, error) { - signature, err := solana.SignatureFromBase58(sig) - if err != nil { - return nil, fmt.Errorf("invalid signature: %w", err) - } - - tx, err := client.GetTransaction( - ctx, - signature, - &rpc.GetTransactionOpts{ - Encoding: solana.EncodingBase64, - Commitment: rpc.CommitmentConfirmed, - MaxSupportedTransactionVersion: func(v uint64) *uint64 { return &v }(0), - }, - ) - if err != nil { - return nil, fmt.Errorf("get transaction failed: %w", err) - } - if tx == nil { - return nil, errors.New("transaction not found") - } - - decodedTx, err := tx.Transaction.GetTransaction() - if err != nil { - return nil, fmt.Errorf("decode transaction failed: %w", err) - } - - msg := decodedTx.Message - accountKeys := msg.AccountKeys - results := make([]*TransferInfo, 0) - - for _, ix := range msg.Instructions { - if int(ix.ProgramIDIndex) >= len(accountKeys) { - continue - } - - programID := accountKeys[ix.ProgramIDIndex] - if !isTokenProgram(programID) { - continue - } - - data := ix.Data - if len(data) == 0 { - continue - } - - switch data[0] { - case InstructionTransfer: - info, err := DecodeTransfer(data, accountKeys, toUint16Slice(ix.Accounts), programID) - if err == nil { - results = append(results, info) - } - case InstructionTransferChecked: - info, err := DecodeTransferCheck(data, accountKeys, toUint16Slice(ix.Accounts), programID) - if err == nil { - results = append(results, info) - } +// findMintFromTokenBalances 从 postTokenBalances 中查找 ATA 对应的 mint 和 decimals +func findMintFromTokenBalances(ataAddress string, txData []byte) (string, int, bool) { + accountKeys := gjson.GetBytes(txData, "result.transaction.message.accountKeys").Array() + accountIndex := -1 + for i, key := range accountKeys { + if key.Get("pubkey").String() == ataAddress { + accountIndex = i + break } } + if accountIndex == -1 { + return "", 0, false + } - return results, nil -} - -func toUint16Slice(in []uint16) []uint16 { - out := make([]uint16, len(in)) - for i, v := range in { - out[i] = uint16(v) - } - return out -} - -func DecodeBase64InstructionData(s string) ([]byte, error) { - return base64.StdEncoding.DecodeString(s) + balances := gjson.GetBytes(txData, "result.meta.postTokenBalances").Array() + for _, balance := range balances { + if int(balance.Get("accountIndex").Int()) == accountIndex { + mint := balance.Get("mint").String() + decimals := int(balance.Get("uiTokenAmount.decimals").Int()) + return mint, decimals, true + } + } + return "", 0, false } diff --git a/src/model/service/sol_task_test.go b/src/model/service/sol_task_test.go index f694a4e..924a2a6 100644 --- a/src/model/service/sol_task_test.go +++ b/src/model/service/sol_task_test.go @@ -1,45 +1,349 @@ package service import ( - "context" + "encoding/json" "fmt" "testing" - "github.com/gagliardetto/solana-go/rpc" + "github.com/tidwall/gjson" ) -func TestParseSolTransaction(t *testing.T) { - client := rpc.New("https://api.mainnet-beta.solana.com") - sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN" - - txInfo, err := ParseTransactionTransfers(context.Background(), client, sig) +func TestSolClientHealthy(t *testing.T) { + bodyData, err := SolRetryClient("getHealth", nil) if err != nil { - panic(err) + t.Fatalf("SolRetryClient failed: %v", err) } - for _, item := range txInfo { - fmt.Println("type =", item.Type) - fmt.Println("program =", item.ProgramID) - fmt.Println("from =", item.Source) - fmt.Println("to =", item.Destination) - fmt.Println("mint =", item.Mint) - fmt.Println("authority =", item.Authority) - fmt.Println("amount =", item.Amount) - if item.Decimals != nil { - fmt.Println("decimals =", *item.Decimals) - } - fmt.Println("-----") + var result map[string]interface{} + err = json.Unmarshal(bodyData, &result) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) } + + status, ok := result["result"].(string) + if !ok { + t.Fatalf("Unexpected response format: %v", result) + } + + t.Logf("RPC Health Status: %s", status) + + if status != "ok" { + t.Errorf("Expected health status 'ok', got '%s'", status) + } +} + +func TestSolClientGetSignaturesForAddress(t *testing.T) { + // Example wallet address (replace with actual test address) + address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu" + + bodyData, err := SolRetryClient("getSignaturesForAddress", []interface{}{address, map[string]interface{}{"commitment": "finalized", "limit": 100}}) + if err != nil { + t.Fatalf("SolRetryClient failed: %v", err) + } + + var result map[string]interface{} + err = json.Unmarshal(bodyData, &result) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + signatures, ok := result["result"].([]interface{}) + if !ok { + t.Fatalf("Unexpected response format: %v", result) + } + + t.Logf("Found %d signatures for address %s", len(signatures), address) + +} + +func TestSolClientGetTransaction(t *testing.T) { + // Example transaction signature (replace with actual test signature) + sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5" + + txData, err := SolRetryClient("getTransaction", []interface{}{sig, map[string]interface{}{"encoding": "jsonParsed", "commitment": "finalized"}}) + if err != nil { + t.Fatalf("SolRetryClient failed: %v", err) + } + fmt.Printf("%v\n", string(txData)) + + var result map[string]interface{} + err = json.Unmarshal(txData, &result) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + txInfo, ok := result["result"].(map[string]interface{}) + if !ok { + t.Fatalf("Unexpected response format: %v", result) + } + + t.Logf("Transaction Info for signature %s: %v", sig, txInfo) } func TestFindATAAddress(t *testing.T) { - owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu" - mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token - - ata, err := FindATAAddress(owner, mint) - if err != nil { - panic(err) + tests := []struct { + name string + owner string + mint string + want string + }{ + { + name: "RAY token ATA", + owner: "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu", + mint: "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + want: "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4", + }, } - fmt.Println("ATA =", ata) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ata, err := FindATAAddress(tt.owner, tt.mint) + if err != nil { + t.Fatalf("FindATAAddress failed: %v", err) + } + + t.Logf("Owner: %s", tt.owner) + t.Logf("Mint: %s", tt.mint) + t.Logf("ATA: %s", ata) + + if tt.want != "" && ata != tt.want { + t.Errorf("Expected ATA %s, got %s", tt.want, ata) + } + }) + } +} + +func TestMatchATAAddress(t *testing.T) { + owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu" + mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token + expectedATA := "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4" + + ok := MatchAtaAddress(owner, mint, expectedATA) + t.Logf("Owner: %s", owner) + t.Logf("Mint: %s", mint) + t.Logf("Expected ATA: %s", expectedATA) + t.Logf("Match result: %v", ok) + + if !ok { + t.Error("Expected ATA to match, but it didn't") + } +} + +func TestMatchUsdtAtaAddress(t *testing.T) { + // Example wallet address (replace with actual test address) + owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu" + + ata, err := FindATAAddress(owner, USDT_Mint) + if err != nil { + t.Fatalf("FindATAAddress failed: %v", err) + } + + t.Logf("Owner: %s", owner) + t.Logf("USDT Mint: %s", USDT_Mint) + t.Logf("USDT ATA: %s", ata) + + ok := MatchUsdtAtaAddress(owner, ata) + if !ok { + t.Error("Expected USDT ATA to match") + } +} + +func TestMatchUsdcAtaAddress(t *testing.T) { + // Example wallet address (replace with actual test address) + owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu" + + ata, err := FindATAAddress(owner, USDC_Mint) + if err != nil { + t.Fatalf("FindATAAddress failed: %v", err) + } + + t.Logf("Owner: %s", owner) + t.Logf("USDC Mint: %s", USDC_Mint) + t.Logf("USDC ATA: %s", ata) + + ok := MatchUsdcAtaAddress(owner, ata) + if !ok { + t.Error("Expected USDC ATA to match") + } +} + +func TestAdjustAmount(t *testing.T) { + tests := []struct { + name string + amount uint64 + decimals int + want float64 + }{ + { + name: "USDT amount (6 decimals)", + amount: 123456789, + decimals: 6, + want: 123.46, + }, + { + name: "USDC amount (6 decimals)", + amount: 1000000, + decimals: 6, + want: 1.0, + }, + { + name: "SOL amount (9 decimals)", + amount: 1000000000, + decimals: 9, + want: 1.0, + }, + { + name: "Zero amount", + amount: 0, + decimals: 6, + want: 0, + }, + { + name: "Small amount", + amount: 1, + decimals: 6, + want: 0.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + adjusted := ADJustAmount(tt.amount, tt.decimals) + t.Logf("Raw amount: %d, Decimals: %d, Adjusted: %.2f", tt.amount, tt.decimals, adjusted) + + if adjusted != tt.want { + t.Errorf("Expected %.2f, got %.2f", tt.want, adjusted) + } + }) + } +} + +func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + // SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances) + sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN" + txData, err := SolGetTransaction(sig) + if err != nil { + t.Fatalf("SolGetTransaction failed: %v", err) + } + + instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array() + var found bool + for _, inst := range instructions { + info, err := ParseTransferInfoFromInstruction(inst, txData) + if err != nil { + t.Logf("parse error (ok to skip): %v", err) + continue + } + if info == nil { + continue + } + found = true + t.Logf("SPL transfer: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d", + info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime) + + if info.Mint == "" { + t.Error("Expected mint to be resolved from postTokenBalances") + } + if info.RawAmount != 50000 { + t.Errorf("Expected raw amount 50000, got %d", info.RawAmount) + } + if info.BlockTime == 0 { + t.Error("Expected non-zero blockTime") + } + } + if !found { + t.Error("No transfer instruction found in transaction") + } +} + +func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + // SPL Token "transferChecked" (has mint and tokenAmount in instruction) + sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5" + txData, err := SolGetTransaction(sig) + if err != nil { + t.Fatalf("SolGetTransaction failed: %v", err) + } + + instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array() + var found bool + for _, inst := range instructions { + info, err := ParseTransferInfoFromInstruction(inst, txData) + if err != nil { + t.Logf("parse error (ok to skip): %v", err) + continue + } + if info == nil { + continue + } + found = true + t.Logf("TransferChecked: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d", + info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime) + + if info.Mint != USDT_Mint { + t.Errorf("Expected USDT mint %s, got %s", USDT_Mint, info.Mint) + } + if info.RawAmount != 300000 { + t.Errorf("Expected raw amount 300000, got %d", info.RawAmount) + } + if info.Amount != 0.3 { + t.Errorf("Expected amount 0.3, got %f", info.Amount) + } + if info.BlockTime == 0 { + t.Error("Expected non-zero blockTime") + } + } + if !found { + t.Error("No transferChecked instruction found in transaction") + } +} + +func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + // System program SOL transfer + sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ" + txData, err := SolGetTransaction(sig) + if err != nil { + t.Fatalf("SolGetTransaction failed: %v", err) + } + + instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array() + transferCount := 0 + for _, inst := range instructions { + info, err := ParseTransferInfoFromInstruction(inst, txData) + if err != nil { + t.Logf("parse error (ok to skip): %v", err) + continue + } + if info == nil { + continue + } + transferCount++ + t.Logf("System transfer #%d: source=%s dest=%s mint=%s amount=%.9f raw=%d blockTime=%d", + transferCount, info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime) + + if info.Mint != "SOL" { + t.Errorf("Expected mint SOL, got %s", info.Mint) + } + if info.RawAmount == 0 { + t.Error("Expected non-zero raw amount") + } + if info.BlockTime == 0 { + t.Error("Expected non-zero blockTime") + } + } + if transferCount == 0 { + t.Error("No system transfer instruction found") + } + t.Logf("Found %d system transfers", transferCount) } diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go index a0bd8bc..a666513 100644 --- a/src/model/service/task_service.go +++ b/src/model/service/task_service.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "errors" "fmt" + "math/big" "net/http" "strings" "sync" @@ -19,6 +20,7 @@ import ( "github.com/assimon/luuu/util/log" "github.com/assimon/luuu/util/math" "github.com/dromara/carbon/v2" + "github.com/ethereum/go-ethereum/common" "github.com/gookit/goutil/stdutil" "github.com/shopspring/decimal" "github.com/tidwall/gjson" @@ -110,7 +112,7 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) { } txID := transfer.Get("txID").String() - tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount) + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "TRX", amount) if err != nil { panic(err) } @@ -134,6 +136,7 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) { req := &request.OrderProcessingRequest{ ReceiveAddress: address, Token: "TRX", + Network: mdb.NetworkTron, TradeId: tradeID, Amount: amount, BlockTransactionId: txID, @@ -212,7 +215,7 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) { } txID := transfer.Get("transaction_id").String() - tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount) + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "USDT", amount) if err != nil { panic(err) } @@ -236,6 +239,7 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) { req := &request.OrderProcessingRequest{ ReceiveAddress: address, Token: "USDT", + Network: mdb.NetworkTron, TradeId: tradeID, Amount: amount, BlockTransactionId: txID, @@ -254,6 +258,84 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) { } } +func TryProcessEthereumERC20Transfer(contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) { + defer func() { + if err := recover(); err != nil { + log.Sugar.Errorf("[ETH-WS] TryProcessEthereumERC20Transfer panic: %v", err) + } + }() + + usdt := common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7") + usdc := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + var tokenSym string + switch contract { + case usdt: + tokenSym = "USDT" + case usdc: + tokenSym = "USDC" + default: + log.Sugar.Warnf("[ETH-WS] skip unsupported contract %s", contract.Hex()) + return + } + + walletAddr := strings.ToLower(toAddr.Hex()) + if rawValue == nil || rawValue.Sign() <= 0 { + log.Sugar.Infof("[ETH-%s][%s] skip non-positive or nil amount", tokenSym, walletAddr) + return + } + decimalQuant := decimal.NewFromBigInt(rawValue, 0) + amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2) + if amount <= 0 { + log.Sugar.Warnf("[ETH-%s][%s] skip non-positive amount %.2f", tokenSym, walletAddr, amount) + return + } + + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, walletAddr, tokenSym, amount) + if err != nil { + log.Sugar.Warnf("[ETH-%s][%s] lock lookup: %v", tokenSym, walletAddr, err) + return + } + if tradeID == "" { + log.Sugar.Warnf("[ETH-%s][%s] skip unmatched tx hash=%s amount=%.2f", tokenSym, walletAddr, txHash, amount) + return + } + + order, err := data.GetOrderInfoByTradeId(tradeID) + if err != nil { + log.Sugar.Warnf("[ETH-%s][%s] load order: %v", tokenSym, walletAddr, err) + return + } + if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkEthereum { + log.Sugar.Warnf("[ETH-%s][%s] skip trade_id=%s network=%q", tokenSym, walletAddr, tradeID, order.Network) + return + } + if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym { + log.Sugar.Warnf("[ETH-%s][%s] skip trade_id=%s token mismatch order=%s", tokenSym, walletAddr, tradeID, order.Token) + return + } + + req := &request.OrderProcessingRequest{ + ReceiveAddress: walletAddr, + Token: tokenSym, + Network: mdb.NetworkEthereum, + TradeId: tradeID, + Amount: amount, + BlockTransactionId: txHash, + } + err = OrderProcessing(req) + if err != nil { + if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { + log.Sugar.Infof("[ETH-%s][%s] skip resolved trade_id=%s hash=%s err=%v", tokenSym, walletAddr, tradeID, txHash, err) + return + } + log.Sugar.Errorf("[ETH-%s][%s] OrderProcessing: %v", tokenSym, walletAddr, err) + return + } + + sendPaymentNotification(order) + log.Sugar.Infof("[ETH-%s][%s] payment processed trade_id=%s hash=%s", tokenSym, walletAddr, tradeID, txHash) +} + func sendPaymentNotification(order *mdb.Orders) { msg := fmt.Sprintf( "🎉 收款成功通知\n\n"+ @@ -263,6 +345,7 @@ func sendPaymentNotification(order *mdb.Orders) { "📋 订单信息\n"+ "├ 交易号:%s\n"+ "├ 订单号:%s\n"+ + "├ 网络:%s\n"+ "└ 钱包地址:%s\n\n"+ "⏰ 时间信息\n"+ "├ 创建时间:%s\n"+ @@ -273,9 +356,26 @@ func sendPaymentNotification(order *mdb.Orders) { strings.ToUpper(order.Token), order.TradeId, order.OrderId, + networkDisplay(order.Network), order.ReceiveAddress, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString(), ) telegram.SendToBot(msg) } + +func networkDisplay(n string) string { + switch strings.ToLower(strings.TrimSpace(n)) { + case mdb.NetworkTron: + return "Tron" + case mdb.NetworkSolana: + return "Solana" + case mdb.NetworkEthereum: + return "Ethereum" + default: + if n == "" { + return "Tron" + } + return strings.ToUpper(n) + } +} diff --git a/src/mq/worker.go b/src/mq/worker.go index c18d763..3797c92 100644 --- a/src/mq/worker.go +++ b/src/mq/worker.go @@ -2,7 +2,10 @@ package mq import ( "errors" + "fmt" + "io" "net/http" + "net/url" "strings" "time" @@ -23,6 +26,7 @@ const sqliteBusyRetryAttempts = 3 type expirableOrder struct { ID uint64 `gorm:"column:id"` TradeId string `gorm:"column:trade_id"` + Network string `gorm:"column:network"` ReceiveAddress string `gorm:"column:receive_address"` Token string `gorm:"column:token"` ActualAmount float64 `gorm:"column:actual_amount"` @@ -65,7 +69,7 @@ func processExpiredOrders() { var orders []expirableOrder err := withSQLiteBusyRetry(func() error { return dao.Mdb.Model(&mdb.Orders{}). - Select("id", "trade_id", "receive_address", "token", "actual_amount"). + Select("id", "trade_id", "network", "receive_address", "token", "actual_amount"). Where("status = ?", mdb.StatusWaitPay). Where("created_at <= ?", expirationCutoff). Order("id asc"). @@ -89,7 +93,7 @@ func processExpiredOrders() { if !expired { continue } - if err = data.UnLockTransaction(order.ReceiveAddress, order.Token, order.ActualAmount); err != nil { + if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil { log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err) } } @@ -161,36 +165,86 @@ func processCallback(tradeID string) { } func sendOrderCallback(order *mdb.Orders) error { - client := http_client.GetHttpClient() - orderResp := response.OrderNotifyResponse{ - TradeId: order.TradeId, - OrderId: order.OrderId, - Amount: order.Amount, - ActualAmount: order.ActualAmount, - ReceiveAddress: order.ReceiveAddress, - Token: order.Token, - BlockTransactionId: order.BlockTransactionId, - Status: mdb.StatusPaySuccess, - } - signature, err := sign.Get(orderResp, config.GetApiAuthToken()) - if err != nil { - return err - } - orderResp.Signature = signature - resp, err := client.R(). - SetHeader("powered-by", "Epusdt(https://github.com/GMwalletApp/epusdt)"). - SetBody(orderResp). - Post(order.NotifyUrl) - if err != nil { - return err - } - if resp.StatusCode() != http.StatusOK { - return errors.New(resp.Status()) - } - if string(resp.Body()) != "ok" { - return errors.New("not ok") + switch order.PaymentType { + case mdb.PaymentTypeEpay: + // 构造 EPay 标准回调参数 + notifyData := response.OrderNotifyResponseEpay{ + PID: config.GetEpayPid(), + TradeNo: order.TradeId, // epusdt 订单号作为 EPay 平台订单号 + OutTradeNo: order.OrderId, // 注意:EPay 回调要求商户订单号使用 out_trade_no 参数 + + Type: "alipay", + Name: order.Name, + Money: fmt.Sprintf("%.4f", order.Amount), + TradeStatus: "TRADE_SUCCESS", + } + + signstr2, err := sign.Get(notifyData, config.GetEpayKey()) + if err != nil { + return err + } + + // 使用 form-encoded POST(EPay 标准协议格式) + formData := url.Values{ + "pid": {fmt.Sprintf("%d", notifyData.PID)}, + "trade_no": {notifyData.TradeNo}, + "out_trade_no": {notifyData.OutTradeNo}, + "type": {notifyData.Type}, + "name": {notifyData.Name}, + "money": {notifyData.Money}, + "trade_status": {notifyData.TradeStatus}, + "sign": {signstr2}, + "sign_type": {"MD5"}, + } + + resp, err := http.PostForm(order.NotifyUrl, formData) + if err != nil { + return err + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + fmt.Printf("notify_url response status: %d, body: %s\n", resp.StatusCode, string(responseBody)) + + default: + + client := http_client.GetHttpClient() + orderResp := response.OrderNotifyResponse{ + TradeId: order.TradeId, + OrderId: order.OrderId, + Amount: order.Amount, + ActualAmount: order.ActualAmount, + ReceiveAddress: order.ReceiveAddress, + Token: order.Token, + BlockTransactionId: order.BlockTransactionId, + Status: mdb.StatusPaySuccess, + } + signature, err := sign.Get(orderResp, config.GetApiAuthToken()) + if err != nil { + return err + } + orderResp.Signature = signature + + resp, err := client.R(). + SetHeader("powered-by", "Epusdt(https://github.com/GMwalletApp/epusdt)"). + SetBody(orderResp). + Post(order.NotifyUrl) + if err != nil { + return err + } + if resp.StatusCode() != http.StatusOK { + return errors.New(resp.Status()) + } + if string(resp.Body()) != "ok" { + return errors.New("not ok") + } } + return nil } diff --git a/src/mq/worker_test.go b/src/mq/worker_test.go index b67804e..e3813c9 100644 --- a/src/mq/worker_test.go +++ b/src/mq/worker_test.go @@ -27,6 +27,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) ActualAmount: 1, ReceiveAddress: "wallet_1", Token: "USDT", + Network: "tron", Status: mdb.StatusWaitPay, NotifyUrl: "https://merchant.example/callback", } @@ -36,7 +37,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) if err := dao.Mdb.Model(order).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil { t.Fatalf("age expired order: %v", err) } - if err := data.LockTransaction(order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil { + if err := data.LockTransaction("tron", order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil { t.Fatalf("lock expired order: %v", err) } @@ -48,13 +49,14 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) ActualAmount: 1.01, ReceiveAddress: "wallet_1", Token: "USDT", + Network: "tron", Status: mdb.StatusWaitPay, NotifyUrl: "https://merchant.example/callback", } if err := dao.Mdb.Create(recentOrder).Error; err != nil { t.Fatalf("create recent order: %v", err) } - if err := data.LockTransaction(recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil { + if err := data.LockTransaction("tron", recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil { t.Fatalf("lock recent order: %v", err) } @@ -67,7 +69,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) if expired.Status != mdb.StatusExpired { t.Fatalf("expired order status = %d, want %d", expired.Status, mdb.StatusExpired) } - lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(order.ReceiveAddress, order.Token, order.ActualAmount) + lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", order.ReceiveAddress, order.Token, order.ActualAmount) if err != nil { t.Fatalf("expired order lock lookup: %v", err) } @@ -82,7 +84,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) if recent.Status != mdb.StatusWaitPay { t.Fatalf("recent order status = %d, want %d", recent.Status, mdb.StatusWaitPay) } - lockTradeID, err = data.GetTradeIdByWalletAddressAndAmountAndToken(recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.ActualAmount) + lockTradeID, err = data.GetTradeIdByWalletAddressAndAmountAndToken("tron", recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.ActualAmount) if err != nil { t.Fatalf("recent order lock lookup: %v", err) } diff --git a/src/route/router.go b/src/route/router.go index ce8e11b..ee54b32 100644 --- a/src/route/router.go +++ b/src/route/router.go @@ -3,11 +3,17 @@ package route import ( "bytes" "encoding/json" + "fmt" "io" "net/http" + "strconv" + "github.com/assimon/luuu/config" "github.com/assimon/luuu/controller/comm" "github.com/assimon/luuu/middleware" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/util/constant" + "github.com/assimon/luuu/util/sign" "github.com/labstack/echo/v4" ) @@ -20,6 +26,7 @@ func RegisterRoute(e *echo.Echo) { payRoute := e.Group("/pay") payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter) payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus) + payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork) // payment routes paymentRoute := e.Group("/payments") @@ -40,7 +47,7 @@ func RegisterRoute(e *echo.Echo) { body["currency"] = "cny" } if _, ok := body["network"]; !ok { - body["network"] = "TRON" + body["network"] = "tron" } ctx.Set("request_body", body) @@ -49,6 +56,7 @@ func RegisterRoute(e *echo.Echo) { return comm.Ctrl.FailJson(ctx, err) } ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes)) + ctx.Request().ContentLength = int64(len(jsonBytes)) return comm.Ctrl.CreateTransaction(ctx) }, middleware.CheckApiSign()) @@ -57,4 +65,73 @@ func RegisterRoute(e *echo.Echo) { gmpayV1 := paymentRoute.Group("/gmpay/v1") gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign()) + // wallet management routes + walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken()) + walletV1.POST("/add", comm.Ctrl.AddWallet) + walletV1.GET("/list", comm.Ctrl.ListWallets) + walletV1.GET("/:id", comm.Ctrl.GetWallet) + walletV1.POST("/:id/status", comm.Ctrl.ChangeWalletStatus) + walletV1.POST("/:id/delete", comm.Ctrl.DeleteWallet) + + // epay v1 routes + epayV1 := paymentRoute.Group("/epay/v1") + epayV1.GET("/order/create-transaction/submit.php", func(ctx echo.Context) error { + money := ctx.QueryParam("money") + name := ctx.QueryParam("name") + notifyURL := ctx.QueryParam("notify_url") + outTradeNo := ctx.QueryParam("out_trade_no") + returnURL := ctx.QueryParam("return_url") + signstr := ctx.QueryParam("sign") + // signType := ctx.QueryParam("sign_type") + + m := map[string]interface{}{ + "money": money, + "name": name, + "notify_url": notifyURL, + "out_trade_no": outTradeNo, + "pid": config.GetEpayPid(), // 注意:验签时需要包含 pid 参数 + "return_url": returnURL, + } + + checkSignature, err := sign.Get(m, config.GetApiAuthToken()) + if err != nil { + return constant.SignatureErr + } + if checkSignature != signstr { + return constant.SignatureErr + } + + amountFloat, err := strconv.ParseFloat(money, 64) + if err != nil { + return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money)) + } + + body := map[string]interface{}{ + "token": "usdt", + "currency": "cny", + "network": "tron", + "amount": amountFloat, + "notify_url": notifyURL, + "order_id": outTradeNo, + "redirect_url": returnURL, + "signature": signstr, + "name": name, + "payment_type": mdb.PaymentTypeEpay, + } + + ctx.Set("request_body", body) + + jsonBytes, err := json.Marshal(body) + if err != nil { + return comm.Ctrl.FailJson(ctx, err) + } + + ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes)) + ctx.Request().ContentLength = int64(len(jsonBytes)) + ctx.Request().Method = http.MethodPost + ctx.Request().Header.Set("Content-Type", "application/json") + + return comm.Ctrl.CreateTransactionAndRedirect(ctx) + + }) } diff --git a/src/route/router_test.go b/src/route/router_test.go new file mode 100644 index 0000000..ddc0a5c --- /dev/null +++ b/src/route/router_test.go @@ -0,0 +1,403 @@ +package route + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/assimon/luuu/model/dao" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/util/log" + "github.com/assimon/luuu/util/sign" + "github.com/labstack/echo/v4" + "github.com/spf13/viper" +) + +const testAPIToken = "test-secret-token" + +func setupTestEnv(t *testing.T) *echo.Echo { + t.Helper() + + tmpDir := t.TempDir() + + // minimal viper config + viper.Reset() + viper.Set("db_type", "sqlite") + viper.Set("api_auth_token", testAPIToken) + viper.Set("app_uri", "http://localhost:8080") + viper.Set("order_expiration_time", 10) + viper.Set("api_rate_url", "") + viper.Set("forced_usdt_rate", 7.0) + viper.Set("runtime_root_path", tmpDir) + viper.Set("log_save_path", tmpDir) + viper.Set("sqlite_database_filename", tmpDir+"/test.db") + viper.Set("runtime_sqlite_filename", tmpDir+"/runtime.db") + + log.Init() + + // init config paths + os.Setenv("EPUSDT_CONFIG", tmpDir) + defer os.Unsetenv("EPUSDT_CONFIG") + + // init DB + if err := dao.DBInit(); err != nil { + t.Fatalf("DBInit: %v", err) + } + if err := dao.RuntimeInit(); err != nil { + t.Fatalf("RuntimeInit: %v", err) + } + + // ensure tables exist (MdbTableInit uses sync.Once, so migrate directly) + dao.Mdb.AutoMigrate(&mdb.Orders{}, &mdb.WalletAddress{}) + + // seed wallet addresses + dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable}) + dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkSolana, Address: "SolTestAddress001", Status: mdb.TokenStatusEnable}) + + e := echo.New() + RegisterRoute(e) + return e +} + +func signBody(body map[string]interface{}) map[string]interface{} { + sig, _ := sign.Get(body, testAPIToken) + body["signature"] = sig + return body +} + +func doPost(e *echo.Echo, path string, body map[string]interface{}) *httptest.ResponseRecorder { + jsonBytes, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// TestCreateOrderEpusdtDefaultTron tests the epusdt compatibility route defaults to tron network. +func TestCreateOrderEpusdtDefaultTron(t *testing.T) { + e := setupTestEnv(t) + + body := signBody(map[string]interface{}{ + "order_id": "test-tron-001", + "amount": 1.00, + "notify_url": "http://localhost/notify", + }) + + rec := doPost(e, "/payments/epusdt/v1/order/create-transaction", body) + t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String()) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected data in response, got: %v", resp) + } + + if data["trade_id"] == nil || data["trade_id"] == "" { + t.Error("expected trade_id in response") + } + if data["receive_address"] != "TTestTronAddress001" { + t.Errorf("expected tron address, got: %v", data["receive_address"]) + } + t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"]) +} + +// TestCreateOrderGmpayV1Solana tests the gmpay route with solana network. +func TestCreateOrderGmpayV1Solana(t *testing.T) { + e := setupTestEnv(t) + + body := signBody(map[string]interface{}{ + "order_id": "test-sol-001", + "amount": 1.00, + "token": "usdt", + "currency": "cny", + "network": "solana", + "notify_url": "http://localhost/notify", + }) + + rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body) + t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String()) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected data in response, got: %v", resp) + } + + if data["trade_id"] == nil || data["trade_id"] == "" { + t.Error("expected trade_id in response") + } + if data["receive_address"] != "SolTestAddress001" { + t.Errorf("expected solana address, got: %v", data["receive_address"]) + } + t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"]) +} + +// TestCreateOrderGmpayV1SolNative tests creating an order for native SOL token. +func TestCreateOrderGmpayV1SolNative(t *testing.T) { + e := setupTestEnv(t) + + body := signBody(map[string]interface{}{ + "order_id": "test-sol-native-001", + "amount": 0.05, + "token": "sol", + "currency": "usd", + "network": "solana", + "notify_url": "http://localhost/notify", + }) + + rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body) + t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String()) + + var resp map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &resp) + t.Logf("Response: %v", resp) + + // This may fail if rate API is not configured, which is expected in test + // The important thing is the route accepts the request with network=solana token=sol + if rec.Code != http.StatusOK { + t.Logf("Note: non-200 may be expected if rate API is not configured for SOL") + } +} + +func doGet(e *echo.Echo, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", testAPIToken) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func doPostWithToken(e *echo.Echo, path string, body map[string]interface{}) *httptest.ResponseRecorder { + jsonBytes, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("Authorization", testAPIToken) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} { + t.Helper() + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return resp +} + +// TestWalletAddAndList tests adding wallets via API and listing them. +func TestWalletAddAndList(t *testing.T) { + e := setupTestEnv(t) + + // Add a solana wallet + rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{ + "network": "solana", + "address": "NewSolWallet001", + }) + t.Logf("Add: %s", rec.Body.String()) + resp := parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("add wallet failed: %v", resp) + } + + // Add a tron wallet + rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{ + "network": "tron", + "address": "NewTronWallet001", + }) + resp = parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("add tron wallet failed: %v", resp) + } + + // List all wallets + rec = doGet(e, "/payments/gmpay/v1/wallet/list") + resp = parseResp(t, rec) + wallets := resp["data"].([]interface{}) + // 2 seeded + 2 added = 4 + if len(wallets) != 4 { + t.Fatalf("expected 4 wallets, got %d: %v", len(wallets), wallets) + } + + // List by network + rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=solana") + resp = parseResp(t, rec) + wallets = resp["data"].([]interface{}) + if len(wallets) != 2 { + t.Fatalf("expected 2 solana wallets, got %d", len(wallets)) + } + + rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=tron") + resp = parseResp(t, rec) + wallets = resp["data"].([]interface{}) + if len(wallets) != 2 { + t.Fatalf("expected 2 tron wallets, got %d", len(wallets)) + } +} + +// TestWalletDuplicateRejected tests that adding the same network+address twice fails. +func TestWalletDuplicateRejected(t *testing.T) { + e := setupTestEnv(t) + + body := map[string]interface{}{"network": "solana", "address": "DupWallet001"} + rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", body) + resp := parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("first add failed: %v", resp) + } + + // Same network+address should fail + rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", body) + resp = parseResp(t, rec) + if resp["status_code"].(float64) == 200 { + t.Fatal("expected duplicate to be rejected") + } + t.Logf("Duplicate rejected: %v", resp["message"]) + + // Same address, different network should succeed + rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{ + "network": "tron", + "address": "DupWallet001", + }) + resp = parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("same address on different network should succeed: %v", resp) + } +} + +// TestWalletStatusAndDelete tests enable/disable/delete operations. +func TestWalletStatusAndDelete(t *testing.T) { + e := setupTestEnv(t) + + // Add a wallet + rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{ + "network": "solana", + "address": "StatusTestWallet", + }) + resp := parseResp(t, rec) + wallet := resp["data"].(map[string]interface{}) + walletID := fmt.Sprintf("%.0f", wallet["id"].(float64)) + + // Get wallet + rec = doGet(e, "/payments/gmpay/v1/wallet/"+walletID) + resp = parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("get wallet failed: %v", resp) + } + + // Disable wallet + rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/"+walletID+"/status", map[string]interface{}{ + "status": 2, + }) + resp = parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("disable wallet failed: %v", resp) + } + + // Verify disabled — should not appear in available list + rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=solana") + resp = parseResp(t, rec) + wallets := resp["data"].([]interface{}) + for _, w := range wallets { + wm := w.(map[string]interface{}) + if wm["address"] == "StatusTestWallet" && wm["status"].(float64) != 2 { + t.Error("wallet should be disabled") + } + } + + // Delete wallet + rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/"+walletID+"/delete", nil) + resp = parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("delete wallet failed: %v", resp) + } + + // Verify deleted + rec = doGet(e, "/payments/gmpay/v1/wallet/"+walletID) + resp = parseResp(t, rec) + // Should return not found + if resp["status_code"].(float64) == 200 { + data := resp["data"].(map[string]interface{}) + if data["id"].(float64) > 0 { + t.Error("wallet should be deleted") + } + } +} + +// TestWalletAuthRequired tests that wallet APIs require auth token. +func TestWalletAuthRequired(t *testing.T) { + e := setupTestEnv(t) + + // No auth header — should not return success + req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/wallet/list", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + // The response should indicate auth failure (not 200 success) + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + // echo may return plain text error + if rec.Code == http.StatusOK { + t.Error("expected auth failure without token") + } + t.Logf("Auth rejected (non-JSON): status=%d body=%s", rec.Code, rec.Body.String()) + return + } + statusCode, _ := resp["status_code"].(float64) + if statusCode == 200 { + t.Error("expected auth failure without token") + } + t.Logf("Auth rejected: %v", resp) +} + +// TestCreateOrderNetworkIsolation verifies tron and solana wallets don't mix. +func TestCreateOrderNetworkIsolation(t *testing.T) { + e := setupTestEnv(t) + + // Try to create a solana order — should get solana address, not tron + body := signBody(map[string]interface{}{ + "order_id": fmt.Sprintf("test-isolation-%d", 1), + "amount": 1.00, + "token": "usdt", + "currency": "cny", + "network": "solana", + "notify_url": "http://localhost/notify", + }) + rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body) + + var resp map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &resp) + + data, ok := resp["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected data, got: %v", resp) + } + if data["receive_address"] == "TTestTronAddress001" { + t.Error("solana order should NOT get a tron address") + } + if data["receive_address"] != "SolTestAddress001" { + t.Errorf("expected SolTestAddress001, got %v", data["receive_address"]) + } +} diff --git a/src/static/index.html b/src/static/index.html index 2b4e4e1..71d00f9 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -1,5 +1,6 @@ + @@ -13,8 +14,8 @@ @variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); @theme inline { - --font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; - --font-nunito: "Nunito Variable", "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; + --font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; + --font-nunito: "Nunito Variable", "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -23,249 +24,524 @@ --color-popover-foreground:var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground:var(--primary-foreground); - --color-primary-hover: var(--primary-hover); --color-secondary: var(--secondary); + --color-secondary-foreground:var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); --color-border: var(--border); - --color-border-hover: var(--border-hover); + --color-input: var(--input); + --color-ring: var(--ring); --color-success: var(--success); --color-warning: var(--warning); - --shadow-card: var(--card-shadow); - --shadow-popover: var(--popover-shadow); --animate-state-in: state-in .28s cubic-bezier(.34,1.56,.64,1); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + } + + /* ── Design tokens ── */ + :root, [data-theme="light"] { + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground:oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground:oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground:oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --radius: 0.875rem; + --success: oklch(0.721 0.193 143.56); + --warning: oklch(0.741 0.188 55.68); + } + [data-theme="dark"] { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground:oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground:oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground:oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --success: oklch(0.747 0.201 143.5); + --warning: oklch(0.762 0.182 61.47); + } + + /* ── Ambient blobs ── */ + @keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } + } + + @keyframes fill-bar { + from { width: 0%; } + to { width: 100%; } + } + + @keyframes slide-out-l { + from { transform: translateX(0); } + to { transform: translateX(calc(-100% - 20px)); } + } + @keyframes slide-out-r { + from { transform: translateX(0); } + to { transform: translateX(calc(100% + 20px)); } + } + @keyframes slide-in-r { + from { transform: translateX(calc(100% + 20px)); } + to { transform: translateX(0); } + } + @keyframes slide-in-l { + from { transform: translateX(calc(-100% - 20px)); } + to { transform: translateX(0); } + } + + @keyframes blob-drift { + 0% { transform: translate(0,0) scale(1); } + 50% { transform: translate(30px,-20px) scale(1.06); } + 100% { transform: translate(-20px,30px) scale(0.96); } + } + + @layer components { + /* Card */ + .card { + @apply bg-card border border-border rounded-2xl shadow-md transition-colors duration-300; + } + + /* Chip / pill button */ + .chip { + @apply bg-card border border-border rounded-full px-3.5 py-1.5 text-card-foreground shadow-xs transition-all; + } + .chip:hover { @apply bg-accent border-ring; } + .chip:active { @apply opacity-60; } + .chip.w-9 { @apply p-0; } + + /* Copy rows */ + .row { + @apply flex items-center px-4 py-3.5 transition-colors; + } + .row:active { @apply bg-muted; } + + /* Icon button */ + .icon-btn { + @apply flex items-center justify-center size-8 rounded-sm text-muted-foreground bg-secondary border border-transparent shrink-0 transition-colors; + } + .icon-btn:hover { @apply bg-accent text-card-foreground border-border; } + + /* Primary button */ + .btn-primary { + @apply flex items-center justify-center bg-primary text-primary-foreground border border-border rounded-xl px-6 py-3 text-base font-semibold tracking-tight cursor-pointer shadow-md transition-[background,border-color,transform,opacity]; + } + .btn-primary:hover { @apply bg-primary/90; } + .btn-primary:active { @apply scale-[0.97] opacity-90; } + .btn-primary:disabled { @apply opacity-45 cursor-not-allowed scale-100; } + + /* Secondary button */ + .btn-secondary { + @apply inline-flex items-center justify-center bg-card text-card-foreground border border-border rounded-xl px-7 py-3 text-base font-medium cursor-pointer shadow-sm transition-all; + } + .btn-secondary:hover { @apply bg-accent border-ring; } + .btn-secondary:active { @apply opacity-70; } + + /* State icon circle */ + .state-icon { + @apply size-20 rounded-full flex items-center justify-center mx-auto; + } + + /* Dropdown menu */ + .menu { + @apply bg-popover border border-border rounded-xl shadow-xl overflow-hidden; + opacity: 0; + transform: translateY(-8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + } + .menu--up { transform: translateY(8px); } + .menu.is-open { opacity: 1; transform: translateY(0); pointer-events: all; } + .menu-item { + @apply flex items-center gap-2.5 px-4 py-2.5 text-sm font-medium text-card-foreground cursor-pointer transition-colors; + } + .menu-item:hover { @apply bg-accent; } + .menu-item.is-selected { @apply text-primary font-semibold; } + .menu-item + .menu-item { @apply border-t border-border; } + .select-trigger.is-open { opacity: 0.8; } + .select-trigger.is-open .select-chevron { transform: rotate(180deg); } + + /* QR wrapper */ + .qr-wrapper { + @apply bg-white rounded-xl p-3.5 border border-border shadow-md inline-block; + } + + /* Timer SVG rings */ + .timer-ring-track { stroke: var(--border); transition: stroke .3s; } + .timer-ring-progress { transform: rotate(-90deg); transform-origin: 50% 50%; transition: stroke-dashoffset 1s linear, stroke .5s; } + .timer-icon { @apply text-muted-foreground; } + + /* Flippable status card */ + .status-card { + position: relative; + perspective: 1600px; + transition: height 0.38s cubic-bezier(.22,1,.36,1); + } + .status-card-inner { + position: relative; width: 100%; + transform-style: preserve-3d; + transition: transform 0.82s cubic-bezier(.22,.9,.24,1); + } + .status-card.is-flipped .status-card-inner { transform: rotateY(180deg); } + .status-face { + @apply absolute inset-0 w-full; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + transform-style: preserve-3d; + } + .status-face-front { z-index: 2; } + .status-face-back { transform: rotateY(180deg); } + .state-panel-shell { @apply pt-px; } + .state-screen { @apply hidden; } + .state-screen.is-active { @apply flex; } + } + + /* ── Panel viewport: hide scrollbars ── */ + #panel-viewport { scrollbar-width: none; } + #panel-viewport::-webkit-scrollbar { display: none; } + + /* ── Toast ── */ + #toast { + @apply fixed left-1/2 bottom-9 text-sm font-medium px-5 py-2.5 rounded-xl border border-border shadow-xl opacity-0 pointer-events-none whitespace-nowrap z-9999; + @apply bg-foreground/95 text-background; + @apply dark:bg-card/95 dark:text-card-foreground; + transform: translateX(-50%) translateY(12px); + transition: opacity 0.22s, transform 0.22s; + } + + @keyframes state-in { + from { opacity: 0; transform: translateY(12px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + + ::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; } + ::view-transition-old(root) { z-index: 1; } + ::view-transition-new(root) { z-index: 9; } + + @media (prefers-reduced-motion: reduce) { + .status-card, .status-card-inner, .status-face, .timer-ring-progress { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } } - - - - - - -
+ -
+
- logo - GM Pay + logo + GM Pay
-
- EN - +
+ EN +
-
-
🇺🇸  English
-
🇨🇳  中文
-
🇯🇵  日本語
-
🇰🇷  한국어
-
🇭🇰  繁體中文
-
🇷🇺  Русский
+
- -
-
- - -
-

Amount to pay

-
- -- - -
-
--
-
- - - - - -
-
-
- - -
-
-
-
- - -
- -
-

Scan or copy address to pay

-
- - - - - -
- -
-
-
- -

--:--

-
-
-
-
-
-
- - - - -
- -
-
-

Amount to pay

-

--

-
- - - -
- -
- -
-
-

Payment address

-

--

-
- - - -
-
- - - - - -
- - Checking blockchain -
- + + - -
-
-
- - -
-
- -
-

Payment Successful

-

Redirecting…

- -
- - -
-
- -
-

Payment Expired

-

Please initiate a new payment

- -
- - -
-
- -
-

Connection Timeout

-

Unable to connect to the payment server

-
- - -
-
- - -
-
- -
-

Order Not Found

-

The order does not exist or has already expired

-
- -
+
+
+
-
+
+ +
+ + +
+
+
+
+
+
+
+
+ + + + + +
+ + +
+
+ +
+

Network

+
+
+ + +
+ +
+
+ +
+

Currency

+
+
+ + +
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + +
+ +
-
+ +
Copied
- -
Copied
- - - - + + + - + + \ No newline at end of file diff --git a/src/static/payment.css b/src/static/payment.css deleted file mode 100644 index 67e1b4b..0000000 --- a/src/static/payment.css +++ /dev/null @@ -1,395 +0,0 @@ -/* ============================================= - iOS 26 Liquid Glass Design System - ============================================= */ - -/* ---------- CSS Variables ---------- */ -:root, [data-theme="light"] { - --background: #f2f2f7; - --foreground: #1c1c1e; - --card: rgba(255,255,255,0.72); - --card-foreground: #1c1c1e; - --popover: rgba(255,255,255,0.88); - --popover-foreground:#1c1c1e; - --primary: #ffffff; - --primary-foreground:#1c1c1e; - --primary-hover: #e5e5ea; - --secondary: rgba(120,120,128,0.12); - --muted: rgba(120,120,128,0.08); - --muted-foreground: #8e8e93; - --accent: rgba(255,255,255,0.12); - --accent-foreground: #1c1c1e; - --border: rgba(60,60,67,0.12); - --border-hover: rgba(60,60,67,0.22); - --success: #34c759; - --warning: #ff9500; - --destructive: #ff3b30; - - --glass-bg: rgba(255,255,255,0.65); - --glass-border: rgba(255,255,255,0.8); - --glass-shadow: 0 8px 40px rgba(0,0,0,.10), 0 1.5px 0 rgba(255,255,255,.9) inset; - --card-shadow: 0 4px 24px rgba(0,0,0,.08); - --popover-shadow: 0 12px 40px rgba(0,0,0,.14); - - --blob-1: rgba(200,200,200,0.18); - --blob-2: rgba(52,199,89,0.14); - --blob-3: rgba(255,149,0,0.10); -} - -[data-theme="dark"] { - --background: #000000; - --foreground: #f5f5f7; - --card: rgba(28,28,30,0.82); - --card-foreground: #f5f5f7; - --popover: rgba(44,44,46,0.92); - --popover-foreground:#e5e5ea; - --primary: #ffffff; - --primary-foreground:#1c1c1e; - --primary-hover: #e5e5ea; - --secondary: rgba(120,120,128,0.18); - --muted: rgba(120,120,128,0.12); - --muted-foreground: #636366; - --accent: rgba(255,255,255,0.15); - --accent-foreground: #ffffff; - --border: rgba(255,255,255,0.1); - --border-hover: rgba(255,255,255,0.18); - --success: #30d158; - --warning: #ff9f0a; - --destructive: #ff453a; - - --glass-bg: rgba(28,28,30,0.75); - --glass-border: rgba(255,255,255,0.10); - --glass-shadow: 0 8px 40px rgba(0,0,0,.55), 0 1px 0 rgba(255,255,255,.06) inset; - --card-shadow: 0 4px 32px rgba(0,0,0,.55); - --popover-shadow: 0 12px 40px rgba(0,0,0,.5); - - --blob-1: rgba(255,255,255,0.14); - --blob-2: rgba(48,209,88,0.10); - --blob-3: rgba(255,159,10,0.08); -} - -/* ---------- Ambient blobs ---------- */ -.blob { - position: absolute; - border-radius: 50%; - filter: blur(80px); - animation: blob-drift 18s ease-in-out infinite alternate; -} -.blob-1 { - width: 420px; height: 420px; - background: var(--blob-1); - top: -120px; left: -100px; - animation-delay: 0s; -} -.blob-2 { - width: 340px; height: 340px; - background: var(--blob-2); - bottom: -80px; right: -60px; - animation-delay: -6s; -} -.blob-3 { - width: 260px; height: 260px; - background: var(--blob-3); - top: 45%; left: 55%; - animation-delay: -12s; -} -@keyframes blob-drift { - 0% { transform: translate(0,0) scale(1); } - 50% { transform: translate(30px,-20px) scale(1.06); } - 100% { transform: translate(-20px,30px) scale(0.96); } -} - -/* ---------- Liquid Glass Card ---------- */ -.glass-card { - background: var(--glass-bg); - -webkit-backdrop-filter: blur(28px) saturate(1.6); - backdrop-filter: blur(28px) saturate(1.6); - border: 1px solid var(--glass-border); - border-radius: 22px; - box-shadow: var(--glass-shadow); - transition: background 0.3s, border-color 0.3s, box-shadow 0.3s; -} - -/* ---------- Flippable Status Card ---------- */ -.status-card { - position: relative; - perspective: 1600px; - transition: height 0.38s cubic-bezier(.22,1,.36,1); -} -.status-card-inner { - position: relative; - width: 100%; - transform-style: preserve-3d; - transition: transform 0.82s cubic-bezier(.22,.9,.24,1); -} -.status-card.is-flipped .status-card-inner { - transform: rotateY(180deg); -} -.status-face { - position: absolute; - inset: 0; - width: 100%; - -webkit-backface-visibility: hidden; - backface-visibility: hidden; - transform-style: preserve-3d; -} -.status-face-front { - z-index: 2; -} -.status-face-back { - transform: rotateY(180deg); -} -.state-panel-shell { - padding-top: 2px; -} -.state-screen { - display: none; -} -.state-screen.is-active { - display: flex; -} - -/* ---------- Timer ---------- */ -.timer-ring-track { - stroke: var(--border); - transition: stroke .3s; -} -.timer-ring-progress { - transform: rotate(-90deg); - transform-origin: 50% 50%; - transition: stroke-dashoffset 1s linear, stroke .5s; -} -.timer-icon { - color: var(--muted-foreground); -} - -/* ---------- QR wrapper ---------- */ -.qr-wrapper { - background: #ffffff; - border-radius: 18px; - padding: 14px; - box-shadow: 0 2px 16px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.04); - display: inline-block; -} - -/* ---------- iOS Pill button ---------- */ -.ios-pill { - background: var(--glass-bg); - -webkit-backdrop-filter: blur(16px) saturate(1.4); - backdrop-filter: blur(16px) saturate(1.4); - border: 1px solid var(--glass-border); - border-radius: 99px; - padding: 6px 13px; - color: var(--card-foreground); - transition: background 0.15s, opacity 0.15s; -} -.ios-pill:hover { opacity: 0.82; } -.ios-pill:active { opacity: 0.6; } -.ios-pill.w-9 { padding: 0; justify-content: center; } - -/* ---------- iOS Rows ---------- */ -.ios-row { - display: flex; - align-items: center; - padding: 14px 16px; - transition: background 0.12s; -} -.ios-row:active { background: var(--muted); } - -/* ---------- Icon button ---------- */ -.ios-icon-btn { - display: flex; - align-items: center; - justify-content: center; - width: 32px; height: 32px; - border-radius: 10px; - color: var(--muted-foreground); - background: var(--secondary); - transition: background 0.15s, color 0.15s; - flex-shrink: 0; -} -.ios-icon-btn:hover { background: var(--accent); color: var(--primary); } - -/* ---------- Primary CTA button ---------- */ -.ios-btn-primary { - display: flex; - align-items: center; - justify-content: center; - background: var(--primary); - color: var(--primary-foreground); - border: none; - border-radius: 16px; - padding: 16px 24px; - font-size: 15px; - font-weight: 600; - letter-spacing: -0.2px; - cursor: pointer; - transition: background 0.15s, transform 0.1s, opacity 0.15s; - box-shadow: 0 4px 18px rgba(0,0,0,0.12), 0 1px 0 rgba(255,255,255,.2) inset; -} -[data-theme="dark"] .ios-btn-primary { - box-shadow: 0 4px 18px rgba(255,255,255,0.18), 0 1px 0 rgba(255,255,255,.12) inset; -} -.ios-btn-primary:hover { background: var(--primary-hover); } -.ios-btn-primary:active { transform: scale(0.97); opacity: 0.9; } -.ios-btn-primary:disabled { opacity: 0.45; cursor: not-allowed; transform: none; } - -/* ---------- Secondary button ---------- */ -.ios-btn-secondary { - display: inline-flex; - align-items: center; - justify-content: center; - background: var(--secondary); - color: var(--card-foreground); - border: 1px solid var(--border); - border-radius: 13px; - padding: 11px 28px; - font-size: 15px; - font-weight: 500; - cursor: pointer; - transition: background 0.15s, opacity 0.15s; -} -.ios-btn-secondary:hover { background: var(--accent); } -.ios-btn-secondary:active { opacity: 0.7; } - -/* ---------- State icon circle ---------- */ -.ios-state-icon { - width: 76px; height: 76px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - margin: 0 auto; -} - -/* ---------- Context Menu ---------- */ -.ios-menu { - background: var(--popover); - -webkit-backdrop-filter: blur(30px) saturate(1.8); - backdrop-filter: blur(30px) saturate(1.8); - border: 1px solid var(--glass-border); - border-radius: 16px; - box-shadow: var(--popover-shadow); - overflow: hidden; -} -.ios-menu.is-open { - opacity: 1; - transform: translateY(0); - pointer-events: all; -} -.ios-menu-item { - display: flex; - align-items: center; - gap: 10px; - padding: 11px 15px; - font-size: 14px; - font-weight: 500; - color: var(--card-foreground); - cursor: pointer; - transition: background 0.1s; -} -.ios-menu-item:hover { background: var(--accent); } -.ios-menu-item.is-selected { color: var(--primary); font-weight: 600; } -.ios-menu-item + .ios-menu-item { border-top: 1px solid var(--border); } - -/* ---------- Select states ---------- */ -.select-trigger.is-open { opacity: 0.8; } -.select-trigger.is-open .select-chevron { transform: rotate(180deg); } - -/* ---------- Toast ---------- */ -#toast { - position: fixed; - bottom: 36px; - left: 50%; - transform: translateX(-50%) translateY(12px); - background: rgba(28,28,30,0.88); - -webkit-backdrop-filter: blur(20px); - backdrop-filter: blur(20px); - color: #fff; - font-size: 13px; - font-weight: 500; - padding: 10px 20px; - border-radius: 20px; - border: 1px solid rgba(255,255,255,0.12); - box-shadow: 0 4px 20px rgba(0,0,0,.3); - opacity: 0; - pointer-events: none; - transition: opacity 0.22s, transform 0.22s; - z-index: 9999; - white-space: nowrap; -} -[data-theme="light"] #toast { - background: rgba(44,44,46,0.9); -} - -/* ---------- Animations ---------- */ -@keyframes state-in { - from { opacity: 0; transform: translateY(12px) scale(0.96); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -/* ---------- View Transition ---------- */ -::view-transition-old(root), -::view-transition-new(root) { - animation: none; - mix-blend-mode: normal; -} -::view-transition-old(root) { z-index: 1; } -::view-transition-new(root) { z-index: 9; } - -/* ---------- Debug toolbar ---------- */ -#debug-bar { - position: fixed; bottom: 20px; right: 20px; z-index: 9999; - display: flex; flex-direction: column; align-items: flex-end; gap: 8px; -} -#debug-toggle { - width: 36px; height: 36px; border-radius: 50%; border: none; - background: #7c3aed; color: #fff; cursor: pointer; - display: flex; align-items: center; justify-content: center; - box-shadow: 0 2px 12px rgba(124,58,237,.5); - transition: background .15s, transform .1s; -} -#debug-toggle:hover { background: #6d28d9; } -#debug-toggle:active { transform: scale(.92); } -#debug-panel { - display: none; flex-direction: column; gap: 5px; - background: rgba(15,15,20,.92); - -webkit-backdrop-filter: blur(10px); - backdrop-filter: blur(10px); - border: 1px solid rgba(124,58,237,.35); border-radius: 14px; - padding: 12px; min-width: 150px; - box-shadow: 0 8px 32px rgba(0,0,0,.5); -} -#debug-panel.is-open { display: flex; } -.debug-label { - font-size: 10px; font-weight: 700; color: #a78bfa; - letter-spacing: .12em; text-transform: uppercase; - margin-bottom: 4px; padding-bottom: 6px; - border-bottom: 1px solid rgba(255,255,255,.08); -} -.debug-btn { - background: rgba(255,255,255,.07); color: #d1d5db; - border: 1px solid rgba(255,255,255,.1); border-radius: 8px; - padding: 6px 10px; font-size: 12px; font-weight: 500; - cursor: pointer; text-align: left; font-family: inherit; - transition: background .12s, color .12s; -} -.debug-btn:hover { background: rgba(255,255,255,.14); color: #fff; } -.debug-btn--reset:hover { border-color: #60a5fa; color: #93c5fd; } -.debug-btn--success:hover { border-color: #34d399; color: #6ee7b7; } -.debug-btn--expired:hover { border-color: #f87171; color: #fca5a5; } -.debug-btn--timeout:hover { border-color: #fb923c; color: #fdba74; } -.debug-btn--urgent:hover { border-color: #facc15; color: #fde68a; } -.debug-btn--not-found { - background: rgba(142,142,147,.15); - color: #8e8e93; -} - -@media (prefers-reduced-motion: reduce) { - .status-card, - .status-card-inner, - .status-face, - .timer-ring-progress { - transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - } -} diff --git a/src/static/payment.js b/src/static/payment.js index 8e2b252..ab4eee3 100644 --- a/src/static/payment.js +++ b/src/static/payment.js @@ -1,24 +1,86 @@ +/* ============================================================= + * GM Pay — payment.js + * 后端对接只需关注: + * 1. CONFIG.api.selectMethod — Step 1 确认接口(POST 币种/网络,返回收款地址) + * 2. CONFIG.api.checkStatus — 轮询接口 URL + * 3. CONFIG.api.statusMap — 状态码映射 + * 4. index.html 底部