[LABS-397] Add v2 support to farmer (#20739)

* Use a separate config for pooling information

* New PlotNFT drivers

* PlotNFT2 Wallet

* PlotNFT V2 RPCs and CLI

* Integrate v2 pooling protocol into farmer

* Comment by @cursor

* Fix test

* Comments by @cursor

* whoops

* Comments by @cursor

* Comments by @cursor

* Comments by @cursor

* Some tidying

* fix test for memo adjustment

* tweak additional memos again

* empirical testing with v1 pools

* Add expiration to `GetAuthRequest`

* Remove pyjwt dep

* Revert `get_login_link` to old behavior

* Add an extra derivation to auth key for v2

* unhardened

* Fix login link test

* Fix login link test again

* Fix network protocol data

* Comments by cursor

* Fix test

* Use correct pool url after redirect

* Fix test for coverage

* Upstream wallet fixes

* Add `REMARK` option to `launch`

* pre-commit

* chmod

* test coverage

* Add wallet name

* test coverage

* moar test coverage

* commentsby @cursor

* moar test coverage

* fix custody architecture namespace

* Comments by @matt-o-how

* Fix /GET farmer to omit the signature rather than None

* Fix protocol test

* fix test

* comments by @cursor

* diff minimizatino

* event dispatch

* Fix test deadlock

* Comments by @cursor

* test coverage

* Comments by @cursor

* Comments by @cursor
This commit is contained in:
Matt Hauff
2026-08-05 14:24:42 -07:00
committed by GitHub
parent 06182a0ab8
commit 7648ef7d87
12 changed files with 695 additions and 267 deletions
+296 -18
View File
@@ -2,6 +2,9 @@ from __future__ import annotations
import json
import logging
import re
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from time import time
from types import TracebackType
@@ -20,13 +23,25 @@ from chia import __version__
from chia._tests.conftest import FarmerOneHarvester, HarvesterFarmerEnvironment
from chia._tests.util.misc import DataCase, Marks, datacases
from chia.consensus.default_constants import DEFAULT_CONSTANTS
from chia.farmer.farmer import UPDATE_POOL_FARMER_INFO_INTERVAL, Farmer, increment_pool_stats, strip_old_entries
from chia.farmer.farmer import (
UPDATE_POOL_FARMER_INFO_INTERVAL,
Farmer,
increment_pool_stats,
strip_old_entries,
)
from chia.farmer.farmer_service import FarmerService
from chia.harvester.harvester_service import HarvesterService
from chia.pools.pool_config import PoolingShareState
from chia.protocols import farmer_protocol, harvester_protocol
from chia.protocols import farmer_protocol, harvester_protocol, pool_protocol
from chia.protocols.harvester_protocol import NewProofOfSpace, RespondSignatures
from chia.protocols.pool_protocol import PoolErrorCode
from chia.protocols.pool_protocol import (
ErrorResponse,
GetAuthResponse,
GetFarmerResponse,
PoolErrorCode,
PostFarmerResponse,
PutFarmerResponse,
)
from chia.server.ws_connection import WSChiaConnection
from chia.simulator.block_tools import BlockTools
from chia.types.blockchain_format.proof_of_space import (
@@ -35,6 +50,11 @@ from chia.types.blockchain_format.proof_of_space import (
verify_and_get_quality_string,
)
from chia.util.hash import std_hash
from chia.wallet.derive_keys import master_sk_to_singleton_owner_sk, master_sk_to_wallet_sk_unhardened
from chia.wallet.puzzles.p2_delegated_puzzle_or_hidden_puzzle import (
DEFAULT_HIDDEN_PUZZLE_HASH,
calculate_synthetic_secret_key,
)
log = logging.getLogger(__name__)
@@ -1026,11 +1046,11 @@ class DummyPoolInfoResponse:
pool_info: dict[str, Any] | None = None
history: tuple[DummyClientResponse, ...] = ()
async def text(self) -> str:
async def json(self, **kwargs: object) -> dict[str, Any]:
if self.pool_info is None:
return ""
return {} # pragma: no cover
return json.dumps(self.pool_info)
return self.pool_info
async def __aenter__(self) -> Self:
return self
@@ -1224,42 +1244,42 @@ class PoolInfoCase(DataCase):
expected_current_difficulty=uint64(42),
),
)
@pytest.mark.parametrize("pool_protocol_version", [1, 2])
@pytest.mark.anyio
async def test_farmer_pool_info_config_update(
mocker: MockerFixture,
farmer_one_harvester: tuple[list[HarvesterService], FarmerService, BlockTools],
case: PoolInfoCase,
pool_protocol_version: int,
) -> None:
_, farmer_service, _ = farmer_one_harvester
p2_singleton_puzzle_hash = bytes32.fromhex("302e05a1e6af431c22043ae2a9a8f71148c955c372697cb8ab348160976283df")
farmer_service._node.authentication_keys = {
p2_singleton_puzzle_hash: PrivateKey.from_bytes(
bytes.fromhex("11ed596eb95b31364a9185e948f6b66be30415f816819449d5d40751dc70e786")
),
}
auth_sk = calculate_synthetic_secret_key(
master_sk_to_wallet_sk_unhardened(farmer_service._node.all_root_sks[0], uint32(0)),
DEFAULT_HIDDEN_PUZZLE_HASH,
)
farmer_service._node.authentication_keys = {p2_singleton_puzzle_hash: auth_sk}
pool_state_overrides: dict[str, Any] = {
"next_farmer_update": time() + UPDATE_POOL_FARMER_INFO_INTERVAL,
}
if case.initial_current_difficulty is not None:
pool_state_overrides["current_difficulty"] = case.initial_current_difficulty
pool_state_overrides["authentication_token_timeout"] = 10
farmer_service._node.pool_state[p2_singleton_puzzle_hash] = make_pool_state(
p2_singleton_puzzle_hash,
overrides=pool_state_overrides,
)
PoolingShareState(
owner_public_key=G1Element.from_bytes(
bytes.fromhex(
"84c3fcf9d5581c1ddc702cb0f3b4a06043303b334dd993ab42b2c320ebfa98e5ce558448615b3f69638ba92cf7f43da5"
)
),
owner_public_key=auth_sk.get_g1(),
p2_singleton_puzzle_hash=p2_singleton_puzzle_hash,
payout_instructions="c2b08e41d766da4116e388357ed957d04ad754623a915f3fd65188a8746cf3e8",
pool_url=case.initial_pool_url_in_config,
launcher_id=bytes32.from_hexstr("ae4ef3b9bfe68949691281a015a9c16630fc8f66d48c19ca548fb80768791afa"),
target_puzzle_hash=bytes32.from_hexstr("344587cf06a39db471d2cc027504e8688a0a67cce961253500c956c73603fd58"),
key_derivation_index=-1,
key_derivation_index=0,
version=pool_protocol_version,
).add(root_path=farmer_service.root_path)
mock_http_get = mocker.patch("aiohttp.ClientSession.get", return_value=case.pool_response)
mock_http_get = mocker.patch("aiohttp.ClientSession.request", return_value=case.pool_response)
await farmer_service._node.update_pool_state()
@@ -1273,6 +1293,122 @@ async def test_farmer_pool_info_config_update(
farmer_service._node.pool_state[p2_singleton_puzzle_hash]["current_difficulty"]
== case.expected_current_difficulty
)
if not case.pool_response.ok:
return
login_link = await farmer_service._node.generate_login_link(pool_config.launcher_id)
assert login_link is not None
escaped_base = re.escape(case.expected_pool_url_in_config)
launcher_hex = re.escape(pool_config.launcher_id.hex())
expected = (
escaped_base
+ rf"/{'v2/' if pool_protocol_version == 2 else ''}login\?launcher_id={launcher_hex}"
+ r"&authentication_token=\d+&signature=[0-9a-f]+"
)
assert re.fullmatch(expected, login_link) is not None
@dataclass
class DummyGetFarmerResponse:
ok: bool
status: int
current_difficulty: int
current_points: int
async def json(self, **kwargs: object) -> dict[str, Any]:
return {
"current_difficulty": self.current_difficulty,
"current_points": self.current_points,
"authentication_public_key": bytes(G1Element()).hex(),
"payout_instructions": "",
}
async def __aenter__(self) -> Self:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
pass
@pytest.mark.parametrize("pool_protocol_version", [1, 2])
@pytest.mark.anyio
async def test_farmer_info_update(
mocker: MockerFixture,
farmer_one_harvester: tuple[list[HarvesterService], FarmerService, BlockTools],
pool_protocol_version: int,
) -> None:
_, farmer_service, _ = farmer_one_harvester
p2_singleton_puzzle_hash = bytes32.fromhex("302e05a1e6af431c22043ae2a9a8f71148c955c372697cb8ab348160976283df")
launcher_id = bytes32.from_hexstr("ae4ef3b9bfe68949691281a015a9c16630fc8f66d48c19ca548fb80768791afa")
auth_sk = calculate_synthetic_secret_key(
master_sk_to_wallet_sk_unhardened(farmer_service._node.all_root_sks[0], uint32(0)),
DEFAULT_HIDDEN_PUZZLE_HASH,
)
farmer_service._node.authentication_keys = {p2_singleton_puzzle_hash: auth_sk}
farmer_service._node.authentication_tokens = {launcher_id: ("", uint64(time() + 3600))}
pool_state_overrides: dict[str, Any] = {
"next_pool_info_update": uint64.MAXIMUM,
"authentication_token_timeout": uint64.MAXIMUM,
}
farmer_service._node.pool_state[p2_singleton_puzzle_hash] = make_pool_state(
p2_singleton_puzzle_hash,
overrides=pool_state_overrides,
)
PoolingShareState(
owner_public_key=auth_sk.get_g1(),
p2_singleton_puzzle_hash=p2_singleton_puzzle_hash,
payout_instructions="c2b08e41d766da4116e388357ed957d04ad754623a915f3fd65188a8746cf3e8",
pool_url="https://dapool.com",
launcher_id=launcher_id,
target_puzzle_hash=bytes32.from_hexstr("344587cf06a39db471d2cc027504e8688a0a67cce961253500c956c73603fd58"),
key_derivation_index=0,
version=pool_protocol_version,
).add(root_path=farmer_service.root_path)
EXPECTED_CURRENT_DIFFICULTY = 1234
EXPECTED_CURRENT_POINTS = 5678
mocker.patch(
"aiohttp.ClientSession.request",
return_value=DummyGetFarmerResponse(
ok=True,
status=200,
current_difficulty=EXPECTED_CURRENT_DIFFICULTY,
current_points=EXPECTED_CURRENT_POINTS,
),
)
await farmer_service._node.update_pool_state()
assert (
farmer_service._node.pool_state[p2_singleton_puzzle_hash]["current_difficulty"] == EXPECTED_CURRENT_DIFFICULTY
)
assert farmer_service._node.pool_state[p2_singleton_puzzle_hash]["current_points"] == EXPECTED_CURRENT_POINTS
mocker.patch(
"aiohttp.ClientSession.request",
return_value=DummyGetFarmerResponse(
ok=False,
status=404,
current_difficulty=EXPECTED_CURRENT_DIFFICULTY,
current_points=EXPECTED_CURRENT_POINTS,
),
)
farmer_service._node.pool_state[p2_singleton_puzzle_hash] = make_pool_state(
p2_singleton_puzzle_hash,
overrides={
"next_farmer_update": 0,
"current_difficulty": EXPECTED_CURRENT_DIFFICULTY,
"current_points": EXPECTED_CURRENT_POINTS,
},
)
await farmer_service._node.update_pool_state()
assert (
farmer_service._node.pool_state[p2_singleton_puzzle_hash]["current_difficulty"] == EXPECTED_CURRENT_DIFFICULTY
)
assert farmer_service._node.pool_state[p2_singleton_puzzle_hash]["current_points"] == EXPECTED_CURRENT_POINTS
@pytest.mark.anyio
@@ -1398,3 +1534,145 @@ async def test_farmer_additional_headers_on_partial_submit(
await farmer_api.new_proof_of_space(new_pos, peer)
mock_http_post.assert_called_once_with(ANY, json=ANY, ssl=ANY, headers=case.expected_headers)
@pytest.mark.parametrize("pool_protocol_version", [1, 2])
@pytest.mark.anyio
async def test_farmer_to_pool_protocol(
mocker: MockerFixture,
farmer_one_harvester: tuple[list[HarvesterService], FarmerService, BlockTools],
pool_protocol_version: int,
caplog: pytest.LogCaptureFixture,
) -> None:
_, farmer_service, _ = farmer_one_harvester
p2_singleton_puzzle_hash = bytes32.fromhex("302e05a1e6af431c22043ae2a9a8f71148c955c372697cb8ab348160976283df")
auth_sk = calculate_synthetic_secret_key(
master_sk_to_wallet_sk_unhardened(farmer_service._node.all_root_sks[0], uint32(0)), DEFAULT_HIDDEN_PUZZLE_HASH
)
farmer_service._node.authentication_keys = {p2_singleton_puzzle_hash: auth_sk}
plotnft_id = bytes32.from_hexstr("ae4ef3b9bfe68949691281a015a9c16630fc8f66d48c19ca548fb80768791afa")
PoolingShareState(
owner_public_key=master_sk_to_singleton_owner_sk(farmer_service._node.all_root_sks[0], uint32(0)).get_g1()
if pool_protocol_version == 1
else auth_sk.get_g1(),
p2_singleton_puzzle_hash=p2_singleton_puzzle_hash,
payout_instructions="c2b08e41d766da4116e388357ed957d04ad754623a915f3fd65188a8746cf3e8",
pool_url="http://doesntmatter.com",
launcher_id=plotnft_id,
target_puzzle_hash=bytes32.from_hexstr("344587cf06a39db471d2cc027504e8688a0a67cce961253500c956c73603fd58"),
key_derivation_index=0,
version=pool_protocol_version,
).add(root_path=farmer_service.root_path)
@dataclass(frozen=True)
class DummyPostFarmerResponse:
ok: bool
async def json(self, **kwargs: object) -> dict[str, Any]:
return PostFarmerResponse(welcome_message="welcome to the pool").to_json_dict()
@dataclass(frozen=True)
class DummyPutFarmerResponse:
ok: bool
async def json(self, **kwargs: object) -> dict[str, Any]:
return PutFarmerResponse(
authentication_public_key=False, suggested_difficulty=False, payout_instructions=True
).to_json_dict()
@dataclass(frozen=True)
class DummyAuthResponse:
ok: bool
async def json(self, **kwargs: object) -> dict[str, Any]:
return GetAuthResponse(
authentication_token="secret", expiration=uint64(farmer_service._node.get_current_time() + 600)
).to_json_dict()
@dataclass(frozen=True)
class DummyGetFarmerResponse:
ok: bool
async def json(self, **kwargs: object) -> dict[str, Any]:
return GetFarmerResponse(
authentication_public_key=auth_sk.get_g1(),
payout_instructions="",
current_difficulty=uint64(0),
current_points=uint64(0),
).to_json_dict()
@dataclass(frozen=True)
class DummyErrorResponse:
ok: bool
status: int | None = None
async def json(self, **kwargs: object) -> dict[str, Any]:
return pool_protocol.ErrorResponse(
error_code=uint16(pool_protocol.PoolErrorCode.SERVER_EXCEPTION.value), error_message=None
).to_json_dict()
@asynccontextmanager
async def client_session_request(method: str, url: str, **kwargs: Any) -> AsyncIterator[Any]:
path = str(URL(url).path).rstrip("/")
if path.endswith("/auth"):
assert method == "GET"
yield DummyAuthResponse(ok=True) # noqa: RUF075
if path.endswith("/farmer"):
if method == "POST":
yield DummyPostFarmerResponse(ok=True) # noqa: RUF075
if method == "PUT":
yield DummyPutFarmerResponse(ok=True) # noqa: RUF075
if method == "GET":
yield DummyGetFarmerResponse(ok=True)
@asynccontextmanager
async def client_session_error(*args: Any, **kwargs: Any) -> AsyncIterator[Any]:
yield DummyErrorResponse(ok=True)
@asynccontextmanager
async def client_session_error_not_ok(*args: Any, **kwargs: Any) -> AsyncIterator[Any]:
yield DummyErrorResponse(ok=False, status=404)
@asynccontextmanager
async def client_session_exception(*args: Any, **kwargs: Any) -> AsyncIterator[Any]:
raise Exception("foo bar")
yield # pragma: no cover
mocker.patch("aiohttp.ClientSession.request", side_effect=client_session_request)
with PoolingShareState.acquire(
root_path=farmer_service.root_path,
p2_singleton_puzzle_hash=p2_singleton_puzzle_hash,
) as pool_config:
pass
assert await farmer_service._node._pool_post_farmer(pool_config, uint8(10)) == PostFarmerResponse(
welcome_message="welcome to the pool"
)
assert await farmer_service._node._pool_put_farmer(pool_config, uint8(10)) == PutFarmerResponse(
authentication_public_key=False, suggested_difficulty=False, payout_instructions=True
)
assert isinstance(await farmer_service._node._pool_get_farmer(pool_config, uint8(10)), GetFarmerResponse)
# Test some errors and especially with getting authentication
if pool_protocol_version == 2:
farmer_service._node.authentication_tokens = {}
mocker.patch("aiohttp.ClientSession.request", side_effect=client_session_error)
with caplog.at_level(logging.WARNING):
assert isinstance(
await farmer_service._node._get_current_authentication_token(pool_config, uint8(10)), ErrorResponse
)
assert "GET /auth response: " in caplog.text
mocker.patch("aiohttp.ClientSession.request", side_effect=client_session_error_not_ok)
with caplog.at_level(logging.ERROR):
assert await farmer_service._node._get_current_authentication_token(pool_config, uint8(10)) is None
assert "Error in GET /auth http://doesntmatter.com, 404" in caplog.text
mocker.patch("aiohttp.ClientSession.request", side_effect=client_session_exception)
with caplog.at_level(logging.ERROR):
assert await farmer_service._node._get_current_authentication_token(pool_config, uint8(10)) is None
assert "Exception in GET /auth http://doesntmatter.com, foo bar" in caplog.text
pool_config.version = 1337
with pytest.raises(ValueError, match=r"Unknown pool protocol version specified in pooling config"):
await farmer_service._node._get_current_authentication_token(pool_config, uint8(10))
+2 -2
View File
@@ -595,7 +595,7 @@ async def test_plotnft_cli_join(
).run()
pool_response_dict["relative_lock_height"] = LOCK_HEIGHT
pool_response_dict["protocol_version"] = 2
pool_response_dict["protocol_version"] = 1 if version == 2 else 2
mock_get.return_value.__aenter__.return_value.text.return_value = json.dumps(pool_response_dict)
with pytest.raises(CliRpcConnectionError, match="Incorrect version"):
@@ -609,7 +609,7 @@ async def test_plotnft_cli_join(
).run()
pool_response_dict["relative_lock_height"] = LOCK_HEIGHT
pool_response_dict["protocol_version"] = 1
pool_response_dict["protocol_version"] = 1 if version == 1 else 2
mock_get.return_value.__aenter__.return_value.text.return_value = json.dumps(pool_response_dict)
if prompt:
+5 -1
View File
@@ -15,6 +15,7 @@ from chia_rs import (
InfusedChallengeChainSubSlot,
PartialProof,
PoolTarget,
Program,
ProofOfSpace,
RespondToPhUpdates,
RewardChainBlock,
@@ -1003,7 +1004,7 @@ respond_peers_introducer = introducer_protocol.RespondPeersIntroducer(
# POOL PROTOCOL
authentication_payload = pool_protocol.AuthenticationPayload(
authentication_payload = pool_protocol.AuthenticationPayloadV1(
"method",
bytes32(bytes.fromhex("0251e3b3a1aacc689091b6b085be7a8d319bd9d1a015faae969cb76d8a45607c")),
bytes32(bytes.fromhex("9de241b508b5e9e2073b7645291cfaa9458d33935340399a861acf2ee1770440")),
@@ -1020,10 +1021,12 @@ get_pool_info_response = pool_protocol.GetPoolInfoResponse(
"pool description.",
bytes32(bytes.fromhex("f6b5120ff1ab7ba661e3b2c91c8b373a8aceea8e4eb6ce3f085f3e80a8655b36")),
uint8(76),
Program.to(None),
)
post_partial_request = pool_protocol.PostPartialRequest(
post_partial_payload,
"",
g2_element,
)
@@ -1073,6 +1076,7 @@ put_farmer_payload = pool_protocol.PutFarmerPayload(
),
"payload",
uint64(201241879360854600),
authentication_token_v2="",
)
put_farmer_request = pool_protocol.PutFarmerRequest(
Binary file not shown.
@@ -2454,6 +2454,7 @@ get_pool_info_response_json: dict[str, Any] = {
"description": "pool description.",
"target_puzzle_hash": "0xf6b5120ff1ab7ba661e3b2c91c8b373a8aceea8e4eb6ce3f085f3e80a8655b36",
"authentication_token_timeout": 76,
"pool_memoization": "0x80",
}
post_partial_payload_json: dict[str, Any] = {
@@ -2496,6 +2497,7 @@ post_partial_request_json: dict[str, Any] = {
"end_of_sub_slot": False,
"harvester_id": "0xf98dff6bdcc3926b33cb8ab22e11bd15c13d6a9b6832ac948b3273f5ccd8e7ec",
},
"authentication_token_v2": "",
"aggregate_signature": "0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
}
@@ -2535,6 +2537,7 @@ put_farmer_payload_json: dict[str, Any] = {
"authentication_public_key": "0xa04c6b5ac7dfb935f6feecfdd72348ccf1d4be4fe7e26acf271ea3b7d308da61e0a308f7a62495328a81f5147b66634c",
"payout_instructions": "payload",
"suggested_difficulty": 201241879360854600,
"authentication_token_v2": "",
}
put_farmer_request_json: dict[str, Any] = {
@@ -2544,6 +2547,7 @@ put_farmer_request_json: dict[str, Any] = {
"authentication_public_key": "0xa04c6b5ac7dfb935f6feecfdd72348ccf1d4be4fe7e26acf271ea3b7d308da61e0a308f7a62495328a81f5147b66634c",
"payout_instructions": "payload",
"suggested_difficulty": 201241879360854600,
"authentication_token_v2": "",
},
"signature": "0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
}
@@ -201,7 +201,11 @@ def test_missing_messages() -> None:
introducer_msgs = {"RequestPeersIntroducer", "RespondPeersIntroducer"}
pool_msgs = {
"AuthenticationPayload",
"AuthenticationPayloadV1",
"GetAuthRequest",
"GetFarmerRequestV1",
"GetFarmerRequestV2",
"GetAuthResponse",
"ErrorResponse",
"GetFarmerResponse",
"GetPoolInfoResponse",
+8 -6
View File
@@ -26,7 +26,6 @@ from chia.cmds.wallet_funcs import print_balance, wallet_coin_unit
from chia.farmer.farmer_rpc_client import FarmerRpcClient
from chia.pools.pool_config import PoolingShareState
from chia.pools.pool_wallet_info import NewPoolWalletInitialTargetState, PoolSingletonState, PoolWalletInfo
from chia.protocols.pool_protocol import POOL_PROTOCOL_VERSION
from chia.rpc.rpc_client import ResponseFailureError
from chia.server.server import ssl_context_for_root
from chia.ssl.create_ssl import get_mozilla_ca_crt
@@ -68,8 +67,8 @@ async def create_pool_args(pool_url: str) -> dict[str, Any]:
if json_dict["relative_lock_height"] > 1000:
raise ValueError("Relative lock height too high for this pool, cannot join")
if json_dict["protocol_version"] != POOL_PROTOCOL_VERSION:
raise ValueError(f"Incorrect version: {json_dict['protocol_version']}, should be {POOL_PROTOCOL_VERSION}")
if json_dict["protocol_version"] not in {1, 2}:
raise ValueError(f"Incorrect version: {json_dict['protocol_version']}, should be 1 or 2")
header_msg = f"\n---- Pool parameters fetched from {pool_url} ----"
print(header_msg)
@@ -368,7 +367,10 @@ async def join_pool(
raise CliRpcConnectionError(f"Pool URLs must be HTTPS on mainnet {pool_url}.")
try:
async with aiohttp.ClientSession() as session:
async with session.get(f"{pool_url}/pool_info", ssl=ssl_context_for_root(get_mozilla_ca_crt())) as response:
async with session.get(
f"{pool_url}/{'v2/' if pool_wallet_info.current.version == 2 else ''}pool_info",
ssl=ssl_context_for_root(get_mozilla_ca_crt()),
) as response:
if response.ok:
json_dict = json.loads(await response.text())
else:
@@ -379,9 +381,9 @@ async def join_pool(
if json_dict["relative_lock_height"] > 1000:
raise CliRpcConnectionError("Relative lock height too high for this pool, cannot join")
if json_dict["protocol_version"] != POOL_PROTOCOL_VERSION:
if json_dict["protocol_version"] != pool_wallet_info.current.version:
raise CliRpcConnectionError(
f"Incorrect version: {json_dict['protocol_version']}, should be {POOL_PROTOCOL_VERSION}"
f"Incorrect version: {json_dict['protocol_version']}, should be {pool_wallet_info.current.version}"
)
pprint(json_dict)
+313 -231
View File
@@ -2,16 +2,16 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import sys
import time
import traceback
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import datetime, timezone
from math import floor
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, cast
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast
import aiohttp
from chia_rs import AugSchemeMPL, ConsensusConstants, G1Element, G2Element, PrivateKey, ProofOfSpace
@@ -22,20 +22,8 @@ from chia.daemon.keychain_proxy import KeychainProxy, connect_to_keychain_and_va
from chia.plot_sync.delta import Delta
from chia.plot_sync.receiver import Receiver
from chia.pools.pool_config import PoolingShareState, perform_migration_from_old_config
from chia.protocols import farmer_protocol, harvester_protocol
from chia.protocols import farmer_protocol, harvester_protocol, pool_protocol
from chia.protocols.outbound_message import NodeType, make_msg
from chia.protocols.pool_protocol import (
AuthenticationPayload,
ErrorResponse,
GetFarmerResponse,
GetPoolInfoResponse,
PoolErrorCode,
PostFarmerPayload,
PostFarmerRequest,
PutFarmerPayload,
PutFarmerRequest,
get_current_authentication_token,
)
from chia.protocols.protocol_message_types import ProtocolMessageTypes
from chia.rpc.rpc_server import StateChangedProtocol, default_get_connections
from chia.server.server import ChiaServer, ssl_context_for_root
@@ -48,13 +36,20 @@ from chia.util.hash import std_hash
from chia.util.keychain import Keychain
from chia.util.logging import TimedDuplicateFilter
from chia.util.profiler import profile_task
from chia.util.streamable import Streamable
from chia.util.task_referencer import create_referenced_task
from chia.wallet.derive_keys import (
find_authentication_sk,
find_owner_sk,
master_sk_to_farmer_sk,
master_sk_to_pool_sk,
master_sk_to_wallet_sk_unhardened,
match_address_to_sk,
singleton_owner_sk_to_authv2_key,
)
from chia.wallet.puzzles.p2_delegated_puzzle_or_hidden_puzzle import (
DEFAULT_HIDDEN_PUZZLE_HASH,
calculate_synthetic_secret_key,
)
from chia.wallet.puzzles.singleton_top_layer import SINGLETON_MOD
@@ -69,7 +64,7 @@ UPDATE_POOL_FARMER_INFO_INTERVAL: int = 300
@dataclass(frozen=True)
class GetPoolInfoResult:
pool_info: GetPoolInfoResponse
pool_info: pool_protocol.GetPoolInfoResponse
new_pool_url: str | None
@@ -110,6 +105,72 @@ def increment_pool_stats(
return
_T_Response = TypeVar("_T_Response", bound=Streamable)
async def make_pool_protocol_request(
*,
self: Farmer,
pool_config: PoolingShareState,
method: Literal["GET", "POST", "PUT"],
endpoint_name: str,
request: Streamable | None,
response_type: type[_T_Response],
) -> tuple[_T_Response | pool_protocol.ErrorResponse | None, aiohttp.ClientResponse | None]:
self.log.debug("%s /%s request %s", method, endpoint_name, request)
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.request(
method,
self._url_for_endpoint(pool_config, endpoint_name),
ssl=ssl_context_for_root(get_mozilla_ca_crt(), log=self.log),
**(
# the POST and PUT requests always are not None
{"json": request.to_json_dict()} # type: ignore[union-attr]
if method in {"POST", "PUT"}
else {"params": request.to_json_dict() if request else None}
),
) as resp:
if resp.ok:
json_response = await resp.json(content_type=None)
log_level = logging.INFO
if "error_code" in json_response:
log_level = logging.WARNING
increment_pool_stats(
self.pool_state,
pool_config.p2_singleton_puzzle_hash,
"pool_errors",
time.time(),
value=json_response,
)
self.log.log(
log_level,
f"{method} /{endpoint_name} response: {json_response}",
)
if "error_code" in json_response:
error_response = pool_protocol.ErrorResponse.from_json_dict(json_response)
if (
error_response.error_code == pool_protocol.PoolErrorCode.INVALID_AUTHENTICATION_TOKEN.value
and pool_config.launcher_id in self.authentication_tokens
):
self.authentication_tokens.pop(pool_config.launcher_id)
return (error_response, resp)
else:
return (response_type.from_json_dict(json_response), resp)
else:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash,
f"Error in {method} /{endpoint_name} {pool_config.pool_url}, {resp.status}",
)
return None, resp
except Exception as e:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash,
f"Exception in {method} /{endpoint_name} {pool_config.pool_url}, {e}",
)
return None, None
"""
HARVESTER PROTOCOL (FARMER <-> HARVESTER)
"""
@@ -173,6 +234,7 @@ class Farmer:
# From p2_singleton to auth PrivateKey
self.authentication_keys: dict[bytes32, PrivateKey] = {}
self.authentication_tokens: dict[bytes32, tuple[str, uint64] | None] = {}
# Last time we updated pool_state based on the config file
self.last_config_access_time: float = 0
@@ -323,6 +385,9 @@ class Farmer:
if self.state_changed_callback is not None:
self.state_changed_callback(change, data)
def get_current_time(self) -> uint64:
return uint64(time.time())
def handle_failed_pool_response(self, p2_singleton_puzzle_hash: bytes32, error_message: str) -> None:
self.log.error(error_message)
increment_pool_stats(
@@ -330,7 +395,9 @@ class Farmer:
p2_singleton_puzzle_hash,
"pool_errors",
time.time(),
value=ErrorResponse(uint16(PoolErrorCode.REQUEST_FAILED.value), error_message).to_json_dict(),
value=pool_protocol.ErrorResponse(
uint16(pool_protocol.PoolErrorCode.REQUEST_FAILED.value), error_message
).to_json_dict(),
)
async def on_disconnect(self, connection: WSChiaConnection) -> None:
@@ -347,199 +414,222 @@ class Farmer:
if receiver.initial_sync() or harvester_updated:
self.state_changed("harvester_update", receiver.to_dict(True))
def _url_for_endpoint(self, pool_config: PoolingShareState, endpoint: str) -> str:
if pool_config.version == 1:
return f"{pool_config.pool_url}/{endpoint}"
else:
return f"{pool_config.pool_url}/v2/{endpoint}"
async def _get_current_authentication_token(
self, pool_config: PoolingShareState, authentication_token_timeout: uint8
) -> str | pool_protocol.ErrorResponse | None:
if pool_config.version == 1:
return str(pool_protocol.get_current_authentication_token(authentication_token_timeout))
elif pool_config.version == 2:
cached_auth_token = self.authentication_tokens.get(pool_config.launcher_id, None)
if cached_auth_token is None or datetime.fromtimestamp(
cached_auth_token[1], tz=timezone.utc
) < datetime.fromtimestamp(self.get_current_time(), tz=timezone.utc):
auth_response = await self._pool_get_auth(pool_config)
if isinstance(auth_response, pool_protocol.GetAuthResponse):
self.authentication_tokens[pool_config.launcher_id] = (
auth_response.authentication_token,
auth_response.expiration,
)
return auth_response.authentication_token
else:
return auth_response
else:
# seems sketchy because in theory we should check for non-None here but
# the auth token can't be None AND expired so semantics guarantee a non-None, non-expired token here
return cached_auth_token[0]
else:
raise ValueError("Unknown pool protocol version specified in pooling config")
async def _pool_get_auth(
self, pool_config: PoolingShareState
) -> pool_protocol.GetAuthResponse | pool_protocol.ErrorResponse | None:
timestamp = self.get_current_time()
message = bytes(timestamp) + bytes(pool_config.launcher_id) + pool_config.target_puzzle_hash
authentication_sk: PrivateKey | None = self.get_authentication_sk(pool_config)
if authentication_sk is None:
return None
signature: G2Element = AugSchemeMPL.sign(singleton_owner_sk_to_authv2_key(authentication_sk), message)
response, _ = await make_pool_protocol_request(
self=self,
pool_config=pool_config,
method="GET",
endpoint_name="auth",
request=pool_protocol.GetAuthRequest(
launcher_id=pool_config.launcher_id,
timestamp=timestamp,
signature=signature,
),
response_type=pool_protocol.GetAuthResponse,
)
return response
async def _pool_get_pool_info(self, pool_config: PoolingShareState) -> GetPoolInfoResult | None:
try:
async with aiohttp.ClientSession(trust_env=True) as session:
url = f"{pool_config.pool_url}/pool_info"
async with session.get(url, ssl=ssl_context_for_root(get_mozilla_ca_crt(), log=self.log)) as resp:
if resp.ok:
response: dict[str, Any] = json.loads(await resp.text())
self.log.info(f"GET /pool_info response: {response}")
if "error_code" in response:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash,
f"Error in GET /pool_info {pool_config.pool_url}, {response}",
)
return None
try:
pool_info = GetPoolInfoResponse.from_json_dict(response)
except Exception as e:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash,
f"Invalid GET /pool_info response {pool_config.pool_url}, {e}",
)
return None
new_pool_url: str | None = None
response_url_str = f"{resp.url}"
if (
response_url_str != url
and len(resp.history) > 0
and all(r.status in {301, 308} for r in resp.history)
):
new_pool_url = response_url_str.replace("/pool_info", "")
response, client_response = await make_pool_protocol_request(
self=self,
pool_config=pool_config,
method="GET",
endpoint_name="pool_info",
request=None,
response_type=pool_protocol.GetPoolInfoResponse,
)
if client_response is None:
return None
new_pool_url: str | None = None
response_url_str = f"{client_response.url}"
if (
response_url_str != self._url_for_endpoint(pool_config, "pool_info")
and len(client_response.history) > 0
and all(r.status in {301, 308} for r in client_response.history)
):
new_pool_url = response_url_str.replace("/pool_info", "")
new_pool_url = new_pool_url.replace(f"/v{pool_config.version}", "")
return GetPoolInfoResult(pool_info=pool_info, new_pool_url=new_pool_url)
else:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash,
f"Error in GET /pool_info {pool_config.pool_url}, {resp.status}",
)
except Exception as e:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash, f"Exception in GET /pool_info {pool_config.pool_url}, {e}"
)
return None
if isinstance(response, pool_protocol.GetPoolInfoResponse):
return GetPoolInfoResult(pool_info=response, new_pool_url=new_pool_url)
else:
return None
async def _pool_get_farmer(
self, pool_config: PoolingShareState, authentication_token_timeout: uint8, authentication_sk: PrivateKey
) -> dict[str, Any] | None:
authentication_token = get_current_authentication_token(authentication_token_timeout)
message: bytes32 = std_hash(
AuthenticationPayload(
"get_farmer", pool_config.launcher_id, pool_config.target_puzzle_hash, authentication_token
self, pool_config: PoolingShareState, authentication_token_timeout: uint8
) -> pool_protocol.GetFarmerResponse | pool_protocol.ErrorResponse | None:
authentication_token = await self._get_current_authentication_token(pool_config, authentication_token_timeout)
if not isinstance(authentication_token, str):
self.log.error(f"Failed to authenticate to pool before aquiring farmer details: {pool_config.pool_url}")
return authentication_token
if pool_config.version == 1:
message: bytes32 = std_hash(
pool_protocol.AuthenticationPayloadV1(
"get_farmer",
pool_config.launcher_id,
pool_config.target_puzzle_hash,
uint64(authentication_token),
)
)
authentication_sk = self.get_authentication_sk(pool_config)
if authentication_sk is None:
return None
get_farmer_params: pool_protocol.GetFarmerRequestV1 | pool_protocol.GetFarmerRequestV2 = (
pool_protocol.GetFarmerRequestV1(
authentication_token=uint64(authentication_token),
launcher_id=pool_config.launcher_id,
signature=AugSchemeMPL.sign(authentication_sk, message),
authentication_token_v2="",
)
)
else:
get_farmer_params = pool_protocol.GetFarmerRequestV2(
authentication_token=uint64(0),
launcher_id=pool_config.launcher_id,
authentication_token_v2=authentication_token,
)
response, _ = await make_pool_protocol_request(
self=self,
pool_config=pool_config,
method="GET",
endpoint_name="farmer",
request=get_farmer_params,
response_type=pool_protocol.GetFarmerResponse,
)
signature: G2Element = AugSchemeMPL.sign(authentication_sk, message)
get_farmer_params: dict[str, str | int] = {
"launcher_id": pool_config.launcher_id.hex(),
"authentication_token": authentication_token,
"signature": bytes(signature).hex(),
}
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
f"{pool_config.pool_url}/farmer",
params=get_farmer_params,
ssl=ssl_context_for_root(get_mozilla_ca_crt(), log=self.log),
) as resp:
if resp.ok:
response: dict[str, Any] = json.loads(await resp.text())
log_level = logging.INFO
if "error_code" in response:
log_level = logging.WARNING
increment_pool_stats(
self.pool_state,
pool_config.p2_singleton_puzzle_hash,
"pool_errors",
time.time(),
value=response,
)
self.log.log(log_level, f"GET /farmer response: {response}")
return response
else:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash,
f"Error in GET /farmer {pool_config.pool_url}, {resp.status}",
)
except Exception as e:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash, f"Exception in GET /farmer {pool_config.pool_url}, {e}"
)
return None
return response
async def _pool_post_farmer(
self, pool_config: PoolingShareState, authentication_token_timeout: uint8, owner_sk: PrivateKey
) -> dict[str, Any] | None:
self, pool_config: PoolingShareState, authentication_token_timeout: uint8
) -> pool_protocol.PostFarmerResponse | pool_protocol.ErrorResponse | None:
auth_sk: PrivateKey | None = self.get_authentication_sk(pool_config)
assert auth_sk is not None
post_farmer_payload: PostFarmerPayload = PostFarmerPayload(
if auth_sk is None:
return None
if pool_config.version == 1:
authentication_token = await self._get_current_authentication_token(
pool_config, authentication_token_timeout
)
if authentication_token is None:
self.log.error(f"Attempting to POST farmer details without being logged into {pool_config.pool_url}")
return None
# impossible for this to fail when get_authentication_sk above succeeds
owner_sk = find_owner_sk(self.all_root_sks, pool_config.owner_public_key)[0] # type: ignore[index]
else:
owner_sk = singleton_owner_sk_to_authv2_key(auth_sk)
post_farmer_payload = pool_protocol.PostFarmerPayload(
pool_config.launcher_id,
get_current_authentication_token(authentication_token_timeout),
uint64(authentication_token) if pool_config.version == 1 else uint64(0), # type: ignore[arg-type]
auth_sk.get_g1(),
pool_config.payout_instructions,
None,
)
assert owner_sk.get_g1() == pool_config.owner_public_key
signature: G2Element = AugSchemeMPL.sign(owner_sk, post_farmer_payload.get_hash())
post_farmer_request = PostFarmerRequest(post_farmer_payload, signature)
self.log.debug(f"POST /farmer request {post_farmer_request}")
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{pool_config.pool_url}/farmer",
json=post_farmer_request.to_json_dict(),
ssl=ssl_context_for_root(get_mozilla_ca_crt(), log=self.log),
) as resp:
if resp.ok:
response: dict[str, Any] = json.loads(await resp.text())
log_level = logging.INFO
if "error_code" in response:
log_level = logging.WARNING
increment_pool_stats(
self.pool_state,
pool_config.p2_singleton_puzzle_hash,
"pool_errors",
time.time(),
value=response,
)
self.log.log(log_level, f"POST /farmer response: {response}")
return response
else:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash,
f"Error in POST /farmer {pool_config.pool_url}, {resp.status}",
)
except Exception as e:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash, f"Exception in POST /farmer {pool_config.pool_url}, {e}"
)
return None
post_farmer_request = pool_protocol.PostFarmerRequest(post_farmer_payload, signature)
response, _ = await make_pool_protocol_request(
self=self,
pool_config=pool_config,
method="POST",
endpoint_name="farmer",
request=post_farmer_request,
response_type=pool_protocol.PostFarmerResponse,
)
return response
async def _pool_put_farmer(
self, pool_config: PoolingShareState, authentication_token_timeout: uint8, owner_sk: PrivateKey
) -> None:
self, pool_config: PoolingShareState, authentication_token_timeout: uint8
) -> pool_protocol.PutFarmerResponse | pool_protocol.ErrorResponse | None:
auth_sk: PrivateKey | None = self.get_authentication_sk(pool_config)
assert auth_sk is not None
put_farmer_payload: PutFarmerPayload = PutFarmerPayload(
if auth_sk is None:
return None
authentication_token = await self._get_current_authentication_token(pool_config, authentication_token_timeout)
if not isinstance(authentication_token, str):
self.log.error(f"Attempting to PUT farmer details without being logged into {pool_config.pool_url}")
return authentication_token
put_farmer_payload = pool_protocol.PutFarmerPayload(
pool_config.launcher_id,
get_current_authentication_token(authentication_token_timeout),
uint64(authentication_token) if pool_config.version == 1 else uint64(0),
auth_sk.get_g1(),
pool_config.payout_instructions,
None,
authentication_token_v2=authentication_token,
)
assert owner_sk.get_g1() == pool_config.owner_public_key
signature: G2Element = AugSchemeMPL.sign(owner_sk, put_farmer_payload.get_hash())
put_farmer_request = PutFarmerRequest(put_farmer_payload, signature)
self.log.debug(f"PUT /farmer request {put_farmer_request}")
try:
async with aiohttp.ClientSession() as session:
async with session.put(
f"{pool_config.pool_url}/farmer",
json=put_farmer_request.to_json_dict(),
ssl=ssl_context_for_root(get_mozilla_ca_crt(), log=self.log),
) as resp:
if resp.ok:
response: dict[str, Any] = json.loads(await resp.text())
log_level = logging.INFO
if "error_code" in response:
log_level = logging.WARNING
increment_pool_stats(
self.pool_state,
pool_config.p2_singleton_puzzle_hash,
"pool_errors",
time.time(),
value=response,
)
self.log.log(log_level, f"PUT /farmer response: {response}")
else:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash,
f"Error in PUT /farmer {pool_config.pool_url}, {resp.status}",
)
except Exception as e:
self.handle_failed_pool_response(
pool_config.p2_singleton_puzzle_hash, f"Exception in PUT /farmer {pool_config.pool_url}, {e}"
)
if pool_config.version == 1:
# impossible for this to fail when get_authentication_sk above succeeds
owner_sk = find_owner_sk(self.all_root_sks, pool_config.owner_public_key)[0] # type: ignore[index]
signature = AugSchemeMPL.sign(owner_sk, put_farmer_payload.get_hash())
else:
signature = None
put_farmer_request = pool_protocol.PutFarmerRequest(put_farmer_payload, signature)
response, _ = await make_pool_protocol_request(
self=self,
pool_config=pool_config,
method="PUT",
endpoint_name="farmer",
request=put_farmer_request,
response_type=pool_protocol.PutFarmerResponse,
)
return response
def get_authentication_sk(self, pool_config: PoolingShareState) -> PrivateKey | None:
if pool_config.p2_singleton_puzzle_hash in self.authentication_keys:
return self.authentication_keys[pool_config.p2_singleton_puzzle_hash]
auth_sk: PrivateKey | None = find_authentication_sk(self.all_root_sks, pool_config.owner_public_key)
if auth_sk is not None:
self.authentication_keys[pool_config.p2_singleton_puzzle_hash] = auth_sk
return auth_sk
if pool_config.version == 1:
if pool_config.p2_singleton_puzzle_hash in self.authentication_keys:
return self.authentication_keys[pool_config.p2_singleton_puzzle_hash]
auth_sk: PrivateKey | None = find_authentication_sk(self.all_root_sks, pool_config.owner_public_key)
if auth_sk is not None:
self.authentication_keys[pool_config.p2_singleton_puzzle_hash] = auth_sk
return auth_sk
else:
for sk in self.all_root_sks:
auth_sk = calculate_synthetic_secret_key(
master_sk_to_wallet_sk_unhardened(sk, uint32(pool_config.key_derivation_index)),
DEFAULT_HIDDEN_PUZZLE_HASH,
)
if auth_sk.get_g1() == pool_config.owner_public_key:
return auth_sk
self.log.error(f"Failed to get authentication sk for pool {pool_config.pool_url}")
return None
async def update_pool_state(self) -> None:
config = load_config(self._root_path, "config.yaml")
@@ -556,11 +646,6 @@ class Farmer:
continue
try:
authentication_sk: PrivateKey | None = self.get_authentication_sk(pool_config)
if authentication_sk is None:
self.log.error(f"Could not find authentication sk for {p2_singleton_puzzle_hash}")
continue
if p2_singleton_puzzle_hash not in self.pool_state:
self.pool_state[p2_singleton_puzzle_hash] = {
"p2_singleton_puzzle_hash": p2_singleton_puzzle_hash.hex(),
@@ -621,49 +706,42 @@ class Farmer:
root_path=self._root_path, p2_singleton_puzzle_hash=p2_singleton_puzzle_hash
) as editable_pool_config:
editable_pool_config.pool_url = pool_info_result.new_pool_url
self.pool_state[p2_singleton_puzzle_hash]["pool_config"] = editable_pool_config
pool_config = editable_pool_config
if time.time() >= pool_state["next_farmer_update"]:
pool_state["next_farmer_update"] = time.time() + UPDATE_POOL_FARMER_INFO_INTERVAL
authentication_token_timeout = pool_state["authentication_token_timeout"]
async def update_pool_farmer_info() -> tuple[GetFarmerResponse | None, PoolErrorCode | None]:
async def update_pool_farmer_info() -> tuple[
pool_protocol.GetFarmerResponse | None, pool_protocol.PoolErrorCode | None
]:
# Run a GET /farmer to see if the farmer is already known by the pool
response = await self._pool_get_farmer(
pool_config, authentication_token_timeout, authentication_sk
)
farmer_response: GetFarmerResponse | None = None
error_code_response: PoolErrorCode | None = None
response = await self._pool_get_farmer(pool_config, authentication_token_timeout)
if response is not None:
if "error_code" not in response:
farmer_response = GetFarmerResponse.from_json_dict(response)
if farmer_response is not None:
pool_state["current_difficulty"] = farmer_response.current_difficulty
pool_state["current_points"] = farmer_response.current_points
if not isinstance(response, pool_protocol.ErrorResponse):
pool_state["current_difficulty"] = response.current_difficulty
pool_state["current_points"] = response.current_points
return response, None
else:
try:
error_code_response = PoolErrorCode(response["error_code"])
error_code = pool_protocol.PoolErrorCode(response.error_code)
return None, error_code
except ValueError:
self.log.error(
f"Invalid error code received from the pool: {response['error_code']}"
)
return farmer_response, error_code_response
self.log.error(f"Invalid error code received from the pool: {response.error_code}")
return None, None
return None, None
if authentication_token_timeout is not None:
farmer_info, error_code = await update_pool_farmer_info()
if error_code == PoolErrorCode.FARMER_NOT_KNOWN:
# Make the farmer known on the pool with a POST /farmer
owner_sk_and_index = find_owner_sk(self.all_root_sks, pool_config.owner_public_key)
assert owner_sk_and_index is not None
post_response = await self._pool_post_farmer(
pool_config, authentication_token_timeout, owner_sk_and_index[0]
)
if post_response is not None and "error_code" not in post_response:
if error_code == pool_protocol.PoolErrorCode.FARMER_NOT_KNOWN:
post_response = await self._pool_post_farmer(pool_config, authentication_token_timeout)
if post_response is not None and not isinstance(post_response, pool_protocol.ErrorResponse):
self.log.info(
f"Welcome message from {pool_config.pool_url}: {post_response['welcome_message']}"
f"Welcome message from {pool_config.pool_url}: {post_response.welcome_message}"
)
# Now we should be able to update the local farmer info
farmer_info, farmer_is_known = await update_pool_farmer_info()
(farmer_info, farmer_is_known) = await update_pool_farmer_info()
if farmer_info is None and not farmer_is_known:
self.log.error("Failed to update farmer info after POST /farmer.")
@@ -673,12 +751,11 @@ class Farmer:
farmer_info is not None
and pool_config.payout_instructions.lower() != farmer_info.payout_instructions.lower()
)
if payout_instructions_update_required or error_code == PoolErrorCode.INVALID_SIGNATURE:
owner_sk_and_index = find_owner_sk(self.all_root_sks, pool_config.owner_public_key)
assert owner_sk_and_index is not None
await self._pool_put_farmer(
pool_config, authentication_token_timeout, owner_sk_and_index[0]
)
if (
payout_instructions_update_required
or error_code == pool_protocol.PoolErrorCode.INVALID_SIGNATURE
):
await self._pool_put_farmer(pool_config, authentication_token_timeout)
else:
self.log.warning(
"No pool specific authentication_token_timeout has been set for "
@@ -758,8 +835,10 @@ class Farmer:
authentication_sk: PrivateKey | None = self.get_authentication_sk(pool_config)
if authentication_sk is None:
self.log.error(f"Could not find authentication sk for {pool_config.p2_singleton_puzzle_hash}")
continue
return None
if pool_config.version == 2:
authentication_sk = singleton_owner_sk_to_authv2_key(authentication_sk)
authentication_token_timeout = pool_state["authentication_token_timeout"]
if authentication_token_timeout is None:
self.log.error(
@@ -768,16 +847,19 @@ class Farmer:
)
return None
authentication_token = get_current_authentication_token(authentication_token_timeout)
message: bytes32 = std_hash(
AuthenticationPayload(
"get_login", pool_config.launcher_id, pool_config.target_puzzle_hash, authentication_token
auth_token = str(pool_protocol.get_current_authentication_token(authentication_token_timeout))
message: bytes = std_hash(
pool_protocol.AuthenticationPayloadV1(
"get_login",
pool_config.launcher_id,
pool_config.target_puzzle_hash,
uint64(auth_token),
)
)
signature: G2Element = AugSchemeMPL.sign(authentication_sk, message)
return (
pool_config.pool_url
+ f"/login?launcher_id={launcher_id.hex()}&authentication_token={authentication_token}"
self._url_for_endpoint(pool_config, "login") + f"?launcher_id={pool_config.launcher_id.hex()}"
f"&authentication_token={auth_token}"
f"&signature={bytes(signature).hex()}"
)
+20 -2
View File
@@ -353,7 +353,25 @@ class FarmerAPI:
agg_sig: G2Element = AugSchemeMPL.aggregate([plot_signature, authentication_signature])
post_partial_request: PostPartialRequest = PostPartialRequest(payload, agg_sig)
current_auth_token = await self.farmer._get_current_authentication_token(
pool_state_dict["pool_config"], authentication_token_timeout
)
if not isinstance(current_auth_token, str):
self.farmer.log.error(
f"Not logged into pool while trying to POST partial: {pool_state_dict['pool_config'].pool_url}"
)
increment_pool_stats(
self.farmer.pool_state,
p2_singleton_puzzle_hash,
"missing_partials",
time.time(),
)
self.farmer.state_changed(
"failed_partial",
{"p2_singleton_puzzle_hash": p2_singleton_puzzle_hash.hex()},
)
return
post_partial_request: PostPartialRequest = PostPartialRequest(payload, current_auth_token, agg_sig)
self.farmer.log.info(
f"Submitting partial for {post_partial_request.payload.launcher_id.hex()} to {pool_url}"
)
@@ -369,7 +387,7 @@ class FarmerAPI:
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{pool_url}/partial",
self.farmer._url_for_endpoint(pool_state_dict["pool_config"], "partial"),
json=post_partial_request.to_json_dict(),
ssl=ssl_context_for_root(get_mozilla_ca_crt(), log=self.farmer.log),
headers={
+36 -3
View File
@@ -4,7 +4,7 @@ import time
from dataclasses import dataclass
from enum import Enum
from chia_rs import G1Element, G2Element, ProofOfSpace
from chia_rs import G1Element, G2Element, Program, ProofOfSpace
from chia_rs.sized_bytes import bytes32
from chia_rs.sized_ints import uint8, uint16, uint32, uint64
@@ -35,13 +35,31 @@ class PoolErrorCode(Enum):
# Used to verify GET /farmer and GET /login
@streamable
@dataclass(frozen=True)
class AuthenticationPayload(Streamable):
class AuthenticationPayloadV1(Streamable):
method_name: str
launcher_id: bytes32
target_puzzle_hash: bytes32
authentication_token: uint64
# GET /auth (only v2)
@streamable
@dataclass(frozen=True)
class GetAuthRequest(Streamable):
launcher_id: bytes32
timestamp: uint64
signature: G2Element
@streamable
@dataclass(frozen=True)
class GetAuthResponse(Streamable):
authentication_token: str
expiration: uint64
# GET /pool_info
@streamable
@dataclass(frozen=True)
@@ -55,6 +73,7 @@ class GetPoolInfoResponse(Streamable):
description: str
target_puzzle_hash: bytes32
authentication_token_timeout: uint8
pool_memoization: Program = Program.to(None) # addition from v1
# POST /partial
@@ -75,6 +94,7 @@ class PostPartialPayload(Streamable):
@dataclass(frozen=True)
class PostPartialRequest(Streamable):
payload: PostPartialPayload
authentication_token_v2: str
aggregate_signature: G2Element
@@ -86,6 +106,18 @@ class PostPartialResponse(Streamable):
# GET /farmer
@streamable
@dataclass(frozen=True, kw_only=True)
class GetFarmerRequestV2(Streamable):
authentication_token: uint64
launcher_id: bytes32
authentication_token_v2: str
@streamable
@dataclass(frozen=True, kw_only=True)
class GetFarmerRequestV1(GetFarmerRequestV2):
signature: G2Element | None = None
# Response in success case
@@ -136,13 +168,14 @@ class PutFarmerPayload(Streamable):
authentication_public_key: G1Element | None
payout_instructions: str | None
suggested_difficulty: uint64 | None
authentication_token_v2: str
@streamable
@dataclass(frozen=True)
class PutFarmerRequest(Streamable):
payload: PutFarmerPayload
signature: G2Element
signature: G2Element | None
# Response in success case
+4
View File
@@ -93,6 +93,10 @@ def master_sk_to_pooling_authentication_sk(master: PrivateKey, pool_wallet_index
return _derive_path(master, [12381, 8444, 6, pool_wallet_index * 10000 + index])
def singleton_owner_sk_to_authv2_key(singleton_owner_sk: PrivateKey) -> PrivateKey:
return _derive_path_unhardened(singleton_owner_sk, [12381])
def find_owner_sk(all_sks: list[PrivateKey], owner_pk: G1Element) -> tuple[PrivateKey, uint32] | None:
for pool_wallet_index in range(MAX_POOL_WALLETS):
for sk in all_sks:
+2 -3
View File
@@ -997,7 +997,7 @@ class WalletStateManager:
else:
matched_plotnft_wallet_id = None
if matched_plotnft_wallet_id is None and coin_spend.coin.parent_coin_info == next_plot_nft.launcher_id:
matched_plotnft_wallet_id = uint32(len(self.wallets) + 1)
matched_plotnft_wallet_id = uint32(max(self.wallets.keys()) + 1)
self.wallets[matched_plotnft_wallet_id] = await PlotNFT2Wallet.create(
wallet_state_manager=self,
xch_wallet=self.main_wallet,
@@ -1030,11 +1030,10 @@ class WalletStateManager:
tx_config_loader = tx_config_loader.override(
min_coin_amount=self.config.get("auto_claim", {}).get("min_amount"),
)
assert self.wallet_node.logged_in_fingerprint is not None
return tx_config_loader.autofill(
constants=self.constants,
config=self.config,
logged_in_fingerprint=self.wallet_node.logged_in_fingerprint,
logged_in_fingerprint=self.root_pubkey.get_fingerprint(),
)
async def auto_claim_coins(self) -> None: