diff --git a/chia/_tests/cmds/wallet/test_wallet.py b/chia/_tests/cmds/wallet/test_wallet.py index 7babd3757b83..b217b879156b 100644 --- a/chia/_tests/cmds/wallet/test_wallet.py +++ b/chia/_tests/cmds/wallet/test_wallet.py @@ -26,7 +26,6 @@ ) from chia.cmds.cmds_util import TransactionBundle from chia.protocols.outbound_message import NodeType -from chia.types.blockchain_format.program import Program from chia.types.signing_mode import SigningMode from chia.util.bech32m import encode_puzzle_hash from chia.wallet.conditions import Condition, ConditionValidTimes @@ -47,6 +46,7 @@ CATAssetIDToNameResponse, CATSetName, CATSetNameResponse, + CATSpend, CATSpendResponse, ClawbackPuzzleDecoratorOverride, CreateOfferForIDsResponse, @@ -385,31 +385,24 @@ async def send_transaction( async def cat_spend( self, - wallet_id: int, + request: CATSpend, tx_config: TXConfig, - amount: Optional[uint64] = None, - inner_address: Optional[str] = None, - fee: uint64 = uint64(0), - memos: Optional[list[str]] = None, - additions: Optional[list[dict[str, Any]]] = None, - removals: Optional[list[Coin]] = None, - cat_discrepancy: Optional[tuple[int, Program, Program]] = None, # (extra_delta, tail_reveal, tail_solution) - push: bool = True, + extra_conditions: tuple[Condition, ...] = tuple(), timelock_info: ConditionValidTimes = ConditionValidTimes(), ) -> CATSpendResponse: self.add_to_log( "cat_spend", ( - wallet_id, + request.wallet_id, tx_config, - amount, - inner_address, - fee, - memos, - additions, - removals, - cat_discrepancy, - push, + request.amount, + request.inner_address, + request.fee, + request.memos, + request.additions, + request.coins, + request.cat_discrepancy, + request.push, timelock_info, ), ) diff --git a/chia/_tests/wallet/rpc/test_wallet_rpc.py b/chia/_tests/wallet/rpc/test_wallet_rpc.py index 6669dcad454d..805191ced1c1 100644 --- a/chia/_tests/wallet/rpc/test_wallet_rpc.py +++ b/chia/_tests/wallet/rpc/test_wallet_rpc.py @@ -6,6 +6,7 @@ import json import logging import random +import re from collections.abc import AsyncIterator from operator import attrgetter from typing import Any, Optional @@ -108,6 +109,7 @@ CATGetAssetID, CATGetName, CATSetName, + CATSpend, CheckDeleteKey, CheckOfferValidity, ClawbackPuzzleDecoratorOverride, @@ -1255,18 +1257,69 @@ async def test_cat_endpoints(wallet_environments: WalletTestFramework, wallet_ty # Test CAT spend without a fee with pytest.raises(ValueError): await env_0.rpc_client.cat_spend( - cat_0_id, - DEFAULT_TX_CONFIG.override( + CATSpend( + wallet_id=cat_0_id, + amount=uint64(4), + inner_address=addr_1, + fee=uint64(0), + memos=["the cat memo"], + push=False, + ), + tx_config=wallet_environments.tx_config.override( excluded_coin_amounts=[uint64(100)], excluded_coin_ids=[bytes32.zeros], ), - uint64(4), - addr_1, - uint64(0), - ["the cat memo"], ) + + # Test some validation errors + with pytest.raises( + ValueError, + match=re.escape('Must specify "additions" or "amount"+"inner_address"+"memos", but not both.'), + ): + await env_0.rpc_client.cat_spend( + CATSpend( + wallet_id=cat_0_id, + amount=uint64(4), + inner_address=addr_1, + memos=["the cat memo"], + additions=[], + ), + tx_config=wallet_environments.tx_config, + ) + + with pytest.raises(ValueError, match=re.escape('Must specify "amount" and "inner_address" together.')): + await env_0.rpc_client.cat_spend( + CATSpend( + wallet_id=cat_0_id, + amount=uint64(4), + inner_address=None, + ), + tx_config=wallet_environments.tx_config, + ) + + with pytest.raises( + ValueError, + match=re.escape('Must specify \\"extra_delta\\", \\"tail_reveal\\" and \\"tail_solution\\" together.'), + ): + await env_0.rpc_client.cat_spend( + CATSpend( + wallet_id=cat_0_id, + additions=[], + extra_delta="1", + ), + tx_config=wallet_environments.tx_config, + ) + tx_res = await env_0.rpc_client.cat_spend( - cat_0_id, wallet_environments.tx_config, uint64(4), addr_1, uint64(0), ["the cat memo"] + CATSpend( + wallet_id=cat_0_id, + amount=uint64(4), + inner_address=addr_1, + fee=uint64(0), + memos=["the cat memo"], + push=True, + ), + tx_config=wallet_environments.tx_config, ) spend_bundle = tx_res.transaction.spend_bundle @@ -1312,7 +1365,15 @@ async def test_cat_endpoints(wallet_environments: WalletTestFramework, wallet_ty # Test CAT spend with a fee tx_res = await env_0.rpc_client.cat_spend( - cat_0_id, wallet_environments.tx_config, uint64(1), addr_1, uint64(5_000_000), ["the cat memo"] + CATSpend( + wallet_id=cat_0_id, + amount=uint64(1), + inner_address=addr_1, + fee=uint64(5_000_000), + memos=["the cat memo"], + push=True, + ), + wallet_environments.tx_config, ) spend_bundle = tx_res.transaction.spend_bundle @@ -1379,13 +1440,16 @@ async def test_cat_endpoints(wallet_environments: WalletTestFramework, wallet_ty ) ) tx_res = await env_0.rpc_client.cat_spend( - cat_0_id, + CATSpend( + wallet_id=cat_0_id, + amount=uint64(1), + inner_address=addr_1, + fee=uint64(5_000_000), + memos=["the cat memo"], + coins=select_coins_response.coins, + push=True, + ), wallet_environments.tx_config, - uint64(1), - addr_1, - uint64(5_000_000), - ["the cat memo"], - removals=select_coins_response.coins, ) spend_bundle = tx_res.transaction.spend_bundle @@ -3109,11 +3173,16 @@ async def test_cat_spend_run_tail(wallet_rpc_environment: WalletRpcTestEnvironme # Attempt to melt it fully tx = ( await client.cat_spend( - cat_wallet_id, - amount=uint64(0), + CATSpend( + wallet_id=cat_wallet_id, + amount=uint64(0), + inner_address=encode_puzzle_hash(our_ph, "txch"), + extra_delta=str(tx_amount * -1), + tail_reveal=b"\x80", + tail_solution=b"\x80", + push=True, + ), tx_config=DEFAULT_TX_CONFIG, - inner_address=encode_puzzle_hash(our_ph, "txch"), - cat_discrepancy=(tx_amount * -1, Program.to(None), Program.to(None)), ) ).transaction transaction_id = tx.name diff --git a/chia/_tests/wallet/vc_wallet/test_vc_wallet.py b/chia/_tests/wallet/vc_wallet/test_vc_wallet.py index b76cfe3ecab2..0d834fe2695f 100644 --- a/chia/_tests/wallet/vc_wallet/test_vc_wallet.py +++ b/chia/_tests/wallet/vc_wallet/test_vc_wallet.py @@ -31,6 +31,7 @@ from chia.wallet.wallet import Wallet from chia.wallet.wallet_node import WalletNode from chia.wallet.wallet_request_types import ( + CATSpend, GetTransactions, GetWallets, VCAddProofs, @@ -383,12 +384,15 @@ async def test_vc_lifecycle(wallet_environments: WalletTestFramework) -> None: wallet_1_addr = encode_puzzle_hash(wallet_1_ph, "txch") txs = ( await client_0.cat_spend( - cr_cat_wallet_0.id(), + CATSpend( + wallet_id=cr_cat_wallet_0.id(), + amount=uint64(90), + inner_address=wallet_1_addr, + fee=uint64(2000000000), + memos=["hey"], + push=True, + ), wallet_environments.tx_config, - uint64(90), - wallet_1_addr, - uint64(2000000000), - memos=["hey"], ) ).transactions await wallet_environments.process_pending_states( @@ -557,10 +561,12 @@ async def test_vc_lifecycle(wallet_environments: WalletTestFramework) -> None: # (Negative test) Try to spend a CR-CAT that we don't have a valid VC for with pytest.raises(ValueError): await client_0.cat_spend( - cr_cat_wallet_0.id(), - wallet_environments.tx_config, - uint64(10), - wallet_1_addr, + CATSpend( + wallet_id=cr_cat_wallet_0.id(), + amount=uint64(10), + inner_address=wallet_1_addr, + ), + tx_config=wallet_environments.tx_config, ) # Test melting a CRCAT @@ -568,12 +574,17 @@ async def test_vc_lifecycle(wallet_environments: WalletTestFramework) -> None: with wallet_environments.new_puzzle_hashes_allowed(): tx = ( await client_1.cat_spend( - env_1.dealias_wallet_id("crcat"), - wallet_environments.tx_config, - uint64(20), - wallet_1_addr, - uint64(0), - cat_discrepancy=(-50, Program.to(None), Program.to(None)), + CATSpend( + wallet_id=env_1.dealias_wallet_id("crcat"), + amount=uint64(20), + inner_address=wallet_1_addr, + fee=uint64(0), + extra_delta=str(-50), + tail_reveal=b"\x80", + tail_solution=b"\x80", + push=True, + ), + tx_config=wallet_environments.tx_config, ) ).transaction [tx] = await wallet_node_1.wallet_state_manager.add_pending_transactions([tx]) diff --git a/chia/cmds/wallet_funcs.py b/chia/cmds/wallet_funcs.py index 1f05cee22e3c..b8170b043e10 100644 --- a/chia/cmds/wallet_funcs.py +++ b/chia/cmds/wallet_funcs.py @@ -49,6 +49,7 @@ CATAssetIDToNameResponse, CATGetName, CATSetName, + CATSpend, CATSpendResponse, ClawbackPuzzleDecoratorOverride, DeleteNotifications, @@ -401,18 +402,20 @@ async def send( elif typ in {WalletType.CAT, WalletType.CRCAT, WalletType.RCAT}: print("Submitting transaction...") res = await wallet_client.cat_spend( - wallet_id, - CMDTXConfigLoader( + CATSpend( + wallet_id=uint32(wallet_id), + amount=final_amount, + inner_address=address.original_address, + fee=fee, + memos=memos, + push=push, + ), + tx_config=CMDTXConfigLoader( min_coin_amount=min_coin_amount, max_coin_amount=max_coin_amount, excluded_coin_ids=list(excluded_coin_ids), reuse_puzhash=reuse_puzhash, ).to_tx_config(mojo_per_unit, config, fingerprint), - final_amount, - address.original_address, - fee, - memos, - push=push, timelock_info=condition_valid_times, ) else: diff --git a/chia/wallet/wallet_request_types.py b/chia/wallet/wallet_request_types.py index 47cff080e6b9..5bf46b15c73d 100644 --- a/chia/wallet/wallet_request_types.py +++ b/chia/wallet/wallet_request_types.py @@ -1388,6 +1388,61 @@ class CombineCoinsResponse(TransactionEndpointResponse): pass +# utility for CATSpend +# unfortunate that we can't use CreateCoin but the memos are taken as strings not bytes +@streamable +@dataclass(frozen=True) +class Addition(Streamable): + amount: uint64 + puzzle_hash: bytes32 + memos: Optional[list[str]] = None + + +@streamable +@kw_only_dataclass +class CATSpend(TransactionEndpointRequest): + wallet_id: uint32 = field(default_factory=default_raise) + additions: Optional[list[Addition]] = None + amount: Optional[uint64] = None + inner_address: Optional[str] = None + memos: Optional[list[str]] = None + coins: Optional[list[Coin]] = None + extra_delta: Optional[str] = None # str to support negative ints :( + tail_reveal: Optional[bytes] = None + tail_solution: Optional[bytes] = None + + def __post_init__(self) -> None: + if ( + self.additions is not None + and (self.amount is not None or self.inner_address is not None or self.memos is not None) + ) or (self.additions is None and self.amount is None and self.inner_address is None and self.memos is None): + raise ValueError('Must specify "additions" or "amount"+"inner_address"+"memos", but not both.') + elif self.additions is None and None in {self.amount, self.inner_address}: + raise ValueError('Must specify "amount" and "inner_address" together.') + super().__post_init__() + + @property + def cat_discrepancy(self) -> Optional[tuple[int, Program, Program]]: + if self.extra_delta is None and self.tail_reveal is None and self.tail_solution is None: + return None + elif None in {self.extra_delta, self.tail_reveal, self.tail_solution}: + raise ValueError('Must specify "extra_delta", "tail_reveal" and "tail_solution" together.') + else: + # Curious that mypy doesn't see the elif and know that none of these are None + return ( + int(self.extra_delta), # type: ignore[arg-type] + Program.from_bytes(self.tail_reveal), # type: ignore[arg-type] + Program.from_bytes(self.tail_solution), # type: ignore[arg-type] + ) + + +@streamable +@dataclass(frozen=True) +class CATSpendResponse(TransactionEndpointResponse): + transaction: TransactionRecord + transaction_id: bytes32 + + @streamable @kw_only_dataclass class DIDMessageSpend(TransactionEndpointRequest): @@ -1780,13 +1835,6 @@ class CreateSignedTransactionsResponse(TransactionEndpointResponse): signed_tx: TransactionRecord -@streamable -@dataclass(frozen=True) -class CATSpendResponse(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 664d0eb884c9..3c951f6c6292 100644 --- a/chia/wallet/wallet_rpc_api.py +++ b/chia/wallet/wallet_rpc_api.py @@ -120,6 +120,8 @@ CATGetNameResponse, CATSetName, CATSetNameResponse, + CATSpend, + CATSpendResponse, CheckDeleteKey, CheckDeleteKeyResponse, CheckOfferValidity, @@ -2137,72 +2139,47 @@ async def get_stray_cats(self, request: Empty) -> GetStrayCATsResponse: return GetStrayCATsResponse(stray_cats=[StrayCAT.from_json_dict(cat) for cat in cats]) @tx_endpoint(push=True) + @marshal async def cat_spend( self, - request: dict[str, Any], + request: CATSpend, action_scope: WalletActionScope, extra_conditions: tuple[Condition, ...] = tuple(), hold_lock: bool = True, - ) -> EndpointResult: + ) -> CATSpendResponse: if await self.service.wallet_state_manager.synced() is False: raise ValueError("Wallet needs to be fully synced.") - wallet_id = uint32(request["wallet_id"]) - wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=CATWallet) + wallet = self.service.wallet_state_manager.get_wallet(id=request.wallet_id, required_type=CATWallet) amounts: list[uint64] = [] puzzle_hashes: list[bytes32] = [] memos: list[list[bytes]] = [] - additions: Optional[list[dict[str, Any]]] = request.get("additions") - if not isinstance(request["fee"], int) or (additions is None and not isinstance(request["amount"], int)): - raise ValueError("An integer amount or fee is required (too many decimals)") - if additions is not None: - for addition in additions: - 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: + if request.additions is not None: + for addition in request.additions: + if addition.amount > self.service.constants.MAX_COIN_AMOUNT: raise ValueError(f"Coin amount cannot exceed {self.service.constants.MAX_COIN_AMOUNT}") - amounts.append(amount) - puzzle_hashes.append(receiver_ph) - if "memos" in addition: - memos.append([mem.encode("utf-8") for mem in addition["memos"]]) + amounts.append(addition.amount) + puzzle_hashes.append(addition.puzzle_hash) + if addition.memos is not None: + memos.append([mem.encode("utf-8") for mem in addition.memos]) else: - amounts.append(uint64(request["amount"])) - puzzle_hashes.append(decode_puzzle_hash(request["inner_address"])) - if "memos" in request: - memos.append([mem.encode("utf-8") for mem in request["memos"]]) + # Our __post_init__ guards against these not being None + amounts.append(request.amount) # type: ignore[arg-type] + puzzle_hashes.append(decode_puzzle_hash(request.inner_address)) # type: ignore[arg-type] + if request.memos is not None: + memos.append([mem.encode("utf-8") for mem in request.memos]) coins: Optional[set[Coin]] = None - if "coins" in request and len(request["coins"]) > 0: - coins = {Coin.from_json_dict(coin_json) for coin_json in request["coins"]} - fee: uint64 = uint64(request.get("fee", 0)) + if request.coins is not None and len(request.coins) > 0: + coins = set(request.coins) - cat_discrepancy_params: tuple[Optional[int], Optional[str], Optional[str]] = ( - request.get("extra_delta", None), - request.get("tail_reveal", None), - request.get("tail_solution", None), - ) - cat_discrepancy: Optional[tuple[int, Program, Program]] = None - if cat_discrepancy_params != (None, None, None): - if None in cat_discrepancy_params: - raise ValueError("Specifying extra_delta, tail_reveal, or tail_solution requires specifying the others") - else: - assert cat_discrepancy_params[0] is not None - assert cat_discrepancy_params[1] is not None - assert cat_discrepancy_params[2] is not None - cat_discrepancy = ( - cat_discrepancy_params[0], # mypy sanitization - Program.fromhex(cat_discrepancy_params[1]), - Program.fromhex(cat_discrepancy_params[2]), - ) if hold_lock: async with self.service.wallet_state_manager.lock: await wallet.generate_signed_transaction( amounts, puzzle_hashes, action_scope, - fee, - cat_discrepancy=cat_discrepancy, + request.fee, + cat_discrepancy=request.cat_discrepancy, coins=coins, memos=memos if memos else None, extra_conditions=extra_conditions, @@ -2212,18 +2189,15 @@ async def cat_spend( amounts, puzzle_hashes, action_scope, - fee, - cat_discrepancy=cat_discrepancy, + request.fee, + cat_discrepancy=request.cat_discrepancy, coins=coins, memos=memos if memos else None, extra_conditions=extra_conditions, ) - return { - "transaction": None, # tx_endpoint wrapper will take care of this - "transactions": None, # tx_endpoint wrapper will take care of this - "transaction_id": None, # tx_endpoint wrapper will take care of this - } + # tx_endpoint will fill in these default values + return CATSpendResponse([], [], transaction=REPLACEABLE_TRANSACTION_RECORD, transaction_id=bytes32.zeros) @marshal async def cat_get_asset_id(self, request: CATGetAssetID) -> CATGetAssetIDResponse: diff --git a/chia/wallet/wallet_rpc_client.py b/chia/wallet/wallet_rpc_client.py index a417427febdb..b8c1522d1776 100644 --- a/chia/wallet/wallet_rpc_client.py +++ b/chia/wallet/wallet_rpc_client.py @@ -8,7 +8,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.types.blockchain_format.program import Program from chia.wallet.conditions import Condition, ConditionValidTimes, conditions_to_json_dicts from chia.wallet.puzzles.clawback.metadata import AutoClaimSettings from chia.wallet.trade_record import TradeRecord @@ -32,6 +31,7 @@ CATGetNameResponse, CATSetName, CATSetNameResponse, + CATSpend, CATSpendResponse, CheckDeleteKey, CheckDeleteKeyResponse, @@ -652,48 +652,16 @@ async def set_cat_name(self, request: CATSetName) -> CATSetNameResponse: async def cat_spend( self, - wallet_id: int, + request: CATSpend, tx_config: TXConfig, - amount: Optional[uint64] = None, - inner_address: Optional[str] = None, - fee: uint64 = uint64(0), - memos: Optional[list[str]] = None, - additions: Optional[list[dict[str, Any]]] = None, - removals: Optional[list[Coin]] = None, - cat_discrepancy: Optional[tuple[int, Program, Program]] = None, # (extra_delta, tail_reveal, tail_solution) extra_conditions: tuple[Condition, ...] = tuple(), timelock_info: ConditionValidTimes = ConditionValidTimes(), - push: bool = True, ) -> CATSpendResponse: - send_dict: dict[str, Any] = { - "wallet_id": wallet_id, - "fee": fee, - "memos": memos if memos is not None else [], - "extra_conditions": conditions_to_json_dicts(extra_conditions), - "push": push, - **tx_config.to_json_dict(), - **timelock_info.to_json_dict(), - } - if amount is not None and inner_address is not None: - send_dict["amount"] = amount - send_dict["inner_address"] = inner_address - elif additions is not None: - 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"] - send_dict["additions"] = additions_hex - else: - raise ValueError("Must specify either amount and inner_address or additions") - if removals is not None and len(removals) > 0: - send_dict["coins"] = [c.to_json_dict() for c in removals] - if cat_discrepancy is not None: - send_dict["extra_delta"] = cat_discrepancy[0] - send_dict["tail_reveal"] = bytes(cat_discrepancy[1]).hex() - send_dict["tail_solution"] = bytes(cat_discrepancy[2]).hex() - res = await self.fetch("cat_spend", send_dict) - return json_deserialize_with_clvm_streamable(res, CATSpendResponse) + return CATSpendResponse.from_json_dict( + await self.fetch( + "cat_spend", request.json_serialize_for_transport(tx_config, extra_conditions, timelock_info) + ) + ) # Offers async def create_offer_for_ids(