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

43
.github/workflows/ci.yaml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Go module cache
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Check formatting
run: |
gofmt -l . | tee /tmp/gofmt.txt
if [ -s /tmp/gofmt.txt ]; then
echo "The following files need gofmt:"
cat /tmp/gofmt.txt
exit 1
fi
- name: Go vet
run: go vet ./...
- name: Run tests
run: go test ./...

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Binaries
/bin/
/dist/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Build artifacts
/coverage/
/tmp/
# Editor files
.DS_Store
*.swp
*.swo
# Configuration overrides
.env
config.local.yaml

18
Makefile Normal file
View File

@@ -0,0 +1,18 @@
SHELL := /bin/bash
.PHONY: fmt vet test run build
fmt:
gofmt -w $(shell go list -f '{{.Dir}}' ./...)
vet:
go vet ./...
test:
go test ./...
build:
go build -o bin/netts-energy ./cmd/server
run:
go run ./cmd/server -config config.yaml

92
README.md Normal file
View File

@@ -0,0 +1,92 @@
# Netts Energy Orchestrator
Netts Energy Orchestrator 是一个使用 Go 实现的独立服务,用于为自动到账系统中的 TRON 地址自动租赁能量。它集成了 [Netts API v2](https://doc.netts.io/api/v2/),支持地址自动加入 Host Mode、轮询剩余能量周期并按需购买能量租赁周期从而显著降低 TRC20 归集的手续费。
## 功能亮点
-**Netts API 集成**:封装 `usdt/analyze``time/add``time/status``time/order` 等核心接口。
- 🔁 **自动周期管理**:当剩余周期低于阈值时自动补充,可选自动 Host Mode 注册。
- 🧩 **独立微服务**:提供 REST API (`POST /api/v1/energy/rent`),便于与现有到账平台对接。
- ⚙️ **可配置化**:通过 `config.yaml` 或环境变量管理 API Key、阈值、重试策略等。
- 📈 **可观察性**:结构化日志、健康检查接口,易于纳入现有监控体系。
## 快速开始
1. 复制配置模板:
```bash
cp config.example.yaml config.yaml
```
2. 编辑 `config.yaml`,填入 Netts API Key、白名单 IP、Host Mode 回调地址等;或通过环境变量覆盖:
```bash
export NETTS_API_KEY=your-key
export NETTS_REAL_IP=1.2.3.4
```
3. 运行服务:
```bash
go run ./cmd/server -config config.yaml
```
4. 调用接口租赁能量:
```bash
curl -X POST http://localhost:8080/api/v1/energy/rent \
-H "Content-Type: application/json" \
-d '{
"from_address": "Txxx",
"to_address": "Tyyy",
"amount": "200.0"
}'
```
返回值包含分析结果、是否新增 Host Mode、下单详情以及最新剩余周期。
## 配置说明
参见 `config.example.yaml`,主要段落如下:
- `server`HTTP 服务监听地址、超时等。
- `netts`Netts API 参数(`apiKey` 可通过 `NETTS_API_KEY` 覆盖)。
- `energy`
- `autoAddHost`:是否自动调用 `time/add` 把地址加入 Host Mode。
- `minCycles` / `targetCycles`:低于最小阈值时补足到目标阈值。
- `postOrderWait`:下单后等待能量生效的时间。
- `logging`日志级别与输出格式text/json
更多环境变量详见 `internal/config/config.go` 中的 `envPrefix` 定义。
## REST API
`POST /api/v1/energy/rent`
```json
{
"from_address": "源地址(监控地址)",
"to_address": "归集地址",
"amount": "USDT 数量,可选,默认 100",
"callback_url": "可选,覆盖 config 默认回调"
}
```
响应字段包括推荐能量、下单情况、当前 Host Mode 状态等,详细 schema 请见 `docs/api.md`。
## 测试与质量
```bash
go test ./...
golangci-lint run # 若本地已安装,可执行静态检查
```
CI 流程(`.github/workflows/ci.yaml`)会执行 `go fmt`, `go vet`, `go test`。
## 与自动到账系统的集成
- 对接点:在归集服务中,当检测到能量不足(或计划归集)时调用本服务接口即可。
- 配置建议:将主系统的数据库中保存的地址同步到 Netts Host Mode或在首次归集前调用 `rent` 接口,由服务自动注册/购买能量。
- 监控:可通过 `/healthz` 结合现有 Prometheus/Consul 健康检查;同时建议订阅 Netts 提供的 webhook 以获知能量下发事件。
更多架构细节、部署流程以及 CI/CD 建议请参阅 `docs/architecture.md` 与 `docs/deployment.md`。

46
cmd/server/main.go Normal file
View File

@@ -0,0 +1,46 @@
package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"syscall"
appconfig "github.com/D36u99er/bc-netts-energy/internal/config"
serverhttp "github.com/D36u99er/bc-netts-energy/internal/http"
applogger "github.com/D36u99er/bc-netts-energy/internal/logger"
"github.com/D36u99er/bc-netts-energy/internal/netts"
"github.com/D36u99er/bc-netts-energy/internal/service"
)
func main() {
configPath := flag.String("config", "", "path to configuration file")
flag.Parse()
cfg, err := appconfig.Load(*configPath)
if err != nil {
log.Fatalf("failed to load configuration: %v", err)
}
logger := applogger.New(cfg.Logging.Level, cfg.Logging.Format)
logger.Info("configuration loaded",
"address", cfg.Server.Address,
"netts_base_url", cfg.Netts.BaseURL,
)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
defer stop()
nettsClient := netts.New(cfg.Netts, logger, nil)
energySvc := service.NewEnergyService(cfg.Energy, nettsClient, logger)
httpServer := serverhttp.NewServer(cfg.Server, energySvc, logger)
if err := httpServer.Run(ctx); err != nil {
logger.Error("server terminated with error", "error", err)
os.Exit(1)
}
logger.Info("shutdown complete")
}

