Skip to content

Commit bb98680

Browse files
Merge pull request #38 from backend-developers-ltd/new-miner
Support InfiniteHash Proxy in aps_miner
2 parents 5964df0 + 2554dcf commit bb98680

11 files changed

Lines changed: 727 additions & 102 deletions

File tree

app/src/infinite_hashes/aps_miner/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ print(result)
222222

223223
For a full end-to-end manual test with the blockchain simulator, run:
224224

225-
- `python manual_test_miner_compose.py` – builds the miner image (or reuses an override), launches the Docker Compose stack (miner plus Braiins proxy), and validates automatic Braiins profile updates.
225+
- `python manual_test_miner_compose.py` – builds the miner image (or reuses an override), launches the Docker Compose stack (miner plus IHP proxy stack), and validates automatic `pools.toml` target hashrate updates with proxy reload signaling.
226226

227227
## Logging
228228

app/src/infinite_hashes/aps_miner/callbacks.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,38 @@
55
Future: integrate with ASIC routing logic.
66
"""
77

8+
import os
9+
810
import structlog
911

1012
from .brainsproxy import update_routing_weights
1113
from .models import AuctionResult, BidResult
14+
from .proxy_routing import pools_config_path, update_subnet_target_hashrate
1215

1316
logger = structlog.get_logger(__name__)
1417

18+
PROXY_MODE_ENV = "APS_MINER_PROXY_MODE"
19+
PROXY_MODE_IHP = "ihp"
20+
PROXY_MODE_BRAIINS = "braiins"
21+
22+
23+
def _resolve_proxy_mode() -> str:
24+
raw_mode = os.environ.get(PROXY_MODE_ENV, "").strip().lower()
25+
if raw_mode == PROXY_MODE_IHP:
26+
return PROXY_MODE_IHP
27+
if raw_mode == PROXY_MODE_BRAIINS:
28+
return PROXY_MODE_BRAIINS
29+
30+
configured_pools_path = os.environ.get("APS_MINER_POOLS_CONFIG_PATH", "").strip()
31+
if configured_pools_path:
32+
try:
33+
if pools_config_path().exists():
34+
return PROXY_MODE_IHP
35+
except OSError:
36+
logger.warning("Unable to inspect APS_MINER_POOLS_CONFIG_PATH; falling back to braiins mode")
37+
38+
return PROXY_MODE_BRAIINS
39+
1540

1641
def handle_auction_results(result: AuctionResult) -> None:
1742
"""
@@ -46,10 +71,14 @@ def handle_auction_results(result: AuctionResult) -> None:
4671
if lost_bids:
4772
_handle_lost_bids(lost_bids, result)
4873

74+
proxy_mode = _resolve_proxy_mode()
4975
try:
50-
update_routing_weights(won_bids, lost_bids)
76+
if proxy_mode == PROXY_MODE_IHP:
77+
update_subnet_target_hashrate(won_bids, lost_bids)
78+
else:
79+
update_routing_weights(won_bids, lost_bids)
5180
except Exception: # noqa: BLE001
52-
logger.exception("Failed to update Braiins routing weights")
81+
logger.exception("Failed to update proxy routing", proxy_mode=proxy_mode)
5382

5483

