first commit
This commit is contained in:
commit
759da033fe
15 changed files with 1905 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
private_keys.txt
|
||||
*.txt
|
||||
proxies.txt
|
||||
__MACOSX
|
||||
BIN
axiom-checker
Executable file
BIN
axiom-checker
Executable file
Binary file not shown.
133
axiom.go
Normal file
133
axiom.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type VerifyResponse struct {
|
||||
Sol string `json:"sol"`
|
||||
Evm string `json:"evm"`
|
||||
IsNewUser bool `json:"isNewUser"`
|
||||
}
|
||||
|
||||
func getAxiomWallet(privateKeyB58 string) (*VerifyResponse, error) {
|
||||
kp, err := keypairFromSecretKey(privateKeyB58)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create keypair: %w", err)
|
||||
}
|
||||
|
||||
addr := kp.PublicKeyBase58()
|
||||
|
||||
// Get nonce
|
||||
transport, err := getNextProxy()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get proxy: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
nonceReq := map[string]string{"walletAddress": addr}
|
||||
nonceBody, _ := json.Marshal(nonceReq)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.axiom.trade/wallet-nonce", bytes.NewReader(nonceBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create nonce request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("content-type", "application/json")
|
||||
req.Header.Set("origin", "https://axiom.trade")
|
||||
req.Header.Set("referer", "https://axiom.trade/")
|
||||
req.Header.Set("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get nonce: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
nonceText, _ := io.ReadAll(resp.Body)
|
||||
nonce := string(nonceText)
|
||||
|
||||
// Try to parse as JSON
|
||||
var nonceData map[string]interface{}
|
||||
if json.Unmarshal(nonceText, &nonceData) == nil {
|
||||
if n, ok := nonceData["nonce"].(string); ok {
|
||||
nonce = n
|
||||
}
|
||||
}
|
||||
|
||||
// Sign message
|
||||
message := fmt.Sprintf("By signing, you agree to Axiom's Terms of Use & Privacy Policy (axiom.trade/legal).\n\nNonce: %s", nonce)
|
||||
signature := signMessage(kp.PrivateKey, []byte(message))
|
||||
|
||||
// Verify wallet
|
||||
verifyReq := map[string]interface{}{
|
||||
"walletAddress": addr,
|
||||
"signature": signature,
|
||||
"nonce": nonce,
|
||||
"referrer": nil,
|
||||
"allowRegistration": true,
|
||||
"isVerify": false,
|
||||
"forAddCredential": false,
|
||||
"allowLinking": false,
|
||||
}
|
||||
verifyBody, _ := json.Marshal(verifyReq)
|
||||
|
||||
req2, err := http.NewRequest("POST", "https://api.axiom.trade/verify-wallet-v2", bytes.NewReader(verifyBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create verify request: %w", err)
|
||||
}
|
||||
|
||||
req2.Header.Set("accept", "application/json, text/plain, */*")
|
||||
req2.Header.Set("content-type", "application/json")
|
||||
req2.Header.Set("origin", "https://axiom.trade")
|
||||
req2.Header.Set("referer", "https://axiom.trade/")
|
||||
req2.Header.Set("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||
|
||||
resp2, err := client.Do(req2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify wallet: %w", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
responseText, _ := io.ReadAll(resp2.Body)
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
errMsg := string(responseText)
|
||||
if len(errMsg) > 200 {
|
||||
errMsg = errMsg[:200]
|
||||
}
|
||||
return nil, fmt.Errorf("axiom error (%d): %s", resp2.StatusCode, errMsg)
|
||||
}
|
||||
|
||||
var result VerifyResponse
|
||||
if err := json.Unmarshal(responseText, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func getAxiomBalance(pk string) (string, float64, error) {
|
||||
ax, err := getAxiomWallet(pk)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
if ax.IsNewUser {
|
||||
return ax.Sol, 0, nil
|
||||
}
|
||||
|
||||
cfg := &Config{JupiterRateLimitMs: 1100}
|
||||
portfolio, err := getPortfolioValue(ax.Sol, cfg)
|
||||
if err != nil {
|
||||
return ax.Sol, 0, err
|
||||
}
|
||||
|
||||
return ax.Sol, portfolio.Value, nil
|
||||
}
|
||||
56
config.go
Normal file
56
config.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
JupiterRateLimitMs int `json:"jupiterRateLimitMs"`
|
||||
JupiterApiKeys []string `json:"jupiterApiKeys"`
|
||||
Threads int `json:"threads"`
|
||||
EnableSolanaCheck bool `json:"enableSolanaCheck"`
|
||||
EnableAxiomModule bool `json:"enableAxiomModule"`
|
||||
EnableEVMModule bool `json:"enableEVMModule"`
|
||||
}
|
||||
|
||||
func loadConfig() (*Config, error) {
|
||||
data, err := os.ReadFile("config.json")
|
||||
if err != nil {
|
||||
return &Config{
|
||||
JupiterRateLimitMs: 1100,
|
||||
Threads: 20,
|
||||
EnableSolanaCheck: true,
|
||||
EnableAxiomModule: false,
|
||||
EnableEVMModule: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.JupiterRateLimitMs <= 0 {
|
||||
cfg.JupiterRateLimitMs = 1100
|
||||
}
|
||||
if cfg.Threads <= 0 {
|
||||
cfg.Threads = 20
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func saveConfig(cfg *Config) error {
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile("config.json", data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
12
config.json
Normal file
12
config.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"jupiterRateLimitMs": 1100,
|
||||
"jupiterApiKeys": [
|
||||
"109e558d-f91c-4f78-8097-2ced322b6fd8",
|
||||
"c1899449-ab4b-4f49-be78-b8165626ed8e",
|
||||
"3016e992-0eb0-4ee4-b560-f962eb77f9e2"
|
||||
],
|
||||
"threads": 25,
|
||||
"enableSolanaCheck": true,
|
||||
"enableAxiomModule": true,
|
||||
"enableEVMModule": true
|
||||
}
|
||||
327
debank.go
Normal file
327
debank.go
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DeBankToken struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Chain string `json:"chain"`
|
||||
Balance string `json:"balance"`
|
||||
Decimals int `json:"decimals"`
|
||||
Price float64 `json:"price"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
type DeBankResponse struct {
|
||||
Data []DeBankToken `json:"data"`
|
||||
}
|
||||
|
||||
var networks = map[string]string{
|
||||
"eth": "Ethereum",
|
||||
"bsc": "BSC",
|
||||
"avax": "Avalanche",
|
||||
"matic": "Polygon",
|
||||
"arb": "Arbitrum",
|
||||
"ftm": "Fantom",
|
||||
"op": "Optimism",
|
||||
"cro": "Cronos",
|
||||
}
|
||||
|
||||
type DebankClient struct {
|
||||
account *BrowserUID
|
||||
nonce *DebankNonce
|
||||
signer *DebankSigner
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewDebankClient(transport *http.Transport) *DebankClient {
|
||||
if transport == nil {
|
||||
transport = &http.Transport{}
|
||||
}
|
||||
|
||||
account := makeBrowserUID()
|
||||
return &DebankClient{
|
||||
account: &account,
|
||||
nonce: NewDebankNonce(),
|
||||
signer: &DebankSigner{},
|
||||
client: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DebankClient) Get(pathname string, params map[string]string) ([]byte, error) {
|
||||
return d.request("GET", pathname, params)
|
||||
}
|
||||
|
||||
func (d *DebankClient) request(method, pathname string, params map[string]string) ([]byte, error) {
|
||||
values := url.Values{}
|
||||
for k, v := range params {
|
||||
values.Add(k, v)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for i, j := 0, len(keys)-1; i < j; i, j = i+1, j-1 {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
|
||||
var queryParts []string
|
||||
for _, k := range keys {
|
||||
queryParts = append(queryParts, url.QueryEscape(k)+"="+url.QueryEscape(values.Get(k)))
|
||||
}
|
||||
queryString := strings.Join(queryParts, "&")
|
||||
|
||||
apiURL := "https://api.debank.com" + pathname
|
||||
if queryString != "" {
|
||||
apiURL += "?" + queryString
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
headers := d.makeHeaders(method, pathname, queryString)
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("debank API error: status %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var errorResp struct {
|
||||
ErrorCode int `json:"error_code"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &errorResp); err == nil && errorResp.ErrorCode != 0 {
|
||||
return nil, fmt.Errorf("%d: %s", errorResp.ErrorCode, errorResp.ErrorMsg)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (d *DebankClient) makeHeaders(method, pathname, query string) map[string]string {
|
||||
if len(query) > 0 && query[0] == '?' {
|
||||
query = query[1:]
|
||||
}
|
||||
|
||||
nonceStr := d.nonce.Next()
|
||||
ts := time.Now().Unix()
|
||||
signature := d.signer.Sign(method, pathname, query, nonceStr, ts)
|
||||
|
||||
accountJSON, _ := json.Marshal(d.account)
|
||||
|
||||
return map[string]string{
|
||||
"x-api-nonce": "n_" + nonceStr,
|
||||
"x-api-sign": signature,
|
||||
"x-api-ts": strconv.FormatInt(ts, 10),
|
||||
"x-api-ver": "v2",
|
||||
"source": "web",
|
||||
"account": string(accountJSON),
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Referer": "https://debank.com/",
|
||||
"Origin": "https://debank.com",
|
||||
}
|
||||
}
|
||||
|
||||
type BrowserUID struct {
|
||||
RandomAt int64 `json:"random_at"`
|
||||
RandomID string `json:"random_id"`
|
||||
UserAddr *string `json:"user_addr"`
|
||||
}
|
||||
|
||||
type DebankNonce struct {
|
||||
abc string
|
||||
local int64
|
||||
}
|
||||
|
||||
func NewDebankNonce() *DebankNonce {
|
||||
return &DebankNonce{
|
||||
abc: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz",
|
||||
local: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *DebankNonce) pcg32() uint32 {
|
||||
const multiplier = 6364136223846793005
|
||||
const increment = 1
|
||||
|
||||
n.local = n.local*multiplier + increment
|
||||
return uint32((uint64(n.local) >> 33) & 0xFFFFFFFF)
|
||||
}
|
||||
|
||||
func (n *DebankNonce) Next() string {
|
||||
result := make([]byte, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
index := int(float64(n.pcg32()) / 2147483647.0 * 61.0)
|
||||
if index >= len(n.abc) {
|
||||
index = len(n.abc) - 1
|
||||
}
|
||||
result[i] = n.abc[index]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
type DebankSigner struct{}
|
||||
|
||||
func (s *DebankSigner) Sign(method, pathname, query string, nonce string, ts int64) string {
|
||||
data1 := method + "\n" + pathname + "\n" + query
|
||||
hash1 := sha256.Sum256([]byte(data1))
|
||||
hash1Hex := hex.EncodeToString(hash1[:])
|
||||
|
||||
data2 := "debank-api\nn_" + nonce + "\n" + strconv.FormatInt(ts, 10)
|
||||
hash2 := sha256.Sum256([]byte(data2))
|
||||
hash2Hex := hex.EncodeToString(hash2[:])
|
||||
|
||||
xor1, xor2 := s.xor(hash2Hex)
|
||||
|
||||
h1Data := xor1 + hash1Hex
|
||||
h1 := sha256.Sum256([]byte(h1Data))
|
||||
|
||||
h2Data := append([]byte(xor2), h1[:]...)
|
||||
h2 := sha256.Sum256(h2Data)
|
||||
|
||||
return hex.EncodeToString(h2[:])
|
||||
}
|
||||
|
||||
func (s *DebankSigner) xor(hash string) (string, string) {
|
||||
rez1 := make([]byte, 64)
|
||||
rez2 := make([]byte, 64)
|
||||
|
||||
for i := 0; i < 64; i++ {
|
||||
rez1[i] = hash[i] ^ 54
|
||||
rez2[i] = hash[i] ^ 92
|
||||
}
|
||||
|
||||
return string(rez1), string(rez2)
|
||||
}
|
||||
|
||||
func makeBrowserUID() BrowserUID {
|
||||
return BrowserUID{
|
||||
RandomAt: time.Now().Unix(),
|
||||
RandomID: strings.ReplaceAll(generateUUID(), "-", ""),
|
||||
UserAddr: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func generateUUID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
||||
|
||||
type DebankUser struct {
|
||||
ID string `json:"id"`
|
||||
Desc struct {
|
||||
BornAt int64 `json:"born_at"`
|
||||
USDValue float64 `json:"usd_value"`
|
||||
} `json:"desc"`
|
||||
Stats struct {
|
||||
USDValue float64 `json:"usd_value"`
|
||||
} `json:"stats"`
|
||||
}
|
||||
|
||||
type DebankUserResponse struct {
|
||||
User DebankUser `json:"user"`
|
||||
}
|
||||
|
||||
func (d *DebankClient) GetUser(address string) (*DebankUserResponse, error) {
|
||||
body, err := d.Get("/user", map[string]string{
|
||||
"id": address,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Data DebankUserResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrapper); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &wrapper.Data, nil
|
||||
}
|
||||
|
||||
func getDeBankBalance(address string) (float64, string, error) {
|
||||
transport, err := getNextProxy()
|
||||
if err != nil {
|
||||
transport = &http.Transport{}
|
||||
}
|
||||
|
||||
client := NewDebankClient(transport)
|
||||
|
||||
userResp, err := client.GetUser(address)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("debank API error: %v", err)
|
||||
}
|
||||
|
||||
totalValue := userResp.User.Desc.USDValue
|
||||
if totalValue == 0 {
|
||||
totalValue = userResp.User.Stats.USDValue
|
||||
}
|
||||
|
||||
if totalValue == 0 {
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
return totalValue, "", nil
|
||||
}
|
||||
|
||||
func getBalanceValue(balance string, decimals int) float64 {
|
||||
if balance == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
bal := new(big.Int)
|
||||
bal.SetString(balance, 10)
|
||||
|
||||
if bal.Sign() == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
divisor := new(big.Float).SetInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil))
|
||||
balFloat := new(big.Float).SetInt(bal)
|
||||
result := new(big.Float).Quo(balFloat, divisor)
|
||||
|
||||
resultFloat, _ := result.Float64()
|
||||
return math.Round(resultFloat*100000) / 100000
|
||||
}
|
||||
176
display.go
Normal file
176
display.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ANSI color codes
|
||||
const (
|
||||
ColorReset = "\033[0m"
|
||||
ColorRed = "\033[31m"
|
||||
ColorGreen = "\033[32m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorBlue = "\033[34m"
|
||||
ColorPurple = "\033[35m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorWhite = "\033[37m"
|
||||
ColorBold = "\033[1m"
|
||||
)
|
||||
|
||||
// ProgressTracker tracks checking progress
|
||||
type ProgressTracker struct {
|
||||
Total int
|
||||
Checked int
|
||||
WithBalance int
|
||||
TotalValue float64
|
||||
StartTime time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewProgressTracker creates a new progress tracker
|
||||
func NewProgressTracker(total int) *ProgressTracker {
|
||||
return &ProgressTracker{
|
||||
Total: total,
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Increment increments progress counters
|
||||
func (pt *ProgressTracker) Increment(balance float64) {
|
||||
pt.mu.Lock()
|
||||
defer pt.mu.Unlock()
|
||||
pt.Checked++
|
||||
pt.TotalValue += balance
|
||||
if balance > 0 {
|
||||
pt.WithBalance++
|
||||
}
|
||||
}
|
||||
|
||||
// GetStats returns current statistics
|
||||
func (pt *ProgressTracker) GetStats() (int, int, int, float64, time.Duration) {
|
||||
pt.mu.Lock()
|
||||
defer pt.mu.Unlock()
|
||||
elapsed := time.Since(pt.StartTime)
|
||||
return pt.Total, pt.Checked, pt.WithBalance, pt.TotalValue, elapsed
|
||||
}
|
||||
|
||||
// CalculateETA calculates estimated time to completion
|
||||
func (pt *ProgressTracker) CalculateETA() string {
|
||||
pt.mu.Lock()
|
||||
defer pt.mu.Unlock()
|
||||
|
||||
if pt.Checked == 0 {
|
||||
return "calculating..."
|
||||
}
|
||||
|
||||
elapsed := time.Since(pt.StartTime)
|
||||
avgTimePerKey := elapsed / time.Duration(pt.Checked)
|
||||
remaining := pt.Total - pt.Checked
|
||||
eta := avgTimePerKey * time.Duration(remaining)
|
||||
|
||||
if eta < time.Second {
|
||||
return "< 1s"
|
||||
} else if eta < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(eta.Seconds()))
|
||||
} else if eta < time.Hour {
|
||||
mins := int(eta.Minutes())
|
||||
secs := int(eta.Seconds()) % 60
|
||||
return fmt.Sprintf("%dm %ds", mins, secs)
|
||||
} else {
|
||||
hours := int(eta.Hours())
|
||||
mins := int(eta.Minutes()) % 60
|
||||
return fmt.Sprintf("%dh %dm", hours, mins)
|
||||
}
|
||||
}
|
||||
|
||||
type StatusDisplay struct {
|
||||
tracker *ProgressTracker
|
||||
stopChan chan bool
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewStatusDisplay creates new status display
|
||||
func NewStatusDisplay(tracker *ProgressTracker) *StatusDisplay {
|
||||
return &StatusDisplay{
|
||||
tracker: tracker,
|
||||
stopChan: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the display update loop
|
||||
func (sd *StatusDisplay) Start() {
|
||||
sd.wg.Add(1)
|
||||
go sd.updateLoop()
|
||||
}
|
||||
|
||||
// Stop stops the display
|
||||
func (sd *StatusDisplay) Stop() {
|
||||
sd.stopChan <- true
|
||||
sd.wg.Wait()
|
||||
sd.clearLine()
|
||||
}
|
||||
|
||||
func (sd *StatusDisplay) clearLine() {
|
||||
fmt.Print("\r\033[K")
|
||||
}
|
||||
|
||||
func (sd *StatusDisplay) updateLoop() {
|
||||
defer sd.wg.Done()
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sd.stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
sd.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sd *StatusDisplay) update() {
|
||||
total, checked, withBalance, totalValue, _ := sd.tracker.GetStats()
|
||||
eta := sd.tracker.CalculateETA()
|
||||
|
||||
percentage := 0
|
||||
if total > 0 {
|
||||
percentage = (checked * 100) / total
|
||||
}
|
||||
|
||||
progressColor := ColorYellow
|
||||
if percentage >= 100 {
|
||||
progressColor = ColorGreen
|
||||
} else if percentage >= 75 {
|
||||
progressColor = ColorCyan
|
||||
}
|
||||
|
||||
status := fmt.Sprintf("%s%s[%d%%]%s %s%d/%d%s checked | %s%d%s with balance | %s$%.2f%s total | ETA: %s%s%s",
|
||||
ColorBold, progressColor, percentage, ColorReset,
|
||||
ColorCyan, checked, total, ColorReset,
|
||||
ColorGreen, withBalance, ColorReset,
|
||||
ColorYellow, totalValue, ColorReset,
|
||||
ColorPurple, eta, ColorReset,
|
||||
)
|
||||
|
||||
sd.clearLine()
|
||||
fmt.Print("\r" + status)
|
||||
}
|
||||
|
||||
func ColorPrint(color, text string) {
|
||||
fmt.Print(color + text + ColorReset)
|
||||
}
|
||||
|
||||
func ColorPrintln(color, text string) {
|
||||
fmt.Println(color + text + ColorReset)
|
||||
}
|
||||
|
||||
func colorPrint(color, text string) {
|
||||
fmt.Print(color + text + ColorReset)
|
||||
}
|
||||
|
||||
func colorPrintln(color, text string) {
|
||||
fmt.Println(color + text + ColorReset)
|
||||
}
|
||||
14
go.mod
Normal file
14
go.mod
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
module axiom-checker
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/manifoldco/promptui v0.9.0
|
||||
github.com/mr-tron/base58 v1.2.0
|
||||
golang.org/x/net v0.20.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
)
|
||||
15
go.sum
Normal file
15
go.sum
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
||||
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
475
main.go
Normal file
475
main.go
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModePrivateKeys Mode = "private-keys"
|
||||
ModeAxiom Mode = "axiom"
|
||||
ModeBoth Mode = "both"
|
||||
)
|
||||
|
||||
type CheckResult struct {
|
||||
Line string
|
||||
Balance float64
|
||||
}
|
||||
|
||||
type Statistics struct {
|
||||
TotalBalance float64
|
||||
KeysWithBalance int
|
||||
EmptyKeys int
|
||||
TotalKeys int
|
||||
Duration time.Duration
|
||||
Mode string
|
||||
}
|
||||
|
||||
var (
|
||||
results []CheckResult
|
||||
resultsMutex sync.Mutex
|
||||
stats Statistics
|
||||
)
|
||||
|
||||
func addResult(line string, balance float64) {
|
||||
resultsMutex.Lock()
|
||||
defer resultsMutex.Unlock()
|
||||
results = append(results, CheckResult{Line: line, Balance: balance})
|
||||
|
||||
stats.TotalBalance += balance
|
||||
if balance > 0 {
|
||||
stats.KeysWithBalance++
|
||||
} else {
|
||||
stats.EmptyKeys++
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
addressFlag := flag.String("address", "", "Check specific address")
|
||||
flag.Parse()
|
||||
|
||||
printHeader()
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
initProxies()
|
||||
|
||||
if *addressFlag != "" {
|
||||
checkSingleAddress(*addressFlag, cfg)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
choice, err := showMainMenu(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\nCancelled\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch choice {
|
||||
case "settings":
|
||||
if err := showSettingsMenu(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\nSettings cancelled\n")
|
||||
continue
|
||||
}
|
||||
|
||||
case "start":
|
||||
initApiKeyWorkers(cfg)
|
||||
|
||||
fmt.Println()
|
||||
printConfigSummary(cfg)
|
||||
|
||||
moduleManager := NewModuleManager(cfg)
|
||||
enabledModules := moduleManager.GetEnabledModules()
|
||||
|
||||
if cfg.EnableSolanaCheck || len(enabledModules) > 0 {
|
||||
colorPrint(ColorGreen, "Active modules: ")
|
||||
modules := []string{}
|
||||
if cfg.EnableSolanaCheck {
|
||||
modules = append(modules, "Solana")
|
||||
}
|
||||
for _, m := range enabledModules {
|
||||
modules = append(modules, m.Name())
|
||||
}
|
||||
for i, m := range modules {
|
||||
if i > 0 {
|
||||
fmt.Print(", ")
|
||||
}
|
||||
colorPrint(ColorCyan, m)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Printf("Loaded proxies: %d\n", len(proxies))
|
||||
|
||||
keys := loadPrivateKeys()
|
||||
if len(keys) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "\nNo keys found!\nAdd to: private_keys.txt or keys/*.txt")
|
||||
continue
|
||||
}
|
||||
stats.TotalKeys = len(keys)
|
||||
|
||||
runChecker(keys, cfg, moduleManager)
|
||||
|
||||
fmt.Println("\nPress Enter to return to main menu...")
|
||||
fmt.Scanln()
|
||||
printHeader()
|
||||
|
||||
case "exit":
|
||||
colorPrintln(ColorYellow, "\nGoodbye!")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printHeader() {
|
||||
colorPrintln(ColorCyan, "")
|
||||
colorPrintln(ColorCyan, " ___ _ ___ _ _ ")
|
||||
colorPrintln(ColorCyan, " / _ \\__ _(_)___ _ __ ___ / __| |_ ___ __| |_____ _ _ ")
|
||||
colorPrintln(ColorCyan, " | (_| \\ \\ / / _ \\ ' \\/ -_) | (__| ' \\/ -_) _| / / -_) '_|")
|
||||
colorPrintln(ColorCyan, " \\___/_\\_\\_\\\\___/_|_|_\\___| \\___|_||_\\___\\__|_\\_\\___|_| ")
|
||||
fmt.Print(" ")
|
||||
colorPrintln(ColorYellow, "by @septum")
|
||||
colorPrintln(ColorCyan, "")
|
||||
}
|
||||
|
||||
func checkSingleAddress(address string, cfg *Config) {
|
||||
if strings.HasPrefix(address, "0x") || strings.HasPrefix(address, "0X") {
|
||||
balance, details, err := getDeBankBalance(address)
|
||||
if err != nil {
|
||||
fmt.Printf("[evm address][debank] %s - Error: %v\n", address, err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[evm address][debank] %s - $%.2f\n", address, balance)
|
||||
if details != "" {
|
||||
fmt.Print(details)
|
||||
}
|
||||
} else {
|
||||
portfolio, err := getPortfolioValue(address, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
tokenStr := ""
|
||||
if portfolio.Value > 0 {
|
||||
if portfolio.TokenCount > 10 {
|
||||
tokenStr = " (>10 tokens)"
|
||||
} else {
|
||||
tokenStr = fmt.Sprintf(" (%d tokens)", portfolio.TokenCount)
|
||||
}
|
||||
}
|
||||
|
||||
valueStr := "0.00"
|
||||
if portfolio.Value > 0 {
|
||||
valueStr = fmt.Sprintf("%.2f", portfolio.Value)
|
||||
}
|
||||
|
||||
fmt.Printf("[solana address][jup.ag] %s - $%s%s\n", address, valueStr, tokenStr)
|
||||
}
|
||||
}
|
||||
|
||||
func runChecker(keys []string, cfg *Config, moduleManager *ModuleManager) {
|
||||
outputFile := fmt.Sprintf("results_%s.txt", time.Now().Format("2006-01-02_15-04-05"))
|
||||
|
||||
fmt.Printf("\n%s\n", strings.Repeat("━", 60))
|
||||
fmt.Printf("Keys to check: %d\n", len(keys))
|
||||
fmt.Printf("Output file: %s\n", outputFile)
|
||||
fmt.Printf("%s\n\n", strings.Repeat("━", 60))
|
||||
|
||||
startTime := time.Now()
|
||||
processKeys(keys, cfg, moduleManager)
|
||||
stats.Duration = time.Since(startTime)
|
||||
|
||||
sort.Slice(results, func(i, j int) bool { return results[i].Balance > results[j].Balance })
|
||||
|
||||
if err := writeResults(outputFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to write results: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n\n")
|
||||
colorPrintln(ColorGreen, fmt.Sprintf("✅ Results saved to: %s", outputFile))
|
||||
printSummary()
|
||||
}
|
||||
|
||||
func processKeys(keys []string, cfg *Config, moduleManager *ModuleManager) {
|
||||
tracker := NewProgressTracker(len(keys))
|
||||
display := NewStatusDisplay(tracker)
|
||||
display.Start()
|
||||
defer display.Stop()
|
||||
|
||||
for i := 0; i < len(keys); i += cfg.Threads {
|
||||
end := min(i+cfg.Threads, len(keys))
|
||||
var wg sync.WaitGroup
|
||||
for _, key := range keys[i:end] {
|
||||
wg.Add(1)
|
||||
go func(pk string) {
|
||||
defer wg.Done()
|
||||
checkKey(pk, cfg, moduleManager, tracker)
|
||||
}(key)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func checkKey(pk string, cfg *Config, moduleManager *ModuleManager, tracker *ProgressTracker) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
line := fmt.Sprintf("Error: %v", r)
|
||||
addResult(line, 0)
|
||||
tracker.Increment(0)
|
||||
}
|
||||
}()
|
||||
|
||||
var line string
|
||||
totalBalance := 0.0
|
||||
|
||||
if cfg.EnableSolanaCheck {
|
||||
kp, err := keypairFromSecretKey(pk)
|
||||
if err != nil {
|
||||
addResult(fmt.Sprintf("Error: %v", err), 0)
|
||||
tracker.Increment(0)
|
||||
return
|
||||
}
|
||||
addr := kp.PublicKeyBase58()
|
||||
pkBalance, _ := getPortfolioValue(addr, cfg)
|
||||
|
||||
line += fmt.Sprintf("[Private Key] %s\n", pk)
|
||||
line += fmt.Sprintf(" [SOL] %s - $%.2f", addr, pkBalance.Value)
|
||||
if pkBalance.TokenCount > 0 {
|
||||
line += fmt.Sprintf(" (%d tokens)", pkBalance.TokenCount)
|
||||
}
|
||||
line += "\n"
|
||||
|
||||
totalBalance += pkBalance.Value
|
||||
} else {
|
||||
line += fmt.Sprintf("[Private Key] %s\n", pk)
|
||||
}
|
||||
|
||||
moduleBalance, moduleDetails := moduleManager.CheckAll(pk)
|
||||
if moduleDetails != "" {
|
||||
line += moduleDetails
|
||||
totalBalance += moduleBalance
|
||||
}
|
||||
|
||||
if totalBalance > 0 {
|
||||
line += fmt.Sprintf(" %s[TOTAL: $%.2f]%s\n", ColorYellow, totalBalance, ColorReset)
|
||||
}
|
||||
|
||||
addResult(line, totalBalance)
|
||||
tracker.Increment(totalBalance)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func printSummary() {
|
||||
fmt.Println("\n" + strings.Repeat("=", 60))
|
||||
fmt.Println("SUMMARY")
|
||||
fmt.Println(strings.Repeat("=", 60))
|
||||
fmt.Printf("Mode: %s\n", stats.Mode)
|
||||
fmt.Printf("Total Balance: $%.2f\n", stats.TotalBalance)
|
||||
fmt.Printf("Keys with Balance: %d\n", stats.KeysWithBalance)
|
||||
fmt.Printf("Empty Keys: %d\n", stats.EmptyKeys)
|
||||
fmt.Printf("Total Keys: %d\n", stats.TotalKeys)
|
||||
fmt.Printf("Duration: %s\n", stats.Duration.Round(time.Second))
|
||||
fmt.Println(strings.Repeat("=", 60))
|
||||
}
|
||||
|
||||
func writeResults(filename string) error {
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fmt.Fprintf(f, "RESULTS\n")
|
||||
fmt.Fprintf(f, "Generated: %s\n\n", time.Now().Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(f, "Mode: %s\n", stats.Mode)
|
||||
fmt.Fprintf(f, "Total Balance: $%.2f\n", stats.TotalBalance)
|
||||
fmt.Fprintf(f, "Accounts with Balance: %d\n", stats.KeysWithBalance)
|
||||
fmt.Fprintf(f, ":0.01$> accounts%d\n", stats.EmptyKeys)
|
||||
fmt.Fprintf(f, "Total Keys: %d\n", stats.TotalKeys)
|
||||
fmt.Fprintf(f, "Duration: %s\n", stats.Duration.Round(time.Second))
|
||||
fmt.Fprintf(f, "\n%s\n\n", strings.Repeat("=", 80))
|
||||
|
||||
// Write sorted results
|
||||
for _, result := range results {
|
||||
fmt.Fprintln(f, result.Line)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func check(pk string, mode Mode, cfg *Config) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
line := fmt.Sprintf("Error: %v", r)
|
||||
fmt.Println(line)
|
||||
addResult(line, 0)
|
||||
}
|
||||
}()
|
||||
|
||||
if mode == ModePrivateKeys {
|
||||
kp, err := keypairFromSecretKey(pk)
|
||||
if err != nil {
|
||||
line := fmt.Sprintf("Error: %v", err)
|
||||
fmt.Println(line)
|
||||
addResult(line, 0)
|
||||
return
|
||||
}
|
||||
|
||||
addr := kp.PublicKeyBase58()
|
||||
portfolio, err := getPortfolioValue(addr, cfg)
|
||||
if err != nil {
|
||||
line := fmt.Sprintf("Error: %v", err)
|
||||
fmt.Println(line)
|
||||
addResult(line, 0)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := ""
|
||||
if portfolio.Value > 0 {
|
||||
if portfolio.TokenCount > 10 {
|
||||
tokenStr = " (>10 tokens)"
|
||||
} else {
|
||||
tokenStr = fmt.Sprintf(" (%d tokens)", portfolio.TokenCount)
|
||||
}
|
||||
}
|
||||
|
||||
valueStr := "0.00"
|
||||
if portfolio.Value > 0 {
|
||||
valueStr = fmt.Sprintf("%.2f", portfolio.Value)
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("[private key][jup.ag] %s | %s - $%s%s", pk, addr, valueStr, tokenStr)
|
||||
fmt.Println(line)
|
||||
addResult(line, portfolio.Value)
|
||||
return
|
||||
}
|
||||
|
||||
ax, err := getAxiomWallet(pk)
|
||||
if err != nil {
|
||||
line := fmt.Sprintf("Error: %v", err)
|
||||
fmt.Println(line)
|
||||
addResult(line, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if ax.IsNewUser {
|
||||
line := fmt.Sprintf("[axiom][jup.ag] %s | %s - NEW USER (skipped)", pk, ax.Sol)
|
||||
fmt.Println(line)
|
||||
addResult(line, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if mode == ModeAxiom {
|
||||
portfolio, err := getPortfolioValue(ax.Sol, cfg)
|
||||
if err != nil {
|
||||
line := fmt.Sprintf("Error: %v", err)
|
||||
fmt.Println(line)
|
||||
addResult(line, 0)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := ""
|
||||
if portfolio.Value > 0 {
|
||||
if portfolio.TokenCount > 10 {
|
||||
tokenStr = " (>10 tokens)"
|
||||
} else {
|
||||
tokenStr = fmt.Sprintf(" (%d tokens)", portfolio.TokenCount)
|
||||
}
|
||||
}
|
||||
|
||||
valueStr := "0.00"
|
||||
if portfolio.Value > 0 {
|
||||
valueStr = fmt.Sprintf("%.2f", portfolio.Value)
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("[axiom][jup.ag] %s | %s - $%s%s", pk, ax.Sol, valueStr, tokenStr)
|
||||
fmt.Println(line)
|
||||
addResult(line, portfolio.Value)
|
||||
return
|
||||
}
|
||||
|
||||
if mode == ModeBoth {
|
||||
kp, err := keypairFromSecretKey(pk)
|
||||
if err != nil {
|
||||
line := fmt.Sprintf("Error: %v", err)
|
||||
fmt.Println(line)
|
||||
addResult(line, 0)
|
||||
return
|
||||
}
|
||||
|
||||
addr := kp.PublicKeyBase58()
|
||||
|
||||
var p1, p2 *PortfolioValue
|
||||
var wg sync.WaitGroup
|
||||
var err1, err2 error
|
||||
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p1, err1 = getPortfolioValue(addr, cfg)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p2, err2 = getPortfolioValue(ax.Sol, cfg)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if err1 != nil || err2 != nil {
|
||||
line := "Error fetching portfolios"
|
||||
fmt.Println(line)
|
||||
addResult(line, 0)
|
||||
return
|
||||
}
|
||||
|
||||
total := p1.Value + p2.Value
|
||||
count := p1.TokenCount + p2.TokenCount
|
||||
|
||||
tokenStr := ""
|
||||
if total > 0 {
|
||||
if count > 10 {
|
||||
tokenStr = " (>10 tokens)"
|
||||
} else {
|
||||
tokenStr = fmt.Sprintf(" (%d tokens)", count)
|
||||
}
|
||||
}
|
||||
|
||||
valueStr := "0.00"
|
||||
if total > 0 {
|
||||
valueStr = fmt.Sprintf("%.2f", total)
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("[pk+axiom][jup.ag] %s | %s | %s - $%s%s", pk, addr, ax.Sol, valueStr, tokenStr)
|
||||
fmt.Println(line)
|
||||
addResult(line, total)
|
||||
}
|
||||
}
|
||||
|
||||
func tern(cond bool, t, f string) string {
|
||||
if cond {
|
||||
return t
|
||||
}
|
||||
return f
|
||||
}
|
||||
160
menu.go
Normal file
160
menu.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/manifoldco/promptui"
|
||||
)
|
||||
|
||||
func showMainMenu(cfg *Config) (string, error) {
|
||||
hasSettings := len(cfg.JupiterApiKeys) > 0
|
||||
|
||||
items := []string{"Settings", "Start", "Exit"}
|
||||
if !hasSettings {
|
||||
items = []string{"Settings (configure API keys first)", "Exit"}
|
||||
}
|
||||
|
||||
mainMenu := promptui.Select{
|
||||
Label: "Main Menu",
|
||||
Items: items,
|
||||
Templates: &promptui.SelectTemplates{
|
||||
Label: "{{ . }}",
|
||||
Active: "▸ {{ . | cyan }}",
|
||||
Inactive: " {{ . }}",
|
||||
Selected: "{{ . | green }}",
|
||||
},
|
||||
}
|
||||
|
||||
idx, _, err := mainMenu.Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !hasSettings {
|
||||
if idx == 0 {
|
||||
return "settings", nil
|
||||
}
|
||||
return "exit", nil
|
||||
}
|
||||
|
||||
switch idx {
|
||||
case 0:
|
||||
return "settings", nil
|
||||
case 1:
|
||||
return "start", nil
|
||||
case 2:
|
||||
return "exit", nil
|
||||
default:
|
||||
return "exit", nil
|
||||
}
|
||||
}
|
||||
|
||||
func showSettingsMenu(cfg *Config) error {
|
||||
for {
|
||||
items := []string{
|
||||
fmt.Sprintf("Jupiter API Keys (%d configured)", len(cfg.JupiterApiKeys)),
|
||||
fmt.Sprintf("Threads: %d", cfg.Threads),
|
||||
"",
|
||||
fmt.Sprintf("Solana Check: %s", tern(cfg.EnableSolanaCheck, "✓ enabled", "✗ disabled")),
|
||||
fmt.Sprintf("Axiom Module: %s", tern(cfg.EnableAxiomModule, "✓ enabled", "✗ disabled")),
|
||||
fmt.Sprintf("EVM Module: %s", tern(cfg.EnableEVMModule, "✓ enabled", "✗ disabled")),
|
||||
"",
|
||||
"Back",
|
||||
}
|
||||
|
||||
settingsMenu := promptui.Select{
|
||||
Label: "Settings",
|
||||
Items: items,
|
||||
Templates: &promptui.SelectTemplates{
|
||||
Label: "{{ . }}",
|
||||
Active: "▸ {{ . | cyan }}",
|
||||
Inactive: " {{ . }}",
|
||||
Selected: "{{ . | green }}",
|
||||
},
|
||||
}
|
||||
|
||||
idx, _, err := settingsMenu.Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch idx {
|
||||
case 0:
|
||||
if err := configureJupiterKeys(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
case 1:
|
||||
if err := configureThreads(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
case 2:
|
||||
continue
|
||||
case 3:
|
||||
cfg.EnableSolanaCheck = !cfg.EnableSolanaCheck
|
||||
saveConfig(cfg)
|
||||
case 4:
|
||||
cfg.EnableAxiomModule = !cfg.EnableAxiomModule
|
||||
saveConfig(cfg)
|
||||
case 5:
|
||||
cfg.EnableEVMModule = !cfg.EnableEVMModule
|
||||
saveConfig(cfg)
|
||||
case 6:
|
||||
continue
|
||||
case 7:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func configureJupiterKeys(cfg *Config) error {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Jupiter API Keys (comma-separated)",
|
||||
Default: strings.Join(cfg.JupiterApiKeys, ","),
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result == "" {
|
||||
cfg.JupiterApiKeys = []string{}
|
||||
} else {
|
||||
keys := strings.Split(result, ",")
|
||||
for i := range keys {
|
||||
keys[i] = strings.TrimSpace(keys[i])
|
||||
}
|
||||
cfg.JupiterApiKeys = keys
|
||||
}
|
||||
|
||||
return saveConfig(cfg)
|
||||
}
|
||||
|
||||
func configureThreads(cfg *Config) error {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Threads",
|
||||
Default: strconv.Itoa(cfg.Threads),
|
||||
Validate: func(s string) error {
|
||||
_, err := strconv.Atoi(s)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.Threads, _ = strconv.Atoi(result)
|
||||
return saveConfig(cfg)
|
||||
}
|
||||
|
||||
func printConfigSummary(cfg *Config) {
|
||||
colorPrint(ColorCyan, "Jupiter API keys: ")
|
||||
fmt.Printf("%d\n", len(cfg.JupiterApiKeys))
|
||||
|
||||
colorPrint(ColorCyan, "Threads: ")
|
||||
fmt.Printf("%d\n", cfg.Threads)
|
||||
}
|
||||
143
modules.go
Normal file
143
modules.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
Name() string
|
||||
Check(pk string, cfg *Config) (*ModuleResult, error)
|
||||
Enabled(cfg *Config) bool
|
||||
}
|
||||
|
||||
type ModuleResult struct {
|
||||
Address string
|
||||
Balance float64
|
||||
Details string
|
||||
Tokens int
|
||||
}
|
||||
|
||||
type ModuleManager struct {
|
||||
modules []Module
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
func NewModuleManager(cfg *Config) *ModuleManager {
|
||||
mm := &ModuleManager{
|
||||
cfg: cfg,
|
||||
modules: []Module{},
|
||||
}
|
||||
|
||||
if cfg.EnableAxiomModule {
|
||||
mm.modules = append(mm.modules, &AxiomModule{})
|
||||
}
|
||||
|
||||
if cfg.EnableEVMModule {
|
||||
mm.modules = append(mm.modules, &EVMModule{})
|
||||
}
|
||||
|
||||
return mm
|
||||
}
|
||||
|
||||
func (mm *ModuleManager) GetEnabledModules() []Module {
|
||||
return mm.modules
|
||||
}
|
||||
|
||||
func (mm *ModuleManager) CheckAll(pk string) (float64, string) {
|
||||
if len(mm.modules) == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
resultsChan := make(chan *ModuleResult, len(mm.modules))
|
||||
|
||||
for _, module := range mm.modules {
|
||||
wg.Add(1)
|
||||
go func(m Module) {
|
||||
defer wg.Done()
|
||||
result, err := m.Check(pk, mm.cfg)
|
||||
if err == nil && result != nil {
|
||||
resultsChan <- result
|
||||
}
|
||||
}(module)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(resultsChan)
|
||||
|
||||
var totalBalance float64
|
||||
var details string
|
||||
|
||||
for result := range resultsChan {
|
||||
totalBalance += result.Balance
|
||||
details += result.Details
|
||||
}
|
||||
|
||||
return totalBalance, details
|
||||
}
|
||||
|
||||
type AxiomModule struct{}
|
||||
|
||||
func (m *AxiomModule) Name() string {
|
||||
return "Axiom"
|
||||
}
|
||||
|
||||
func (m *AxiomModule) Enabled(cfg *Config) bool {
|
||||
return cfg.EnableAxiomModule
|
||||
}
|
||||
|
||||
func (m *AxiomModule) Check(pk string, cfg *Config) (*ModuleResult, error) {
|
||||
address, balance, err := getAxiomBalance(pk)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &ModuleResult{
|
||||
Address: address,
|
||||
Balance: balance,
|
||||
Details: fmt.Sprintf(" [Axiom] %s - $%.2f\n", address, balance),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type EVMModule struct{}
|
||||
|
||||
func (m *EVMModule) Name() string {
|
||||
return "EVM (DeBank)"
|
||||
}
|
||||
|
||||
func (m *EVMModule) Enabled(cfg *Config) bool {
|
||||
return cfg.EnableEVMModule
|
||||
}
|
||||
|
||||
func (m *EVMModule) Check(pk string, cfg *Config) (*ModuleResult, error) {
|
||||
ax, err := getAxiomWallet(pk)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if ax.Evm == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
balance, details, err := getDeBankBalance(ax.Evm)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if balance == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := &ModuleResult{
|
||||
Address: ax.Evm,
|
||||
Balance: balance,
|
||||
Details: fmt.Sprintf(" [EVM] %s - $%.2f\n", ax.Evm, balance),
|
||||
}
|
||||
|
||||
if details != "" {
|
||||
result.Details += details
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
231
portfolio.go
Normal file
231
portfolio.go
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const SOL_MINT = "So11111111111111111111111111111111111111112"
|
||||
|
||||
type ApiKeyWorker struct {
|
||||
apiKey string
|
||||
lastRequestTime time.Time
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
var (
|
||||
apiKeyWorkers []*ApiKeyWorker
|
||||
workerIndex int
|
||||
workerMutex sync.Mutex
|
||||
)
|
||||
|
||||
func initApiKeyWorkers(cfg *Config) {
|
||||
apiKeyWorkers = make([]*ApiKeyWorker, len(cfg.JupiterApiKeys))
|
||||
for i, key := range cfg.JupiterApiKeys {
|
||||
apiKeyWorkers[i] = &ApiKeyWorker{
|
||||
apiKey: key,
|
||||
lastRequestTime: time.Time{},
|
||||
}
|
||||
}
|
||||
workerIndex = 0
|
||||
}
|
||||
|
||||
func getNextApiKeyWorker() *ApiKeyWorker {
|
||||
workerMutex.Lock()
|
||||
defer workerMutex.Unlock()
|
||||
|
||||
worker := apiKeyWorkers[workerIndex]
|
||||
workerIndex = (workerIndex + 1) % len(apiKeyWorkers)
|
||||
return worker
|
||||
}
|
||||
|
||||
func (w *ApiKeyWorker) rateLimit(cfg *Config) {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
|
||||
elapsed := time.Since(w.lastRequestTime)
|
||||
waitTime := time.Duration(cfg.JupiterRateLimitMs)*time.Millisecond - elapsed
|
||||
|
||||
if waitTime > 0 {
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
|
||||
w.lastRequestTime = time.Now()
|
||||
}
|
||||
|
||||
type PortfolioValue struct {
|
||||
Value float64
|
||||
TokenCount int
|
||||
}
|
||||
|
||||
type Balance struct {
|
||||
UiAmount float64 `json:"uiAmount"`
|
||||
}
|
||||
|
||||
type PriceData struct {
|
||||
UsdPrice float64 `json:"usdPrice"`
|
||||
}
|
||||
|
||||
func getPortfolioValue(address string, cfg *Config) (*PortfolioValue, error) {
|
||||
const MAX_RETRIES = 2
|
||||
|
||||
// Get a worker for this request
|
||||
worker := getNextApiKeyWorker()
|
||||
|
||||
for attempt := 0; attempt < MAX_RETRIES; attempt++ {
|
||||
result, err := attemptGetPortfolio(address, cfg, worker)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if attempt == MAX_RETRIES-1 {
|
||||
return &PortfolioValue{Value: 0, TokenCount: 0}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &PortfolioValue{Value: 0, TokenCount: 0}, nil
|
||||
}
|
||||
|
||||
func attemptGetPortfolio(address string, cfg *Config, worker *ApiKeyWorker) (*PortfolioValue, error) {
|
||||
const BATCH_SIZE = 100
|
||||
const MAX_TOKENS = 100
|
||||
|
||||
worker.rateLimit(cfg)
|
||||
|
||||
transport, err := getNextProxy()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get proxy: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Get balances
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("https://api.jup.ag/ultra/v1/balances/%s", address), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("x-api-key", worker.apiKey)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("balances request failed: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var balances map[string]Balance
|
||||
if err := json.Unmarshal(body, &balances); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(balances) == 0 {
|
||||
return &PortfolioValue{Value: 0, TokenCount: 0}, nil
|
||||
}
|
||||
|
||||
// Build token list
|
||||
var tokens []string
|
||||
for addr := range balances {
|
||||
if addr != "SOL" {
|
||||
tokens = append(tokens, addr)
|
||||
}
|
||||
}
|
||||
|
||||
// Add SOL mint if SOL balance exists
|
||||
if _, hasSol := balances["SOL"]; hasSol {
|
||||
tokens = append(tokens, SOL_MINT)
|
||||
}
|
||||
|
||||
if len(tokens) > MAX_TOKENS {
|
||||
tokens = tokens[:MAX_TOKENS]
|
||||
}
|
||||
|
||||
if len(tokens) == 0 {
|
||||
return &PortfolioValue{Value: 0, TokenCount: 0}, nil
|
||||
}
|
||||
|
||||
// Split into batches
|
||||
var batches [][]string
|
||||
for i := 0; i < len(tokens); i += BATCH_SIZE {
|
||||
end := i + BATCH_SIZE
|
||||
if end > len(tokens) {
|
||||
end = len(tokens)
|
||||
}
|
||||
batches = append(batches, tokens[i:end])
|
||||
}
|
||||
|
||||
worker.rateLimit(cfg)
|
||||
|
||||
// Fetch prices for all batches
|
||||
allPrices := make(map[string]PriceData)
|
||||
for _, batch := range batches {
|
||||
ids := strings.Join(batch, "%2C")
|
||||
url := fmt.Sprintf("https://api.jup.ag/price/v3?ids=%s", ids)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("x-api-key", worker.apiKey)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err == nil {
|
||||
var prices map[string]PriceData
|
||||
if json.Unmarshal(body, &prices) == nil {
|
||||
for k, v := range prices {
|
||||
allPrices[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total value
|
||||
var total float64
|
||||
var count int
|
||||
|
||||
for addr, bal := range balances {
|
||||
mint := addr
|
||||
if addr == "SOL" {
|
||||
mint = SOL_MINT
|
||||
}
|
||||
|
||||
price := 0.0
|
||||
if priceData, ok := allPrices[mint]; ok {
|
||||
price = priceData.UsdPrice
|
||||
}
|
||||
|
||||
val := bal.UiAmount * price
|
||||
if val > 0.01 {
|
||||
total += val
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return &PortfolioValue{Value: total, TokenCount: count}, nil
|
||||
}
|
||||
41
solana.go
Normal file
41
solana.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
|
||||
"github.com/mr-tron/base58"
|
||||
)
|
||||
|
||||
type Keypair struct {
|
||||
PublicKey ed25519.PublicKey
|
||||
PrivateKey ed25519.PrivateKey
|
||||
}
|
||||
|
||||
func keypairFromSecretKey(privateKeyB58 string) (*Keypair, error) {
|
||||
secretKey, err := base58.Decode(privateKeyB58)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode private key: %w", err)
|
||||
}
|
||||
|
||||
if len(secretKey) != 64 {
|
||||
return nil, fmt.Errorf("invalid secret key length: expected 64, got %d", len(secretKey))
|
||||
}
|
||||
|
||||
privateKey := ed25519.PrivateKey(secretKey)
|
||||
publicKey := privateKey.Public().(ed25519.PublicKey)
|
||||
|
||||
return &Keypair{
|
||||
PublicKey: publicKey,
|
||||
PrivateKey: privateKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (kp *Keypair) PublicKeyBase58() string {
|
||||
return base58.Encode(kp.PublicKey)
|
||||
}
|
||||
|
||||
func signMessage(privateKey ed25519.PrivateKey, message []byte) string {
|
||||
signature := ed25519.Sign(privateKey, message)
|
||||
return base58.Encode(signature)
|
||||
}
|
||||
118
utils.go
Normal file
118
utils.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mr-tron/base58"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
func readLines(path string) []string {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func isValidKey(k string) bool {
|
||||
decoded, err := base58.Decode(k)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return len(decoded) == 64
|
||||
}
|
||||
|
||||
func loadPrivateKeys() []string {
|
||||
keys := []string{}
|
||||
|
||||
// Load from private_keys.txt
|
||||
for _, key := range readLines("private_keys.txt") {
|
||||
if isValidKey(key) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Load from keys/*.txt
|
||||
keysDir := "keys"
|
||||
if info, err := os.Stat(keysDir); err == nil && info.IsDir() {
|
||||
files, _ := filepath.Glob(filepath.Join(keysDir, "*.txt"))
|
||||
for _, file := range files {
|
||||
for _, key := range readLines(file) {
|
||||
if isValidKey(key) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func loadProxies() []string {
|
||||
return readLines("proxies.txt")
|
||||
}
|
||||
|
||||
var proxies []string
|
||||
var proxyIndex int
|
||||
|
||||
func initProxies() {
|
||||
proxies = loadProxies()
|
||||
proxyIndex = 0
|
||||
}
|
||||
|
||||
func getNextProxy() (*http.Transport, error) {
|
||||
if len(proxies) == 0 {
|
||||
return &http.Transport{}, nil
|
||||
}
|
||||
|
||||
proxyURL := proxies[proxyIndex]
|
||||
proxyIndex = (proxyIndex + 1) % len(proxies)
|
||||
|
||||
// Parse SOCKS5 proxy
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy URL: %w", err)
|
||||
}
|
||||
|
||||
if u.Scheme == "socks5" {
|
||||
var auth *proxy.Auth
|
||||
if u.User != nil {
|
||||
password, _ := u.User.Password()
|
||||
auth = &proxy.Auth{
|
||||
User: u.User.Username(),
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
dialer, err := proxy.SOCKS5("tcp", u.Host, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SOCKS5 dialer: %w", err)
|
||||
}
|
||||
|
||||
return &http.Transport{
|
||||
Dial: dialer.Dial,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// For HTTP/HTTPS proxies
|
||||
return &http.Transport{
|
||||
Proxy: http.ProxyURL(u),
|
||||
}, nil
|
||||
}
|
||||
Loading…
Reference in a new issue