from __future__ import annotations import dataclasses import logging import time from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Set, Tuple, cast from chia_rs import G1Element, G2Element, PrivateKey from typing_extensions import final from chia.clvm.singleton import SINGLETON_LAUNCHER from chia.pools.pool_config import PoolWalletConfig, load_pool_config, update_pool_config from chia.pools.pool_puzzles import ( create_absorb_spend, create_full_puzzle, create_pooling_inner_puzzle, create_travel_spend, create_waiting_room_inner_puzzle, get_delayed_puz_info_from_launcher_spend, get_most_recent_singleton_coin_from_coin_spend, is_pool_member_inner_puzzle, is_pool_waitingroom_inner_puzzle, launcher_id_to_p2_puzzle_hash, pool_state_to_inner_puzzle, solution_to_pool_state, uncurry_pool_member_inner_puzzle, uncurry_pool_waitingroom_inner_puzzle, ) from chia.pools.pool_wallet_info import ( FARMING_TO_POOL, LEAVING_POOL, SELF_POOLING, PoolSingletonState, PoolState, PoolWalletInfo, create_pool_state, ) from chia.protocols.pool_protocol import POOL_PROTOCOL_VERSION from chia.server.ws_connection import WSChiaConnection from chia.types.announcement import Announcement from chia.types.blockchain_format.coin import Coin from chia.types.blockchain_format.program import Program from chia.types.blockchain_format.serialized_program import SerializedProgram from chia.types.blockchain_format.sized_bytes import bytes32 from chia.types.coin_spend import CoinSpend, compute_additions from chia.types.spend_bundle import SpendBundle, estimate_fees from chia.util.ints import uint32, uint64, uint128 from chia.wallet.conditions import Condition, ConditionValidTimes, parse_timelock_info from chia.wallet.derive_keys import find_owner_sk from chia.wallet.puzzles.p2_delegated_puzzle_or_hidden_puzzle import puzzle_hash_for_synthetic_public_key from chia.wallet.sign_coin_spends import sign_coin_spends from chia.wallet.transaction_record import TransactionRecord from chia.wallet.util.transaction_type import TransactionType from chia.wallet.util.tx_config import DEFAULT_TX_CONFIG, CoinSelectionConfig, TXConfig from chia.wallet.util.wallet_types import WalletType from chia.wallet.wallet import Wallet from chia.wallet.wallet_coin_record import WalletCoinRecord from chia.wallet.wallet_info import WalletInfo if TYPE_CHECKING: from chia.wallet.wallet_state_manager import WalletStateManager @final @dataclasses.dataclass class PoolWallet: if TYPE_CHECKING: from chia.wallet.wallet_protocol import WalletProtocol _protocol_check: ClassVar[WalletProtocol[object]] = cast("PoolWallet", None) MINIMUM_INITIAL_BALANCE = 1 MINIMUM_RELATIVE_LOCK_HEIGHT = 5 MAXIMUM_RELATIVE_LOCK_HEIGHT = 1000 DEFAULT_MAX_CLAIM_SPENDS = 100 wallet_state_manager: WalletStateManager log: logging.Logger wallet_info: WalletInfo standard_wallet: Wallet wallet_id: int next_transaction_fee: uint64 = uint64(0) next_tx_config: TXConfig = DEFAULT_TX_CONFIG target_state: Optional[PoolState] = None _owner_sk_and_index: Optional[Tuple[PrivateKey, uint32]] = None """ From the user's perspective, this is not a wallet at all, but a way to control whether their pooling-enabled plots are being self-farmed, or farmed by a pool, and by which pool. Self-pooling and joint pooling rewards are swept into the users' regular wallet. If this wallet is in SELF_POOLING state, the coin ID associated with the current pool wallet contains the rewards gained while self-farming, so care must be taken to disallow joining a new pool while we still have money on the pooling singleton UTXO. Pools can be joined anonymously, without an account or prior signup. The ability to change the farm-to target prevents abuse from pools by giving the user the ability to quickly change pools, or self-farm. The pool is also protected, by not allowing members to cheat by quickly leaving a pool, and claiming a block that was pledged to the pool. The pooling protocol and smart coin prevents a user from quickly leaving a pool by enforcing a wait time when leaving the pool. A minimum number of blocks must pass after the user declares that they are leaving the pool, and before they can start to self-claim rewards again. Control of switching states is granted to the owner public key. We reveal the inner_puzzle to the pool during setup of the pooling protocol. The pool can prove to itself that the inner puzzle pays to the pooling address, and it can follow state changes in the pooling puzzle by tracing destruction and creation of coins associate with this pooling singleton (the singleton controlling this pool group). The user trusts the pool to send mining rewards to the TODO: We should mark which address is receiving funds for our current state. If the pool misbehaves, it is the user's responsibility to leave the pool It is the Pool's responsibility to claim the rewards sent to the pool_puzzlehash. The timeout for leaving the pool is expressed in number of blocks from the time the user expresses their intent to leave. """ @classmethod def type(cls) -> WalletType: return WalletType.POOLING_WALLET def id(self) -> uint32: return self.wallet_info.id @classmethod def _verify_self_pooled(cls, state: PoolState) -> Optional[str]: err = "" if state.pool_url not in [None, ""]: err += " Unneeded pool_url for self-pooling" if state.relative_lock_height != 0: err += " Incorrect relative_lock_height for self-pooling" return None if err == "" else err @classmethod def _verify_pooling_state(cls, state: PoolState) -> Optional[str]: err = "" if state.relative_lock_height < cls.MINIMUM_RELATIVE_LOCK_HEIGHT: err += ( f" Pool relative_lock_height ({state.relative_lock_height})" f"is less than recommended minimum ({cls.MINIMUM_RELATIVE_LOCK_HEIGHT})" ) elif state.relative_lock_height > cls.MAXIMUM_RELATIVE_LOCK_HEIGHT: err += ( f" Pool relative_lock_height ({state.relative_lock_height})" f"is greater than recommended maximum ({cls.MAXIMUM_RELATIVE_LOCK_HEIGHT})" ) if state.pool_url in [None, ""]: err += " Empty pool url in pooling state" return err @classmethod def _verify_pool_state(cls, state: PoolState) -> Optional[str]: if state.target_puzzle_hash is None: return "Invalid puzzle_hash" if state.version > POOL_PROTOCOL_VERSION: return ( f"Detected pool protocol version {state.version}, which is " f"newer than this wallet's version ({POOL_PROTOCOL_VERSION}). Please upgrade " f"to use this pooling wallet" ) if state.state == PoolSingletonState.SELF_POOLING.value: return cls._verify_self_pooled(state) elif ( state.state == PoolSingletonState.FARMING_TO_POOL.value or state.state == PoolSingletonState.LEAVING_POOL.value ): return cls._verify_pooling_state(state) else: return "Internal Error" @classmethod def _verify_initial_target_state(cls, initial_target_state: PoolState) -> None: err = cls._verify_pool_state(initial_target_state) if err: raise ValueError(f"Invalid internal Pool State: {err}: {initial_target_state}") async def get_spend_history(self) -> List[Tuple[uint32, CoinSpend]]: return await self.wallet_state_manager.pool_store.get_spends_for_wallet(self.wallet_id) async def get_current_state(self) -> PoolWalletInfo: history: List[Tuple[uint32, CoinSpend]] = await self.get_spend_history() all_spends: List[CoinSpend] = [cs for _, cs in history] # We must have at least the launcher spend assert len(all_spends) >= 1 launcher_coin: Coin = all_spends[0].coin delayed_seconds, delayed_puzhash = get_delayed_puz_info_from_launcher_spend(all_spends[0]) tip_singleton_coin: Optional[Coin] = get_most_recent_singleton_coin_from_coin_spend(all_spends[-1]) launcher_id: bytes32 = launcher_coin.name() p2_singleton_puzzle_hash = launcher_id_to_p2_puzzle_hash(launcher_id, delayed_seconds, delayed_puzhash) assert tip_singleton_coin is not None curr_spend_i = len(all_spends) - 1 pool_state: Optional[PoolState] = None last_singleton_spend_height = uint32(0) while pool_state is None: full_spend: CoinSpend = all_spends[curr_spend_i] pool_state = solution_to_pool_state(full_spend) last_singleton_spend_height = uint32(history[curr_spend_i][0]) curr_spend_i -= 1 assert pool_state is not None current_inner = pool_state_to_inner_puzzle( pool_state, launcher_coin.name(), self.wallet_state_manager.constants.GENESIS_CHALLENGE, delayed_seconds, delayed_puzhash, ) return PoolWalletInfo( pool_state, self.target_state, launcher_coin, launcher_id, p2_singleton_puzzle_hash, current_inner, tip_singleton_coin.name(), last_singleton_spend_height, ) async def get_unconfirmed_transactions(self) -> List[TransactionRecord]: return await self.wallet_state_manager.tx_store.get_unconfirmed_for_wallet(self.wallet_id) async def get_tip(self) -> Tuple[uint32, CoinSpend]: return (await self.wallet_state_manager.pool_store.get_spends_for_wallet(self.wallet_id))[-1] async def update_pool_config(self) -> None: current_state: PoolWalletInfo = await self.get_current_state() pool_config_list: List[PoolWalletConfig] = load_pool_config(self.wallet_state_manager.root_path) pool_config_dict: Dict[bytes32, PoolWalletConfig] = {c.launcher_id: c for c in pool_config_list} existing_config: Optional[PoolWalletConfig] = pool_config_dict.get(current_state.launcher_id, None) payout_instructions: str = existing_config.payout_instructions if existing_config is not None else "" if len(payout_instructions) == 0: payout_instructions = (await self.standard_wallet.get_new_puzzlehash()).hex() self.log.info(f"New config entry. Generated payout_instructions puzzle hash: {payout_instructions}") new_config: PoolWalletConfig = PoolWalletConfig( current_state.launcher_id, current_state.current.pool_url if current_state.current.pool_url else "", payout_instructions, current_state.current.target_puzzle_hash, current_state.p2_singleton_puzzle_hash, current_state.current.owner_pubkey, ) pool_config_dict[new_config.launcher_id] = new_config await update_pool_config(self.wallet_state_manager.root_path, list(pool_config_dict.values())) async def apply_state_transition(self, new_state: CoinSpend, block_height: uint32) -> bool: """ Updates the Pool state (including DB) with new singleton spends. The DB must be committed after calling this method. All validation should be done here. Returns True iff the spend is a valid transition spend for the singleton, False otherwise. """ tip: Tuple[uint32, CoinSpend] = await self.get_tip() tip_spend = tip[1] tip_coin: Optional[Coin] = get_most_recent_singleton_coin_from_coin_spend(tip_spend) assert tip_coin is not None spent_coin_name: bytes32 = tip_coin.name() if spent_coin_name != new_state.coin.name(): history: List[Tuple[uint32, CoinSpend]] = await self.get_spend_history() if new_state.coin.name() in [sp.coin.name() for _, sp in history]: self.log.info(f"Already have state transition: {new_state.coin.name().hex()}") else: self.log.warning( f"Failed to apply state transition. tip: {tip_coin} new_state: {new_state} height {block_height}" ) return False await self.wallet_state_manager.pool_store.add_spend(self.wallet_id, new_state, block_height) tip_spend = (await self.get_tip())[1] self.log.info(f"New PoolWallet singleton tip_coin: {tip_spend} farmed at height {block_height}") # If we have reached the target state, resets it to None. Loops back to get current state for _, added_spend in reversed( await self.wallet_state_manager.pool_store.get_spends_for_wallet(self.wallet_id) ): latest_state: Optional[PoolState] = solution_to_pool_state(added_spend) if latest_state is not None: if self.target_state == latest_state: self.target_state = None self.next_transaction_fee = uint64(0) self.next_tx_config = DEFAULT_TX_CONFIG break await self.update_pool_config() return True async def rewind(self, block_height: int) -> bool: """ Rolls back all transactions after block_height, and if creation was after block_height, deletes the wallet. Returns True if the wallet should be removed. """ try: history: List[Tuple[uint32, CoinSpend]] = await self.wallet_state_manager.pool_store.get_spends_for_wallet( self.wallet_id ) prev_state: PoolWalletInfo = await self.get_current_state() await self.wallet_state_manager.pool_store.rollback(block_height, self.wallet_id) if len(history) > 0 and history[0][0] > block_height: return True else: if await self.get_current_state() != prev_state: await self.update_pool_config() return False except Exception as e: self.log.error(f"Exception rewinding: {e}") return False @classmethod async def create( cls, wallet_state_manager: Any, wallet: Wallet, launcher_coin_id: bytes32, block_spends: List[CoinSpend], block_height: uint32, *, name: Optional[str] = None, ) -> PoolWallet: """ This creates a new PoolWallet with only one spend: the launcher spend. The DB MUST be committed after calling this method. """ wallet_info = await wallet_state_manager.user_store.create_wallet( "Pool wallet", WalletType.POOLING_WALLET.value, "" ) pool_wallet = cls( wallet_state_manager=wallet_state_manager, log=logging.getLogger(name if name else __name__), wallet_info=wallet_info, wallet_id=wallet_info.id, standard_wallet=wallet, ) launcher_spend: Optional[CoinSpend] = None for spend in block_spends: if spend.coin.name() == launcher_coin_id: launcher_spend = spend assert launcher_spend is not None await wallet_state_manager.pool_store.add_spend(pool_wallet.wallet_id, launcher_spend, block_height) await pool_wallet.update_pool_config() p2_puzzle_hash: bytes32 = (await pool_wallet.get_current_state()).p2_singleton_puzzle_hash await wallet_state_manager.add_new_wallet(pool_wallet) await wallet_state_manager.add_interested_puzzle_hashes([p2_puzzle_hash], [pool_wallet.wallet_id]) return pool_wallet @classmethod async def create_from_db( cls, wallet_state_manager: Any, wallet: Wallet, wallet_info: WalletInfo, name: Optional[str] = None, ) -> PoolWallet: """ This creates a PoolWallet from DB. However, all data is already handled by WalletPoolStore, so we don't need to do anything here. """ pool_wallet = cls( wallet_state_manager=wallet_state_manager, log=logging.getLogger(name if name else __name__), wallet_info=wallet_info, wallet_id=wallet_info.id, standard_wallet=wallet, ) return pool_wallet @staticmethod async def create_new_pool_wallet_transaction( wallet_state_manager: Any, main_wallet: Wallet, initial_target_state: PoolState, tx_config: TXConfig, fee: uint64 = uint64(0), p2_singleton_delay_time: Optional[uint64] = None, p2_singleton_delayed_ph: Optional[bytes32] = None, extra_conditions: Tuple[Condition, ...] = tuple(), ) -> Tuple[TransactionRecord, bytes32, bytes32]: """ A "plot NFT", or pool wallet, represents the idea of a set of plots that all pay to the same pooling puzzle. This puzzle is a `chia singleton` that is parameterized with a public key controlled by the user's wallet (a `smart coin`). It contains an inner puzzle that can switch between paying block rewards to a pool, or to a user's own wallet. Call under the wallet state manager lock """ amount = 1 standard_wallet = main_wallet if p2_singleton_delayed_ph is None: p2_singleton_delayed_ph = await main_wallet.get_new_puzzlehash() if p2_singleton_delay_time is None: p2_singleton_delay_time = uint64(604800) unspent_records = await wallet_state_manager.coin_store.get_unspent_coins_for_wallet(standard_wallet.wallet_id) balance = await standard_wallet.get_confirmed_balance(unspent_records) if balance < PoolWallet.MINIMUM_INITIAL_BALANCE: raise ValueError("Not enough balance in main wallet to create a managed plotting pool.") if balance < PoolWallet.MINIMUM_INITIAL_BALANCE + fee: raise ValueError("Not enough balance in main wallet to create a managed plotting pool with fee {fee}.") # Verify Parameters - raise if invalid PoolWallet._verify_initial_target_state(initial_target_state) spend_bundle, singleton_puzzle_hash, launcher_coin_id = await PoolWallet.generate_launcher_spend( standard_wallet, uint64(1), fee, initial_target_state, wallet_state_manager.constants.GENESIS_CHALLENGE, p2_singleton_delay_time, p2_singleton_delayed_ph, tx_config, extra_conditions=extra_conditions, ) if spend_bundle is None: raise ValueError("failed to generate ID for wallet") standard_wallet_record = TransactionRecord( confirmed_at_height=uint32(0), created_at_time=uint64(int(time.time())), to_puzzle_hash=singleton_puzzle_hash, amount=uint64(amount), fee_amount=fee, confirmed=False, sent=uint32(0), spend_bundle=spend_bundle, additions=spend_bundle.additions(), removals=spend_bundle.removals(), wallet_id=wallet_state_manager.main_wallet.id(), sent_to=[], memos=[], trade_id=None, type=uint32(TransactionType.OUTGOING_TX.value), name=spend_bundle.name(), valid_times=parse_timelock_info(extra_conditions), ) await standard_wallet.push_transaction(standard_wallet_record) p2_singleton_puzzle_hash: bytes32 = launcher_id_to_p2_puzzle_hash( launcher_coin_id, p2_singleton_delay_time, p2_singleton_delayed_ph ) return standard_wallet_record, p2_singleton_puzzle_hash, launcher_coin_id async def _get_owner_key_cache(self) -> Tuple[PrivateKey, uint32]: if self._owner_sk_and_index is None: self._owner_sk_and_index = find_owner_sk( [self.wallet_state_manager.private_key], (await self.get_current_state()).current.owner_pubkey ) assert self._owner_sk_and_index is not None return self._owner_sk_and_index async def get_pool_wallet_index(self) -> uint32: return (await self._get_owner_key_cache())[1] async def sign(self, coin_spend: CoinSpend) -> SpendBundle: async def pk_to_sk(pk: G1Element) -> PrivateKey: s = find_owner_sk([self.wallet_state_manager.private_key], pk) if s is None: # pragma: no cover # No pool wallet transactions _should_ hit this, but it can't hurt to have a backstop private_key = await self.wallet_state_manager.get_private_key_for_pubkey(pk) if private_key is None: raise ValueError(f"No private key for pubkey: {pk}") return private_key else: # Note that pool_wallet_index may be from another wallet than self.wallet_id owner_sk, pool_wallet_index = s if owner_sk is None: # pragma: no cover # TODO: this code is dead, per hinting at least # No pool wallet transactions _should_ hit this, but it can't hurt to have a backstop private_key = await self.wallet_state_manager.get_private_key_for_pubkey(pk) if private_key is None: raise ValueError(f"No private key for pubkey: {pk}") return private_key return owner_sk return await sign_coin_spends( [coin_spend], pk_to_sk, self.wallet_state_manager.get_synthetic_private_key_for_puzzle_hash, self.wallet_state_manager.constants.AGG_SIG_ME_ADDITIONAL_DATA, self.wallet_state_manager.constants.MAX_BLOCK_COST_CLVM, [puzzle_hash_for_synthetic_public_key], ) async def generate_fee_transaction( self, fee: uint64, tx_config: TXConfig, coin_announcements: Optional[Set[Announcement]] = None, ) -> TransactionRecord: [fee_tx] = await self.standard_wallet.generate_signed_transaction( uint64(0), (await self.standard_wallet.get_new_puzzlehash()), tx_config, fee=fee, origin_id=None, coins=None, primaries=None, ignore_max_send_amount=False, coin_announcements_to_consume=coin_announcements, ) return fee_tx async def publish_transactions(self, travel_tx: TransactionRecord, fee_tx: Optional[TransactionRecord]) -> None: # We create two transaction records, one for the pool wallet to keep track of the travel TX, and another # for the standard wallet to keep track of the fee. However, we will only submit the first one to the # blockchain, and this one has the fee inside it as well. # The fee tx, if present, will be added to the DB with no spend_bundle set, which has the effect that it # will not be sent to full nodes. await self.wallet_state_manager.add_pending_transaction(travel_tx) if fee_tx is not None: await self.wallet_state_manager.add_pending_transaction(dataclasses.replace(fee_tx, spend_bundle=None)) async def generate_travel_transactions( self, fee: uint64, tx_config: TXConfig ) -> Tuple[TransactionRecord, Optional[TransactionRecord]]: # target_state is contained within pool_wallet_state pool_wallet_info: PoolWalletInfo = await self.get_current_state() spend_history = await self.get_spend_history() last_coin_spend: CoinSpend = spend_history[-1][1] delayed_seconds, delayed_puzhash = get_delayed_puz_info_from_launcher_spend(spend_history[0][1]) assert pool_wallet_info.target is not None next_state = pool_wallet_info.target if pool_wallet_info.current.state == FARMING_TO_POOL.value: next_state = create_pool_state( LEAVING_POOL, pool_wallet_info.current.target_puzzle_hash, pool_wallet_info.current.owner_pubkey, pool_wallet_info.current.pool_url, pool_wallet_info.current.relative_lock_height, ) new_inner_puzzle = pool_state_to_inner_puzzle( next_state, pool_wallet_info.launcher_coin.name(), self.wallet_state_manager.constants.GENESIS_CHALLENGE, delayed_seconds, delayed_puzhash, ) new_full_puzzle: SerializedProgram = SerializedProgram.from_program( create_full_puzzle(new_inner_puzzle, pool_wallet_info.launcher_coin.name()) ) outgoing_coin_spend, inner_puzzle = create_travel_spend( last_coin_spend, pool_wallet_info.launcher_coin, pool_wallet_info.current, next_state, self.wallet_state_manager.constants.GENESIS_CHALLENGE, delayed_seconds, delayed_puzhash, ) tip = (await self.get_tip())[1] tip_coin = tip.coin singleton = compute_additions(tip)[0] singleton_id = singleton.name() assert outgoing_coin_spend.coin.parent_coin_info == tip_coin.name() assert outgoing_coin_spend.coin.name() == singleton_id assert new_inner_puzzle != inner_puzzle if is_pool_member_inner_puzzle(inner_puzzle): ( inner_f, target_puzzle_hash, p2_singleton_hash, pubkey_as_program, pool_reward_prefix, escape_puzzle_hash, ) = uncurry_pool_member_inner_puzzle(inner_puzzle) elif is_pool_waitingroom_inner_puzzle(inner_puzzle): ( target_puzzle_hash, # payout_puzzle_hash relative_lock_height, pubkey_as_program, p2_singleton_hash, ) = uncurry_pool_waitingroom_inner_puzzle(inner_puzzle) else: raise RuntimeError("Invalid state") pk_bytes = pubkey_as_program.as_atom() assert pk_bytes is not None assert len(pk_bytes) == 48 owner_pubkey = G1Element.from_bytes(pk_bytes) assert owner_pubkey == pool_wallet_info.current.owner_pubkey signed_spend_bundle = await self.sign(outgoing_coin_spend) assert signed_spend_bundle.removals()[0].puzzle_hash == singleton.puzzle_hash assert signed_spend_bundle.removals()[0].name() == singleton.name() assert signed_spend_bundle is not None fee_tx: Optional[TransactionRecord] = None if fee > 0: fee_tx = await self.generate_fee_transaction(fee, tx_config) assert fee_tx.spend_bundle is not None signed_spend_bundle = SpendBundle.aggregate([signed_spend_bundle, fee_tx.spend_bundle]) tx_record = TransactionRecord( confirmed_at_height=uint32(0), created_at_time=uint64(int(time.time())), to_puzzle_hash=new_full_puzzle.get_tree_hash(), amount=uint64(1), fee_amount=fee, confirmed=False, sent=uint32(0), spend_bundle=signed_spend_bundle, additions=signed_spend_bundle.additions(), removals=signed_spend_bundle.removals(), wallet_id=self.id(), sent_to=[], trade_id=None, memos=[], type=uint32(TransactionType.OUTGOING_TX.value), name=signed_spend_bundle.name(), valid_times=ConditionValidTimes(), ) await self.publish_transactions(tx_record, fee_tx) return tx_record, fee_tx @staticmethod async def generate_launcher_spend( standard_wallet: Wallet, amount: uint64, fee: uint64, initial_target_state: PoolState, genesis_challenge: bytes32, delay_time: uint64, delay_ph: bytes32, tx_config: TXConfig, extra_conditions: Tuple[Condition, ...] = tuple(), ) -> Tuple[SpendBundle, bytes32, bytes32]: """ Creates the initial singleton, which includes spending an origin coin, the launcher, and creating a singleton with the "pooling" inner state, which can be either self pooling or using a pool """ coins: Set[Coin] = await standard_wallet.select_coins(uint64(amount + fee), tx_config.coin_selection_config) if coins is None: raise ValueError("Not enough coins to create pool wallet") launcher_parent: Coin = coins.copy().pop() genesis_launcher_puz: Program = SINGLETON_LAUNCHER launcher_coin: Coin = Coin(launcher_parent.name(), genesis_launcher_puz.get_tree_hash(), amount) escaping_inner_puzzle: Program = create_waiting_room_inner_puzzle( initial_target_state.target_puzzle_hash, initial_target_state.relative_lock_height, initial_target_state.owner_pubkey, launcher_coin.name(), genesis_challenge, delay_time, delay_ph, ) escaping_inner_puzzle_hash = escaping_inner_puzzle.get_tree_hash() self_pooling_inner_puzzle: Program = create_pooling_inner_puzzle( initial_target_state.target_puzzle_hash, escaping_inner_puzzle_hash, initial_target_state.owner_pubkey, launcher_coin.name(), genesis_challenge, delay_time, delay_ph, ) if initial_target_state.state == SELF_POOLING.value: puzzle = escaping_inner_puzzle elif initial_target_state.state == FARMING_TO_POOL.value: puzzle = self_pooling_inner_puzzle else: raise ValueError("Invalid initial state") full_pooling_puzzle: Program = create_full_puzzle(puzzle, launcher_id=launcher_coin.name()) puzzle_hash: bytes32 = full_pooling_puzzle.get_tree_hash() pool_state_bytes = Program.to([("p", bytes(initial_target_state)), ("t", delay_time), ("h", delay_ph)]) announcement_set: Set[Announcement] = set() announcement_message = Program.to([puzzle_hash, amount, pool_state_bytes]).get_tree_hash() announcement_set.add(Announcement(launcher_coin.name(), announcement_message)) [create_launcher_tx_record] = await standard_wallet.generate_signed_transaction( amount, genesis_launcher_puz.get_tree_hash(), tx_config, fee, coins, None, False, announcement_set, origin_id=launcher_parent.name(), extra_conditions=extra_conditions, ) assert create_launcher_tx_record.spend_bundle is not None genesis_launcher_solution: Program = Program.to([puzzle_hash, amount, pool_state_bytes]) launcher_cs: CoinSpend = CoinSpend( launcher_coin, SerializedProgram.from_program(genesis_launcher_puz), SerializedProgram.from_program(genesis_launcher_solution), ) launcher_sb: SpendBundle = SpendBundle([launcher_cs], G2Element()) # Current inner will be updated when state is verified on the blockchain full_spend: SpendBundle = SpendBundle.aggregate([create_launcher_tx_record.spend_bundle, launcher_sb]) return full_spend, puzzle_hash, launcher_coin.name() async def join_pool( self, target_state: PoolState, fee: uint64, tx_config: TXConfig ) -> Tuple[uint64, TransactionRecord, Optional[TransactionRecord]]: if target_state.state != FARMING_TO_POOL.value: raise ValueError(f"join_pool must be called with target_state={FARMING_TO_POOL} (FARMING_TO_POOL)") if self.target_state is not None: raise ValueError(f"Cannot join a pool while waiting for target state: {self.target_state}") if await self.have_unconfirmed_transaction(): raise ValueError( "Cannot join pool due to unconfirmed transaction. If this is stuck, delete the unconfirmed transaction." ) current_state: PoolWalletInfo = await self.get_current_state() total_fee = fee if current_state.current == target_state: self.target_state = None msg = f"Asked to change to current state. Target = {target_state}" self.log.info(msg) raise ValueError(msg) elif current_state.current.state in [SELF_POOLING.value, LEAVING_POOL.value]: total_fee = fee elif current_state.current.state == FARMING_TO_POOL.value: total_fee = uint64(fee * 2) if self.target_state is not None: raise ValueError( f"Cannot change to state {target_state} when already having target state: {self.target_state}" ) PoolWallet._verify_initial_target_state(target_state) if current_state.current.state == LEAVING_POOL.value: history: List[Tuple[uint32, CoinSpend]] = await self.get_spend_history() last_height: uint32 = history[-1][0] if ( await self.wallet_state_manager.blockchain.get_finished_sync_up_to() <= last_height + current_state.current.relative_lock_height ): raise ValueError( f"Cannot join a pool until height {last_height + current_state.current.relative_lock_height}" ) self.target_state = target_state self.next_transaction_fee = fee self.next_tx_config = tx_config travel_tx, fee_tx = await self.generate_travel_transactions(fee, tx_config) return total_fee, travel_tx, fee_tx async def self_pool( self, fee: uint64, tx_config: TXConfig ) -> Tuple[uint64, TransactionRecord, Optional[TransactionRecord]]: if await self.have_unconfirmed_transaction(): raise ValueError( "Cannot self pool due to unconfirmed transaction. If this is stuck, delete the unconfirmed transaction." ) pool_wallet_info: PoolWalletInfo = await self.get_current_state() if pool_wallet_info.current.state == SELF_POOLING.value: raise ValueError("Attempted to self pool when already self pooling") if self.target_state is not None: raise ValueError(f"Cannot self pool when already having target state: {self.target_state}") # Note the implications of getting owner_puzzlehash from our local wallet right now # vs. having pre-arranged the target self-pooling address owner_puzzlehash = await self.standard_wallet.get_new_puzzlehash() owner_pubkey = pool_wallet_info.current.owner_pubkey current_state: PoolWalletInfo = await self.get_current_state() total_fee = uint64(fee * 2) if current_state.current.state == LEAVING_POOL.value: total_fee = fee history: List[Tuple[uint32, CoinSpend]] = await self.get_spend_history() last_height: uint32 = history[-1][0] if ( await self.wallet_state_manager.blockchain.get_finished_sync_up_to() <= last_height + current_state.current.relative_lock_height ): raise ValueError( f"Cannot self pool until height {last_height + current_state.current.relative_lock_height}" ) self.target_state = create_pool_state( SELF_POOLING, owner_puzzlehash, owner_pubkey, pool_url=None, relative_lock_height=uint32(0) ) self.next_transaction_fee = fee self.next_tx_config = tx_config travel_tx, fee_tx = await self.generate_travel_transactions(fee, tx_config) return total_fee, travel_tx, fee_tx async def claim_pool_rewards( self, fee: uint64, max_spends_in_tx: Optional[int], tx_config: TXConfig ) -> Tuple[TransactionRecord, Optional[TransactionRecord]]: # Search for p2_puzzle_hash coins, and spend them with the singleton if await self.have_unconfirmed_transaction(): raise ValueError( "Cannot claim due to unconfirmed transaction. If this is stuck, delete the unconfirmed transaction." ) if max_spends_in_tx is None: max_spends_in_tx = self.DEFAULT_MAX_CLAIM_SPENDS elif max_spends_in_tx <= 0: self.log.info(f"Bad max_spends_in_tx value of {max_spends_in_tx}. Set to {self.DEFAULT_MAX_CLAIM_SPENDS}.") max_spends_in_tx = self.DEFAULT_MAX_CLAIM_SPENDS unspent_coin_records = await self.wallet_state_manager.coin_store.get_unspent_coins_for_wallet(self.wallet_id) if len(unspent_coin_records) == 0: raise ValueError("Nothing to claim, no transactions to p2_singleton_puzzle_hash") farming_rewards: List[TransactionRecord] = await self.wallet_state_manager.tx_store.get_farming_rewards() coin_to_height_farmed: Dict[Coin, uint32] = {} for tx_record in farming_rewards: height_farmed: Optional[uint32] = tx_record.height_farmed( self.wallet_state_manager.constants.GENESIS_CHALLENGE ) assert height_farmed is not None coin_to_height_farmed[tx_record.additions[0]] = height_farmed history: List[Tuple[uint32, CoinSpend]] = await self.get_spend_history() assert len(history) > 0 delayed_seconds, delayed_puzhash = get_delayed_puz_info_from_launcher_spend(history[0][1]) current_state: PoolWalletInfo = await self.get_current_state() last_solution: CoinSpend = history[-1][1] all_spends: List[CoinSpend] = [] total_amount = 0 # The coins being claimed are gathered into the `SpendBundle`, :absorb_spend: # We use an announcement in the fee spend to ensure that the claim spend is spent in the same block as the fee # We only need to do this for one of the coins, because each `SpendBundle` can only be spent as a unit first_coin_record = None for coin_record in unspent_coin_records: if coin_record.coin not in coin_to_height_farmed: continue if first_coin_record is None: first_coin_record = coin_record if len(all_spends) >= max_spends_in_tx: # Limit the total number of spends, so the SpendBundle fits into the block self.log.info(f"pool wallet truncating absorb to {max_spends_in_tx} spends to fit into block") print(f"pool wallet truncating absorb to {max_spends_in_tx} spends to fit into block") break absorb_spend: List[CoinSpend] = create_absorb_spend( last_solution, current_state.current, current_state.launcher_coin, coin_to_height_farmed[coin_record.coin], self.wallet_state_manager.constants.GENESIS_CHALLENGE, delayed_seconds, delayed_puzhash, ) last_solution = absorb_spend[0] all_spends += absorb_spend total_amount += coin_record.coin.amount self.log.info( f"Farmer coin: {coin_record.coin} {coin_record.coin.name()} {coin_to_height_farmed[coin_record.coin]}" ) if len(all_spends) == 0 or first_coin_record is None: raise ValueError("Nothing to claim, no unspent coinbase rewards") claim_spend: SpendBundle = SpendBundle(all_spends, G2Element()) # If fee is 0, no signatures are required to absorb full_spend: SpendBundle = claim_spend fee_tx = None if fee > 0: absorb_announce = Announcement(first_coin_record.coin.name(), b"$") assert absorb_announce is not None fee_tx = await self.generate_fee_transaction(fee, tx_config, coin_announcements={absorb_announce}) assert fee_tx.spend_bundle is not None full_spend = SpendBundle.aggregate([fee_tx.spend_bundle, claim_spend]) assert estimate_fees(full_spend) == fee current_time = uint64(int(time.time())) # The claim spend, minus the fee amount from the main wallet absorb_transaction: TransactionRecord = TransactionRecord( confirmed_at_height=uint32(0), created_at_time=current_time, to_puzzle_hash=current_state.current.target_puzzle_hash, amount=uint64(total_amount), fee_amount=fee, # This will not be double counted in self.standard_wallet confirmed=False, sent=uint32(0), spend_bundle=full_spend, additions=full_spend.additions(), removals=full_spend.removals(), wallet_id=uint32(self.wallet_id), sent_to=[], memos=[], trade_id=None, type=uint32(TransactionType.OUTGOING_TX.value), name=full_spend.name(), valid_times=ConditionValidTimes(), ) await self.publish_transactions(absorb_transaction, fee_tx) return absorb_transaction, fee_tx async def new_peak(self, peak_height: uint32) -> None: # This gets called from the WalletStateManager whenever there is a new peak pool_wallet_info: PoolWalletInfo = await self.get_current_state() tip_height, tip_spend = await self.get_tip() if self.target_state is None: return if self.target_state == pool_wallet_info.current: self.target_state = None raise ValueError(f"Internal error. Pool wallet {self.wallet_id} state: {pool_wallet_info.current}") if ( self.target_state.state in [FARMING_TO_POOL.value, SELF_POOLING.value] and pool_wallet_info.current.state == LEAVING_POOL.value ): leave_height = tip_height + pool_wallet_info.current.relative_lock_height # Add some buffer (+2) to reduce chances of a reorg if peak_height > leave_height + 2: unconfirmed: List[ TransactionRecord ] = await self.wallet_state_manager.tx_store.get_unconfirmed_for_wallet(self.wallet_id) next_tip: Optional[Coin] = get_most_recent_singleton_coin_from_coin_spend(tip_spend) assert next_tip is not None if any([rem.name() == next_tip.name() for tx_rec in unconfirmed for rem in tx_rec.removals]): self.log.info("Already submitted second transaction, will not resubmit.") return self.log.info(f"Attempting to leave from\n{pool_wallet_info.current}\nto\n{self.target_state}") assert self.target_state.version == POOL_PROTOCOL_VERSION assert pool_wallet_info.current.state == LEAVING_POOL.value assert self.target_state.target_puzzle_hash is not None if self.target_state.state == SELF_POOLING.value: assert self.target_state.relative_lock_height == 0 assert self.target_state.pool_url is None elif self.target_state.state == FARMING_TO_POOL.value: assert self.target_state.relative_lock_height >= self.MINIMUM_RELATIVE_LOCK_HEIGHT assert self.target_state.pool_url is not None await self.generate_travel_transactions(self.next_transaction_fee, self.next_tx_config) async def have_unconfirmed_transaction(self) -> bool: unconfirmed: List[TransactionRecord] = await self.wallet_state_manager.tx_store.get_unconfirmed_for_wallet( self.wallet_id ) return len(unconfirmed) > 0 async def get_confirmed_balance(self, _: Optional[object] = None) -> uint128: amount: uint128 = uint128(0) if (await self.get_current_state()).current.state == SELF_POOLING.value: unspent_coin_records: List[WalletCoinRecord] = list( await self.wallet_state_manager.coin_store.get_unspent_coins_for_wallet(self.wallet_id) ) for record in unspent_coin_records: if record.coinbase: amount = uint128(amount + record.coin.amount) return amount async def get_unconfirmed_balance(self, record_list: Optional[object] = None) -> uint128: return await self.get_confirmed_balance(record_list) async def get_spendable_balance(self, record_list: Optional[object] = None) -> uint128: return await self.get_confirmed_balance(record_list) async def get_pending_change_balance(self) -> uint64: return uint64(0) async def get_max_send_amount(self, records: Optional[Set[WalletCoinRecord]] = None) -> uint128: return uint128(0) async def coin_added(self, coin: Coin, height: uint32, peer: WSChiaConnection, coin_data: Optional[object]) -> None: pass async def select_coins(self, amount: uint64, coin_selection_config: CoinSelectionConfig) -> Set[Coin]: raise RuntimeError("PoolWallet does not support select_coins()") def require_derivation_paths(self) -> bool: return False def puzzle_hash_for_pk(self, pubkey: G1Element) -> bytes32: raise RuntimeError("PoolWallet does not support puzzle_hash_for_pk") def get_name(self) -> str: return self.wallet_info.name async def match_hinted_coin(self, coin: Coin, hint: bytes32) -> bool: # pragma: no cover return False # PoolWallet pre-dates hints