diff --git a/chia/_tests/cmds/cmd_test_utils.py b/chia/_tests/cmds/cmd_test_utils.py index 4a732aeda9ed..b7753cafa311 100644 --- a/chia/_tests/cmds/cmd_test_utils.py +++ b/chia/_tests/cmds/cmd_test_utils.py @@ -13,7 +13,7 @@ import chia.cmds.wallet_funcs from chia._tests.cmds.testing_classes import create_test_block_record -from chia._tests.cmds.wallet.test_consts import STD_TX, STD_UTX, get_bytes32 +from chia._tests.cmds.wallet.test_consts import get_bytes32 from chia.cmds.chia import cli as chia_cli from chia.cmds.cmds_util import _T_RpcClient, node_config_section_names from chia.consensus.default_constants import DEFAULT_CONSTANTS @@ -30,7 +30,6 @@ from chia.wallet.nft_wallet.nft_wallet import NFTWallet from chia.wallet.transaction_record import TransactionRecord from chia.wallet.util.transaction_type import TransactionType -from chia.wallet.util.tx_config import TXConfig from chia.wallet.util.wallet_types import WalletType from chia.wallet.wallet_request_types import ( CATAssetIDToName, @@ -46,7 +45,6 @@ NFTCalculateRoyaltiesResponse, NFTGetInfo, NFTGetInfoResponse, - SendTransactionMultiResponse, SignMessageByAddress, SignMessageByAddressResponse, SignMessageByID, @@ -235,44 +233,6 @@ async def nft_calculate_royalties( ) ) - async def send_transaction_multi( - self, - wallet_id: int, - additions: list[dict[str, object]], - tx_config: TXConfig, - coins: Optional[list[Coin]] = None, - fee: uint64 = uint64(0), - push: bool = True, - timelock_info: ConditionValidTimes = ConditionValidTimes(), - ) -> SendTransactionMultiResponse: - self.add_to_log("send_transaction_multi", (wallet_id, additions, tx_config, coins, fee, push, timelock_info)) - name = bytes32([2] * 32) - return SendTransactionMultiResponse( - [STD_UTX], - [STD_TX], - TransactionRecord( - confirmed_at_height=uint32(1), - created_at_time=uint64(1234), - to_puzzle_hash=bytes32([1] * 32), - to_address=encode_puzzle_hash(bytes32([1] * 32), "xch"), - amount=uint64(12345678), - fee_amount=uint64(1234567), - confirmed=False, - sent=uint32(0), - spend_bundle=WalletSpendBundle([], G2Element()), - additions=[Coin(bytes32([1] * 32), bytes32([2] * 32), uint64(12345678))], - removals=[Coin(bytes32([2] * 32), bytes32([4] * 32), uint64(12345678))], - wallet_id=uint32(1), - sent_to=[("aaaaa", uint8(1), None)], - trade_id=None, - type=uint32(TransactionType.OUTGOING_TX.value), - name=name, - memos={bytes32([3] * 32): [bytes([4] * 32)]}, - valid_times=ConditionValidTimes(), - ), - name, - ) - @dataclass class TestFullNodeRpcClient(TestRpcClient): diff --git a/chia/_tests/wallet/rpc/test_wallet_rpc.py b/chia/_tests/wallet/rpc/test_wallet_rpc.py index dee875174888..65033b8e3f5d 100644 --- a/chia/_tests/wallet/rpc/test_wallet_rpc.py +++ b/chia/_tests/wallet/rpc/test_wallet_rpc.py @@ -162,6 +162,7 @@ SelectCoins, SendNotification, SendTransaction, + SendTransactionMulti, SetWalletResyncOnStartup, SpendClawbackCoins, SplitCoins, @@ -1003,11 +1004,14 @@ async def test_send_transaction_multi(wallet_rpc_environment: WalletRpcTestEnvir send_tx_res: TransactionRecord = ( await client.send_transaction_multi( - 1, - [{**output.to_json_dict(), "puzzle_hash": output.puzzle_hash} for output in outputs], - DEFAULT_TX_CONFIG, - coins=select_coins_response.coins, - fee=amount_fee, + SendTransactionMulti( + wallet_id=uint32(1), + additions=outputs, + coins=select_coins_response.coins, + fee=amount_fee, + push=True, + ), + tx_config=DEFAULT_TX_CONFIG, ) ).transaction spend_bundle = send_tx_res.spend_bundle @@ -1503,10 +1507,11 @@ async def test_offer_endpoints(wallet_environments: WalletTestFramework, wallet_ # Creates a wallet for the same CAT on wallet_2 and send 4 CAT from wallet_1 to it await env_2.rpc_client.create_wallet_for_existing_cat(cat_asset_id) wallet_2_address = (await env_2.rpc_client.get_next_address(GetNextAddress(cat_wallet_id, False))).address - adds = [{"puzzle_hash": decode_puzzle_hash(wallet_2_address), "amount": uint64(4), "memos": ["the cat memo"]}] + adds = [Addition(puzzle_hash=decode_puzzle_hash(wallet_2_address), amount=uint64(4), memos=["the cat memo"])] tx_res = ( await env_1.rpc_client.send_transaction_multi( - cat_wallet_id, additions=adds, tx_config=wallet_environments.tx_config, fee=uint64(0) + SendTransactionMulti(wallet_id=uint32(cat_wallet_id), additions=adds, fee=uint64(0), push=True), + tx_config=wallet_environments.tx_config, ) ).transaction spend_bundle = tx_res.spend_bundle diff --git a/chia/wallet/wallet_request_types.py b/chia/wallet/wallet_request_types.py index c593cb725b19..ee4b6322464b 100644 --- a/chia/wallet/wallet_request_types.py +++ b/chia/wallet/wallet_request_types.py @@ -3,7 +3,7 @@ import sys from dataclasses import dataclass, field from functools import cached_property -from typing import Any, BinaryIO, Optional, Union, final +from typing import Any, BinaryIO, Optional, TypeVar, Union, final from chia_rs import Coin, G1Element, G2Element, PrivateKey from chia_rs.sized_bytes import bytes32 @@ -1870,17 +1870,6 @@ class VCRevokeResponse(TransactionEndpointResponse): pass -# TODO: The section below needs corresponding request types -# TODO: The section below should be added to the API (currently only for client) - - -@streamable -@dataclass(frozen=True) -class SendTransactionMultiResponse(TransactionEndpointResponse): - transaction: TransactionRecord - transaction_id: bytes32 - - @streamable @dataclass(frozen=True) class CSTCoinAnnouncement(Streamable): @@ -1945,6 +1934,94 @@ class CreateSignedTransactionsResponse(TransactionEndpointResponse): signed_tx: TransactionRecord +_T_SendTransactionMultiProxy = TypeVar("_T_SendTransactionMultiProxy", CATSpend, CreateSignedTransaction) + + +@streamable +@dataclass(frozen=True) +class SendTransactionMulti(TransactionEndpointRequest): + # primarily for cat_spend + wallet_id: uint32 = field(default_factory=default_raise) + additions: Optional[list[Addition]] = None # for both + amount: Optional[uint64] = None + inner_address: Optional[str] = None + memos: Optional[list[str]] = None + coins: Optional[list[Coin]] = None # for both + extra_delta: Optional[str] = None # str to support negative ints :( + tail_reveal: Optional[bytes] = None + tail_solution: Optional[bytes] = None + # for create_signed_transaction + morph_bytes: Optional[bytes] = None + coin_announcements: Optional[list[CSTCoinAnnouncement]] = None + puzzle_announcements: Optional[list[CSTPuzzleAnnouncement]] = None + + def convert_to_proxy(self, proxy_type: type[_T_SendTransactionMultiProxy]) -> _T_SendTransactionMultiProxy: + if proxy_type is CATSpend: + if self.morph_bytes is not None: + raise ValueError( + 'Specified "morph_bytes" for a CAT-type wallet. Maybe you meant to specify an XCH wallet?' + ) + elif self.coin_announcements or self.puzzle_announcements is not None: + raise ValueError( + 'Specified "coin/puzzle_announcements" for a CAT-type wallet.' + "Maybe you meant to specify an XCH wallet?" + ) + + # not sure why mypy hasn't understood this is purely a CATSpend + return proxy_type( + wallet_id=self.wallet_id, + additions=self.additions, # type: ignore[arg-type] + amount=self.amount, # type: ignore[call-arg] + inner_address=self.inner_address, + memos=self.memos, + coins=self.coins, + extra_delta=self.extra_delta, + tail_reveal=self.tail_reveal, + tail_solution=self.tail_solution, + fee=self.fee, + push=self.push, + sign=self.sign, + ) + elif proxy_type is CreateSignedTransaction: + if self.amount is not None: + raise ValueError('Specified "amount" for an XCH wallet. Maybe you meant to specify a CAT-type wallet?') + elif self.inner_address is not None: + raise ValueError( + 'Specified "inner_address" for an XCH wallet. Maybe you meant to specify a CAT-type wallet?' + ) + elif self.memos is not None: + raise ValueError('Specified "memos" for an XCH wallet. Maybe you meant to specify a CAT-type wallet?') + elif self.extra_delta is not None or self.tail_reveal is not None or self.tail_solution is not None: + raise ValueError( + 'Specified "extra_delta", "tail_reveal", or "tail_solution" for an XCH wallet.' + "Maybe you meant to specify a CAT-type wallet?" + ) + elif self.additions is None: + raise ValueError('"additions" are required for XCH wallets.') + + # not sure why mypy hasn't understood this is purely a CreateSignedTransaction + return proxy_type( + additions=self.additions, + wallet_id=self.wallet_id, + coins=self.coins, + morph_bytes=self.morph_bytes, # type: ignore[call-arg] + coin_announcements=self.coin_announcements if self.coin_announcements is not None else [], + puzzle_announcements=self.puzzle_announcements if self.puzzle_announcements is not None else [], + fee=self.fee, + push=self.push, + sign=self.sign, + ) + else: + raise ValueError("An unsupported wallet type was selected for `send_transaction_multi`") + + +@streamable +@dataclass(frozen=True) +class SendTransactionMultiResponse(TransactionEndpointResponse): + transaction: TransactionRecord + transaction_id: bytes32 + + @streamable @dataclass(frozen=True) class _OfferEndpointResponse(TransactionEndpointResponse): diff --git a/chia/wallet/wallet_rpc_api.py b/chia/wallet/wallet_rpc_api.py index 742a38441540..b9d50e130395 100644 --- a/chia/wallet/wallet_rpc_api.py +++ b/chia/wallet/wallet_rpc_api.py @@ -279,6 +279,8 @@ SendNotification, SendNotificationResponse, SendTransaction, + SendTransactionMulti, + SendTransactionMultiResponse, SendTransactionResponse, SetWalletResyncOnStartup, SignMessageByAddress, @@ -374,25 +376,40 @@ async def rpc_endpoint( ): raise ValueError("Relative timelocks are not currently supported in the RPC") - async with self.service.wallet_state_manager.new_action_scope( - tx_config, - push=request.get("push", push), - merge_spends=request.get("merge_spends", merge_spends), - sign=request.get("sign", self.service.config.get("auto_sign_txs", True)), - ) as action_scope: + if "action_scope_override" in kwargs: response: EndpointResult = await func( self, request, *args, - action_scope, + kwargs["action_scope_override"], extra_conditions=extra_conditions, - **kwargs, + **{k: v for k, v in kwargs.items() if k != "action_scope_override"}, ) + action_scope = cast(WalletActionScope, kwargs["action_scope_override"]) + else: + async with self.service.wallet_state_manager.new_action_scope( + tx_config, + push=request.get("push", push), + merge_spends=request.get("merge_spends", merge_spends), + sign=request.get("sign", self.service.config.get("auto_sign_txs", True)), + ) as action_scope: + response = await func( + self, + request, + *args, + action_scope, + extra_conditions=extra_conditions, + **kwargs, + ) if func.__name__ == "create_new_wallet" and "transactions" not in response: # unfortunately, this API isn't solely a tx endpoint return response + if "action_scope_override" in kwargs: + # deferring to parent action scope + return response + unsigned_txs = await self.service.wallet_state_manager.gather_signing_info_for_txs( action_scope.side_effects.transactions ) @@ -1665,35 +1682,41 @@ async def send_transaction( # tx_endpoint will take care of the default values here return SendTransactionResponse([], [], transaction=REPLACEABLE_TRANSACTION_RECORD, transaction_id=bytes32.zeros) - async def send_transaction_multi(self, request: dict[str, Any]) -> EndpointResult: + @tx_endpoint(push=True) + @marshal + async def send_transaction_multi( + self, + request: SendTransactionMulti, + action_scope: WalletActionScope, + extra_conditions: tuple[Condition, ...] = tuple(), + ) -> SendTransactionMultiResponse: if await self.service.wallet_state_manager.synced() is False: raise ValueError("Wallet needs to be fully synced before sending transactions") - # This is required because this is a "@tx_endpoint" that calls other @tx_endpoints - request.setdefault("push", True) - request.setdefault("merge_spends", True) - - wallet_id = uint32(request["wallet_id"]) - wallet = self.service.wallet_state_manager.wallets[wallet_id] + wallet = self.service.wallet_state_manager.wallets[request.wallet_id] async with self.service.wallet_state_manager.lock: - if wallet.type() in {WalletType.CAT, WalletType.CRCAT, WalletType.RCAT}: - assert isinstance(wallet, CATWallet) - response = await self.cat_spend(request, hold_lock=False) - transaction = response["transaction"] - transactions = response["transactions"] + if issubclass(type(wallet), CATWallet): + await self.cat_spend( + request.convert_to_proxy(CATSpend).json_serialize_for_transport( + action_scope.config.tx_config, extra_conditions, ConditionValidTimes() + ), + hold_lock=False, + action_scope_override=action_scope, + ) else: - response = await self.create_signed_transaction(request, hold_lock=False) - transaction = response["signed_tx"] - transactions = response["transactions"] + await self.create_signed_transaction( + request.convert_to_proxy(CreateSignedTransaction).json_serialize_for_transport( + action_scope.config.tx_config, extra_conditions, ConditionValidTimes() + ), + hold_lock=False, + action_scope_override=action_scope, + ) - # Transaction may not have been included in the mempool yet. Use get_transaction to check. - return { - "transaction": transaction, - "transaction_id": TransactionRecord.from_json_dict(transaction).name, - "transactions": transactions, - "unsigned_transactions": response["unsigned_transactions"], - } + # tx_endpoint will take care of these values + return SendTransactionMultiResponse( + [], [], transaction=REPLACEABLE_TRANSACTION_RECORD, transaction_id=bytes32.zeros + ) @tx_endpoint(push=True, merge_spends=False) @marshal diff --git a/chia/wallet/wallet_rpc_client.py b/chia/wallet/wallet_rpc_client.py index c5033b893cf6..5a623b2667df 100644 --- a/chia/wallet/wallet_rpc_client.py +++ b/chia/wallet/wallet_rpc_client.py @@ -7,7 +7,6 @@ from chia.data_layer.data_layer_util import DLProof, VerifyProofResponse from chia.rpc.rpc_client import RpcClient -from chia.types.blockchain_format.coin import Coin from chia.wallet.conditions import Condition, ConditionValidTimes, conditions_to_json_dicts from chia.wallet.puzzles.clawback.metadata import AutoClaimSettings from chia.wallet.transaction_record import TransactionRecord @@ -184,6 +183,7 @@ SendNotification, SendNotificationResponse, SendTransaction, + SendTransactionMulti, SendTransactionMultiResponse, SendTransactionResponse, SetWalletResyncOnStartup, @@ -340,33 +340,17 @@ async def send_transaction( async def send_transaction_multi( self, - wallet_id: int, - additions: list[dict[str, Any]], + request: SendTransactionMulti, tx_config: TXConfig, - coins: Optional[list[Coin]] = None, - fee: uint64 = uint64(0), - push: bool = True, + extra_conditions: tuple[Condition, ...] = tuple(), timelock_info: ConditionValidTimes = ConditionValidTimes(), ) -> SendTransactionMultiResponse: - # 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 = { - "wallet_id": wallet_id, - "additions": additions_hex, - "fee": fee, - "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 - response = await self.fetch("send_transaction_multi", request) - return json_deserialize_with_clvm_streamable(response, SendTransactionMultiResponse) + return SendTransactionMultiResponse.from_json_dict( + await self.fetch( + "send_transaction_multi", + request.json_serialize_for_transport(tx_config, extra_conditions, timelock_info), + ) + ) async def spend_clawback_coins( self,