Network
+Currency
+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 @@
+
Amount to pay
-Scan or copy address to pay
---:--
-Amount to pay
---
-Payment address
---
-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
+