async def apply_state_transitions(self, new_state: CoinSpend, block_height: uint32): """ Updates the Pool state (including DB) with new singleton spends. The block spends can contain many spends that we are not interested in, and can contain many ephemeral spends. They must all be in the same block. The DB must be committed after calling this method. All validation should be done here. """ 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(): self.log.warning(f"Failed to apply state transition. tip: {tip_coin} new_state: {new_state} ") return 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}") # If we have reached the target state, resets it to None. Loops back to get current state for _, added_spend in reversed(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) break await self.update_pool_config(False)
async def apply_state_transitions(self, block_spends: List[CoinSpend], block_height: uint32): """ Updates the Pool state (including DB) with new singleton spends. The block spends can contain many spends that we are not interested in, and can contain many ephemeral spends. They must all be in the same block. The DB must be committed after calling this method. All validation should be done here. """ coin_name_to_spend: Dict[bytes32, CoinSpend] = {cs.coin.name(): cs for cs in block_spends} tip: Tuple[uint32, CoinSpend] = await self.get_tip() tip_height = tip[0] tip_spend = tip[1] assert block_height >= tip_height # We should not have a spend with a lesser block height while True: 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 not in coin_name_to_spend: break spend: CoinSpend = coin_name_to_spend[spent_coin_name] await self.wallet_state_manager.pool_store.add_spend(self.wallet_id, spend, block_height) tip_spend = (await self.get_tip())[1] self.log.info(f"New PoolWallet singleton tip_coin: {tip_spend}") coin_name_to_spend.pop(spent_coin_name) # If we have reached the target state, resets it to None. Loops back to get current state for _, added_spend in reversed(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) break await self.update_pool_config(False)
def get_next_interesting_coin_ids(spend: CoinSpend) -> List[bytes32]: # CoinSpend of one of the coins that we cared about. This coin was spent in a block, but might be in a reorg # If we return a value, it is a coin ID that we are also interested in (to support two transitions per block) coin: Optional[Coin] = get_most_recent_singleton_coin_from_coin_spend(spend) if coin is not None: return [coin.name()] return []
async def new_peak(self, peak: BlockRecord) -> 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.state: self.target_state = None raise ValueError("Internal error") if ( self.target_state.state in [FARMING_TO_POOL, SELF_POOLING] and pool_wallet_info.current.state == LEAVING_POOL ): leave_height = tip_height + pool_wallet_info.current.relative_lock_height curr: BlockRecord = peak while not curr.is_transaction_block: curr = self.wallet_state_manager.blockchain.block_record(curr.prev_hash) self.log.info(f"Last transaction block height: {curr.height} OK to leave at height {leave_height}") # Add some buffer (+2) to reduce chances of a reorg if curr.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 assert self.target_state.target_puzzle_hash is not None if self.target_state.state == SELF_POOLING: assert self.target_state.relative_lock_height == 0 assert self.target_state.pool_url is None elif self.target_state.state == FARMING_TO_POOL: assert self.target_state.relative_lock_height >= self.MINIMUM_RELATIVE_LOCK_HEIGHT assert self.target_state.pool_url is not None tx_record = await self.generate_travel_transaction(self.next_transaction_fee) await self.wallet_state_manager.add_pending_transaction(tx_record)
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 extra_data: Optional[PoolState] = None last_singleton_spend_height = uint32(0) while extra_data is None: full_spend: CoinSpend = all_spends[curr_spend_i] extra_data = solution_to_extra_data(full_spend) last_singleton_spend_height = uint32(history[curr_spend_i][0]) curr_spend_i -= 1 assert extra_data is not None current_inner = pool_state_to_inner_puzzle( extra_data, launcher_coin.name(), self.wallet_state_manager.constants.GENESIS_CHALLENGE, delayed_seconds, delayed_puzhash, ) return PoolWalletInfo( extra_data, self.target_state, launcher_coin, launcher_id, p2_singleton_puzzle_hash, current_inner, tip_singleton_coin.name(), last_singleton_spend_height, )
def test_pool_lifecycle(self): # START TESTS # Generate starting info key_lookup = KeyTool() sk: PrivateKey = PrivateKey.from_bytes( secret_exponent_for_index(1).to_bytes(32, "big"), ) pk: G1Element = G1Element.from_bytes( public_key_for_index(1, key_lookup)) starting_puzzle: Program = puzzle_for_pk(pk) starting_ph: bytes32 = starting_puzzle.get_tree_hash() # Get our starting standard coin created START_AMOUNT: uint64 = 1023 coin_db = CoinStore() time = CoinTimestamp(10000000, 1) coin_db.farm_coin(starting_ph, time, START_AMOUNT) starting_coin: Coin = next(coin_db.all_unspent_coins()) # LAUNCHING # Create the escaping inner puzzle GENESIS_CHALLENGE = bytes32.fromhex( "ccd5bb71183532bff220ba46c268991a3ff07eb358e8255a65c30a2dce0e5fbb") launcher_coin = singleton_top_layer.generate_launcher_coin( starting_coin, START_AMOUNT, ) DELAY_TIME = uint64(60800) DELAY_PH = starting_ph launcher_id = launcher_coin.name() relative_lock_height: uint32 = uint32(5000) # use a dummy pool state pool_state = PoolState( owner_pubkey=pk, pool_url="", relative_lock_height=relative_lock_height, state=3, # farming to pool target_puzzle_hash=starting_ph, version=1, ) # create a new dummy pool state for travelling target_pool_state = PoolState( owner_pubkey=pk, pool_url="", relative_lock_height=relative_lock_height, state=2, # Leaving pool target_puzzle_hash=starting_ph, version=1, ) # Standard format comment comment = Program.to([("p", bytes(pool_state)), ("t", DELAY_TIME), ("h", DELAY_PH)]) pool_wr_innerpuz: bytes32 = create_waiting_room_inner_puzzle( starting_ph, relative_lock_height, pk, launcher_id, GENESIS_CHALLENGE, DELAY_TIME, DELAY_PH, ) pool_wr_inner_hash = pool_wr_innerpuz.get_tree_hash() pooling_innerpuz: Program = create_pooling_inner_puzzle( starting_ph, pool_wr_inner_hash, pk, launcher_id, GENESIS_CHALLENGE, DELAY_TIME, DELAY_PH, ) # Driver tests assert is_pool_singleton_inner_puzzle(pooling_innerpuz) assert is_pool_singleton_inner_puzzle(pool_wr_innerpuz) assert get_pubkey_from_member_inner_puzzle(pooling_innerpuz) == pk # Generating launcher information conditions, launcher_coinsol = singleton_top_layer.launch_conditions_and_coinsol( starting_coin, pooling_innerpuz, comment, START_AMOUNT) # Creating solution for standard transaction delegated_puzzle: Program = puzzle_for_conditions(conditions) full_solution: Program = solution_for_conditions(conditions) starting_coinsol = CoinSpend( starting_coin, starting_puzzle, full_solution, ) # Create the spend bundle sig: G2Element = sign_delegated_puz(delegated_puzzle, starting_coin) spend_bundle = SpendBundle( [starting_coinsol, launcher_coinsol], sig, ) # Spend it! coin_db.update_coin_store_for_spend_bundle( spend_bundle, time, DEFAULT_CONSTANTS.MAX_BLOCK_COST_CLVM, ) # Test that we can retrieve the extra data assert get_delayed_puz_info_from_launcher_spend(launcher_coinsol) == ( DELAY_TIME, DELAY_PH) assert solution_to_pool_state(launcher_coinsol) == pool_state # TEST TRAVEL AFTER LAUNCH # fork the state fork_coin_db: CoinStore = copy.deepcopy(coin_db) post_launch_coinsol, _ = create_travel_spend( launcher_coinsol, launcher_coin, pool_state, target_pool_state, GENESIS_CHALLENGE, DELAY_TIME, DELAY_PH, ) # Spend it! fork_coin_db.update_coin_store_for_spend_bundle( SpendBundle([post_launch_coinsol], G2Element()), time, DEFAULT_CONSTANTS.MAX_BLOCK_COST_CLVM, ) # HONEST ABSORB time = CoinTimestamp(10000030, 2) # create the farming reward p2_singleton_puz: Program = create_p2_singleton_puzzle( SINGLETON_MOD_HASH, launcher_id, DELAY_TIME, DELAY_PH, ) p2_singleton_ph: bytes32 = p2_singleton_puz.get_tree_hash() assert uncurry_pool_waitingroom_inner_puzzle(pool_wr_innerpuz) == ( starting_ph, relative_lock_height, pk, p2_singleton_ph, ) assert launcher_id_to_p2_puzzle_hash(launcher_id, DELAY_TIME, DELAY_PH) == p2_singleton_ph assert get_seconds_and_delayed_puzhash_from_p2_singleton_puzzle( p2_singleton_puz) == (DELAY_TIME, DELAY_PH) coin_db.farm_coin(p2_singleton_ph, time, 1750000000000) coin_sols: List[CoinSpend] = create_absorb_spend( launcher_coinsol, pool_state, launcher_coin, 2, GENESIS_CHALLENGE, DELAY_TIME, DELAY_PH, # height ) # Spend it! coin_db.update_coin_store_for_spend_bundle( SpendBundle(coin_sols, G2Element()), time, DEFAULT_CONSTANTS.MAX_BLOCK_COST_CLVM, ) # ABSORB A NON EXISTENT REWARD (Negative test) last_coinsol: CoinSpend = list( filter( lambda e: e.coin.amount == START_AMOUNT, coin_sols, ))[0] coin_sols: List[CoinSpend] = create_absorb_spend( last_coinsol, pool_state, launcher_coin, 2, GENESIS_CHALLENGE, DELAY_TIME, DELAY_PH, # height ) # filter for only the singleton solution singleton_coinsol: CoinSpend = list( filter( lambda e: e.coin.amount == START_AMOUNT, coin_sols, ))[0] # Spend it and hope it fails! try: coin_db.update_coin_store_for_spend_bundle( SpendBundle([singleton_coinsol], G2Element()), time, DEFAULT_CONSTANTS.MAX_BLOCK_COST_CLVM, ) except BadSpendBundleError as e: assert str( e ) == "condition validation failure Err.ASSERT_ANNOUNCE_CONSUMED_FAILED" # SPEND A NON-REWARD P2_SINGLETON (Negative test) # create the dummy coin non_reward_p2_singleton = Coin( bytes32(32 * b"3"), p2_singleton_ph, uint64(1337), ) coin_db._add_coin_entry(non_reward_p2_singleton, time) # construct coin solution for the p2_singleton coin bad_coinsol = CoinSpend( non_reward_p2_singleton, p2_singleton_puz, Program.to([ pooling_innerpuz.get_tree_hash(), non_reward_p2_singleton.name(), ]), ) # Spend it and hope it fails! try: coin_db.update_coin_store_for_spend_bundle( SpendBundle([singleton_coinsol, bad_coinsol], G2Element()), time, DEFAULT_CONSTANTS.MAX_BLOCK_COST_CLVM, ) except BadSpendBundleError as e: assert str( e ) == "condition validation failure Err.ASSERT_ANNOUNCE_CONSUMED_FAILED" # ENTER WAITING ROOM # find the singleton singleton = get_most_recent_singleton_coin_from_coin_spend( last_coinsol) # get the relevant coin solution travel_coinsol, _ = create_travel_spend( last_coinsol, launcher_coin, pool_state, target_pool_state, GENESIS_CHALLENGE, DELAY_TIME, DELAY_PH, ) # Test that we can retrieve the extra data assert solution_to_pool_state(travel_coinsol) == target_pool_state # sign the serialized state data = Program.to(bytes(target_pool_state)).get_tree_hash() sig: G2Element = AugSchemeMPL.sign( sk, (data + singleton.name() + DEFAULT_CONSTANTS.AGG_SIG_ME_ADDITIONAL_DATA), ) # Spend it! coin_db.update_coin_store_for_spend_bundle( SpendBundle([travel_coinsol], sig), time, DEFAULT_CONSTANTS.MAX_BLOCK_COST_CLVM, ) # ESCAPE TOO FAST (Negative test) # find the singleton singleton = get_most_recent_singleton_coin_from_coin_spend( travel_coinsol) # get the relevant coin solution return_coinsol, _ = create_travel_spend( travel_coinsol, launcher_coin, target_pool_state, pool_state, GENESIS_CHALLENGE, DELAY_TIME, DELAY_PH, ) # sign the serialized target state sig = AugSchemeMPL.sign( sk, (data + singleton.name() + DEFAULT_CONSTANTS.AGG_SIG_ME_ADDITIONAL_DATA), ) # Spend it and hope it fails! try: coin_db.update_coin_store_for_spend_bundle( SpendBundle([return_coinsol], sig), time, DEFAULT_CONSTANTS.MAX_BLOCK_COST_CLVM, ) except BadSpendBundleError as e: assert str( e ) == "condition validation failure Err.ASSERT_HEIGHT_RELATIVE_FAILED" # ABSORB WHILE IN WAITING ROOM time = CoinTimestamp(10000060, 3) # create the farming reward coin_db.farm_coin(p2_singleton_ph, time, 1750000000000) # generate relevant coin solutions coin_sols: List[CoinSpend] = create_absorb_spend( travel_coinsol, target_pool_state, launcher_coin, 3, GENESIS_CHALLENGE, DELAY_TIME, DELAY_PH, # height ) # Spend it! coin_db.update_coin_store_for_spend_bundle( SpendBundle(coin_sols, G2Element()), time, DEFAULT_CONSTANTS.MAX_BLOCK_COST_CLVM, ) # LEAVE THE WAITING ROOM time = CoinTimestamp(20000000, 10000) # find the singleton singleton_coinsol: CoinSpend = list( filter( lambda e: e.coin.amount == START_AMOUNT, coin_sols, ))[0] singleton: Coin = get_most_recent_singleton_coin_from_coin_spend( singleton_coinsol) # get the relevant coin solution return_coinsol, _ = create_travel_spend( singleton_coinsol, launcher_coin, target_pool_state, pool_state, GENESIS_CHALLENGE, DELAY_TIME, DELAY_PH, ) # Test that we can retrieve the extra data assert solution_to_pool_state(return_coinsol) == pool_state # sign the serialized target state data = Program.to([ pooling_innerpuz.get_tree_hash(), START_AMOUNT, bytes(pool_state) ]).get_tree_hash() sig: G2Element = AugSchemeMPL.sign( sk, (data + singleton.name() + DEFAULT_CONSTANTS.AGG_SIG_ME_ADDITIONAL_DATA), ) # Spend it! coin_db.update_coin_store_for_spend_bundle( SpendBundle([return_coinsol], sig), time, DEFAULT_CONSTANTS.MAX_BLOCK_COST_CLVM, ) # ABSORB ONCE MORE FOR GOOD MEASURE time = CoinTimestamp(20000000, 10005) # create the farming reward coin_db.farm_coin(p2_singleton_ph, time, 1750000000000) coin_sols: List[CoinSpend] = create_absorb_spend( return_coinsol, pool_state, launcher_coin, 10005, GENESIS_CHALLENGE, DELAY_TIME, DELAY_PH, # height ) # Spend it! coin_db.update_coin_store_for_spend_bundle( SpendBundle(coin_sols, G2Element()), time, DEFAULT_CONSTANTS.MAX_BLOCK_COST_CLVM, )
async def collect_pool_rewards_loop(self): """ Iterates through the blockchain, looking for pool rewards, and claims them, creating a transaction to the pool's puzzle_hash. """ while True: try: if not self.blockchain_state["sync"]["synced"]: await asyncio.sleep(60) continue scan_phs: List[bytes32] = list( self.scan_p2_singleton_puzzle_hashes) peak_height = self.blockchain_state["peak"].height # Only get puzzle hashes with a certain number of confirmations or more, to avoid reorg issues coin_records: List[ CoinRecord] = await self.node_rpc_client.get_coin_records_by_puzzle_hashes( scan_phs, include_spent_coins=False, start_height=self.scan_start_height, ) self.log.info( f"Scanning for block rewards from {self.scan_start_height} to {peak_height}. " f"Found: {len(coin_records)}") ph_to_amounts: Dict[bytes32, int] = {} ph_to_coins: Dict[bytes32, List[CoinRecord]] = {} not_buried_amounts = 0 for cr in coin_records: if not cr.coinbase: self.log.info( f"Non coinbase coin: {cr.coin}, ignoring") continue if cr.confirmed_block_index > peak_height - self.confirmation_security_threshold: not_buried_amounts += cr.coin.amount continue if cr.coin.puzzle_hash not in ph_to_amounts: ph_to_amounts[cr.coin.puzzle_hash] = 0 ph_to_coins[cr.coin.puzzle_hash] = [] ph_to_amounts[cr.coin.puzzle_hash] += cr.coin.amount ph_to_coins[cr.coin.puzzle_hash].append(cr) # For each p2sph, get the FarmerRecords farmer_records = await self.store.get_farmer_records_for_p2_singleton_phs( set(ph for ph in ph_to_amounts.keys())) # For each singleton, create, submit, and save a claim transaction claimable_amounts = 0 not_claimable_amounts = 0 for rec in farmer_records: if rec.is_pool_member: claimable_amounts += ph_to_amounts[ rec.p2_singleton_puzzle_hash] else: not_claimable_amounts += ph_to_amounts[ rec.p2_singleton_puzzle_hash] if len(coin_records) > 0: self.log.info( f"Claimable amount: {claimable_amounts / (10 ** 12)}") self.log.info( f"Not claimable amount: {not_claimable_amounts / (10 ** 12)}" ) self.log.info( f"Not buried amounts: {not_buried_amounts / (10 ** 12)}" ) for rec in farmer_records: if rec.is_pool_member: singleton_tip: Optional[ Coin] = get_most_recent_singleton_coin_from_coin_spend( rec.singleton_tip) if singleton_tip is None: continue singleton_coin_record: Optional[ CoinRecord] = await self.node_rpc_client.get_coin_record_by_name( singleton_tip.name()) if singleton_coin_record is None: continue if singleton_coin_record.spent: self.log.warning( f"Singleton coin {singleton_coin_record.coin.name()} is spent, will not " f"claim rewards") continue spend_bundle = await create_absorb_transaction( self.node_rpc_client, rec, self.blockchain_state["peak"].height, ph_to_coins[rec.p2_singleton_puzzle_hash], self.constants.GENESIS_CHALLENGE, self.claim_fee, self.wallet_rpc_client, self.default_target_puzzle_hash, ) if spend_bundle is None: continue push_tx_response: Dict = await self.node_rpc_client.push_tx( spend_bundle) if push_tx_response["status"] == "SUCCESS": # TODO(pool): save transaction in records self.log.info( f"Submitted transaction successfully: {spend_bundle.name().hex()}" ) else: self.log.error( f"Error submitting transaction: {push_tx_response}" ) await asyncio.sleep(self.collect_pool_rewards_interval) except asyncio.CancelledError: self.log.info("Cancelled collect_pool_rewards_loop, closing") return except Exception as e: error_stack = traceback.format_exc() self.log.error( f"Unexpected error in collect_pool_rewards_loop: {e} {error_stack}" ) await asyncio.sleep(self.collect_pool_rewards_interval)
async def get_singleton_state( node_rpc_client: FullNodeRpcClient, launcher_id: bytes32, farmer_record: Optional[FarmerRecord], peak_height: uint32, confirmation_security_threshold: int, genesis_challenge: bytes32, ) -> Optional[Tuple[CoinSpend, PoolState, PoolState]]: try: if farmer_record is None: launcher_coin: Optional[ CoinRecord] = await node_rpc_client.get_coin_record_by_name( launcher_id) if launcher_coin is None: log.warning(f"Can not find genesis coin {launcher_id}") return None if not launcher_coin.spent: log.warning(f"Genesis coin {launcher_id} not spent") return None last_spend: Optional[CoinSpend] = await get_coin_spend( node_rpc_client, launcher_coin) delay_time, delay_puzzle_hash = get_delayed_puz_info_from_launcher_spend( last_spend) saved_state = solution_to_pool_state(last_spend) assert last_spend is not None and saved_state is not None else: last_spend = farmer_record.singleton_tip saved_state = farmer_record.singleton_tip_state delay_time = farmer_record.delay_time delay_puzzle_hash = farmer_record.delay_puzzle_hash saved_spend = last_spend last_not_none_state: PoolState = saved_state assert last_spend is not None last_coin_record: Optional[ CoinRecord] = await node_rpc_client.get_coin_record_by_name( last_spend.coin.name()) assert last_coin_record is not None while True: # Get next coin solution next_coin: Optional[ Coin] = get_most_recent_singleton_coin_from_coin_spend( last_spend) if next_coin is None: # This means the singleton is invalid return None next_coin_record: Optional[ CoinRecord] = await node_rpc_client.get_coin_record_by_name( next_coin.name()) assert next_coin_record is not None if not next_coin_record.spent: if not validate_puzzle_hash( launcher_id, delay_puzzle_hash, delay_time, last_not_none_state, next_coin_record.coin.puzzle_hash, genesis_challenge, ): log.warning( f"Invalid singleton puzzle_hash for {launcher_id}") return None break last_spend: Optional[CoinSpend] = await get_coin_spend( node_rpc_client, next_coin_record) assert last_spend is not None pool_state: Optional[PoolState] = solution_to_pool_state( last_spend) if pool_state is not None: last_not_none_state = pool_state if peak_height - confirmation_security_threshold >= next_coin_record.spent_block_index: # There is a state transition, and it is sufficiently buried saved_spend = last_spend saved_state = last_not_none_state return saved_spend, saved_state, last_not_none_state except Exception as e: log.error(f"Error getting singleton: {e}") return None