[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:
Matt Hauff
2026-08-20 14:04:50 -07:00
committed by GitHub
parent d75e404650
commit 9492119b2f
4 changed files with 186 additions and 164 deletions
+2 -2
View File
@@ -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),
+174
View File
@@ -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,
)
+4 -5
View File
@@ -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,
+6 -157
View File
@@ -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