29
config.example.yaml Normal file
View File

@@ -0,0 +1,29 @@
server:
address: ":8080"
readTimeout: 10s
writeTimeout: 10s
idleTimeout: 60s
shutdownTimeout: 15s
netts:
apiKey: "replace-with-netts-api-key"
baseUrl: "https://netts.io"
realIp: "1.2.3.4"
callbackUrl: "https://your-domain.com/netts/callback"
httpTimeout: 15s
retry:
maxAttempts: 3
backoff: 2s
maxBackoff: 10s
energy:
autoAddHost: true
minCycles: 3
targetCycles: 10
minEnergyThreshold: 32000
postOrderWait: 3s
defaultAnalyzeValue: "100.00"
logging:
level: info
format: text

95
docs/api.md Normal file
View File

@@ -0,0 +1,95 @@
# REST API 文档
## POST `/api/v1/energy/rent`
为指定 TRON 地址分析能量需求、确保 Host Mode 状态,并根据配置自动购买能量周期。
### 请求体
```json
{
"from_address": "T地址", // 必填,需与 Netts Host Mode 地址一致
"to_address": "T地址", // 必填,归集目标地址
"amount": "200.0", // 可选USDT 数量,默认 100.00
"callback_url": "https://..."// 可选,覆盖配置中的 callback
}
```
### 成功响应
```json
{
"from_address": "TFrom...",
"to_address": "TTo...",
"amount": "200.0",
"address_added": true,
"recommended_energy": 131000,
"energy_needed": 131000,
"cycles_before": 2,
"cycles_after": 5,
"cycles_purchased": 3,
"total_cycles": 5,
"order_id": "ORD-20231109-XXXX",
"order_status": "confirmed",
"total_cost": 9.75,
"next_delegation_time": 1700000000,
"analysis": {
"sender_address": "...",
"receiver_address": "...",
"usdt_amount": "200.0",
"recommended_energy": 131000,
"energy_needed": 131000,
"bandwidth_needed": 345,
"cost_breakdown": {
"energy_cost": "4.5",
"bandwidth_cost": "1.0",
"total_cost_trx": "5.5"
},
"savings_analysis": {
"vs_direct_burn": "21.0",
"vs_staking": "7.5",
"savings_percentage": 80.1
}
},
"status": {
"mode": "normal",
"status": "active",
"cycles_remaining": 5,
"open_orders": 0,
"expiry_time": 1700100000
}
}
```
字段说明:
| 字段 | 含义 |
| ---- | ---- |
| `address_added` | 是否在本次调用中自动执行了 `time/add` |
| `cycles_before` / `cycles_after` | 调用前后剩余周期 |
| `cycles_purchased` | 本次下单购买的周期数(如果无需下单,字段不存在) |
| `analysis` | 直接透传 Netts `usdt/analyze` 的结果 |
| `status` | 透传 Netts `time/status` 的核心字段 |
### 错误响应
```json
{
"error": "failed to ensure energy",
"details": "address Txxx not in host mode and autoAddHost disabled"
}
```
HTTP 状态码:
| 状态码 | 说明 |
| ------ | ---- |
| `200` | 成功 |
| `400` | 请求体格式错误或地址校验失败 |
| `502` | 与 Netts API 通信失败、或 Netts 返回错误 |
### 使用建议
- 在归集任务执行前调用,确保能量充足。
- 结果可缓存短时间(例如 60s避免频繁重复请求。
- 如需批量处理,可在外层编排多个地址循环调用。

80
docs/architecture.md Normal file
View File

@@ -0,0 +1,80 @@
# 架构设计
## 总体概览
```
┌───────────────┐ ┌────────────────┐ ┌─────────────────────┐
│ Client/BC系统 │ ---> │ Netts Energy API│ ---> │ Netts.io (Host Mode) │
└───────────────┘ └────────────────┘ └─────────────────────┘
│ │
│ REST /api/v1/energy/rent│
▼ ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ Netts Energy Orchestrator │
│ ┌────────────┐ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ HTTP Server│→│ Energy Service │→│ Netts Client (API v2) │ │
│ │ (chi) │ │ - 业务规则 │ │ - usdt/analyze │ │
│ │ │ │ - Host Mode判断 │ │ - time/status/add/order │ │
│ └────────────┘ └──────────────────┘ └──────────────────────────────┘ │
│ ▲ │ │
│ └──────────── config/logger ────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
```
## 模块分解
### 1. `cmd/server`
- 应用入口,解析 `-config` 参数。
- 初始化配置、日志、Netts Client、业务服务与 HTTP Server。
### 2. `internal/config`
- 负责加载 YAML 配置与环境变量覆盖。
- 内置默认值、校验逻辑,确保 API Key 与阈值正确。
### 3. `internal/logger`
- 基于 `log/slog` 的结构化日志工厂。
- 支持 text / json 输出格式。
### 4. `internal/netts`
- 轻量封装 Netts API。
- 具备重试、User-Agent、X-Real-IP 注入、错误码处理等。
-`GetAddressStatus` 中把 “Address not found” 映射为可识别的 `ErrAddressNotFound`
### 5. `internal/service`
- 业务编排核心:
1. 调用 `usdt/analyze` 计算推荐能量;
2. 检查 Host Mode 状态,必要时自动 `time/add`
3. 当周期低于阈值时,根据配置 `targetCycles` 自动下单;
4. 返回分析详情、周期变化、下单记录。
- 通过接口解耦,便于单元测试和未来 mock。
### 6. `internal/http`
- 使用 `chi` 构建 REST API。
- 提供健康检查与 `POST /api/v1/energy/rent` 主入口。
- 响应中包含分析、Host Mode 状态以及订单结果,方便上游系统直接使用。
## 关键流程
1. **租赁请求**:上游在归集前发送 `from_address``to_address`,可附带金额。
2. **能量分析**:调用 Netts `usdt/analyze` 获取推荐能量。
3. **Host Mode 确认**
- 如果地址未加入,且配置允许自动加入 ⇒ 调用 `time/add`
- 否则直接报错,交由上游处理。
4. **周期管理**
- 读取 `time/status`
-`cycles_remaining < minCycles` ⇒ 计算补足数量并调用 `time/order`
- 下单后可选等待 `postOrderWait` 秒,再次查询状态。
5. **响应汇总**:返回推荐能量、是否新增 Host Mode、下单详情、剩余周期、分析数据等。
## 与既有系统的协作
- **触发时机**:在归集执行前(或按周期任务)调用本服务,确保地址有足够能量。
- **地址生命周期**:推荐在创建新充值地址时即调用 `rent` 接口,让服务负责 Host Mode 注册;现有地址可批量调用 `time/add` 或逐次触发。
- **成本监控**:可据 `analysis.cost_breakdown``OrderResult` 中的 `total_cost` 统计能量开销,并和旧的 Feee 系统成本对比。
- **告警策略**:根据 `cycles_after``status.cycles_remaining` 制定阈值告警Netts 自带 webhook 可配合使用。
## 扩展点
- **多币种支持**Netts 文档提供更多接口(如 `prices``usdt-public`),可按需扩展。
- **持久化缓存**:目前状态全由 Netts API 提供,如需本地缓存可在 `service` 层引入数据库或 Redis。
- **批量调度**:可增加定时任务扫描地址列表并调用 `EnsureEnergy`,形成全自动化闭环。

92
docs/deployment.md Normal file
View File

@@ -0,0 +1,92 @@
# 部署与运维指南
## 环境要求
- Go 1.24+
- Netts API v2 账号,并完成:
- 获取 API Key
- 配置 IP 白名单(若使用代理,可在 `config.yaml` 中设置 `netts.realIp`
- 充值余额,以便购买能量周期。
## 构建
```bash
go build -o bin/netts-energy ./cmd/server
```
可选 Dockerfile伪代码
```dockerfile
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o /build/netts-energy ./cmd/server
FROM alpine:3.20
WORKDIR /srv/app
COPY --from=builder /build/netts-energy /usr/local/bin/netts-energy
COPY config.example.yaml ./config.yaml
CMD ["netts-energy", "-config", "/srv/app/config.yaml"]
```
## 运行时配置
1. 根据 `config.example.yaml` 编辑正式配置,或注入环境变量(`NETTS_API_KEY` 等)。
2. 建议通过 systemd / Supervisor 运行,示例:
```
[Unit]
Description=Netts Energy Orchestrator
After=network-online.target
[Service]
ExecStart=/srv/netts/netts-energy -config /srv/netts/config.yaml
Restart=on-failure
Environment=NETTS_API_KEY=***
[Install]
WantedBy=multi-user.target
```
3. 健康检查:
- HTTP GET `http://host:port/healthz` 返回 200。
- 可接入 Kubernetes / Nginx upstream 的健康探测。
## CI/CD 建议
仓库内置 GitHub Actions workflow (`.github/workflows/ci.yaml`)
- 安装依赖;
- `go fmt` + `go vet`
- `go test ./...`。
若需要自动部署服务器,可以在 CI 中加入以下步骤:
1. 在 GitHub 仓库配置 Secrets
- `SSH_HOST` / `SSH_USER` / `SSH_KEY`
- `DEPLOY_PATH` 等自定义变量。
2. 在 workflow 中追加 job简例
```yaml
- name: Deploy
if: github.ref == 'refs/heads/main'
run: |
scp bin/netts-energy ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.DEPLOY_PATH }}
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} 'systemctl restart netts-energy'
```
> 注:部署脚本默认留空,需根据实际服务器环境完善。
## 与现有自动到账系统的集成
1. **地址维护**:在充值服务创建新用户地址后,触发一次 `rent` 调用,让服务自动加入 Host Mode。
2. **归集流程**:在执行 TRC20 归集前调用 `rent` 接口,确保能量充足后再发起链上交易。
3. **降级策略**:若 Netts API 不可用,可回退到旧的 Feee 方案;建议通过 Prometheus 指标或报警系统监控 `rent` 接口的失败率。
4. **费用对比**:响应中的 `analysis.cost_breakdown` 与订单 `total_cost` 方便和现有成本核算系统对接。
## 运维建议
- **日志**:标准输出为结构化日志,建议接入 Loki / ELK 做集中采集。
- **监控**:结合 Netts webhook 与本服务返回数据,设置周期不足、下单失败等告警。
- **升级**:新增 Netts 接口(例如 `time/infinitystart`)时,可在 `internal/netts` 中扩展方法并追加 service 策略。
- **安全**:谨慎保管 API Key生产环境务必使用 HTTPS 代理或反向代理,限制访问来源。

14
go.mod Normal file
View File

@@ -0,0 +1,14 @@
module github.com/D36u99er/bc-netts-energy
go 1.24.3
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
)

12
go.sum Normal file
View File

@@ -0,0 +1,12 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

274
internal/config/config.go Normal file
View File

@@ -0,0 +1,274 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
defaultConfigFile = "config.yaml"
envPrefix = "NETTS"
)
// Config represents the full application configuration.
type Config struct {
Server ServerConfig `yaml:"server"`
Netts NettsConfig `yaml:"netts"`
Energy EnergyConfig `yaml:"energy"`
Logging LoggingConfig `yaml:"logging"`
}
// ServerConfig controls HTTP server behaviour.
type ServerConfig struct {
Address string `yaml:"address"`
ReadTimeout Duration `yaml:"readTimeout"`
WriteTimeout Duration `yaml:"writeTimeout"`
IdleTimeout Duration `yaml:"idleTimeout"`
ShutdownTimeout Duration `yaml:"shutdownTimeout"`
}
// NettsConfig contains credentials and settings for the Netts API.
type NettsConfig struct {
APIKey string `yaml:"apiKey"`
BaseURL string `yaml:"baseUrl"`
RealIP string `yaml:"realIp"`
CallbackURL string `yaml:"callbackUrl"`
HTTPTimeout Duration `yaml:"httpTimeout"`
Retry Retry `yaml:"retry"`
}
// Retry controls API retry behaviour.
type Retry struct {
MaxAttempts int `yaml:"maxAttempts"`
Backoff Duration `yaml:"backoff"`
MaxBackoff Duration `yaml:"maxBackoff"`
}
// EnergyConfig controls orchestration thresholds.
type EnergyConfig struct {
AutoAddHost bool `yaml:"autoAddHost"`
MinCycles int `yaml:"minCycles"`
TargetCycles int `yaml:"targetCycles"`
MinEnergyThreshold int `yaml:"minEnergyThreshold"`
PostOrderWait Duration `yaml:"postOrderWait"`
DefaultAnalyzeValue string `yaml:"defaultAnalyzeValue"`
}
// LoggingConfig controls logger behaviour.
type LoggingConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
// Load reads configuration from the provided path or default locations.
func Load(path string) (*Config, error) {
cfg := defaultConfig()
configFile, err := resolveConfigPath(path)
if err != nil {
return nil, err
}
if configFile != "" {
raw, err := os.ReadFile(configFile)
if err != nil {
return nil, fmt.Errorf("read config file %q: %w", configFile, err)
}
if err := yaml.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("parse config file %q: %w", configFile, err)
}
}
applyDefaults(&cfg)
applyEnvOverrides(&cfg)
if err := cfg.Validate(); err != nil {
return nil, err
}
return &cfg, nil
}
// DefaultConfig returns a configuration populated with defaults.
func defaultConfig() Config {
return Config{
Server: ServerConfig{
Address: ":8080",
ReadTimeout: Duration(10 * time.Second),
WriteTimeout: Duration(10 * time.Second),
IdleTimeout: Duration(60 * time.Second),
ShutdownTimeout: Duration(15 * time.Second),
},
Netts: NettsConfig{
BaseURL: "https://netts.io",
HTTPTimeout: Duration(15 * time.Second),
Retry: Retry{
MaxAttempts: 3,
Backoff: Duration(2 * time.Second),
MaxBackoff: Duration(10 * time.Second),
},
},
Energy: EnergyConfig{
AutoAddHost: true,
MinCycles: 3,
TargetCycles: 10,
MinEnergyThreshold: 32000,
PostOrderWait: Duration(3 * time.Second),
DefaultAnalyzeValue: "100.00",
},
Logging: LoggingConfig{
Level: "info",
Format: "text",
},
}
}
func applyDefaults(cfg *Config) {
if strings.TrimSpace(cfg.Server.Address) == "" {
cfg.Server.Address = ":8080"
}
cfg.Server.ReadTimeout.SetDefault(10 * time.Second)
cfg.Server.WriteTimeout.SetDefault(10 * time.Second)
cfg.Server.IdleTimeout.SetDefault(60 * time.Second)
cfg.Server.ShutdownTimeout.SetDefault(15 * time.Second)
if strings.TrimSpace(cfg.Netts.BaseURL) == "" {
cfg.Netts.BaseURL = "https://netts.io"
}
cfg.Netts.HTTPTimeout.SetDefault(15 * time.Second)
if cfg.Netts.Retry.MaxAttempts <= 0 {
cfg.Netts.Retry.MaxAttempts = 3
}
cfg.Netts.Retry.Backoff.SetDefault(2 * time.Second)
cfg.Netts.Retry.MaxBackoff.SetDefault(10 * time.Second)
if cfg.Energy.MinCycles <= 0 {
cfg.Energy.MinCycles = 3
}
if cfg.Energy.TargetCycles <= 0 {
cfg.Energy.TargetCycles = 10
}
if cfg.Energy.TargetCycles < cfg.Energy.MinCycles {
cfg.Energy.TargetCycles = cfg.Energy.MinCycles
}
if cfg.Energy.MinEnergyThreshold <= 0 {
cfg.Energy.MinEnergyThreshold = 32000
}
cfg.Energy.PostOrderWait.SetDefault(3 * time.Second)
if cfg.Energy.DefaultAnalyzeValue == "" {
cfg.Energy.DefaultAnalyzeValue = "100.00"
}
if cfg.Logging.Level == "" {
cfg.Logging.Level = "info"
}
if cfg.Logging.Format == "" {
cfg.Logging.Format = "text"
}
}
func applyEnvOverrides(cfg *Config) {
if v := os.Getenv(envVar("API_KEY")); v != "" {
cfg.Netts.APIKey = v
}
if v := os.Getenv(envVar("BASE_URL")); v != "" {
cfg.Netts.BaseURL = v
}
if v := os.Getenv(envVar("REAL_IP")); v != "" {
cfg.Netts.RealIP = v
}
if v := os.Getenv(envVar("CALLBACK_URL")); v != "" {
cfg.Netts.CallbackURL = v
}
if v := os.Getenv(envVar("AUTO_ADD_HOST")); v != "" {
cfg.Energy.AutoAddHost = strings.EqualFold(v, "true") || v == "1"
}
if v := os.Getenv(envVar("MIN_CYCLES")); v != "" {
if parsed, err := parseInt(v); err == nil {
cfg.Energy.MinCycles = parsed
}
}
if v := os.Getenv(envVar("TARGET_CYCLES")); v != "" {
if parsed, err := parseInt(v); err == nil {
cfg.Energy.TargetCycles = parsed
}
}
if v := os.Getenv(envVar("MIN_ENERGY_THRESHOLD")); v != "" {
if parsed, err := parseInt(v); err == nil {
cfg.Energy.MinEnergyThreshold = parsed
}
}
if v := os.Getenv(envVar("LOG_LEVEL")); v != "" {
cfg.Logging.Level = v
}
if v := os.Getenv(envVar("LOG_FORMAT")); v != "" {
cfg.Logging.Format = v
}
}
// Validate performs configuration validation.
func (c *Config) Validate() error {
var problems []string
if strings.TrimSpace(c.Netts.APIKey) == "" {
problems = append(problems, "netts.apiKey is required (or NETTS_API_KEY env)")
}
if !strings.HasPrefix(c.Netts.BaseURL, "http") {
problems = append(problems, "netts.baseUrl must be an absolute URL")
}
if c.Energy.MinCycles <= 0 {
problems = append(problems, "energy.minCycles must be > 0")
}
if c.Energy.TargetCycles < c.Energy.MinCycles {
problems = append(problems, "energy.targetCycles must be >= energy.minCycles")
}
if c.Energy.MinEnergyThreshold <= 0 {
problems = append(problems, "energy.minEnergyThreshold must be > 0")
}
if len(problems) > 0 {
return errors.New(strings.Join(problems, "; "))
}
return nil
}
func resolveConfigPath(path string) (string, error) {
if path != "" {
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("config file %q: %w", path, err)
}
return path, nil
}
searchPaths := []string{
defaultConfigFile,
filepath.Join("config", defaultConfigFile),
filepath.Join("..", defaultConfigFile),
}
for _, candidate := range searchPaths {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", nil
}
func envVar(key string) string {
return fmt.Sprintf("%s_%s", envPrefix, strings.ToUpper(strings.ReplaceAll(key, ".", "_")))
}
func parseInt(value string) (int, error) {
var parsed int
_, err := fmt.Sscanf(value, "%d", &parsed)
if err != nil {
return 0, fmt.Errorf("parse int %q: %w", value, err)
}
return parsed, nil
}

View File

@@ -0,0 +1,53 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadDefaultsWithEnv(t *testing.T) {
t.Setenv("NETTS_API_KEY", "env-key")
cfg, err := Load("")
require.NoError(t, err)
assert.Equal(t, ":8080", cfg.Server.Address)
assert.Equal(t, "https://netts.io", cfg.Netts.BaseURL)
assert.Equal(t, 3, cfg.Energy.MinCycles)
assert.Equal(t, 10, cfg.Energy.TargetCycles)
assert.Equal(t, "env-key", cfg.Netts.APIKey)
}
func TestLoadFromFileOverridesDefaults(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "config.yaml")
body := `
server:
address: ":9090"
readTimeout: 5s
netts:
apiKey: "file-key"
baseUrl: "https://example.com"
energy:
minCycles: 5
targetCycles: 8
logging:
level: debug
`
require.NoError(t, os.WriteFile(file, []byte(body), 0o600))
cfg, err := Load(file)
require.NoError(t, err)
assert.Equal(t, ":9090", cfg.Server.Address)
assert.Equal(t, "https://example.com", cfg.Netts.BaseURL)
assert.Equal(t, 5, cfg.Energy.MinCycles)
assert.Equal(t, 8, cfg.Energy.TargetCycles)
assert.Equal(t, "file-key", cfg.Netts.APIKey)
assert.Equal(t, "debug", cfg.Logging.Level)
}

