From 891c32e288c24af25e272512cd23b845d6918f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E7=9A=84=E7=94=A8=E6=88=B7=E5=90=8D?= <你的邮箱> Date: Mon, 3 Nov 2025 19:26:48 +0800 Subject: [PATCH] feat: initial Netts energy orchestrator --- .github/workflows/ci.yaml | 43 ++++ .gitignore | 21 ++ Makefile | 18 ++ README.md | 92 ++++++++ cmd/server/main.go | 46 ++++ config.example.yaml | 29 +++ docs/api.md | 95 ++++++++ docs/architecture.md | 80 +++++++ docs/deployment.md | 92 ++++++++ go.mod | 14 ++ go.sum | 12 + internal/config/config.go | 274 +++++++++++++++++++++++ internal/config/config_test.go | 53 +++++ internal/config/types.go | 46 ++++ internal/http/doc.go | 2 + internal/http/server.go | 223 ++++++++++++++++++ internal/logger/logger.go | 49 ++++ internal/netts/client.go | 285 ++++++++++++++++++++++++ internal/netts/client_test.go | 185 +++++++++++++++ internal/netts/doc.go | 2 + internal/netts/models.go | 87 ++++++++ internal/service/doc.go | 2 + internal/service/energy_service.go | 241 ++++++++++++++++++++ internal/service/energy_service_test.go | 183 +++++++++++++++ pkg/errors/api_error.go | 36 +++ 25 files changed, 2210 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 config.example.yaml create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/deployment.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/types.go create mode 100644 internal/http/doc.go create mode 100644 internal/http/server.go create mode 100644 internal/logger/logger.go create mode 100644 internal/netts/client.go create mode 100644 internal/netts/client_test.go create mode 100644 internal/netts/doc.go create mode 100644 internal/netts/models.go create mode 100644 internal/service/doc.go create mode 100644 internal/service/energy_service.go create mode 100644 internal/service/energy_service_test.go create mode 100644 pkg/errors/api_error.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..1f3343d --- /dev/null +++ b/.github/workflows/ci.yaml @@ -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 ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eccd399 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1ba5074 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..53821d5 --- /dev/null +++ b/README.md @@ -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`。 diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..bef1a3d --- /dev/null +++ b/cmd/server/main.go @@ -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") +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..0e53116 --- /dev/null +++ b/config.example.yaml @@ -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 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..dbbe15e --- /dev/null +++ b/docs/api.md @@ -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),避免频繁重复请求。 +- 如需批量处理,可在外层编排多个地址循环调用。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5535551 --- /dev/null +++ b/docs/architecture.md @@ -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`,形成全自动化闭环。 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..a14ee5b --- /dev/null +++ b/docs/deployment.md @@ -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 代理或反向代理,限制访问来源。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..49c598e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..67f0533 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..abcc3e5 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c4b2701 --- /dev/null +++ b/internal/config/config_test.go @@ -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) +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..4bc24a9 --- /dev/null +++ b/internal/config/types.go @@ -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) + } +} diff --git a/internal/http/doc.go b/internal/http/doc.go new file mode 100644 index 0000000..dd3bdae --- /dev/null +++ b/internal/http/doc.go @@ -0,0 +1,2 @@ +// Package http provides HTTP handlers for the energy service. +package http diff --git a/internal/http/server.go b/internal/http/server.go new file mode 100644 index 0000000..eab3a0a --- /dev/null +++ b/internal/http/server.go @@ -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, + }) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..c9812eb --- /dev/null +++ b/internal/logger/logger.go @@ -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 + } +} diff --git a/internal/netts/client.go b/internal/netts/client.go new file mode 100644 index 0000000..8c9c1ad --- /dev/null +++ b/internal/netts/client.go @@ -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 +} diff --git a/internal/netts/client_test.go b/internal/netts/client_test.go new file mode 100644 index 0000000..3a3bfb5 --- /dev/null +++ b/internal/netts/client_test.go @@ -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) +} diff --git a/internal/netts/doc.go b/internal/netts/doc.go new file mode 100644 index 0000000..b496c9b --- /dev/null +++ b/internal/netts/doc.go @@ -0,0 +1,2 @@ +// Package netts contains the Netts API client. +package netts diff --git a/internal/netts/models.go b/internal/netts/models.go new file mode 100644 index 0000000..f19682d --- /dev/null +++ b/internal/netts/models.go @@ -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"` +} diff --git a/internal/service/doc.go b/internal/service/doc.go new file mode 100644 index 0000000..fbdf334 --- /dev/null +++ b/internal/service/doc.go @@ -0,0 +1,2 @@ +// Package service contains business logic for energy orchestration. +package service diff --git a/internal/service/energy_service.go b/internal/service/energy_service.go new file mode 100644 index 0000000..8064b91 --- /dev/null +++ b/internal/service/energy_service.go @@ -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 +} diff --git a/internal/service/energy_service_test.go b/internal/service/energy_service_test.go new file mode 100644 index 0000000..4191905 --- /dev/null +++ b/internal/service/energy_service_test.go @@ -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 } diff --git a/pkg/errors/api_error.go b/pkg/errors/api_error.go new file mode 100644 index 0000000..23b61c0 --- /dev/null +++ b/pkg/errors/api_error.go @@ -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 "" + } + 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 + } +}