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)
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, )
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 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