diff --git a/chia/_tests/farmer_harvester/test_farmer.py b/chia/_tests/farmer_harvester/test_farmer.py index 401c69aced..c0161b4954 100644 --- a/chia/_tests/farmer_harvester/test_farmer.py +++ b/chia/_tests/farmer_harvester/test_farmer.py @@ -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)) diff --git a/chia/_tests/pools/test_pool_cmdline.py b/chia/_tests/pools/test_pool_cmdline.py index 438937fe2f..f4724441c0 100644 --- a/chia/_tests/pools/test_pool_cmdline.py +++ b/chia/_tests/pools/test_pool_cmdline.py @@ -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: diff --git a/chia/_tests/util/network_protocol_data.py b/chia/_tests/util/network_protocol_data.py index b44d59a1f5..15c7dadc04 100644 --- a/chia/_tests/util/network_protocol_data.py +++ b/chia/_tests/util/network_protocol_data.py @@ -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( diff --git a/chia/_tests/util/protocol_messages_bytes-v1.0 b/chia/_tests/util/protocol_messages_bytes-v1.0 index 6acb31960e..cdaedddb37 100644 Binary files a/chia/_tests/util/protocol_messages_bytes-v1.0 and b/chia/_tests/util/protocol_messages_bytes-v1.0 differ diff --git a/chia/_tests/util/protocol_messages_json.py b/chia/_tests/util/protocol_messages_json.py index 8df6b9121f..7d0634ca54 100644 --- a/chia/_tests/util/protocol_messages_json.py +++ b/chia/_tests/util/protocol_messages_json.py @@ -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", } diff --git a/chia/_tests/util/test_network_protocol_test.py b/chia/_tests/util/test_network_protocol_test.py index 2440e56928..732c0c678a 100644 --- a/chia/_tests/util/test_network_protocol_test.py +++ b/chia/_tests/util/test_network_protocol_test.py @@ -201,7 +201,11 @@ def test_missing_messages() -> None: introducer_msgs = {"RequestPeersIntroducer", "RespondPeersIntroducer"} pool_msgs = { - "AuthenticationPayload", + "AuthenticationPayloadV1", + "GetAuthRequest", + "GetFarmerRequestV1", + "GetFarmerRequestV2", + "GetAuthResponse", "ErrorResponse", "GetFarmerResponse", "GetPoolInfoResponse", diff --git a/chia/cmds/plotnft_funcs.py b/chia/cmds/plotnft_funcs.py index 93abe074a8..893a992e88 100644 --- a/chia/cmds/plotnft_funcs.py +++ b/chia/cmds/plotnft_funcs.py @@ -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) diff --git a/chia/farmer/farmer.py b/chia/farmer/farmer.py index a8dad92087..cbc292698a 100644 --- a/chia/farmer/farmer.py +++ b/chia/farmer/farmer.py @@ -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()}" ) diff --git a/chia/farmer/farmer_api.py b/chia/farmer/farmer_api.py index 31031319e2..3fb1721334 100644 --- a/chia/farmer/farmer_api.py +++ b/chia/farmer/farmer_api.py @@ -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={ diff --git a/chia/protocols/pool_protocol.py b/chia/protocols/pool_protocol.py index f57c4a50ff..61bddc7ee3 100644 --- a/chia/protocols/pool_protocol.py +++ b/chia/protocols/pool_protocol.py @@ -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 diff --git a/chia/wallet/derive_keys.py b/chia/wallet/derive_keys.py index 2989bc9a21..82384970c6 100644 --- a/chia/wallet/derive_keys.py +++ b/chia/wallet/derive_keys.py @@ -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: diff --git a/chia/wallet/wallet_state_manager.py b/chia/wallet/wallet_state_manager.py index a65760d899..13e0f44d83 100644 --- a/chia/wallet/wallet_state_manager.py +++ b/chia/wallet/wallet_state_manager.py @@ -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: