feat: initial Netts energy orchestrator

This commit is contained in:
你的用户名
2025-11-03 19:26:48 +08:00
commit 891c32e288
25 changed files with 2210 additions and 0 deletions

2
internal/service/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package service contains business logic for energy orchestration.
package service

View 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
}

View 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 }