feat: initial Netts energy orchestrator
This commit is contained in:
43
.github/workflows/ci.yaml
vendored
Normal file
43
.github/workflows/ci.yaml
vendored
Normal 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
21
.gitignore
vendored
Normal 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
18
Makefile
Normal 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
92
README.md
Normal 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
46
cmd/server/main.go
Normal 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
29
config.example.yaml
Normal 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
95
docs/api.md
Normal 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
80
docs/architecture.md
Normal 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
92
docs/deployment.md
Normal 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
14
go.mod
Normal 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
12
go.sum
Normal 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
274
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
53
internal/config/config_test.go
Normal file
53
internal/config/config_test.go
Normal 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
46
internal/config/types.go
Normal 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
2
internal/http/doc.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Package http provides HTTP handlers for the energy service.
|
||||||
|
package http
|
||||||
223
internal/http/server.go
Normal file
223
internal/http/server.go
Normal 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
49
internal/logger/logger.go
Normal 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
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"`
|
||||||
|
}
|
||||||
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 }
|
||||||
36
pkg/errors/api_error.go
Normal file
36
pkg/errors/api_error.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user