46
internal/config/types.go Normal file
View File

@@ -0,0 +1,46 @@
package config
import (
"fmt"
"time"
"gopkg.in/yaml.v3"
)
// Duration wraps time.Duration to support YAML unmarshalling from strings.
type Duration time.Duration
// UnmarshalYAML parses a duration string such as "5s" or "2m".
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.ScalarNode {
return fmt.Errorf("duration must be a string, got %s", value.ShortTag())
}
parsed, err := time.ParseDuration(value.Value)
if err != nil {
return fmt.Errorf("parse duration %q: %w", value.Value, err)
}
*d = Duration(parsed)
return nil
}
// MarshalYAML converts the duration to a string value.
func (d Duration) MarshalYAML() (any, error) {
return time.Duration(d).String(), nil
}
// Duration returns the underlying time.Duration.
func (d Duration) Duration() time.Duration {
return time.Duration(d)
}
// IsZero reports whether the duration has been set.
func (d Duration) IsZero() bool {
return time.Duration(d) == 0
}
// SetDefault assigns the provided default if the duration is zero.
func (d *Duration) SetDefault(def time.Duration) {
if d.IsZero() {
*d = Duration(def)
}
}

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

@@ -0,0 +1,2 @@
// Package http provides HTTP handlers for the energy service.
package http

