80d46dcd5d
Co-authored-by: Cursor <cursoragent@cursor.com>
506 lines
15 KiB
Python
506 lines
15 KiB
Python
"""节点授权订单。付款凭证先入库,管理员通过后才开通节点。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
|
|
from sqlalchemy import case, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
from app.clock import utcnow
|
|
from app.config import settings
|
|
from app.errors import AppError
|
|
from app.models import LicenseOrder, OrderStatus
|
|
from app.money import money
|
|
from app.services.registry import register_node
|
|
|
|
|
|
def place_order(session: Session, user_id: int, tx_id: str) -> LicenseOrder:
|
|
digest = tx_id.strip()
|
|
if session.scalar(select(LicenseOrder.id).where(LicenseOrder.tx_id == digest)) is not None:
|
|
raise AppError("这笔交易已经提交过", 409)
|
|
order = LicenseOrder(
|
|
user_id=user_id,
|
|
amount=money(settings.license_price),
|
|
asset_symbol=settings.license_asset.strip().upper() or "USDT",
|
|
status=OrderStatus.PENDING,
|
|
tx_id=digest,
|
|
created_at=utcnow(),
|
|
)
|
|
session.add(order)
|
|
try:
|
|
session.flush()
|
|
except IntegrityError as exc:
|
|
session.rollback()
|
|
raise AppError("这笔交易已经提交过", 409) from exc
|
|
return order
|
|
|
|
|
|
def approve_order(session: Session, order_id: int) -> LicenseOrder:
|
|
order = _pending_order(session, order_id)
|
|
token = secrets.token_urlsafe(24)
|
|
node = register_node(
|
|
session,
|
|
order.user.wallet_address,
|
|
f"HN{order.id:06d}",
|
|
order.amount,
|
|
node_token=token,
|
|
)
|
|
order.status = OrderStatus.APPROVED
|
|
order.node = node
|
|
order.reviewed_at = utcnow()
|
|
session.flush()
|
|
return order
|
|
|
|
|
|
def reject_order(session: Session, order_id: int, note: str | None = None) -> LicenseOrder:
|
|
order = _pending_order(session, order_id)
|
|
order.status = OrderStatus.REJECTED
|
|
order.note = note
|
|
order.reviewed_at = utcnow()
|
|
session.flush()
|
|
return order
|
|
|
|
|
|
def list_orders(session: Session, user_id: int | None = None) -> list[LicenseOrder]:
|
|
pending_first = case((LicenseOrder.status == OrderStatus.PENDING, 0), else_=1)
|
|
query = select(LicenseOrder).options(selectinload(LicenseOrder.user), selectinload(LicenseOrder.node))
|
|
if user_id is not None:
|
|
query = query.where(LicenseOrder.user_id == user_id)
|
|
return list(session.scalars(query.order_by(pending_first, LicenseOrder.id.desc())).all())
|
|
|
|
|
|
_CLIENT = """# HyperNode 挂机客户端。这个文件里有节点 Token,不要发给别人。
|
|
import ctypes
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
|
|
CONFIG = json.loads(__CONFIG__)
|
|
|
|
|
|
SHARES = 0
|
|
_REFERENCE_MH = (
|
|
("4090", 130.0),
|
|
("4080 SUPER", 95.0),
|
|
("4080", 90.0),
|
|
("4070 TI SUPER", 75.0),
|
|
("4070 TI", 70.0),
|
|
("4070 SUPER", 62.0),
|
|
("4070", 55.0),
|
|
("3090 TI", 125.0),
|
|
("3090", 115.0),
|
|
("3080 TI", 105.0),
|
|
("3080", 95.0),
|
|
("3070 TI", 65.0),
|
|
("3070", 60.0),
|
|
("3060 TI", 48.5),
|
|
("3060", 40.0),
|
|
)
|
|
|
|
|
|
def current_mode():
|
|
mode = os.environ.get("HYPERNODE_MODE") or CONFIG.get("current_mode") or "pow_mining"
|
|
return mode if mode in {"pow_mining", "gpu_rental"} else "pow_mining"
|
|
|
|
|
|
def _fingerprint(gpu_uuid):
|
|
board = _board_serial() or ""
|
|
mac = f"{uuid.getnode():012x}"
|
|
material = "|".join([gpu_uuid or "", board, mac])
|
|
if not material.strip("|"):
|
|
return None
|
|
return hashlib.sha256(material.encode()).hexdigest()
|
|
|
|
|
|
def _board_serial():
|
|
if os.name == "nt":
|
|
try:
|
|
output = subprocess.check_output(
|
|
["wmic", "baseboard", "get", "serialnumber"],
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=8,
|
|
text=True,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
lines = [line.strip() for line in output.splitlines() if line.strip() and "SerialNumber" not in line]
|
|
return lines[0] if lines else None
|
|
for path in ("/sys/class/dmi/id/board_serial", "/sys/class/dmi/id/product_uuid"):
|
|
try:
|
|
with open(path, encoding="utf-8") as handle:
|
|
value = handle.read().strip()
|
|
except OSError:
|
|
continue
|
|
if value and value.lower() not in {"none", "to be filled by o.e.m."}:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _gpu():
|
|
probed = _pynvml()
|
|
if probed and probed.get("name"):
|
|
return probed
|
|
probed = _nvidia_smi()
|
|
return probed or {}
|
|
|
|
|
|
def _blank_gpu():
|
|
return {
|
|
"name": None,
|
|
"vram_gb": None,
|
|
"uuid": None,
|
|
"vram_used_mb": None,
|
|
"vram_total_mb": None,
|
|
"gpu_temp": None,
|
|
"fan_speed": None,
|
|
"power_usage": None,
|
|
"utilization": None,
|
|
}
|
|
|
|
|
|
def _pynvml():
|
|
try:
|
|
import pynvml
|
|
except ImportError:
|
|
return None
|
|
try:
|
|
pynvml.nvmlInit()
|
|
best = None
|
|
best_bytes = -1
|
|
for index in range(pynvml.nvmlDeviceGetCount()):
|
|
handle = pynvml.nvmlDeviceGetHandleByIndex(index)
|
|
name = pynvml.nvmlDeviceGetName(handle)
|
|
if isinstance(name, bytes):
|
|
name = name.decode()
|
|
memory = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
|
total = int(memory.total)
|
|
if total <= best_bytes:
|
|
continue
|
|
uid = pynvml.nvmlDeviceGetUUID(handle)
|
|
if isinstance(uid, bytes):
|
|
uid = uid.decode()
|
|
best_bytes = total
|
|
best = _blank_gpu()
|
|
best.update({
|
|
"name": str(name),
|
|
"vram_gb": round(total / (1024 ** 3), 2),
|
|
"uuid": str(uid),
|
|
"vram_used_mb": int(round(int(memory.used) / (1024 ** 2))),
|
|
"vram_total_mb": int(round(total / (1024 ** 2))),
|
|
"gpu_temp": _nvml_call(pynvml.nvmlDeviceGetTemperature, handle, pynvml.NVML_TEMPERATURE_GPU),
|
|
"fan_speed": _nvml_call(pynvml.nvmlDeviceGetFanSpeed, handle),
|
|
"power_usage": _nvml_watts(pynvml, handle),
|
|
"utilization": _nvml_util(pynvml, handle),
|
|
})
|
|
pynvml.nvmlShutdown()
|
|
except Exception:
|
|
return None
|
|
return best
|
|
|
|
|
|
def _nvml_call(func, *args):
|
|
try:
|
|
value = func(*args)
|
|
return int(value)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _nvml_watts(pynvml, handle):
|
|
try:
|
|
return round(pynvml.nvmlDeviceGetPowerUsage(handle) / 1000, 1)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _nvml_util(pynvml, handle):
|
|
try:
|
|
return int(pynvml.nvmlDeviceGetUtilizationRates(handle).gpu)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _nvidia_smi():
|
|
query = "name,memory.total,memory.used,uuid,temperature.gpu,fan.speed,power.draw,utilization.gpu"
|
|
try:
|
|
output = subprocess.check_output(
|
|
["nvidia-smi", "--query-gpu=" + query, "--format=csv,noheader,nounits"],
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=8,
|
|
text=True,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
best = None
|
|
best_mib = -1.0
|
|
for line in output.splitlines():
|
|
parts = [part.strip() for part in line.split(",")]
|
|
if len(parts) < 8:
|
|
continue
|
|
total = _number(parts[-7])
|
|
if total is None or total <= best_mib:
|
|
continue
|
|
best_mib = total
|
|
best = _blank_gpu()
|
|
best.update({
|
|
"name": ",".join(parts[:-7]).strip() or None,
|
|
"vram_gb": round(total / 1024, 2),
|
|
"vram_total_mb": int(round(total)),
|
|
"vram_used_mb": _int_number(parts[-6]),
|
|
"uuid": parts[-5] or None,
|
|
"gpu_temp": _int_number(parts[-4]),
|
|
"fan_speed": _int_number(parts[-3]),
|
|
"power_usage": _number(parts[-2]),
|
|
"utilization": _int_number(parts[-1]),
|
|
})
|
|
return best
|
|
|
|
|
|
def _number(value):
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _int_number(value):
|
|
number = _number(value)
|
|
if number is None:
|
|
return None
|
|
return int(round(number))
|
|
|
|
|
|
def _memory_gb():
|
|
if os.name == "nt":
|
|
class MemoryStatus(ctypes.Structure):
|
|
_fields_ = [
|
|
("dwLength", ctypes.c_ulong),
|
|
("dwMemoryLoad", ctypes.c_ulong),
|
|
("ullTotalPhys", ctypes.c_ulonglong),
|
|
("ullAvailPhys", ctypes.c_ulonglong),
|
|
("ullTotalPageFile", ctypes.c_ulonglong),
|
|
("ullAvailPageFile", ctypes.c_ulonglong),
|
|
("ullTotalVirtual", ctypes.c_ulonglong),
|
|
("ullAvailVirtual", ctypes.c_ulonglong),
|
|
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
|
]
|
|
|
|
status = MemoryStatus()
|
|
status.dwLength = ctypes.sizeof(MemoryStatus)
|
|
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)):
|
|
return round(status.ullTotalPhys / (1024 ** 3), 2)
|
|
return None
|
|
try:
|
|
with open("/proc/meminfo", encoding="utf-8") as handle:
|
|
for line in handle:
|
|
if line.startswith("MemTotal:"):
|
|
return round(int(line.split()[1]) / 1024 / 1024, 2)
|
|
except OSError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def beat():
|
|
global SHARES
|
|
gpu = _gpu()
|
|
payload = hardware_from(gpu)
|
|
body = json.dumps(payload).encode()
|
|
request = urllib.request.Request(
|
|
CONFIG["base"] + "/api/v1/node/heartbeat",
|
|
data=body,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=20) as response:
|
|
reply = json.loads(response.read().decode())
|
|
SHARES += 1
|
|
_save_shares(SHARES)
|
|
_report(gpu, reply, SHARES)
|
|
|
|
|
|
def hardware_from(gpu):
|
|
card = gpu or {}
|
|
return {
|
|
"sn": CONFIG["sn"],
|
|
"token": CONFIG["token"],
|
|
"gpu_model": card.get("name"),
|
|
"vram_gb": card.get("vram_gb"),
|
|
"cpu_count": os.cpu_count(),
|
|
"memory_gb": _memory_gb(),
|
|
"hardware_fingerprint": _fingerprint(card.get("uuid")),
|
|
}
|
|
|
|
|
|
def _report(gpu, reply, shares):
|
|
card = gpu or {}
|
|
name = card.get("name") or "GPU not detected"
|
|
vram = card.get("vram_gb")
|
|
vram_text = f"{vram:.2f} GB" if isinstance(vram, (int, float)) else "n/a"
|
|
_log("INFO", f"{name} · {vram_text}")
|
|
_log("HARDWARE", _hardware_line(card))
|
|
if current_mode() == "gpu_rental":
|
|
load = card.get("utilization")
|
|
load_text = f"{load}% Load" if load is not None else "n/a"
|
|
_log("TASK", f"[AI Task Executing] · {load_text}")
|
|
else:
|
|
rate = _hashrate(name)
|
|
rate_text = f"{rate:.1f} MH/s" if rate is not None else "n/a"
|
|
_log("HASHRATE", f"{rate_text} · Accepted {shares}")
|
|
weight = reply.get("hardware_weight")
|
|
weight_text = f"{float(weight):.1f}" if isinstance(weight, (int, float)) else "n/a"
|
|
node_id = reply.get("display_node_id") or "----"
|
|
status = reply.get("status") or "unknown"
|
|
_log("HEARTBEAT", f"{status} · Node #{node_id} · Weight {weight_text}")
|
|
_log("RECOVERY", _recovery_line(reply))
|
|
print(flush=True)
|
|
|
|
|
|
def _hardware_line(card):
|
|
used = card.get("vram_used_mb")
|
|
total = card.get("vram_total_mb")
|
|
vram = f"{used} / {total} MB" if used is not None and total is not None else "n/a"
|
|
return " · ".join([
|
|
_suffix(card.get("gpu_temp"), "°C"),
|
|
"Fan " + _suffix(card.get("fan_speed"), "%"),
|
|
_suffix(card.get("power_usage"), " W"),
|
|
"VRAM " + vram,
|
|
])
|
|
|
|
|
|
def _recovery_line(reply):
|
|
days = int(reply.get("recovery_days") or 0)
|
|
purchase = _decimal(reply.get("purchase_amount"))
|
|
remaining = _decimal(reply.get("recoverable_stake_amount"))
|
|
estimate = reply.get("estimated_daily_points") or "0"
|
|
if purchase is not None and remaining is not None and purchase > 0:
|
|
done = max(0.0, (purchase - remaining) / purchase * 100)
|
|
progress = f"{done:.1f}% returned"
|
|
else:
|
|
progress = "n/a"
|
|
title = "30-day fast recovery" if days == 30 else f"{days}-day recovery"
|
|
return f"{title} · {progress} · Est. Points {estimate}"
|
|
|
|
|
|
def _hashrate(name):
|
|
upper = (name or "").upper().replace("-", " ")
|
|
for token, rate in _REFERENCE_MH:
|
|
if token in upper:
|
|
return rate
|
|
return None
|
|
|
|
|
|
def _suffix(value, suffix):
|
|
if value is None:
|
|
return "n/a"
|
|
return f"{value}{suffix}"
|
|
|
|
|
|
def _decimal(value):
|
|
if value is None or value == "":
|
|
return None
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _log(tag, message):
|
|
color = {
|
|
"INFO": "36",
|
|
"HARDWARE": "33",
|
|
"HASHRATE": "32",
|
|
"TASK": "32",
|
|
"HEARTBEAT": "96",
|
|
"RECOVERY": "33",
|
|
"WARN": "31",
|
|
}.get(tag, "37")
|
|
label = f"[{tag}]".ljust(12)
|
|
if sys.stdout.isatty():
|
|
label = f"\033[1;{color}m{label}\033[0m"
|
|
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
print(f"{stamp} {label} {message}", flush=True)
|
|
|
|
|
|
def _share_path():
|
|
folder = os.path.dirname(os.path.abspath(__file__))
|
|
return os.path.join(folder, "." + CONFIG["sn"] + ".shares")
|
|
|
|
|
|
def _load_shares():
|
|
try:
|
|
with open(_share_path(), encoding="utf-8") as handle:
|
|
return max(0, int(handle.read().strip() or "0"))
|
|
except (OSError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _save_shares(count):
|
|
try:
|
|
with open(_share_path(), "w", encoding="utf-8") as handle:
|
|
handle.write(str(count))
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _enable_ansi():
|
|
if os.name != "nt":
|
|
return
|
|
try:
|
|
kernel = ctypes.windll.kernel32
|
|
handle = kernel.GetStdHandle(-11)
|
|
mode = ctypes.c_ulong()
|
|
if kernel.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
kernel.SetConsoleMode(handle, mode.value | 0x0004)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_enable_ansi()
|
|
SHARES = _load_shares()
|
|
_log("INFO", "HyperNode client · " + CONFIG["sn"] + " · " + current_mode())
|
|
while True:
|
|
try:
|
|
beat()
|
|
except urllib.error.HTTPError as exc:
|
|
detail = exc.read().decode(errors="replace").strip()
|
|
_log("WARN", f"heartbeat {exc.code} {detail}")
|
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
_log("WARN", str(exc))
|
|
time.sleep(60)
|
|
"""
|
|
|
|
|
|
def render_client(sn: str, token: str, base_url: str) -> str:
|
|
config = json.dumps(
|
|
{
|
|
"sn": sn,
|
|
"token": token,
|
|
"base": base_url.rstrip("/"),
|
|
"current_mode": "pow_mining",
|
|
},
|
|
ensure_ascii=True,
|
|
)
|
|
return _CLIENT.replace("__CONFIG__", repr(config))
|
|
|
|
|
|
def _pending_order(session: Session, order_id: int) -> LicenseOrder:
|
|
order = session.scalar(
|
|
select(LicenseOrder)
|
|
.options(selectinload(LicenseOrder.user), selectinload(LicenseOrder.node))
|
|
.where(LicenseOrder.id == order_id)
|
|
)
|
|
if order is None:
|
|
raise AppError("订单不存在", 404)
|
|
if order.status != OrderStatus.PENDING:
|
|
raise AppError("只有待审核的订单可以处理", 409)
|
|
return order
|