286 lines
7.0 KiB
Go
286 lines
7.0 KiB
Go
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
|
|
}
|