feat: initial Netts energy orchestrator
This commit is contained in:
285
internal/netts/client.go
Normal file
285
internal/netts/client.go
Normal file
@@ -0,0 +1,285 @@
|
||||
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
|
||||
}
|
||||
185
internal/netts/client_test.go
Normal file
185
internal/netts/client_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package netts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
appconfig "github.com/D36u99er/bc-netts-energy/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
func TestClientAnalyzeUSDT(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/apiv2/usdt/analyze":
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "test-key", r.Header.Get("X-API-KEY"))
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"transfer_details": {
|
||||
"sender_address": "TFrom",
|
||||
"receiver_address": "TTo",
|
||||
"usdt_amount": "10.00",
|
||||
"recommended_energy": 131000,
|
||||
"energy_needed": 131000,
|
||||
"bandwidth_needed": 350,
|
||||
"cost_breakdown": {
|
||||
"energy_cost": "3.5",
|
||||
"bandwidth_cost": "1.0",
|
||||
"total_cost_trx": "4.5"
|
||||
},
|
||||
"savings_analysis": {
|
||||
"vs_direct_burn": "20.0",
|
||||
"vs_staking": "5.0",
|
||||
"savings_percentage": 80.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(server, t)
|
||||
|
||||
resp, err := client.AnalyzeUSDT(context.Background(), AnalyzeUSDTRequest{
|
||||
Sender: "TFrom",
|
||||
Receiver: "TTo",
|
||||
Amount: "10.00",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 131000, resp.TransferDetails.RecommendedEnergy)
|
||||
assert.Equal(t, 131000, resp.TransferDetails.EnergyNeeded)
|
||||
assert.InDelta(t, 80.5, resp.TransferDetails.SavingsAnalysis.SavingsPercentage, 0.01)
|
||||
}
|
||||
|
||||
func TestClientGetAddressStatus(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/apiv2/time/status/TExists":
|
||||
if calls == 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"address": "TExists",
|
||||
"mode": "normal",
|
||||
"status": "active",
|
||||
"cycles_remaining": 2,
|
||||
"open_orders": 0,
|
||||
"expiry_time": 1700000000
|
||||
}
|
||||
}`))
|
||||
}
|
||||
calls++
|
||||
case "/apiv2/time/status/TMissing":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"code": -1,
|
||||
"msg": "Address not found in Host Mode",
|
||||
"data": null
|
||||
}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(server, t)
|
||||
|
||||
status, err := client.GetAddressStatus(context.Background(), "TExists")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, status.CyclesRemaining)
|
||||
|
||||
_, err = client.GetAddressStatus(context.Background(), "TMissing")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrAddressNotFound)
|
||||
}
|
||||
|
||||
func TestClientMutations(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/apiv2/time/add":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"code": 0,
|
||||
"msg": "Address added",
|
||||
"data": {
|
||||
"address": "TAdded",
|
||||
"callback_url": "https://callback",
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}`))
|
||||
case "/apiv2/time/order":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"address": "TAdded",
|
||||
"cycles_purchased": 5,
|
||||
"total_cycles": 8,
|
||||
"previous_cycles": 3,
|
||||
"total_cost": 10.5,
|
||||
"price_per_cycle": 2.1,
|
||||
"order_id": "ORD-1",
|
||||
"transaction_hash": "hash",
|
||||
"payment_method": "account_balance",
|
||||
"balance_after": 100.5,
|
||||
"next_delegation_time": 1700000000,
|
||||
"expiry_time": 1700100000,
|
||||
"status": "confirmed"
|
||||
}
|
||||
}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(server, t)
|
||||
|
||||
addResp, err := client.AddHostAddress(context.Background(), "TAdded", "https://callback")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "TAdded", addResp.Address)
|
||||
|
||||
orderResp, err := client.OrderCycles(context.Background(), "TAdded", 5)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5, orderResp.CyclesPurchased)
|
||||
assert.Equal(t, "ORD-1", orderResp.OrderID)
|
||||
}
|
||||
|
||||
func newTestClient(server *httptest.Server, t *testing.T) *Client {
|
||||
t.Helper()
|
||||
|
||||
cfg := appconfig.NettsConfig{
|
||||
APIKey: "test-key",
|
||||
BaseURL: server.URL,
|
||||
HTTPTimeout: appconfig.Duration(5 * time.Second),
|
||||
Retry: appconfig.Retry{
|
||||
MaxAttempts: 1,
|
||||
},
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
httpClient := server.Client()
|
||||
|
||||
return New(cfg, logger, httpClient)
|
||||
}
|
||||
2
internal/netts/doc.go
Normal file
2
internal/netts/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package netts contains the Netts API client.
|
||||
package netts
|
||||
87
internal/netts/models.go
Normal file
87
internal/netts/models.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package netts
|
||||
|
||||
// APIResponse represents Netts standard API response envelope.
|
||||
type APIResponse[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
// AnalyzeUSDTRequest describes the payload for /apiv2/usdt/analyze.
|
||||
type AnalyzeUSDTRequest struct {
|
||||
Sender string `json:"sender"`
|
||||
Receiver string `json:"receiver"`
|
||||
Amount string `json:"amount"`
|
||||
}
|
||||
|
||||
// AnalyzeUSDTData contains analysis details for USDT transfer.
|
||||
type AnalyzeUSDTData struct {
|
||||
TransferDetails TransferDetails `json:"transfer_details"`
|
||||
}
|
||||
|
||||
// TransferDetails describes recommended resources for a transfer.
|
||||
type TransferDetails struct {
|
||||
SenderAddress string `json:"sender_address"`
|
||||
ReceiverAddress string `json:"receiver_address"`
|
||||
USDTAmount string `json:"usdt_amount"`
|
||||
RecommendedEnergy int `json:"recommended_energy"`
|
||||
EnergyNeeded int `json:"energy_needed"`
|
||||
BandwidthNeeded int `json:"bandwidth_needed"`
|
||||
CostBreakdown CostBreakdown `json:"cost_breakdown"`
|
||||
SavingsAnalysis SavingsAnalysis `json:"savings_analysis"`
|
||||
HasSufficientUSDT *bool `json:"has_usdt,omitempty"`
|
||||
RecommendedReserve int `json:"recommended_energy_reserve,omitempty"`
|
||||
}
|
||||
|
||||
// CostBreakdown details TRX costs.
|
||||
type CostBreakdown struct {
|
||||
EnergyCost string `json:"energy_cost"`
|
||||
BandwidthCost string `json:"bandwidth_cost"`
|
||||
TotalCostTRX string `json:"total_cost_trx"`
|
||||
}
|
||||
|
||||
// SavingsAnalysis summarises cost savings.
|
||||
type SavingsAnalysis struct {
|
||||
VsDirectBurn string `json:"vs_direct_burn"`
|
||||
VsStaking string `json:"vs_staking"`
|
||||
SavingsPercentage float64 `json:"savings_percentage"`
|
||||
}
|
||||
|
||||
// AddressStatus represents Host Mode status for an address.
|
||||
type AddressStatus struct {
|
||||
Address string `json:"address"`
|
||||
Mode string `json:"mode"`
|
||||
Status string `json:"status"`
|
||||
CyclesOrdered int `json:"cycles_ordered"`
|
||||
CycleSet int `json:"cycle_set"`
|
||||
CyclesCompleted int `json:"cycles_completed"`
|
||||
CyclesRemaining int `json:"cycles_remaining"`
|
||||
OpenOrders int `json:"open_orders"`
|
||||
NextDelegationTime int64 `json:"next_delegation_time"`
|
||||
ExpiryTime int64 `json:"expiry_time"`
|
||||
BalanceAfter float64 `json:"balance_after"`
|
||||
}
|
||||
|
||||
// AddAddressResult captures response from /time/add.
|
||||
type AddAddressResult struct {
|
||||
Address string `json:"address"`
|
||||
CallbackURL string `json:"callback_url,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// OrderResult captures order summary from /time/order.
|
||||
type OrderResult struct {
|
||||
Address string `json:"address"`
|
||||
CyclesPurchased int `json:"cycles_purchased"`
|
||||
TotalCycles int `json:"total_cycles"`
|
||||
PreviousCycles int `json:"previous_cycles"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PricePerCycle float64 `json:"price_per_cycle"`
|
||||
OrderID string `json:"order_id"`
|
||||
TransactionHash string `json:"transaction_hash"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
BalanceAfter float64 `json:"balance_after"`
|
||||
NextDelegation int64 `json:"next_delegation_time"`
|
||||
ExpiryTime int64 `json:"expiry_time"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
Reference in New Issue
Block a user