feat: initial Netts energy orchestrator
This commit is contained in:
2
internal/service/doc.go
Normal file
2
internal/service/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package service contains business logic for energy orchestration.
|
||||
package service
|
||||
241
internal/service/energy_service.go
Normal file
241
internal/service/energy_service.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
appconfig "github.com/D36u99er/bc-netts-energy/internal/config"
|
||||
"github.com/D36u99er/bc-netts-energy/internal/netts"
|
||||
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
var tronAddressPattern = regexp.MustCompile(`^T[1-9A-HJ-NP-Za-km-z]{33}$`)
|
||||
|
||||
// EnsureEnergyRequest describes a request to guarantee sufficient energy.
|
||||
type EnsureEnergyRequest struct {
|
||||
FromAddress string
|
||||
ToAddress string
|
||||
Amount string
|
||||
CallbackURL string
|
||||
}
|
||||
|
||||
// EnsureEnergyResponse summarises the orchestration result.
|
||||
type EnsureEnergyResponse struct {
|
||||
FromAddress string `json:"from_address"`
|
||||
ToAddress string `json:"to_address"`
|
||||
Amount string `json:"amount"`
|
||||
AddressAdded bool `json:"address_added"`
|
||||
RecommendedEnergy int `json:"recommended_energy"`
|
||||
EnergyNeeded int `json:"energy_needed"`
|
||||
CyclesBefore int `json:"cycles_before"`
|
||||
CyclesAfter int `json:"cycles_after"`
|
||||
CyclesPurchased int `json:"cycles_purchased,omitempty"`
|
||||
TotalCycles int `json:"total_cycles,omitempty"`
|
||||
OrderID string `json:"order_id,omitempty"`
|
||||
OrderStatus string `json:"order_status,omitempty"`
|
||||
TotalCost float64 `json:"total_cost,omitempty"`
|
||||
NextDelegation int64 `json:"next_delegation_time,omitempty"`
|
||||
Analysis netts.TransferDetails `json:"analysis"`
|
||||
OrderRaw *netts.OrderResult `json:"-"`
|
||||
Status *netts.AddressStatus `json:"-"`
|
||||
}
|
||||
|
||||
// EnergyService coordinates Netts energy rentals.
|
||||
type NettsClient interface {
|
||||
AnalyzeUSDT(ctx context.Context, req netts.AnalyzeUSDTRequest) (*netts.AnalyzeUSDTData, error)
|
||||
GetAddressStatus(ctx context.Context, address string) (*netts.AddressStatus, error)
|
||||
AddHostAddress(ctx context.Context, address, callback string) (*netts.AddAddressResult, error)
|
||||
OrderCycles(ctx context.Context, address string, cycles int) (*netts.OrderResult, error)
|
||||
}
|
||||
|
||||
type EnergyService struct {
|
||||
cfg appconfig.EnergyConfig
|
||||
nettsClient NettsClient
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewEnergyService instantiates an EnergyService.
|
||||
func NewEnergyService(cfg appconfig.EnergyConfig, client NettsClient, logger *slog.Logger) *EnergyService {
|
||||
return &EnergyService{
|
||||
cfg: cfg,
|
||||
nettsClient: client,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureEnergy ensures an address has sufficient energy and cycles.
|
||||
func (s *EnergyService) EnsureEnergy(ctx context.Context, req EnsureEnergyRequest) (*EnsureEnergyResponse, error) {
|
||||
if err := s.validateRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
amount := req.Amount
|
||||
if amount == "" {
|
||||
amount = s.cfg.DefaultAnalyzeValue
|
||||
}
|
||||
|
||||
analysis, err := s.nettsClient.AnalyzeUSDT(ctx, netts.AnalyzeUSDTRequest{
|
||||
Sender: req.FromAddress,
|
||||
Receiver: req.ToAddress,
|
||||
Amount: amount,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze transfer: %w", err)
|
||||
}
|
||||
|
||||
transfer := analysis.TransferDetails
|
||||
energyNeeded := transfer.EnergyNeeded
|
||||
if energyNeeded == 0 {
|
||||
energyNeeded = transfer.RecommendedEnergy
|
||||
}
|
||||
|
||||
s.logger.InfoContext(ctx, "analysis completed",
|
||||
"from", req.FromAddress,
|
||||
"to", req.ToAddress,
|
||||
"recommended_energy", transfer.RecommendedEnergy,
|
||||
"energy_needed", energyNeeded,
|
||||
)
|
||||
|
||||
status, addressAdded, err := s.ensureHostMode(ctx, req.FromAddress, req.CallbackURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
cyclesBefore = 0
|
||||
cyclesAfter = 0
|
||||
order *netts.OrderResult
|
||||
)
|
||||
|
||||
if status != nil {
|
||||
cyclesBefore = status.CyclesRemaining
|
||||
if shouldOrderCycles(status, s.cfg.MinCycles) {
|
||||
targetCycles := s.cfg.TargetCycles
|
||||
if targetCycles < s.cfg.MinCycles {
|
||||
targetCycles = s.cfg.MinCycles
|
||||
}
|
||||
cyclesToBuy := targetCycles - safeCycleValue(status.CyclesRemaining)
|
||||
if cyclesToBuy > 0 {
|
||||
order, err = s.orderCycles(ctx, req.FromAddress, cyclesToBuy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.cfg.PostOrderWait.Duration() > 0 {
|
||||
select {
|
||||
case <-time.After(s.cfg.PostOrderWait.Duration()):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
status, _, err = s.ensureHostMode(ctx, req.FromAddress, req.CallbackURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if status != nil {
|
||||
cyclesAfter = status.CyclesRemaining
|
||||
}
|
||||
|
||||
resp := &EnsureEnergyResponse{
|
||||
FromAddress: req.FromAddress,
|
||||
ToAddress: req.ToAddress,
|
||||
Amount: amount,
|
||||
AddressAdded: addressAdded,
|
||||
RecommendedEnergy: transfer.RecommendedEnergy,
|
||||
EnergyNeeded: energyNeeded,
|
||||
CyclesBefore: cyclesBefore,
|
||||
CyclesAfter: cyclesAfter,
|
||||
Analysis: transfer,
|
||||
OrderRaw: order,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if order != nil {
|
||||
resp.CyclesPurchased = order.CyclesPurchased
|
||||
resp.TotalCycles = order.TotalCycles
|
||||
resp.OrderID = order.OrderID
|
||||
resp.TotalCost = order.TotalCost
|
||||
resp.OrderStatus = order.Status
|
||||
resp.NextDelegation = order.NextDelegation
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *EnergyService) ensureHostMode(ctx context.Context, address, callbackURL string) (*netts.AddressStatus, bool, error) {
|
||||
status, err := s.nettsClient.GetAddressStatus(ctx, address)
|
||||
if err == nil {
|
||||
return status, false, nil
|
||||
}
|
||||
if !errors.Is(err, netts.ErrAddressNotFound) {
|
||||
return nil, false, fmt.Errorf("get address status: %w", err)
|
||||
}
|
||||
if !s.cfg.AutoAddHost {
|
||||
return nil, false, fmt.Errorf("address %s not in host mode and autoAddHost disabled", address)
|
||||
}
|
||||
if _, err := s.nettsClient.AddHostAddress(ctx, address, callbackURL); err != nil {
|
||||
return nil, false, fmt.Errorf("add host address: %w", err)
|
||||
}
|
||||
s.logger.InfoContext(ctx, "address added to Host Mode", "address", address)
|
||||
|
||||
status, err = s.nettsClient.GetAddressStatus(ctx, address)
|
||||
if err != nil {
|
||||
return nil, true, fmt.Errorf("get address status after add: %w", err)
|
||||
}
|
||||
return status, true, nil
|
||||
}
|
||||
|
||||
func (s *EnergyService) orderCycles(ctx context.Context, address string, cycles int) (*netts.OrderResult, error) {
|
||||
if cycles <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if cycles > 1000 {
|
||||
cycles = 1000
|
||||
}
|
||||
order, err := s.nettsClient.OrderCycles(ctx, address, cycles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("order cycles: %w", err)
|
||||
}
|
||||
s.logger.InfoContext(ctx, "cycles ordered",
|
||||
"address", address,
|
||||
"cycles_purchased", order.CyclesPurchased,
|
||||
"total_cycles", order.TotalCycles,
|
||||
"order_id", order.OrderID,
|
||||
)
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (s *EnergyService) validateRequest(req EnsureEnergyRequest) error {
|
||||
if !tronAddressPattern.MatchString(req.FromAddress) {
|
||||
return fmt.Errorf("invalid from_address: %s", req.FromAddress)
|
||||
}
|
||||
if !tronAddressPattern.MatchString(req.ToAddress) {
|
||||
return fmt.Errorf("invalid to_address: %s", req.ToAddress)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldOrderCycles(status *netts.AddressStatus, minCycles int) bool {
|
||||
if status == nil {
|
||||
return true
|
||||
}
|
||||
if status.CyclesRemaining < 0 {
|
||||
// Infinity mode (-1) requires no manual orders.
|
||||
return false
|
||||
}
|
||||
return status.CyclesRemaining < minCycles
|
||||
}
|
||||
|
||||
func safeCycleValue(value int) int {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
183
internal/service/energy_service_test.go
Normal file
183
internal/service/energy_service_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
appconfig "github.com/D36u99er/bc-netts-energy/internal/config"
|
||||
"github.com/D36u99er/bc-netts-energy/internal/netts"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
func TestEnsureEnergyAddsAddressAndOrdersCycles(t *testing.T) {
|
||||
mock := &mockNettsClient{
|
||||
analyzeData: &netts.AnalyzeUSDTData{
|
||||
TransferDetails: netts.TransferDetails{
|
||||
RecommendedEnergy: 131000,
|
||||
EnergyNeeded: 131000,
|
||||
},
|
||||
},
|
||||
statusResponses: []statusResponse{
|
||||
{err: netts.ErrAddressNotFound},
|
||||
{status: &netts.AddressStatus{CyclesRemaining: 2}},
|
||||
{status: &netts.AddressStatus{CyclesRemaining: 5}},
|
||||
},
|
||||
orderResult: &netts.OrderResult{
|
||||
CyclesPurchased: 3,
|
||||
TotalCycles: 5,
|
||||
OrderID: "ORD-123",
|
||||
Status: "confirmed",
|
||||
TotalCost: 7.5,
|
||||
},
|
||||
}
|
||||
|
||||
cfg := appconfig.EnergyConfig{
|
||||
AutoAddHost: true,
|
||||
MinCycles: 3,
|
||||
TargetCycles: 5,
|
||||
PostOrderWait: appconfig.Duration(0),
|
||||
DefaultAnalyzeValue: "100.00",
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(ioDiscard{}, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
svc := NewEnergyService(cfg, mock, logger)
|
||||
|
||||
resp, err := svc.EnsureEnergy(context.Background(), EnsureEnergyRequest{
|
||||
FromAddress: "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
|
||||
ToAddress: "TUmyHQNzAkT6SrwThVvay7G4yfGZAyWhmy",
|
||||
Amount: "50.0",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, resp.AddressAdded)
|
||||
assert.Equal(t, 3, resp.CyclesPurchased)
|
||||
assert.Equal(t, 5, resp.CyclesAfter)
|
||||
assert.Equal(t, "ORD-123", resp.OrderID)
|
||||
assert.Equal(t, 131000, resp.RecommendedEnergy)
|
||||
assert.Equal(t, 131000, resp.EnergyNeeded)
|
||||
|
||||
assert.Equal(t, 1, mock.addCalls)
|
||||
assert.Equal(t, []int{3}, mock.orderCalls)
|
||||
assert.Equal(t, 0, len(mock.statusResponses))
|
||||
}
|
||||
|
||||
func TestEnsureEnergyFailsWhenAutoAddDisabled(t *testing.T) {
|
||||
mock := &mockNettsClient{
|
||||
analyzeData: &netts.AnalyzeUSDTData{TransferDetails: netts.TransferDetails{RecommendedEnergy: 1000}},
|
||||
statusResponses: []statusResponse{
|
||||
{err: netts.ErrAddressNotFound},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := appconfig.EnergyConfig{
|
||||
AutoAddHost: false,
|
||||
MinCycles: 2,
|
||||
TargetCycles: 4,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(ioDiscard{}, nil))
|
||||
svc := NewEnergyService(cfg, mock, logger)
|
||||
|
||||
_, err := svc.EnsureEnergy(context.Background(), EnsureEnergyRequest{
|
||||
FromAddress: "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
|
||||
ToAddress: "TUmyHQNzAkT6SrwThVvay7G4yfGZAyWhmy",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 0, mock.addCalls)
|
||||
}
|
||||
|
||||
func TestEnsureEnergySkipsOrderWhenSufficientCycles(t *testing.T) {
|
||||
mock := &mockNettsClient{
|
||||
analyzeData: &netts.AnalyzeUSDTData{
|
||||
TransferDetails: netts.TransferDetails{
|
||||
RecommendedEnergy: 90000,
|
||||
EnergyNeeded: 80000,
|
||||
},
|
||||
},
|
||||
statusResponses: []statusResponse{
|
||||
{status: &netts.AddressStatus{CyclesRemaining: 5}},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := appconfig.EnergyConfig{
|
||||
AutoAddHost: true,
|
||||
MinCycles: 3,
|
||||
TargetCycles: 6,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(ioDiscard{}, nil))
|
||||
svc := NewEnergyService(cfg, mock, logger)
|
||||
|
||||
resp, err := svc.EnsureEnergy(context.Background(), EnsureEnergyRequest{
|
||||
FromAddress: "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
|
||||
ToAddress: "TUmyHQNzAkT6SrwThVvay7G4yfGZAyWhmy",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, resp.AddressAdded)
|
||||
assert.Equal(t, 0, resp.CyclesPurchased)
|
||||
assert.Equal(t, 5, resp.CyclesAfter)
|
||||
assert.Empty(t, mock.orderCalls)
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
status *netts.AddressStatus
|
||||
err error
|
||||
}
|
||||
|
||||
type mockNettsClient struct {
|
||||
analyzeData *netts.AnalyzeUSDTData
|
||||
statusResponses []statusResponse
|
||||
orderResult *netts.OrderResult
|
||||
|
||||
addCalls int
|
||||
orderCalls []int
|
||||
}
|
||||
|
||||
func (m *mockNettsClient) AnalyzeUSDT(ctx context.Context, req netts.AnalyzeUSDTRequest) (*netts.AnalyzeUSDTData, error) {
|
||||
if m.analyzeData == nil {
|
||||
return nil, errors.New("no analysis data")
|
||||
}
|
||||
return m.analyzeData, nil
|
||||
}
|
||||
|
||||
func (m *mockNettsClient) GetAddressStatus(ctx context.Context, address string) (*netts.AddressStatus, error) {
|
||||
if len(m.statusResponses) == 0 {
|
||||
return nil, errors.New("no status response")
|
||||
}
|
||||
resp := m.statusResponses[0]
|
||||
m.statusResponses = m.statusResponses[1:]
|
||||
return resp.status, resp.err
|
||||
}
|
||||
|
||||
func (m *mockNettsClient) AddHostAddress(ctx context.Context, address, callback string) (*netts.AddAddressResult, error) {
|
||||
m.addCalls++
|
||||
return &netts.AddAddressResult{
|
||||
Address: address,
|
||||
CallbackURL: callback,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockNettsClient) OrderCycles(ctx context.Context, address string, cycles int) (*netts.OrderResult, error) {
|
||||
m.orderCalls = append(m.orderCalls, cycles)
|
||||
if m.orderResult != nil {
|
||||
return m.orderResult, nil
|
||||
}
|
||||
return &netts.OrderResult{
|
||||
CyclesPurchased: cycles,
|
||||
TotalCycles: cycles,
|
||||
OrderID: "ORD",
|
||||
Status: "confirmed",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ioDiscard struct{}
|
||||
|
||||
func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil }
|
||||
Reference in New Issue
Block a user