mirror of
https://github.com/Chia-Network/chia-blockchain.git
synced 2026-08-24 02:24:23 -05:00
[LABS-490] Extract coin splitting functionality into its own module (#21161)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> **Medium Risk**
> Refactors wallet coin split/combine paths used by RPC; behavior should
be equivalent but touches core transaction construction and coin
selection.
>
> **Overview**
> Moves **split** and **combine** coin logic out of `WalletStateManager`
into a new **`FungibilityManager`**, wired on the state manager at
startup and used by the wallet RPC for `split_coins` / `combine_coins`.
>
> RPC handlers now resolve a fungible wallet via `get_fungible_wallet`
(standard or CAT only) and call the manager instead of
`wallet_state_manager.split_coins` / `combine_coins`. Invalid wallet
types raise **`Wallet {id} is not eligible for coin splitting`** (tests
updated for split and combine RPC paths). **`coin_num_limit`** is no
longer forwarded into the combine implementation; limits still apply via
`CombineCoins` request validation.
>
> Behavior of coin selection, fees, and transaction building is intended
to stay the same—this is primarily structural cleanup for LABS-490.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
28e52c2070. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
@@ -4109,7 +4109,7 @@ async def test_split_coins(wallet_environments: WalletTestFramework, capsys: pyt
|
||||
|
||||
# This one only "works" on the RPC
|
||||
env.wallet_state_manager.wallets[uint32(42)] = object() # type: ignore[assignment]
|
||||
with pytest.raises(ResponseFailureError, match="Cannot split coins from non-fungible wallet types"):
|
||||
with pytest.raises(ResponseFailureError, match="Wallet 42 is not eligible for coin splitting"):
|
||||
assert xch_request.amount_per_coin is not None # hey there mypy
|
||||
rpc_request = SplitCoins(
|
||||
wallet_id=uint32(42),
|
||||
@@ -4299,7 +4299,7 @@ async def test_combine_coins(wallet_environments: WalletTestFramework, capsys: p
|
||||
|
||||
# This one only "works" on the RPC
|
||||
env.wallet_state_manager.wallets[uint32(42)] = object() # type: ignore[assignment]
|
||||
with pytest.raises(ResponseFailureError, match="Cannot combine coins from non-fungible wallet types"):
|
||||
with pytest.raises(ResponseFailureError, match="Wallet 42 is not eligible for coin splitting"):
|
||||
assert xch_combine_request.target_amount is not None # hey there mypy
|
||||
rpc_request = CombineCoins(
|
||||
wallet_id=uint32(42),
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from chia_rs import Coin
|
||||
from chia_rs.sized_bytes import bytes32
|
||||
from chia_rs.sized_ints import uint8, uint16, uint32, uint64
|
||||
|
||||
from chia.util.streamable import UInt32Range, UInt64Range
|
||||
from chia.wallet.cat_wallet.cat_wallet import CATWallet
|
||||
from chia.wallet.conditions import Condition, CreateCoin
|
||||
from chia.wallet.util.query_filter import FilterMode, HashFilter
|
||||
from chia.wallet.util.wallet_types import WalletType
|
||||
from chia.wallet.wallet import Wallet
|
||||
from chia.wallet.wallet_action_scope import WalletActionScope
|
||||
from chia.wallet.wallet_coin_store import CoinRecordOrder, WalletCoinStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from chia.wallet.wallet_state_manager import WalletStateManager
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FungibilityManager:
|
||||
coin_store: WalletCoinStore
|
||||
# TODO: this is only a dependency because of the puzzle hash generation
|
||||
wallet_state_manager: WalletStateManager
|
||||
|
||||
def get_fungible_wallet(self, wallet_id: uint32) -> Wallet | CATWallet:
|
||||
if (wallet := self.wallet_state_manager.wallets.get(wallet_id)) is None or not isinstance(
|
||||
wallet, (Wallet, CATWallet)
|
||||
):
|
||||
raise ValueError(f"Wallet {wallet_id} is not eligible for coin splitting")
|
||||
return wallet
|
||||
|
||||
async def split_coins(
|
||||
self,
|
||||
*,
|
||||
action_scope: WalletActionScope,
|
||||
wallet: Wallet | CATWallet,
|
||||
target_coin_id: bytes32,
|
||||
amount_per_coin: uint64,
|
||||
number_of_coins: uint16,
|
||||
fee: uint64,
|
||||
extra_conditions: tuple[Condition, ...] = tuple(),
|
||||
) -> None:
|
||||
optional_coin = await self.coin_store.get_coin_record(target_coin_id)
|
||||
if optional_coin is None:
|
||||
raise ValueError(f"Could not find coin with ID {target_coin_id}")
|
||||
else:
|
||||
coin = optional_coin.coin
|
||||
|
||||
total_amount = amount_per_coin * number_of_coins
|
||||
|
||||
if coin.amount < total_amount:
|
||||
raise ValueError(f"Coin amount: {coin.amount} is less than the total amount of the split: {total_amount}.")
|
||||
|
||||
outputs = [
|
||||
CreateCoin(
|
||||
await action_scope.get_puzzle_hash(self.wallet_state_manager, override_reuse_puzhash_with=False),
|
||||
amount_per_coin,
|
||||
)
|
||||
for _ in range(number_of_coins)
|
||||
]
|
||||
|
||||
if wallet.type() == WalletType.STANDARD_WALLET and coin.amount < total_amount + fee:
|
||||
async with action_scope.use() as interface:
|
||||
interface.side_effects.selected_coins.append(coin)
|
||||
coins = await wallet.select_coins(
|
||||
uint64(total_amount + fee - coin.amount),
|
||||
action_scope,
|
||||
)
|
||||
coins.add(coin)
|
||||
else:
|
||||
coins = {coin}
|
||||
|
||||
await wallet.generate_signed_transaction(
|
||||
[output.amount for output in outputs],
|
||||
[output.puzzle_hash for output in outputs],
|
||||
action_scope,
|
||||
fee,
|
||||
coins=coins,
|
||||
extra_conditions=extra_conditions,
|
||||
)
|
||||
|
||||
async def combine_coins(
|
||||
self,
|
||||
*,
|
||||
action_scope: WalletActionScope,
|
||||
wallet: Wallet | CATWallet,
|
||||
number_of_coins: uint16,
|
||||
largest_first: bool,
|
||||
fee: uint64,
|
||||
target_coin_amount: uint64 | None = None,
|
||||
target_coin_ids: list[bytes32] | None = None,
|
||||
extra_conditions: tuple[Condition, ...] = tuple(),
|
||||
) -> None:
|
||||
coins: list[Coin] = []
|
||||
|
||||
# First get the coin IDs specified
|
||||
if target_coin_ids is not None:
|
||||
target_records = (
|
||||
await self.coin_store.get_coin_records(
|
||||
wallet_id=wallet.id(),
|
||||
coin_id_filter=HashFilter(target_coin_ids, mode=uint8(FilterMode.include.value)),
|
||||
)
|
||||
).records
|
||||
spent_ids = [cr.coin.name() for cr in target_records if cr.spent]
|
||||
if spent_ids:
|
||||
raise ValueError(f"Cannot combine already-spent coins: {', '.join(c.hex() for c in spent_ids)}")
|
||||
coins.extend(cr.coin for cr in target_records)
|
||||
|
||||
async with action_scope.use() as interface:
|
||||
interface.side_effects.selected_coins.extend(coins)
|
||||
|
||||
# Next let's select enough coins to meet the target + fee if there is one
|
||||
fungible_amount_needed = uint64(0) if target_coin_amount is None else target_coin_amount
|
||||
if isinstance(wallet, Wallet):
|
||||
fungible_amount_needed = uint64(fungible_amount_needed + fee)
|
||||
amount_selected = sum(c.amount for c in coins)
|
||||
if amount_selected < fungible_amount_needed: # implicit fungible_amount_needed > 0 here
|
||||
coins.extend(
|
||||
await wallet.select_coins(
|
||||
amount=uint64(fungible_amount_needed - amount_selected), action_scope=action_scope
|
||||
)
|
||||
)
|
||||
|
||||
if len(coins) > number_of_coins:
|
||||
raise ValueError(
|
||||
f"Options specified cannot be met without selecting more coins than specified: {len(coins)}"
|
||||
)
|
||||
|
||||
# Now let's select enough coins to get to the target number to combine
|
||||
if len(coins) < number_of_coins:
|
||||
coin_selection_config = action_scope.config.tx_config.coin_selection_config
|
||||
async with action_scope.use() as interface:
|
||||
coins.extend(
|
||||
cr.coin
|
||||
for cr in (
|
||||
await self.coin_store.get_coin_records(
|
||||
wallet_id=wallet.id(),
|
||||
limit=uint32(number_of_coins - len(coins)),
|
||||
order=CoinRecordOrder.amount,
|
||||
coin_id_filter=HashFilter(
|
||||
[c.name() for c in interface.side_effects.selected_coins],
|
||||
mode=uint8(FilterMode.exclude.value),
|
||||
),
|
||||
reverse=largest_first,
|
||||
spent_range=UInt32Range(stop=uint32(0)),
|
||||
amount_range=UInt64Range(
|
||||
start=coin_selection_config.min_coin_amount,
|
||||
stop=coin_selection_config.max_coin_amount,
|
||||
),
|
||||
)
|
||||
).records
|
||||
)
|
||||
|
||||
async with action_scope.use() as interface:
|
||||
interface.side_effects.selected_coins.extend(coins)
|
||||
|
||||
primary_output_amount = (
|
||||
uint64(sum(c.amount for c in coins)) if target_coin_amount is None else target_coin_amount
|
||||
)
|
||||
if isinstance(wallet, Wallet):
|
||||
primary_output_amount = uint64(primary_output_amount - fee)
|
||||
|
||||
await wallet.generate_signed_transaction(
|
||||
[primary_output_amount],
|
||||
[await action_scope.get_puzzle_hash(self.wallet_state_manager)],
|
||||
action_scope,
|
||||
fee,
|
||||
coins=set(coins),
|
||||
extra_conditions=extra_conditions,
|
||||
)
|
||||
@@ -1304,9 +1304,9 @@ class WalletRpcApi:
|
||||
async def split_coins(
|
||||
self, request: SplitCoins, action_scope: WalletActionScope, extra_conditions: tuple[Condition, ...] = tuple()
|
||||
) -> SplitCoinsResponse:
|
||||
await self.service.wallet_state_manager.split_coins(
|
||||
await self.service.wallet_state_manager.fungibility_manager.split_coins(
|
||||
action_scope=action_scope,
|
||||
wallet_id=request.wallet_id,
|
||||
wallet=self.service.wallet_state_manager.fungibility_manager.get_fungible_wallet(request.wallet_id),
|
||||
target_coin_id=request.target_coin_id,
|
||||
amount_per_coin=request.amount_per_coin,
|
||||
number_of_coins=request.number_of_coins,
|
||||
@@ -1320,12 +1320,11 @@ class WalletRpcApi:
|
||||
async def combine_coins(
|
||||
self, request: CombineCoins, action_scope: WalletActionScope, extra_conditions: tuple[Condition, ...] = tuple()
|
||||
) -> CombineCoinsResponse:
|
||||
await self.service.wallet_state_manager.combine_coins(
|
||||
await self.service.wallet_state_manager.fungibility_manager.combine_coins(
|
||||
action_scope=action_scope,
|
||||
wallet_id=request.wallet_id,
|
||||
wallet=self.service.wallet_state_manager.fungibility_manager.get_fungible_wallet(request.wallet_id),
|
||||
number_of_coins=request.number_of_coins,
|
||||
largest_first=request.largest_first,
|
||||
coin_num_limit=request.coin_num_limit,
|
||||
fee=request.fee,
|
||||
target_coin_amount=request.target_coin_amount,
|
||||
target_coin_ids=request.target_coin_ids if request.target_coin_ids != [] else None,
|
||||
|
||||
@@ -42,7 +42,7 @@ from chia.util.errors import Err
|
||||
from chia.util.hash import std_hash
|
||||
from chia.util.lru_cache import LRUCache
|
||||
from chia.util.path import path_from_root
|
||||
from chia.util.streamable import Streamable, UInt32Range, UInt64Range, VersionedBlob
|
||||
from chia.util.streamable import Streamable, VersionedBlob
|
||||
from chia.wallet.cat_wallet.cat_constants import DEFAULT_CATS
|
||||
from chia.wallet.cat_wallet.cat_info import CATCoinData, CATInfo, CRCATInfo
|
||||
from chia.wallet.cat_wallet.cat_utils import CAT_MOD, CAT_MOD_HASH, construct_cat_puzzle, match_cat_puzzle
|
||||
@@ -52,7 +52,6 @@ from chia.wallet.clawback_manager import ClawbackManager
|
||||
from chia.wallet.conditions import (
|
||||
Condition,
|
||||
ConditionValidTimes,
|
||||
CreateCoin,
|
||||
parse_timelock_info,
|
||||
)
|
||||
from chia.wallet.derivation_record import DerivationRecord
|
||||
@@ -75,6 +74,7 @@ from chia.wallet.did_wallet.did_wallet_puzzles import (
|
||||
match_did_puzzle,
|
||||
metadata_to_program,
|
||||
)
|
||||
from chia.wallet.fungibility_manager import FungibilityManager
|
||||
from chia.wallet.key_val_store import KeyValStore
|
||||
from chia.wallet.nft_wallet import nft_puzzle_utils
|
||||
from chia.wallet.nft_wallet.nft_info import NFTCoinInfo, NFTInfo
|
||||
@@ -103,7 +103,7 @@ from chia.wallet.util.compute_hints import compute_spend_hints_and_additions
|
||||
from chia.wallet.util.compute_memos import compute_memos
|
||||
from chia.wallet.util.curry_and_treehash import NIL_TREEHASH
|
||||
from chia.wallet.util.puzzle_decorator import PuzzleDecoratorManager
|
||||
from chia.wallet.util.query_filter import FilterMode, HashFilter
|
||||
from chia.wallet.util.query_filter import HashFilter
|
||||
from chia.wallet.util.transaction_type import CLAWBACK_INCOMING_TRANSACTION_TYPES, TransactionType
|
||||
from chia.wallet.util.tx_config import TXConfig, TXConfigLoader
|
||||
from chia.wallet.util.wallet_sync_utils import (
|
||||
@@ -121,7 +121,7 @@ from chia.wallet.wallet import Wallet
|
||||
from chia.wallet.wallet_action_scope import PlotNFTTargetStateInfo, WalletActionScope, new_wallet_action_scope
|
||||
from chia.wallet.wallet_blockchain import WalletBlockchain
|
||||
from chia.wallet.wallet_coin_record import WalletCoinRecord
|
||||
from chia.wallet.wallet_coin_store import CoinRecordOrder, WalletCoinStore
|
||||
from chia.wallet.wallet_coin_store import WalletCoinStore
|
||||
from chia.wallet.wallet_info import WalletInfo
|
||||
from chia.wallet.wallet_interested_store import WalletInterestedStore
|
||||
from chia.wallet.wallet_nft_store import WalletNftStore
|
||||
@@ -205,6 +205,7 @@ class WalletStateManager:
|
||||
decorator_manager: PuzzleDecoratorManager
|
||||
signer: WalletSigner
|
||||
clawback_manager: ClawbackManager
|
||||
fungibility_manager: FungibilityManager
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
@@ -309,6 +310,7 @@ class WalletStateManager:
|
||||
puzzle_hash_encoder=self.encode_puzzle_hash,
|
||||
action_scope_sandbox=self.new_action_scope,
|
||||
)
|
||||
self.fungibility_manager = FungibilityManager(coin_store=self.coin_store, wallet_state_manager=self)
|
||||
|
||||
self.wallets = {main_wallet_info.id: self.main_wallet}
|
||||
|
||||
@@ -2729,159 +2731,6 @@ class WalletStateManager:
|
||||
valid_times=parse_timelock_info(extra_conditions),
|
||||
)
|
||||
|
||||
async def split_coins(
|
||||
self,
|
||||
*,
|
||||
action_scope: WalletActionScope,
|
||||
wallet_id: uint32,
|
||||
target_coin_id: bytes32,
|
||||
amount_per_coin: uint64,
|
||||
number_of_coins: uint16,
|
||||
fee: uint64,
|
||||
extra_conditions: tuple[Condition, ...] = tuple(),
|
||||
) -> None:
|
||||
optional_coin = await self.coin_store.get_coin_record(target_coin_id)
|
||||
if optional_coin is None:
|
||||
raise ValueError(f"Could not find coin with ID {target_coin_id}")
|
||||
else:
|
||||
coin = optional_coin.coin
|
||||
|
||||
total_amount = amount_per_coin * number_of_coins
|
||||
|
||||
if coin.amount < total_amount:
|
||||
raise ValueError(f"Coin amount: {coin.amount} is less than the total amount of the split: {total_amount}.")
|
||||
|
||||
if wallet_id not in self.wallets:
|
||||
raise ValueError(f"Wallet with ID {wallet_id} does not exist")
|
||||
wallet = self.wallets[wallet_id]
|
||||
if not isinstance(wallet, (Wallet, CATWallet)):
|
||||
raise ValueError("Cannot split coins from non-fungible wallet types")
|
||||
|
||||
outputs = [
|
||||
CreateCoin(
|
||||
await action_scope.get_puzzle_hash(self, override_reuse_puzhash_with=False),
|
||||
amount_per_coin,
|
||||
)
|
||||
for _ in range(number_of_coins)
|
||||
]
|
||||
|
||||
if wallet.type() == WalletType.STANDARD_WALLET and coin.amount < total_amount + fee:
|
||||
async with action_scope.use() as interface:
|
||||
interface.side_effects.selected_coins.append(coin)
|
||||
coins = await wallet.select_coins(
|
||||
uint64(total_amount + fee - coin.amount),
|
||||
action_scope,
|
||||
)
|
||||
coins.add(coin)
|
||||
else:
|
||||
coins = {coin}
|
||||
|
||||
await wallet.generate_signed_transaction(
|
||||
[output.amount for output in outputs],
|
||||
[output.puzzle_hash for output in outputs],
|
||||
action_scope,
|
||||
fee,
|
||||
coins=coins,
|
||||
extra_conditions=extra_conditions,
|
||||
)
|
||||
|
||||
async def combine_coins(
|
||||
self,
|
||||
*,
|
||||
action_scope: WalletActionScope,
|
||||
wallet_id: uint32,
|
||||
number_of_coins: uint16,
|
||||
largest_first: bool,
|
||||
coin_num_limit: uint16,
|
||||
fee: uint64,
|
||||
target_coin_amount: uint64 | None = None,
|
||||
target_coin_ids: list[bytes32] | None = None,
|
||||
extra_conditions: tuple[Condition, ...] = tuple(),
|
||||
) -> None:
|
||||
if wallet_id not in self.wallets:
|
||||
raise ValueError(f"Wallet with ID {wallet_id} does not exist")
|
||||
wallet = self.wallets[wallet_id]
|
||||
if not isinstance(wallet, (Wallet, CATWallet)):
|
||||
raise ValueError("Cannot combine coins from non-fungible wallet types")
|
||||
|
||||
coins: list[Coin] = []
|
||||
|
||||
# First get the coin IDs specified
|
||||
if target_coin_ids is not None:
|
||||
target_records = (
|
||||
await self.coin_store.get_coin_records(
|
||||
wallet_id=wallet_id,
|
||||
coin_id_filter=HashFilter(target_coin_ids, mode=uint8(FilterMode.include.value)),
|
||||
)
|
||||
).records
|
||||
spent_ids = [cr.coin.name() for cr in target_records if cr.spent]
|
||||
if spent_ids:
|
||||
raise ValueError(f"Cannot combine already-spent coins: {', '.join(c.hex() for c in spent_ids)}")
|
||||
coins.extend(cr.coin for cr in target_records)
|
||||
|
||||
async with action_scope.use() as interface:
|
||||
interface.side_effects.selected_coins.extend(coins)
|
||||
|
||||
# Next let's select enough coins to meet the target + fee if there is one
|
||||
fungible_amount_needed = uint64(0) if target_coin_amount is None else target_coin_amount
|
||||
if isinstance(wallet, Wallet):
|
||||
fungible_amount_needed = uint64(fungible_amount_needed + fee)
|
||||
amount_selected = sum(c.amount for c in coins)
|
||||
if amount_selected < fungible_amount_needed: # implicit fungible_amount_needed > 0 here
|
||||
coins.extend(
|
||||
await wallet.select_coins(
|
||||
amount=uint64(fungible_amount_needed - amount_selected), action_scope=action_scope
|
||||
)
|
||||
)
|
||||
|
||||
if len(coins) > number_of_coins:
|
||||
raise ValueError(
|
||||
f"Options specified cannot be met without selecting more coins than specified: {len(coins)}"
|
||||
)
|
||||
|
||||
# Now let's select enough coins to get to the target number to combine
|
||||
if len(coins) < number_of_coins:
|
||||
coin_selection_config = action_scope.config.tx_config.coin_selection_config
|
||||
async with action_scope.use() as interface:
|
||||
coins.extend(
|
||||
cr.coin
|
||||
for cr in (
|
||||
await self.coin_store.get_coin_records(
|
||||
wallet_id=wallet_id,
|
||||
limit=uint32(number_of_coins - len(coins)),
|
||||
order=CoinRecordOrder.amount,
|
||||
coin_id_filter=HashFilter(
|
||||
[c.name() for c in interface.side_effects.selected_coins],
|
||||
mode=uint8(FilterMode.exclude.value),
|
||||
),
|
||||
reverse=largest_first,
|
||||
spent_range=UInt32Range(stop=uint32(0)),
|
||||
amount_range=UInt64Range(
|
||||
start=coin_selection_config.min_coin_amount,
|
||||
stop=coin_selection_config.max_coin_amount,
|
||||
),
|
||||
)
|
||||
).records
|
||||
)
|
||||
|
||||
async with action_scope.use() as interface:
|
||||
interface.side_effects.selected_coins.extend(coins)
|
||||
|
||||
primary_output_amount = (
|
||||
uint64(sum(c.amount for c in coins)) if target_coin_amount is None else target_coin_amount
|
||||
)
|
||||
if isinstance(wallet, Wallet):
|
||||
primary_output_amount = uint64(primary_output_amount - fee)
|
||||
|
||||
await wallet.generate_signed_transaction(
|
||||
[primary_output_amount],
|
||||
[await action_scope.get_puzzle_hash(self)],
|
||||
action_scope,
|
||||
fee,
|
||||
coins=set(coins),
|
||||
extra_conditions=extra_conditions,
|
||||
)
|
||||
|
||||
def new_pool_wallet_pubkey(self) -> G1Element:
|
||||
# We assign a pseudo unique id to each pool wallet, so that each one gets its own deterministic
|
||||
# owner and auth keys. The public keys will go on the blockchain, and the private keys can be found
|
||||
|
||||
Reference in New Issue
Block a user