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 = CoinSolution( 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_extra_data(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[CoinSolution] = 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: CoinSolution = list( filter( lambda e: e.coin.amount == START_AMOUNT, coin_sols, ))[0] coin_sols: List[CoinSolution] = 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: CoinSolution = 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 = CoinSolution( 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_solution( 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_extra_data(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_solution( 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[CoinSolution] = 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: CoinSolution = list( filter( lambda e: e.coin.amount == START_AMOUNT, coin_sols, ))[0] singleton: Coin = get_most_recent_singleton_coin_from_coin_solution( 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_extra_data(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[CoinSolution] = 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 claim_pool_rewards(self, fee: uint64) -> 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." ) unspent_coin_records: List[CoinRecord] = list( 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 for coin_record in unspent_coin_records: if coin_record.coin not in coin_to_height_farmed: continue if len(all_spends) >= 100: # Limit the total number of spends, so it fits into the 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: raise ValueError("Nothing to claim, no unspent coinbase rewards") # No signatures are required to absorb spend_bundle: SpendBundle = SpendBundle(all_spends, G2Element()) absorb_transaction: TransactionRecord = TransactionRecord( confirmed_at_height=uint32(0), created_at_time=uint64(int(time.time())), to_puzzle_hash=current_state.current.target_puzzle_hash, amount=uint64(total_amount), fee_amount=fee, confirmed=False, sent=uint32(0), spend_bundle=spend_bundle, additions=spend_bundle.additions(), removals=spend_bundle.removals(), wallet_id=uint32(self.wallet_id), sent_to=[], memos=[], trade_id=None, type=uint32(TransactionType.OUTGOING_TX.value), name=spend_bundle.name(), ) await self.wallet_state_manager.add_pending_transaction(absorb_transaction) return absorb_transaction
async def generate_travel_transaction(self, fee: uint64) -> 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 in [FARMING_TO_POOL]: 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 = tip.additions()[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) pk_bytes: bytes = bytes(pubkey_as_program.as_atom()) assert len(pk_bytes) == 48 owner_pubkey = G1Element.from_bytes(pk_bytes) assert owner_pubkey == pool_wallet_info.current.owner_pubkey elif is_pool_waitingroom_inner_puzzle(inner_puzzle): ( target_puzzle_hash, # payout_puzzle_hash relative_lock_height, owner_pubkey, p2_singleton_hash, ) = uncurry_pool_waitingroom_inner_puzzle(inner_puzzle) pk_bytes = bytes(owner_pubkey.as_atom()) assert len(pk_bytes) == 48 assert owner_pubkey == pool_wallet_info.current.owner_pubkey else: raise RuntimeError("Invalid state") 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 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(), ) return tx_record
async def add_farmer(self, request: PostFarmerRequest) -> Dict: async with self.store.lock: farmer_record: Optional[ FarmerRecord] = await self.store.get_farmer_record( request.payload.launcher_id) if farmer_record is not None: return error_dict( PoolErrorCode.FARMER_ALREADY_KNOWN, f"Farmer with launcher_id {request.payload.launcher_id} already known.", ) singleton_state_tuple: Optional[Tuple[ CoinSolution, PoolState]] = await self.get_and_validate_singleton_state( request.payload.launcher_id) if singleton_state_tuple is None: return error_dict(PoolErrorCode.INVALID_SINGLETON, f"Invalid singleton, or not a pool member") last_spend, last_state = singleton_state_tuple if (request.payload.suggested_difficulty is None or request.payload.suggested_difficulty < self.min_difficulty): difficulty: uint64 = self.default_difficulty else: difficulty = request.payload.suggested_difficulty if len(hexstr_to_bytes(request.payload.payout_instructions)) != 32: return error_dict( PoolErrorCode.INVALID_PAYOUT_INSTRUCTIONS, f"Payout instructions must be an xch address for this pool.", ) if not AugSchemeMPL.verify(last_state.owner_pubkey, request.payload.get_hash(), request.signature): return error_dict(PoolErrorCode.INVALID_SIGNATURE, f"Invalid signature") launcher_coin: Optional[ CoinRecord] = await self.node_rpc_client.get_coin_record_by_name( request.payload.launcher_id) assert launcher_coin is not None and launcher_coin.spent launcher_solution: Optional[CoinSolution] = await get_coin_spend( self.node_rpc_client, launcher_coin) delay_time, delay_puzzle_hash = get_delayed_puz_info_from_launcher_spend( launcher_solution) if delay_time < 3600: return error_dict( PoolErrorCode.DELAY_TIME_TOO_SHORT, f"Delay time too short, must be at least 1 hour") p2_singleton_puzzle_hash = launcher_id_to_p2_puzzle_hash( request.payload.launcher_id, delay_time, delay_puzzle_hash) farmer_record = FarmerRecord( request.payload.launcher_id, p2_singleton_puzzle_hash, delay_time, delay_puzzle_hash, request.payload.authentication_public_key, last_spend, last_state, uint64(0), difficulty, request.payload.payout_instructions, True, ) self.scan_p2_singleton_puzzle_hashes.add(p2_singleton_puzzle_hash) await self.store.add_farmer_record(farmer_record) return PostFarmerResponse(self.welcome_message).to_json_dict()
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[CoinSolution, 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_solution: Optional[CoinSolution] = await get_coin_spend( node_rpc_client, launcher_coin) delay_time, delay_puzzle_hash = get_delayed_puz_info_from_launcher_spend( last_solution) saved_state = solution_to_extra_data(last_solution) assert last_solution is not None and saved_state is not None else: last_solution = 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_solution = last_solution last_not_none_state: PoolState = saved_state assert last_solution is not None last_coin_record: Optional[ CoinRecord] = await node_rpc_client.get_coin_record_by_name( last_solution.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_solution( last_solution) 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 await 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_solution: Optional[CoinSolution] = await get_coin_spend( node_rpc_client, next_coin_record) assert last_solution is not None pool_state: Optional[PoolState] = solution_to_extra_data( last_solution) 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_solution = last_solution saved_state = last_not_none_state return saved_solution, saved_state, last_not_none_state except Exception as e: log.error(f"Error getting singleton: {e}") return None