223
internal/http/server.go Normal file
View File

@@ -0,0 +1,223 @@
package http
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
appconfig "github.com/D36u99er/bc-netts-energy/internal/config"
"github.com/D36u99er/bc-netts-energy/internal/netts"
"github.com/D36u99er/bc-netts-energy/internal/service"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"log/slog"
)
// Server wraps the HTTP server.
type Server struct {
cfg appconfig.ServerConfig
httpServer *http.Server
energySvc *service.EnergyService
logger *slog.Logger
shutdownDur time.Duration
}
// NewServer constructs a Server with routes wired.
func NewServer(cfg appconfig.ServerConfig, energySvc *service.EnergyService, logger *slog.Logger) *Server {
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
router.Use(middleware.Recoverer)
s := &Server{
cfg: cfg,
energySvc: energySvc,
logger: logger,
shutdownDur: cfg.ShutdownTimeout.Duration(),
}
router.Use(s.loggingMiddleware)
router.Get("/healthz", s.handleHealth)
router.Post("/api/v1/energy/rent", s.handleRentEnergy)
httpServer := &http.Server{
Addr: cfg.Address,
Handler: router,
ReadTimeout: cfg.ReadTimeout.Duration(),
WriteTimeout: cfg.WriteTimeout.Duration(),
IdleTimeout: cfg.IdleTimeout.Duration(),
}
s.httpServer = httpServer
return s
}
// Run starts the HTTP server and listens for shutdown from context.
func (s *Server) Run(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
s.logger.Info("http server listening", "address", s.cfg.Address)
if err := s.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case <-ctx.Done():
return s.shutdown(context.Background())
case err := <-errCh:
return err
}
}
func (s *Server) shutdown(ctx context.Context) error {
timeout := s.shutdownDur
if timeout <= 0 {
timeout = 15 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
s.logger.Info("shutting down http server", "timeout", timeout)
return s.httpServer.Shutdown(ctx)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (s *Server) handleRentEnergy(w http.ResponseWriter, r *http.Request) {
var req rentEnergyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request payload", err)
return
}
defer r.Body.Close()
ctx := r.Context()
resp, err := s.energySvc.EnsureEnergy(ctx, service.EnsureEnergyRequest{
FromAddress: req.FromAddress,
ToAddress: req.ToAddress,
Amount: req.Amount,
CallbackURL: req.CallbackURL,
})
if err != nil {
s.logger.ErrorContext(ctx, "ensure energy failed", "error", err)
writeError(w, http.StatusBadGateway, "failed to ensure energy", err)
return
}
writeJSON(w, http.StatusOK, newRentEnergyResponse(resp))
}
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
s.logger.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration", time.Since(start),
"request_id", middleware.GetReqID(r.Context()),
)
})
}
type rentEnergyRequest struct {
FromAddress string `json:"from_address"`
ToAddress string `json:"to_address"`
Amount string `json:"amount"`
CallbackURL string `json:"callback_url"`
}
type rentEnergyResponse 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"`
Status *statusSummary `json:"status,omitempty"`
}
type statusSummary struct {
Mode string `json:"mode"`
Status string `json:"status"`
CyclesRemaining int `json:"cycles_remaining"`
OpenOrders int `json:"open_orders"`
ExpiryTime int64 `json:"expiry_time"`
}
func newRentEnergyResponse(resp *service.EnsureEnergyResponse) rentEnergyResponse {
var status *statusSummary
if resp.Status != nil {
status = &statusSummary{
Mode: resp.Status.Mode,
Status: resp.Status.Status,
CyclesRemaining: resp.Status.CyclesRemaining,
OpenOrders: resp.Status.OpenOrders,
ExpiryTime: resp.Status.ExpiryTime,
}
}
return rentEnergyResponse{
FromAddress: resp.FromAddress,
ToAddress: resp.ToAddress,
Amount: resp.Amount,
AddressAdded: resp.AddressAdded,
RecommendedEnergy: resp.RecommendedEnergy,
EnergyNeeded: resp.EnergyNeeded,
CyclesBefore: resp.CyclesBefore,
CyclesAfter: resp.CyclesAfter,
CyclesPurchased: resp.CyclesPurchased,
TotalCycles: resp.TotalCycles,
OrderID: resp.OrderID,
OrderStatus: resp.OrderStatus,
TotalCost: resp.TotalCost,
NextDelegation: resp.NextDelegation,
Analysis: resp.Analysis,
Status: status,
}
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, message string, err error) {
type errorBody struct {
Error string `json:"error"`
Details string `json:"details,omitempty"`
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
detail := ""
if err != nil {
detail = err.Error()
}
_ = json.NewEncoder(w).Encode(errorBody{
Error: message,
Details: detail,
})
}

49
internal/logger/logger.go Normal file
View File

@@ -0,0 +1,49 @@
package logger
import (
"log/slog"
"os"
"strings"
"time"
)
// New builds a slog.Logger based on level and format configuration.
func New(level, format string) *slog.Logger {
slogLevel := parseLevel(level)
var handler slog.Handler
opts := &slog.HandlerOptions{
Level: slogLevel,
AddSource: true,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
a.Value = slog.StringValue(time.Now().UTC().Format(time.RFC3339Nano))
}
return a
},
}
switch strings.ToLower(format) {
case "json":
handler = slog.NewJSONHandler(os.Stdout, opts)
default:
handler = slog.NewTextHandler(os.Stdout, opts)
}
return slog.New(handler)
}
func parseLevel(level string) slog.Level {
switch strings.ToLower(level) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
case "info", "":
return slog.LevelInfo
default:
return slog.LevelInfo
}
}

285
internal/netts/client.go Normal file
View 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
}

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

@@ -0,0 +1,2 @@
// Package netts contains the Netts API client.
package netts

87
internal/netts/models.go Normal file
View 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"`
}

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 }

36
pkg/errors/api_error.go Normal file
View File

@@ -0,0 +1,36 @@
package errors
import "fmt"
// APIError represents a structured error from an upstream API.
type APIError struct {
Code int
Message string
HTTPStatus int
}
func (e *APIError) Error() string {
if e == nil {
return "<nil>"
}
if e.HTTPStatus > 0 {
return fmt.Sprintf("api error: status=%d code=%d message=%s", e.HTTPStatus, e.Code, e.Message)
}
return fmt.Sprintf("api error: code=%d message=%s", e.Code, e.Message)
}
// Temporary returns true when the error is potentially recoverable.
func (e *APIError) Temporary() bool {
if e == nil {
return false
}
if e.HTTPStatus >= 500 {
return true
}
switch e.Code {
case 429:
return true
default:
return false
}
}