5584
def _handle_won_bids(won_bids: list[BidResult], result: AuctionResult) -> None:
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
Integration helpers for InfiniteHash Proxy pool routing.
3+
4+
This module updates subnet pool target hashrate in pools.toml so winning bids
5+
receive an absolute allocation while private pools can consume the remainder.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import os
11+
from collections.abc import Iterable
12+
from pathlib import Path
13+
14+
import structlog
15+
import tomlkit
16+
17+
from .models import BidResult
18+
19+
logger = structlog.get_logger(__name__)
20+
21+
DEFAULT_POOLS_CONFIG_PATH = "/root/src/proxy/pools.toml"
22+
DEFAULT_SUBNET_POOL_NAME = "central-proxy"
23+
DEFAULT_RELOAD_SENTINEL_PATH = "/root/src/proxy/.reload-ihp"
24+
25+
26+
def pools_config_path() -> Path:
27+
configured_path = os.environ.get("APS_MINER_POOLS_CONFIG_PATH", DEFAULT_POOLS_CONFIG_PATH)
28+
return Path(configured_path)
29+
30+
31+
def reload_sentinel_path() -> Path:
32+
configured_path = os.environ.get("APS_MINER_IHP_RELOAD_SENTINEL", DEFAULT_RELOAD_SENTINEL_PATH)
33+
return Path(configured_path)
34+
35+
36+
def _sum_hashrates_ph(bids: Iterable[BidResult]) -> float:
37+
total = 0.0
38+
for bid in bids:
39+
try:
40+
total += float(bid.hashrate)
41+
except (TypeError, ValueError):
42+
logger.warning("Failed to parse bid hashrate for proxy routing", hashrate=bid.hashrate)
43+
return total
44+
45+
46+
def _format_target_hashrate_from_ph(total_ph: float) -> str:
47+
target_th = max(total_ph, 0.0) * 1000.0
48+
target_str = f"{target_th:.3f}".rstrip("0").rstrip(".")
49+
if not target_str:
50+
target_str = "0"
51+
return f"{target_str}TH/s"
52+
53+
54+
def _is_subnet_pool(pool: dict, subnet_pool_name: str) -> bool:
55+
pool_name = str(pool.get("name", "")).strip().lower()
56+
return pool_name == subnet_pool_name
57+
58+
59+
def update_subnet_target_hashrate(won_bids: Iterable[BidResult], _lost_bids: Iterable[BidResult]) -> None:
60+
"""
61+
Update the subnet pool target hashrate in pools.toml.
62+
63+
Won hashrate is assigned to subnet pools as absolute `target_hashrate`.
64+
Remaining hashrate continues to flow to private pools through their weights.
65+
"""
66+
config_path = pools_config_path()
67+
if not config_path.exists():
68+
logger.info("InfiniteHash proxy pools config not present - skipping update", path=str(config_path))
69+
return
70+
71+
subnet_pool_name = os.environ.get("APS_MINER_SUBNET_POOL_NAME", DEFAULT_SUBNET_POOL_NAME).strip().lower()
72+
target_hashrate = _format_target_hashrate_from_ph(_sum_hashrates_ph(won_bids))
73+
74+
try:
75+
with config_path.open("r", encoding="utf-8") as f:
76+
doc = tomlkit.load(f)
77+
except Exception:
78+
logger.exception("Unable to load InfiniteHash proxy pools config", path=str(config_path))
79+
return
80+
81+
pools_section = doc.get("pools")
82+
if not isinstance(pools_section, dict):
83+
logger.warning("Missing [pools] section in proxy config", path=str(config_path))
84+
return
85+
86+
main_pools = pools_section.get("main")
87+
if not isinstance(main_pools, list):
88+
logger.warning("Missing [[pools.main]] entries in proxy config", path=str(config_path))
89+
return
90+
91+
updated = False
92+
matched = 0
93+
for pool in main_pools:
94+
if not isinstance(pool, dict):
95+
continue
96+
if not _is_subnet_pool(pool, subnet_pool_name):
97+
continue
98+
99+
matched += 1
100+
old_target_hashrate = pool.get("target_hashrate")
101+
if old_target_hashrate != target_hashrate:
102+
pool["target_hashrate"] = target_hashrate
103+
updated = True
104+
105+
if "weight" in pool:
106+
del pool["weight"]
107+
updated = True
108+
109+
if matched == 0:
110+
logger.warning(
111+
"No subnet pool entries matched for target hashrate update",
112+
name=subnet_pool_name,
113+
path=str(config_path),
114+
)
115+
return
116+
117+
if not updated:
118+
logger.debug("Subnet target hashrate already up to date", target_hashrate=target_hashrate)
119+
return
120+
121+
try:
122+
with config_path.open("w", encoding="utf-8") as f:
123+
tomlkit.dump(doc, f)
124+
except Exception:
125+
logger.exception("Failed to persist proxy pools config updates", path=str(config_path))
126+
return
127+
128+
sentinel_path = reload_sentinel_path()
129+
try:
130+
sentinel_path.parent.mkdir(parents=True, exist_ok=True)
131+
sentinel_path.touch()
132+
except Exception:
133+
logger.exception("Failed to signal IHP proxy reload", sentinel=str(sentinel_path))
134+
135+
logger.info(
136+
"Updated subnet target hashrate",
137+
name=subnet_pool_name,
138+
target_hashrate=target_hashrate,
139+
matched_pools=matched,
140+
)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from __future__ import annotations
2+
3+
from datetime import UTC, datetime
4+
5+
from infinite_hashes.aps_miner import callbacks
6+
from infinite_hashes.aps_miner.models import AuctionResult, BidResult
7+
8+
9+
def _build_result() -> AuctionResult:
10+
now = datetime.now(tz=UTC)
11+
return AuctionResult(
12+
epoch_start=1,
13+
start_block=10,
14+
end_block=20,
15+
window_start_time=now,
16+
window_end_time=now,
17+
commitments_count=2,
18+
all_winners=[],
19+
my_bids=[
20+
BidResult(hashrate="0.1", price_fp18=1, won=True),
21+
BidResult(hashrate="0.2", price_fp18=1, won=False),
22+
],
23+
)
24+
25+
26+
def test_handle_auction_results_uses_ihp_proxy_when_configured(monkeypatch) -> None:
27+
calls = {"ihp": 0, "braiins": 0}
28+
29+
def _ihp_handler(*_args, **_kwargs) -> None:
30+
calls["ihp"] += 1
31+
32+
def _braiins_handler(*_args, **_kwargs) -> None:
33+
calls["braiins"] += 1
34+
35+
monkeypatch.setenv("APS_MINER_PROXY_MODE", "ihp")
36+
monkeypatch.setattr(callbacks, "update_subnet_target_hashrate", _ihp_handler)
37+
monkeypatch.setattr(callbacks, "update_routing_weights", _braiins_handler)
38+
39+
callbacks.handle_auction_results(_build_result())
40+
41+
assert calls["ihp"] == 1
42+
assert calls["braiins"] == 0
43+
44+
45+
def test_handle_auction_results_auto_detects_ihp_proxy_from_pools_file(monkeypatch, tmp_path) -> None:
46+
calls = {"ihp": 0, "braiins": 0}
47+
48+
def _ihp_handler(*_args, **_kwargs) -> None:
49+
calls["ihp"] += 1
50+
51+
def _braiins_handler(*_args, **_kwargs) -> None:
52+
calls["braiins"] += 1
53+
54+
pools_file = tmp_path / "pools.toml"
55+
pools_file.write_text("[pools]\n", encoding="utf-8")
56+
57+
monkeypatch.delenv("APS_MINER_PROXY_MODE", raising=False)
58+
monkeypatch.setenv("APS_MINER_POOLS_CONFIG_PATH", str(pools_file))
59+
monkeypatch.setattr(callbacks, "update_subnet_target_hashrate", _ihp_handler)
60+
monkeypatch.setattr(callbacks, "update_routing_weights", _braiins_handler)
61+
62+
callbacks.handle_auction_results(_build_result())
63+
64+
assert calls["ihp"] == 1
65+
assert calls["braiins"] == 0
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from __future__ import annotations
2+
3+
import tomli
4+
5+
from infinite_hashes.aps_miner.models import BidResult
6+
from infinite_hashes.aps_miner.proxy_routing import update_subnet_target_hashrate
7+
8+
9+
def test_update_subnet_target_hashrate_sets_absolute_target_and_keeps_private_weights(monkeypatch, tmp_path) -> None:
10+
pools_file = tmp_path / "pools.toml"
11+
sentinel_file = tmp_path / ".reload-ihp"
12+
pools_file.write_text(
13+
"""
14+
[pools]
15+
backup = { name = "backup", host = "backup.pool", port = 3333 }
16+
17+
[[pools.main]]
18+
name = "subnet"
19+
host = "stratum.infinitehash.xyz"
20+
port = 9332
21+
weight = 1
22+
23+
[[pools.main]]
24+
name = "private"
25+
host = "btc.global.luxor.tech"
26+
port = 700
27+
weight = 7
28+
""",
29+
encoding="utf-8",
30+
)
31+
32+
monkeypatch.setenv("APS_MINER_POOLS_CONFIG_PATH", str(pools_file))
33+
monkeypatch.setenv("APS_MINER_SUBNET_POOL_NAME", "subnet")
34+
monkeypatch.setenv("APS_MINER_IHP_RELOAD_SENTINEL", str(sentinel_file))
35+
36+
won_bids = [
37+
BidResult(hashrate="0.1", price_fp18=1, won=True),
38+
BidResult(hashrate="0.2", price_fp18=1, won=True),
39+
]
40+
lost_bids = [BidResult(hashrate="0.3", price_fp18=1, won=False)]
41+
42+
update_subnet_target_hashrate(won_bids, lost_bids)
43+
44+
with pools_file.open("rb") as f:
45+
data = tomli.load(f)
46+
47+
subnet_pool = data["pools"]["main"][0]
48+
private_pool = data["pools"]["main"][1]
49+
50+
assert subnet_pool["target_hashrate"] == "300TH/s"
51+
assert "weight" not in subnet_pool
52+
assert private_pool["weight"] == 7
53+
assert sentinel_file.exists()
54+
55+
56+
def test_update_subnet_target_hashrate_sets_zero_when_no_wins(monkeypatch, tmp_path) -> None:
57+
pools_file = tmp_path / "pools.toml"
58+
sentinel_file = tmp_path / ".reload-ihp"
59+
pools_file.write_text(
60+
"""
61+
[pools]
62+
backup = { name = "backup", host = "backup.pool", port = 3333 }
63+
64+
[[pools.main]]
65+
name = "subnet"
66+
host = "stratum.infinitehash.xyz"
67+
port = 9332
68+
weight = 1
69+
""",
70+
encoding="utf-8",
71+
)
72+
73+
monkeypatch.setenv("APS_MINER_POOLS_CONFIG_PATH", str(pools_file))
74+
monkeypatch.setenv("APS_MINER_SUBNET_POOL_NAME", "subnet")
75+
monkeypatch.setenv("APS_MINER_IHP_RELOAD_SENTINEL", str(sentinel_file))
76+
77+
update_subnet_target_hashrate([], [BidResult(hashrate="0.3", price_fp18=1, won=False)])
78+
79+
with pools_file.open("rb") as f:
80+
data = tomli.load(f)
81+
82+
subnet_pool = data["pools"]["main"][0]
83+
assert subnet_pool["target_hashrate"] == "0TH/s"
84+
assert sentinel_file.exists()

0 commit comments

Comments
 (0)