package netts import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" appconfig "github.com/D36u99er/bc-netts-energy/internal/config" appErrors "github.com/D36u99er/bc-netts-energy/pkg/errors" "log/slog" ) var ( // ErrAddressNotFound is returned when an address is not registered in Host Mode. ErrAddressNotFound = errors.New("netts: address not found in host mode") ) // Client interacts with the Netts API. type Client struct { baseURL string apiKey string realIP string callbackURL string userAgent string httpClient *http.Client logger *slog.Logger retry RetryConfig } // RetryConfig describes retry behaviour. type RetryConfig struct { MaxAttempts int Backoff time.Duration MaxBackoff time.Duration } // New creates a Netts client with provided configuration. func New(cfg appconfig.NettsConfig, logger *slog.Logger, httpClient *http.Client) *Client { if httpClient == nil { httpClient = &http.Client{ Timeout: cfg.HTTPTimeout.Duration(), } } return &Client{ baseURL: strings.TrimRight(cfg.BaseURL, "/"), apiKey: cfg.APIKey, realIP: cfg.RealIP, callbackURL: cfg.CallbackURL, userAgent: "bc-netts-energy/1.0 (+https://github.com/D36u99er/bc-netts-energy)", httpClient: httpClient, logger: logger, retry: RetryConfig{ MaxAttempts: max(cfg.Retry.MaxAttempts, 1), Backoff: cfg.Retry.Backoff.Duration(), MaxBackoff: cfg.Retry.MaxBackoff.Duration(), }, } } // WithHTTPClient returns a shallow copy of the client using the provided HTTP client. func (c *Client) WithHTTPClient(httpClient *http.Client) *Client { if httpClient == nil { return c } clone := *c clone.httpClient = httpClient return &clone } // AnalyzeUSDT analyses a USDT transfer and returns recommended energy usage. func (c *Client) AnalyzeUSDT(ctx context.Context, req AnalyzeUSDTRequest) (*AnalyzeUSDTData, error) { var resp APIResponse[AnalyzeUSDTData] err := c.do(ctx, http.MethodPost, "/apiv2/usdt/analyze", req, func(r *http.Request) { r.Header.Set("Content-Type", "application/json") r.Header.Set("X-API-KEY", c.apiKey) if c.realIP != "" { r.Header.Set("X-Real-IP", c.realIP) } }, &resp) if err != nil { return nil, err } if resp.Code != 0 { return nil, c.apiError(resp.Code, resp.Msg, 0) } return &resp.Data, nil } // GetAddressStatus retrieves Host Mode status for an address. func (c *Client) GetAddressStatus(ctx context.Context, address string) (*AddressStatus, error) { var resp APIResponse[AddressStatus] path := fmt.Sprintf("/apiv2/time/status/%s", url.PathEscape(address)) err := c.do(ctx, http.MethodGet, path, nil, func(r *http.Request) { r.Header.Set("X-API-KEY", c.apiKey) if c.realIP != "" { r.Header.Set("X-Real-IP", c.realIP) } }, &resp) if err != nil { var apiErr *appErrors.APIError if errors.As(err, &apiErr) && apiErr.Code == -1 && strings.Contains(strings.ToLower(apiErr.Message), "not found") { return nil, ErrAddressNotFound } return nil, err } if resp.Code != 0 { if strings.Contains(strings.ToLower(resp.Msg), "not found") { return nil, ErrAddressNotFound } return nil, c.apiError(resp.Code, resp.Msg, 0) } return &resp.Data, nil } // AddHostAddress registers an address in Host Mode. func (c *Client) AddHostAddress(ctx context.Context, address, callback string) (*AddAddressResult, error) { payload := map[string]any{ "api_key": c.apiKey, "address": address, } if callback != "" { payload["callback_url"] = callback } else if c.callbackURL != "" { payload["callback_url"] = c.callbackURL } var resp APIResponse[AddAddressResult] err := c.do(ctx, http.MethodPost, "/apiv2/time/add", payload, func(r *http.Request) { r.Header.Set("Content-Type", "application/json") if c.realIP != "" { r.Header.Set("X-Real-IP", c.realIP) } }, &resp) if err != nil { return nil, err } if resp.Code != 0 { return nil, c.apiError(resp.Code, resp.Msg, 0) } return &resp.Data, nil } // OrderCycles purchases a number of energy cycles for an address. func (c *Client) OrderCycles(ctx context.Context, address string, cycles int) (*OrderResult, error) { payload := map[string]any{ "api_key": c.apiKey, "address": address, "cycles": cycles, } var resp APIResponse[OrderResult] err := c.do(ctx, http.MethodPost, "/apiv2/time/order", payload, func(r *http.Request) { r.Header.Set("Content-Type", "application/json") if c.realIP != "" { r.Header.Set("X-Real-IP", c.realIP) } }, &resp) if err != nil { return nil, err } if resp.Code != 0 { return nil, c.apiError(resp.Code, resp.Msg, 0) } return &resp.Data, nil } func (c *Client) do(ctx context.Context, method, path string, body any, headerFn func(*http.Request), out any) error { fullURL := c.baseURL + path var payload []byte var err error if body != nil { payload, err = json.Marshal(body) if err != nil { return fmt.Errorf("marshal request body: %w", err) } } attempt := 0 delay := c.retry.Backoff if delay <= 0 { delay = 2 * time.Second } maxBackoff := c.retry.MaxBackoff if maxBackoff <= 0 { maxBackoff = 15 * time.Second } for { attempt++ req, err := http.NewRequestWithContext(ctx, method, fullURL, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("User-Agent", c.userAgent) if headerFn != nil { headerFn(req) } c.logger.Debug("netts request", "method", method, "url", fullURL, "attempt", attempt) resp, err := c.httpClient.Do(req) if err != nil { if attempt < c.retry.MaxAttempts { c.logger.Warn("netts request error, retrying", "error", err, "attempt", attempt) select { case <-time.After(delay): case <-ctx.Done(): return ctx.Err() } delay = nextBackoff(delay, maxBackoff) continue } return fmt.Errorf("execute request: %w", err) } bodyBytes, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { return fmt.Errorf("read response: %w", err) } c.logger.Debug("netts response", "status", resp.StatusCode, "attempt", attempt) if resp.StatusCode >= 500 && attempt < c.retry.MaxAttempts { c.logger.Warn("netts server error, retrying", "status", resp.StatusCode, "attempt", attempt) select { case <-time.After(delay): case <-ctx.Done(): return ctx.Err() } delay = nextBackoff(delay, maxBackoff) continue } if resp.StatusCode >= 300 { return &appErrors.APIError{ HTTPStatus: resp.StatusCode, Message: strings.TrimSpace(string(bodyBytes)), } } if out == nil { return nil } if err := json.Unmarshal(bodyBytes, out); err != nil { return fmt.Errorf("parse response: %w", err) } return nil } } func (c *Client) apiError(code int, message string, status int) error { return &appErrors.APIError{ Code: code, Message: message, HTTPStatus: status, } } func nextBackoff(current, max time.Duration) time.Duration { next := current * 2 if next > max { return max } return next } func max(a, b int) int { if a > b { return a } return b }