diff --git a/chia/_tests/wallet/rpc/test_wallet_rpc.py b/chia/_tests/wallet/rpc/test_wallet_rpc.py index febaaa715f2d..dee875174888 100644 --- a/chia/_tests/wallet/rpc/test_wallet_rpc.py +++ b/chia/_tests/wallet/rpc/test_wallet_rpc.py @@ -105,6 +105,7 @@ from chia.wallet.wallet_node import WalletNode from chia.wallet.wallet_protocol import WalletProtocol from chia.wallet.wallet_request_types import ( + Addition, AddKey, CancelOffer, CancelOffers, @@ -118,6 +119,7 @@ ClawbackPuzzleDecoratorOverride, CombineCoins, CreateOfferForIDs, + CreateSignedTransaction, DefaultCAT, DeleteKey, DeleteNotifications, @@ -294,21 +296,18 @@ async def wallet_rpc_environment( yield WalletRpcTestEnvironment(wallet_bundle_1, wallet_bundle_2, node_bundle) -async def create_tx_outputs(wallet: Wallet, output_args: list[tuple[int, Optional[list[str]]]]) -> list[dict[str, Any]]: - outputs = [] +async def create_tx_outputs(wallet: Wallet, output_args: list[tuple[int, Optional[list[str]]]]) -> list[Addition]: async with wallet.wallet_state_manager.new_action_scope(DEFAULT_TX_CONFIG, push=True) as action_scope: - for args in output_args: - output = { - "amount": uint64(args[0]), - "puzzle_hash": await action_scope.get_puzzle_hash( + return [ + Addition( + amount=uint64(args[0]), + puzzle_hash=await action_scope.get_puzzle_hash( wallet.wallet_state_manager, override_reuse_puzhash_with=False ), - } - if args[1] is not None: - assert len(args[1]) > 0 - output["memos"] = args[1] - outputs.append(output) - return outputs + memos=None if args[1] is None or len(args[1]) == 0 else args[1], + ) + for args in output_args + ] async def assert_wallet_types(client: WalletRpcClient, expected: dict[WalletType, int]) -> None: @@ -323,20 +322,20 @@ async def assert_wallet_types(client: WalletRpcClient, expected: dict[WalletType def assert_tx_amounts( tx: TransactionRecord, - outputs: list[dict[str, Any]], + outputs: list[Addition], *, amount_fee: uint64, change_expected: bool, is_cat: bool = False, ) -> None: assert tx.fee_amount == amount_fee - assert tx.amount == sum(output["amount"] for output in outputs) + assert tx.amount == sum(output.amount for output in outputs) expected_additions = len(outputs) + 1 if change_expected else len(outputs) assert len(tx.additions) == expected_additions addition_amounts = [addition.amount for addition in tx.additions] removal_amounts = [removal.amount for removal in tx.removals] for output in outputs: - assert output["amount"] in addition_amounts + assert output.amount in addition_amounts if is_cat: assert (sum(removal_amounts) - sum(addition_amounts)) == 0 else: @@ -486,9 +485,8 @@ async def test_push_transactions(wallet_rpc_environment: WalletRpcTestEnvironmen tx = ( await client.create_signed_transactions( - outputs, + CreateSignedTransaction(additions=outputs, fee=uint64(100)), tx_config=DEFAULT_TX_CONFIG, - fee=uint64(100), ) ).signed_tx @@ -654,11 +652,11 @@ async def test_create_signed_transaction( await farm_transaction_block(full_node_api, wallet_1_node) outputs = await create_tx_outputs(wallet_2, output_args) - amount_outputs = sum(output["amount"] for output in outputs) + amount_outputs = sum(output.amount for output in outputs) amount_fee = uint64(fee) if is_cat: - amount_total = amount_outputs + amount_total: int = amount_outputs else: amount_total = amount_outputs + amount_fee @@ -666,7 +664,9 @@ async def test_create_signed_transaction( if select_coin: select_coins_response = await wallet_1_rpc.select_coins( SelectCoins.from_coin_selection_config( - amount=amount_total, wallet_id=uint32(wallet_id), coin_selection_config=DEFAULT_COIN_SELECTION_CONFIG + amount=uint64(amount_total), + wallet_id=uint32(wallet_id), + coin_selection_config=DEFAULT_COIN_SELECTION_CONFIG, ) ) assert len(select_coins_response.coins) == 1 @@ -674,15 +674,17 @@ async def test_create_signed_transaction( txs = ( await wallet_1_rpc.create_signed_transactions( - outputs, - coins=[selected_coin] if selected_coin is not None else [], - fee=amount_fee, - wallet_id=wallet_id, + CreateSignedTransaction( + additions=outputs, + coins=[selected_coin] if selected_coin is not None else None, + fee=amount_fee, + wallet_id=uint32(wallet_id), + push=True, + ), # shouldn't actually block it tx_config=DEFAULT_TX_CONFIG.override( excluded_coin_amounts=[uint64(selected_coin.amount)] if selected_coin is not None else [], ), - push=True, ) ).transactions change_expected = not selected_coin or selected_coin.amount - amount_total > 0 @@ -711,18 +713,18 @@ async def test_create_signed_transaction( addition_dict: dict[bytes32, Coin] = {addition.name(): addition for addition in additions} memo_dictionary: dict[bytes32, list[bytes]] = compute_memos(spend_bundle) for output in outputs: - if "memos" in output: + if output.memos is not None: found: bool = False for addition_id, addition in addition_dict.items(): if ( is_cat - and addition.amount == output["amount"] - and memo_dictionary[addition_id][0] == output["puzzle_hash"] - and memo_dictionary[addition_id][1:] == [memo.encode() for memo in output["memos"]] + and addition.amount == output.amount + and memo_dictionary[addition_id][0] == output.puzzle_hash + and memo_dictionary[addition_id][1:] == [memo.encode() for memo in output.memos] ) or ( - addition.amount == output["amount"] - and addition.puzzle_hash == output["puzzle_hash"] - and memo_dictionary[addition_id] == [memo.encode() for memo in output["memos"]] + addition.amount == output.amount + and addition.puzzle_hash == output.puzzle_hash + and memo_dictionary[addition_id] == [memo.encode() for memo in output.memos] ): found = True assert found @@ -755,7 +757,9 @@ async def test_create_signed_transaction_with_coin_announcement( outputs = await create_tx_outputs(wallet_2, [(signed_tx_amount, None)]) tx_res: TransactionRecord = ( await client.create_signed_transactions( - outputs, tx_config=DEFAULT_TX_CONFIG, extra_conditions=(*tx_coin_announcements,) + CreateSignedTransaction(additions=outputs), + tx_config=DEFAULT_TX_CONFIG, + extra_conditions=(*tx_coin_announcements,), ) ).signed_tx assert_tx_amounts(tx_res, outputs, amount_fee=uint64(0), change_expected=True) @@ -789,7 +793,9 @@ async def test_create_signed_transaction_with_puzzle_announcement( outputs = await create_tx_outputs(wallet_2, [(signed_tx_amount, None)]) tx_res = ( await client.create_signed_transactions( - outputs, tx_config=DEFAULT_TX_CONFIG, extra_conditions=(*tx_puzzle_announcements,) + CreateSignedTransaction(additions=outputs), + tx_config=DEFAULT_TX_CONFIG, + extra_conditions=(*tx_puzzle_announcements,), ) ).signed_tx assert_tx_amounts(tx_res, outputs, amount_fee=uint64(0), change_expected=True) @@ -816,7 +822,7 @@ async def it_does_not_include_the_excluded_coins() -> None: tx = ( await wallet_1_rpc.create_signed_transactions( - outputs, + CreateSignedTransaction(additions=outputs), DEFAULT_TX_CONFIG.override( excluded_coin_ids=[c.name() for c in select_coins_response.coins], ), @@ -839,7 +845,7 @@ async def it_throws_an_error_when_all_spendable_coins_are_excluded() -> None: with pytest.raises(ValueError): await wallet_1_rpc.create_signed_transactions( - outputs, + CreateSignedTransaction(additions=outputs), DEFAULT_TX_CONFIG.override( excluded_coin_ids=[c.name() for c in select_coins_response.coins], ), @@ -992,13 +998,13 @@ async def test_send_transaction_multi(wallet_rpc_environment: WalletRpcTestEnvir ) ) # we want a coin that won't be selected by default outputs = await create_tx_outputs(wallet_2, [(uint64(1), ["memo_1"]), (uint64(2), ["memo_2"])]) - amount_outputs = sum(output["amount"] for output in outputs) + amount_outputs = sum(output.amount for output in outputs) amount_fee = uint64(amount_outputs + 1) send_tx_res: TransactionRecord = ( await client.send_transaction_multi( 1, - outputs, + [{**output.to_json_dict(), "puzzle_hash": output.puzzle_hash} for output in outputs], DEFAULT_TX_CONFIG, coins=select_coins_response.coins, fee=amount_fee, @@ -1021,7 +1027,8 @@ async def test_send_transaction_multi(wallet_rpc_environment: WalletRpcTestEnvir memos = tx_confirmed.memos assert len(memos) == len(outputs) for output in outputs: - assert [output["memos"][0].encode()] in memos.values() + assert output.memos is not None + assert [output.memos[0].encode()] in memos.values() spend_bundle = send_tx_res.spend_bundle assert spend_bundle is not None for key in memos.keys(): diff --git a/chia/_tests/wallet/vc_wallet/test_vc_wallet.py b/chia/_tests/wallet/vc_wallet/test_vc_wallet.py index 0d834fe2695f..5925de821feb 100644 --- a/chia/_tests/wallet/vc_wallet/test_vc_wallet.py +++ b/chia/_tests/wallet/vc_wallet/test_vc_wallet.py @@ -31,7 +31,9 @@ from chia.wallet.wallet import Wallet from chia.wallet.wallet_node import WalletNode from chia.wallet.wallet_request_types import ( + Addition, CATSpend, + CreateSignedTransaction, GetTransactions, GetWallets, VCAddProofs, @@ -70,14 +72,16 @@ async def mint_cr_cat( await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=20) tx = ( await client_0.create_signed_transactions( - [ - { - "puzzle_hash": cat_puzzle.get_tree_hash(), - "amount": CAT_AMOUNT_0, - } - ], + CreateSignedTransaction( + wallet_id=uint32(1), + additions=[ + Addition( + puzzle_hash=cat_puzzle.get_tree_hash(), + amount=CAT_AMOUNT_0, + ) + ], + ), tx_config, - wallet_id=1, ) ).signed_tx spend_bundle = tx.spend_bundle diff --git a/chia/wallet/wallet_request_types.py b/chia/wallet/wallet_request_types.py index 06dc4f74954e..c593cb725b19 100644 --- a/chia/wallet/wallet_request_types.py +++ b/chia/wallet/wallet_request_types.py @@ -16,8 +16,15 @@ from chia.types.blockchain_format.program import Program from chia.types.coin_record import CoinRecord from chia.util.byte_types import hexstr_to_bytes +from chia.util.hash import std_hash from chia.util.streamable import Streamable, streamable -from chia.wallet.conditions import Condition, ConditionValidTimes, conditions_to_json_dicts +from chia.wallet.conditions import ( + AssertCoinAnnouncement, + AssertPuzzleAnnouncement, + Condition, + ConditionValidTimes, + conditions_to_json_dicts, +) from chia.wallet.nft_wallet.nft_info import NFTInfo from chia.wallet.notification_store import Notification from chia.wallet.puzzle_drivers import PuzzleInfo, Solver @@ -1434,7 +1441,7 @@ class CombineCoinsResponse(TransactionEndpointResponse): pass -# utility for CATSpend +# utility for CATSpend/CreateSignedTransaction # unfortunate that we can't use CreateCoin but the memos are taken as strings not bytes @streamable @dataclass(frozen=True) @@ -1874,6 +1881,63 @@ class SendTransactionMultiResponse(TransactionEndpointResponse): transaction_id: bytes32 +@streamable +@dataclass(frozen=True) +class CSTCoinAnnouncement(Streamable): + coin_id: bytes32 + message: bytes + + +@streamable +@dataclass(frozen=True) +class CSTPuzzleAnnouncement(Streamable): + puzzle_hash: bytes32 + message: bytes + + +@streamable +@dataclass(frozen=True) +class CreateSignedTransaction(TransactionEndpointRequest): + additions: list[Addition] = field(default_factory=default_raise) + wallet_id: Optional[uint32] = None + coins: Optional[list[Coin]] = None + morph_bytes: Optional[bytes] = None + coin_announcements: list[CSTCoinAnnouncement] = field(default_factory=list) + puzzle_announcements: list[CSTPuzzleAnnouncement] = field(default_factory=list) + + def __post_init__(self) -> None: + if len(self.additions) < 1: + raise ValueError("Must have at least one addition") + super().__post_init__() + + @property + def coin_set(self) -> Optional[set[Coin]]: + if self.coins is None: + return None + else: + return set(self.coins) + + @property + def asserted_coin_announcements(self) -> tuple[AssertCoinAnnouncement, ...]: + return tuple( + AssertCoinAnnouncement( + asserted_id=ca.coin_id, + asserted_msg=(ca.message if self.morph_bytes is None else std_hash(self.morph_bytes + ca.message)), + ) + for ca in self.coin_announcements + ) + + @property + def asserted_puzzle_announcements(self) -> tuple[AssertPuzzleAnnouncement, ...]: + return tuple( + AssertPuzzleAnnouncement( + asserted_ph=pa.puzzle_hash, + asserted_msg=(pa.message if self.morph_bytes is None else std_hash(self.morph_bytes + pa.message)), + ) + for pa in self.puzzle_announcements + ) + + @streamable @dataclass(frozen=True) class CreateSignedTransactionsResponse(TransactionEndpointResponse): diff --git a/chia/wallet/wallet_rpc_api.py b/chia/wallet/wallet_rpc_api.py index 15bcf7ddf8ed..742a38441540 100644 --- a/chia/wallet/wallet_rpc_api.py +++ b/chia/wallet/wallet_rpc_api.py @@ -29,7 +29,6 @@ from chia.util.byte_types import hexstr_to_bytes from chia.util.config import load_config from chia.util.errors import KeychainIsLocked -from chia.util.hash import std_hash from chia.util.keychain import bytes_to_mnemonic, generate_mnemonic from chia.util.path import path_from_root from chia.util.streamable import Streamable, UInt32Range, streamable @@ -37,9 +36,8 @@ from chia.wallet.cat_wallet.cat_constants import DEFAULT_CATS from chia.wallet.cat_wallet.cat_info import CRCATInfo from chia.wallet.cat_wallet.cat_wallet import CATWallet +from chia.wallet.cat_wallet.r_cat_wallet import RCATWallet from chia.wallet.conditions import ( - AssertCoinAnnouncement, - AssertPuzzleAnnouncement, Condition, ConditionValidTimes, CreateCoin, @@ -136,6 +134,8 @@ CreateNewDLResponse, CreateOfferForIDs, CreateOfferForIDsResponse, + CreateSignedTransaction, + CreateSignedTransactionsResponse, DefaultCAT, DeleteKey, DeleteNotifications, @@ -3502,88 +3502,59 @@ async def get_farmed_amount(self, request: dict[str, Any]) -> EndpointResult: } @tx_endpoint(push=False) + @marshal async def create_signed_transaction( self, - request: dict[str, Any], + request: CreateSignedTransaction, action_scope: WalletActionScope, extra_conditions: tuple[Condition, ...] = tuple(), hold_lock: bool = True, - ) -> EndpointResult: - if "wallet_id" in request: - wallet_id = uint32(request["wallet_id"]) - wallet = self.service.wallet_state_manager.wallets[wallet_id] + ) -> CreateSignedTransactionsResponse: + if request.wallet_id is not None: + wallet = self.service.wallet_state_manager.wallets[request.wallet_id] else: wallet = self.service.wallet_state_manager.main_wallet - assert isinstance(wallet, (Wallet, CATWallet, CRCATWallet)), ( + assert isinstance(wallet, (Wallet, CATWallet, CRCATWallet, RCATWallet)), ( "create_signed_transaction only works for standard and CAT wallets" ) - if "additions" not in request or len(request["additions"]) < 1: + if len(request.additions) < 1: raise ValueError("Specify additions list") - additions: list[dict[str, Any]] = request["additions"] - amount_0: uint64 = uint64(additions[0]["amount"]) + amount_0: uint64 = uint64(request.additions[0].amount) assert amount_0 <= self.service.constants.MAX_COIN_AMOUNT - puzzle_hash_0 = bytes32.from_hexstr(additions[0]["puzzle_hash"]) + puzzle_hash_0 = request.additions[0].puzzle_hash if len(puzzle_hash_0) != 32: raise ValueError(f"Address must be 32 bytes. {puzzle_hash_0.hex()}") - memos_0 = [] if "memos" not in additions[0] else [mem.encode("utf-8") for mem in additions[0]["memos"]] + memos_0 = ( + [] if request.additions[0].memos is None else [mem.encode("utf-8") for mem in request.additions[0].memos] + ) additional_outputs: list[CreateCoin] = [] - for addition in additions[1:]: - receiver_ph = bytes32.from_hexstr(addition["puzzle_hash"]) - if len(receiver_ph) != 32: - raise ValueError(f"Address must be 32 bytes. {receiver_ph.hex()}") - amount = uint64(addition["amount"]) - if amount > self.service.constants.MAX_COIN_AMOUNT: + for addition in request.additions[1:]: + if addition.amount > self.service.constants.MAX_COIN_AMOUNT: raise ValueError(f"Coin amount cannot exceed {self.service.constants.MAX_COIN_AMOUNT}") - memos = [] if "memos" not in addition else [mem.encode("utf-8") for mem in addition["memos"]] - additional_outputs.append(CreateCoin(receiver_ph, amount, memos)) - - fee: uint64 = uint64(request.get("fee", 0)) - - coins = None - if "coins" in request and len(request["coins"]) > 0: - coins = {Coin.from_json_dict(coin_json) for coin_json in request["coins"]} + memos = [] if addition.memos is None else [mem.encode("utf-8") for mem in addition.memos] + additional_outputs.append(CreateCoin(addition.puzzle_hash, addition.amount, memos)) - async def _generate_signed_transaction() -> EndpointResult: + async def _generate_signed_transaction() -> CreateSignedTransactionsResponse: await wallet.generate_signed_transaction( [amount_0] + [output.amount for output in additional_outputs], [bytes32(puzzle_hash_0)] + [output.puzzle_hash for output in additional_outputs], action_scope, - fee, - coins=coins, + request.fee, + coins=request.coin_set, memos=[memos_0] + [output.memos if output.memos is not None else [] for output in additional_outputs], extra_conditions=( *extra_conditions, - *( - AssertCoinAnnouncement( - asserted_id=bytes32.from_hexstr(ca["coin_id"]), - asserted_msg=( - hexstr_to_bytes(ca["message"]) - if request.get("morph_bytes") is None - else std_hash(hexstr_to_bytes(ca["morph_bytes"]) + hexstr_to_bytes(ca["message"])) - ), - ) - for ca in request.get("coin_announcements", []) - ), - *( - AssertPuzzleAnnouncement( - asserted_ph=bytes32.from_hexstr(pa["puzzle_hash"]), - asserted_msg=( - hexstr_to_bytes(pa["message"]) - if request.get("morph_bytes") is None - else std_hash(hexstr_to_bytes(pa["morph_bytes"]) + hexstr_to_bytes(pa["message"])) - ), - ) - for pa in request.get("puzzle_announcements", []) - ), + *request.asserted_coin_announcements, + *request.asserted_puzzle_announcements, ), ) - # tx_endpoint wrapper will take care of this - return {"signed_txs": None, "signed_tx": None, "transactions": None} + # tx_endpoint wrapper will take care of these default values + return CreateSignedTransactionsResponse([], [], [], REPLACEABLE_TRANSACTION_RECORD) if hold_lock: async with self.service.wallet_state_manager.lock: diff --git a/chia/wallet/wallet_rpc_client.py b/chia/wallet/wallet_rpc_client.py index 4d10114d7c18..c5033b893cf6 100644 --- a/chia/wallet/wallet_rpc_client.py +++ b/chia/wallet/wallet_rpc_client.py @@ -43,6 +43,7 @@ CreateNewDLResponse, CreateOfferForIDs, CreateOfferForIDsResponse, + CreateSignedTransaction, CreateSignedTransactionsResponse, DeleteKey, DeleteNotifications, @@ -396,40 +397,17 @@ async def get_farmed_amount(self, include_pool_rewards: bool = False) -> dict[st async def create_signed_transactions( self, - additions: list[dict[str, Any]], + request: CreateSignedTransaction, tx_config: TXConfig, - coins: Optional[list[Coin]] = None, - fee: uint64 = uint64(0), - wallet_id: Optional[int] = None, extra_conditions: tuple[Condition, ...] = tuple(), timelock_info: ConditionValidTimes = ConditionValidTimes(), - push: bool = False, ) -> CreateSignedTransactionsResponse: - # Converts bytes to hex for puzzle hashes - additions_hex = [] - for ad in additions: - additions_hex.append({"amount": ad["amount"], "puzzle_hash": ad["puzzle_hash"].hex()}) - if "memos" in ad: - additions_hex[-1]["memos"] = ad["memos"] - - request = { - "additions": additions_hex, - "fee": fee, - "extra_conditions": conditions_to_json_dicts(extra_conditions), - "push": push, - **tx_config.to_json_dict(), - **timelock_info.to_json_dict(), - } - - if coins is not None and len(coins) > 0: - coins_json = [c.to_json_dict() for c in coins] - request["coins"] = coins_json - - if wallet_id: - request["wallet_id"] = wallet_id - - response = await self.fetch("create_signed_transaction", request) - return json_deserialize_with_clvm_streamable(response, CreateSignedTransactionsResponse) + return CreateSignedTransactionsResponse.from_json_dict( + await self.fetch( + "create_signed_transaction", + request.json_serialize_for_transport(tx_config, extra_conditions, timelock_info), + ) + ) async def select_coins(self, request: SelectCoins) -> SelectCoinsResponse: return SelectCoinsResponse.from_json_dict(await self.fetch("select_coins", request.to_json_dict()))