Repository: Chia-Network/chia-blockchain
Pull request #19439: CHIA-2604 Separate identical spend deduplication and singleton fast forward into their own classes
Author: login: AmineKhaldi
Labels: name: ready_to_merge
description: Submitter and reviewers think this is ready
name: Changed
description: Required label for PR that categorizes merge commit message as "Changed" for changelog
Created: 2025-03-27T12:56:15Z | Merged: 2025-04-10T13:22:03Z

### Purpose:

This separates `EligibleCoinSpends` class into `IdenticalSpendDedup` and `SingletonFastForward` classes.

### Current Behavior:

Both identical spend deduplication and singleton fast forward are handled by `EligibleCoinSpends` class.

### New Behavior:

These are handled by two separate classes.




Diff:
diff --git a/chia/_tests/core/mempool/test_mempool_manager.py b/chia/_tests/core/mempool/test_mempool_manager.py
index b26853975fd6..97ef91700ce7 100644
--- a/chia/_tests/core/mempool/test_mempool_manager.py
+++ b/chia/_tests/core/mempool/test_mempool_manager.py
@@ -59,7 +59,7 @@
 from chia.types.eligible_coin_spends import (
     DedupCoinSpend,
     EligibilityAndAdditions,
-    EligibleCoinSpends,
+    IdenticalSpendDedup,
     SkipDedup,
     UnspentLineageInfo,
     run_for_cost,
@@ -1506,14 +1506,14 @@ def test_dedup_info_nothing_to_do() -> None:
     ]
     sb = spend_bundle_from_conditions(conditions, TEST_COIN, sig)
     mempool_item = mempool_item_from_spendbundle(sb)
-    eligible_coin_spends = EligibleCoinSpends()
-    unique_coin_spends, cost_saving, unique_additions = eligible_coin_spends.get_deduplication_info(
+    dedup_coin_spends = IdenticalSpendDedup()
+    unique_coin_spends, cost_saving, unique_additions = dedup_coin_spends.get_deduplication_info(
         bundle_coin_spends=mempool_item.bundle_coin_spends, max_cost=mempool_item.conds.cost
     )
     assert unique_coin_spends == sb.coin_spends
     assert cost_saving == 0
     assert unique_additions == [Coin(TEST_COIN_ID, IDENTITY_PUZZLE_HASH, uint64(1))]
-    assert eligible_coin_spends == EligibleCoinSpends()
+    assert dedup_coin_spends == IdenticalSpendDedup()
 
 
 def test_dedup_info_eligible_1st_time() -> None:
@@ -1525,9 +1525,9 @@ def test_dedup_info_eligible_1st_time() -> None:
     sb = spend_bundle_from_conditions(conditions, TEST_COIN)
     mempool_item = mempool_item_from_spendbundle(sb)
     assert mempool_item.conds is not None
-    eligible_coin_spends = EligibleCoinSpends()
+    dedup_coin_spends = IdenticalSpendDedup()
     solution = SerializedProgram.to(conditions)
-    unique_coin_spends, cost_saving, unique_additions = eligible_coin_spends.get_deduplication_info(
+    unique_coin_spends, cost_saving, unique_additions = dedup_coin_spends.get_deduplication_info(
         bundle_coin_spends=mempool_item.bundle_coin_spends, max_cost=mempool_item.conds.cost
     )
     assert unique_coin_spends == sb.coin_spends
@@ -1536,7 +1536,7 @@ def test_dedup_info_eligible_1st_time() -> None:
         Coin(TEST_COIN_ID, IDENTITY_PUZZLE_HASH, uint64(1)),
         Coin(TEST_COIN_ID, IDENTITY_PUZZLE_HASH, uint64(TEST_COIN_AMOUNT - 1)),
     }
-    assert eligible_coin_spends == EligibleCoinSpends({TEST_COIN_ID: DedupCoinSpend(solution=solution, cost=None)})
+    assert dedup_coin_spends == IdenticalSpendDedup({TEST_COIN_ID: DedupCoinSpend(solution=solution, cost=None)})
 
 
 def test_dedup_info_eligible_but_different_solution() -> None:
@@ -1546,12 +1546,12 @@ def test_dedup_info_eligible_but_different_solution() -> None:
         [ConditionOpcode.CREATE_COIN, IDENTITY_PUZZLE_HASH, TEST_COIN_AMOUNT],
     ]
     initial_solution = SerializedProgram.to(initial_conditions)
-    eligible_coin_spends = EligibleCoinSpends({TEST_COIN_ID: DedupCoinSpend(solution=initial_solution, cost=None)})
+    dedup_coin_spends = IdenticalSpendDedup({TEST_COIN_ID: DedupCoinSpend(solution=initial_solution, cost=None)})
     conditions = [[ConditionOpcode.CREATE_COIN, IDENTITY_PUZZLE_HASH, TEST_COIN_AMOUNT]]
     sb = spend_bundle_from_conditions(conditions, TEST_COIN)
     mempool_item = mempool_item_from_spendbundle(sb)
     with pytest.raises(SkipDedup, match="Solution is different from what we're deduplicating on"):
-        eligible_coin_spends.get_deduplication_info(
+        dedup_coin_spends.get_deduplication_info(
             bundle_coin_spends=mempool_item.bundle_coin_spends, max_cost=mempool_item.conds.cost
         )
 
@@ -1563,7 +1563,7 @@ def test_dedup_info_eligible_2nd_time_and_another_1st_time() -> None:
         [ConditionOpcode.CREATE_COIN, IDENTITY_PUZZLE_HASH, TEST_COIN_AMOUNT - 1],
     ]
     initial_solution = SerializedProgram.to(initial_conditions)
-    eligible_coin_spends = EligibleCoinSpends({TEST_COIN_ID: DedupCoinSpend(solution=initial_solution, cost=None)})
+    dedup_coin_spends = IdenticalSpendDedup({TEST_COIN_ID: DedupCoinSpend(solution=initial_solution, cost=None)})
     sb1 = spend_bundle_from_conditions(initial_conditions, TEST_COIN)
     second_conditions = [[ConditionOpcode.CREATE_COIN, IDENTITY_PUZZLE_HASH, TEST_COIN_AMOUNT2]]
     second_solution = SerializedProgram.to(second_conditions)
@@ -1571,7 +1571,7 @@ def test_dedup_info_eligible_2nd_time_and_another_1st_time() -> None:
     sb = SpendBundle.aggregate([sb1, sb2])
     mempool_item = mempool_item_from_spendbundle(sb)
     assert mempool_item.conds is not None
-    unique_coin_spends, cost_saving, unique_additions = eligible_coin_spends.get_deduplication_info(
+    unique_coin_spends, cost_saving, unique_additions = dedup_coin_spends.get_deduplication_info(
         bundle_coin_spends=mempool_item.bundle_coin_spends, max_cost=mempool_item.conds.cost
     )
     # Only the eligible one that we encountered more than once gets deduplicated
@@ -1581,13 +1581,13 @@ def test_dedup_info_eligible_2nd_time_and_another_1st_time() -> None:
     assert unique_additions == [Coin(TEST_COIN_ID2, IDENTITY_PUZZLE_HASH, TEST_COIN_AMOUNT2)]
     # The coin we encountered a second time has its cost and additions properly updated
     # The coin we encountered for the first time gets cost None and an empty set of additions
-    expected_eligible_spends = EligibleCoinSpends(
+    expected_dedup_coin_spends = IdenticalSpendDedup(
         {
             TEST_COIN_ID: DedupCoinSpend(solution=initial_solution, cost=saved_cost),
             TEST_COIN_ID2: DedupCoinSpend(solution=second_solution, cost=None),
         }
     )
-    assert eligible_coin_spends == expected_eligible_spends
+    assert dedup_coin_spends == expected_dedup_coin_spends
 
 
 def test_dedup_info_eligible_3rd_time_another_2nd_time_and_one_non_eligible() -> None:
@@ -1600,7 +1600,7 @@ def test_dedup_info_eligible_3rd_time_another_2nd_time_and_one_non_eligible() ->
     second_conditions = [[ConditionOpcode.CREATE_COIN, IDENTITY_PUZZLE_HASH, TEST_COIN_AMOUNT2]]
     second_solution = SerializedProgram.to(second_conditions)
     saved_cost = uint64(3600044)
-    eligible_coin_spends = EligibleCoinSpends(
+    dedup_coin_spends = IdenticalSpendDedup(
         {
             TEST_COIN_ID: DedupCoinSpend(solution=initial_solution, cost=saved_cost),
             TEST_COIN_ID2: DedupCoinSpend(solution=second_solution, cost=None),
@@ -1619,20 +1619,20 @@ def test_dedup_info_eligible_3rd_time_another_2nd_time_and_one_non_eligible() ->
     sb = SpendBundle.aggregate([sb1, sb2, sb3])
     mempool_item = mempool_item_from_spendbundle(sb)
     assert mempool_item.conds is not None
-    unique_coin_spends, cost_saving, unique_additions = eligible_coin_spends.get_deduplication_info(
+    unique_coin_spends, cost_saving, unique_additions = dedup_coin_spends.get_deduplication_info(
         bundle_coin_spends=mempool_item.bundle_coin_spends, max_cost=mempool_item.conds.cost
     )
     assert unique_coin_spends == sb3.coin_spends
     saved_cost2 = uint64(1800044)
     assert cost_saving == saved_cost + saved_cost2
     assert unique_additions == [Coin(TEST_COIN_ID3, IDENTITY_PUZZLE_HASH, TEST_COIN_AMOUNT3)]
-    expected_eligible_spends = EligibleCoinSpends(
+    expected_dedup_coin_spends = IdenticalSpendDedup(
         {
             TEST_COIN_ID: DedupCoinSpend(initial_solution, saved_cost),
             TEST_COIN_ID2: DedupCoinSpend(second_solution, saved_cost2),
         }
     )
-    assert eligible_coin_spends == expected_eligible_spends
+    assert dedup_coin_spends == expected_dedup_coin_spends
 
 
 @pytest.mark.anyio
diff --git a/chia/_tests/core/mempool/test_singleton_fast_forward.py b/chia/_tests/core/mempool/test_singleton_fast_forward.py
index 3dde1fda5957..e1f517c2eaf9 100644
--- a/chia/_tests/core/mempool/test_singleton_fast_forward.py
+++ b/chia/_tests/core/mempool/test_singleton_fast_forward.py
@@ -28,7 +28,11 @@
 from chia.types.blockchain_format.serialized_program import SerializedProgram
 from chia.types.coin_spend import CoinSpend, make_spend
 from chia.types.condition_opcodes import ConditionOpcode
-from chia.types.eligible_coin_spends import EligibleCoinSpends, UnspentLineageInfo, perform_the_fast_forward
+from chia.types.eligible_coin_spends import (
+    SingletonFastForward,
+    UnspentLineageInfo,
+    perform_the_fast_forward,
+)
 from chia.types.internal_mempool_item import InternalMempoolItem
 from chia.types.mempool_inclusion_status import MempoolInclusionStatus
 from chia.types.mempool_item import BundleCoinSpend
@@ -58,14 +62,14 @@ async def get_unspent_lineage_info_for_puzzle_hash(_: bytes32) -> Optional[Unspe
     assert item.bundle_coin_spends[TEST_COIN_ID].eligible_for_fast_forward is False
     internal_mempool_item = InternalMempoolItem(sb, item.conds, item.height_added_to_mempool, item.bundle_coin_spends)
     original_version = dataclasses.replace(internal_mempool_item)
-    eligible_coin_spends = EligibleCoinSpends()
-    bundle_coin_spends = await eligible_coin_spends.process_fast_forward_spends(
+    singleton_ff = SingletonFastForward()
+    bundle_coin_spends = await singleton_ff.process_fast_forward_spends(
         mempool_item=internal_mempool_item,
         get_unspent_lineage_info_for_puzzle_hash=get_unspent_lineage_info_for_puzzle_hash,
         height=TEST_HEIGHT,
         constants=DEFAULT_CONSTANTS,
     )
-    assert eligible_coin_spends == EligibleCoinSpends()
+    assert singleton_ff == SingletonFastForward()
     assert bundle_coin_spends == original_version.bundle_coin_spends
 
 
@@ -88,11 +92,11 @@ async def get_unspent_lineage_info_for_puzzle_hash(puzzle_hash: bytes32) -> Opti
     # The coin is eligible for fast forward
     assert item.bundle_coin_spends[test_coin.name()].eligible_for_fast_forward is True
     internal_mempool_item = InternalMempoolItem(sb, item.conds, item.height_added_to_mempool, item.bundle_coin_spends)
-    eligible_coin_spends = EligibleCoinSpends()
+    singleton_ff = SingletonFastForward()
     # We have no fast forward records yet, so we'll process this coin for the
     # first time here, but the DB lookup will return None
     with pytest.raises(ValueError, match="Cannot proceed with singleton spend fast forward."):
-        await eligible_coin_spends.process_fast_forward_spends(
+        await singleton_ff.process_fast_forward_spends(
             mempool_item=internal_mempool_item,
             get_unspent_lineage_info_for_puzzle_hash=get_unspent_lineage_info_for_puzzle_hash,
             height=TEST_HEIGHT,
@@ -127,8 +131,8 @@ async def get_unspent_lineage_info_for_puzzle_hash(puzzle_hash: bytes32) -> Opti
     assert item.bundle_coin_spends[test_coin.name()].eligible_for_fast_forward is True
     internal_mempool_item = InternalMempoolItem(sb, item.conds, item.height_added_to_mempool, item.bundle_coin_spends)
     original_version = dataclasses.replace(internal_mempool_item)
-    eligible_coin_spends = EligibleCoinSpends()
-    bundle_coin_spends = await eligible_coin_spends.process_fast_forward_spends(
+    singleton_ff = SingletonFastForward()
+    bundle_coin_spends = await singleton_ff.process_fast_forward_spends(
         mempool_item=internal_mempool_item,
         get_unspent_lineage_info_for_puzzle_hash=get_unspent_lineage_info_for_puzzle_hash,
         height=TEST_HEIGHT,
@@ -141,7 +145,7 @@ async def get_unspent_lineage_info_for_puzzle_hash(puzzle_hash: bytes32) -> Opti
         )
     }
     # We have set the next version from our additions to chain ff spends
-    assert eligible_coin_spends.fast_forward_spends == expected_fast_forward_spends
+    assert singleton_ff.fast_forward_spends == expected_fast_forward_spends
     # We didn't need to fast forward the item so it stays as is
     assert bundle_coin_spends == original_version.bundle_coin_spends
 
diff --git a/chia/full_node/mempool.py b/chia/full_node/mempool.py
index 0c2859c24c74..6d22e580be6f 100644
--- a/chia/full_node/mempool.py
+++ b/chia/full_node/mempool.py
@@ -30,7 +30,12 @@
 from chia.types.blockchain_format.serialized_program import SerializedProgram
 from chia.types.clvm_cost import CLVMCost
 from chia.types.coin_spend import CoinSpend
-from chia.types.eligible_coin_spends import EligibleCoinSpends, SkipDedup, UnspentLineageInfo
+from chia.types.eligible_coin_spends import (
+    IdenticalSpendDedup,
+    SingletonFastForward,
+    SkipDedup,
+    UnspentLineageInfo,
+)
 from chia.types.generator_types import NewBlockGenerator
 from chia.types.internal_mempool_item import InternalMempoolItem
 from chia.types.mempool_item import MempoolItem
@@ -573,15 +578,16 @@ async def create_bundle_from_mempool_items(
         fee_sum = 0  # Checks that total fees don't exceed 64 bits
         processed_spend_bundles = 0
         additions: list[Coin] = []
-        # This contains:
-        # 1. A map of coin ID to a coin spend solution and its isolated cost
-        #   We reconstruct it for every bundle we create from mempool items because we
-        #   deduplicate on the first coin spend solution that comes with the highest
-        #   fee rate item, and that can change across calls
-        # 2. A map of fast forward eligible singleton puzzle hash to the most
-        #   recent unspent singleton data, to allow chaining fast forward
-        #   singleton spends
-        eligible_coin_spends = EligibleCoinSpends()
+        # This contains a map of coin ID to a coin spend solution and its
+        # isolated cost. We reconstruct it for every bundle we create from
+        # mempool items because we deduplicate on the first coin spend solution
+        # that comes with the highest fee rate item, and that can change across
+        # calls.
+        dedup_coin_spends = IdenticalSpendDedup()
+        # This contains a map of fast forward eligible singleton puzzle hash to
+        # the most recent unspent singleton data, to allow chaining fast forward
+        # singleton spends.
+        singleton_ff = SingletonFastForward()
         coin_spends: list[CoinSpend] = []
         sigs: list[G2Element] = []
         log.info(f"Starting to make block, max cost: {self.mempool_info.max_block_clvm_cost}")
@@ -619,13 +625,13 @@ async def create_bundle_from_mempool_items(
                         unique_additions.extend(spend_data.additions)
                     cost_saving = 0
                 else:
-                    bundle_coin_spends = await eligible_coin_spends.process_fast_forward_spends(
+                    bundle_coin_spends = await singleton_ff.process_fast_forward_spends(
                         mempool_item=item,
                         get_unspent_lineage_info_for_puzzle_hash=get_unspent_lineage_info_for_puzzle_hash,
                         height=height,
                         constants=constants,
                     )
-                    unique_coin_spends, cost_saving, unique_additions = eligible_coin_spends.get_deduplication_info(
+                    unique_coin_spends, cost_saving, unique_additions = dedup_coin_spends.get_deduplication_info(
                         bundle_coin_spends=bundle_coin_spends, max_cost=cost
                     )
                 item_cost = cost - cost_saving
@@ -696,7 +702,8 @@ async def create_block_generator2(
         additions: list[Coin] = []
         removals: list[Coin] = []
 
-        eligible_coin_spends = EligibleCoinSpends()
+        dedup_coin_spends = IdenticalSpendDedup()
+        singleton_ff = SingletonFastForward()
         log.info(f"Starting to make block, max cost: {self.mempool_info.max_block_clvm_cost}")
         generator_creation_start = monotonic()
         cursor = self._db_conn.execute("SELECT name, fee FROM tx ORDER BY fee_per_cost DESC, seq ASC")
@@ -724,13 +731,13 @@ async def create_block_generator2(
             try:
                 assert item.conds is not None
                 cost = item.conds.condition_cost + item.conds.execution_cost
-                bundle_coin_spends = await eligible_coin_spends.process_fast_forward_spends(
+                bundle_coin_spends = await singleton_ff.process_fast_forward_spends(
                     mempool_item=item,
                     get_unspent_lineage_info_for_puzzle_hash=get_unspent_lineage_info_for_puzzle_hash,
                     height=height,
                     constants=constants,
                 )
-                unique_coin_spends, cost_saving, unique_additions = eligible_coin_spends.get_deduplication_info(
+                unique_coin_spends, cost_saving, unique_additions = dedup_coin_spends.get_deduplication_info(
                     bundle_coin_spends=bundle_coin_spends, max_cost=cost
                 )
                 new_fee_sum = fee_sum + fee
diff --git a/chia/types/eligible_coin_spends.py b/chia/types/eligible_coin_spends.py
index ed0ba602bc28..2b9cb7fb113a 100644
--- a/chia/types/eligible_coin_spends.py
+++ b/chia/types/eligible_coin_spends.py
@@ -144,9 +144,8 @@ class SkipDedup(BaseException):
 
 
 @dataclasses.dataclass(frozen=True)
-class EligibleCoinSpends:
+class IdenticalSpendDedup:
     deduplication_spends: dict[bytes32, DedupCoinSpend] = dataclasses.field(default_factory=dict)
-    fast_forward_spends: dict[bytes32, UnspentLineageInfo] = dataclasses.field(default_factory=dict)
 
     def get_deduplication_info(
         self, *, bundle_coin_spends: dict[bytes32, BundleCoinSpend], max_cost: int
@@ -229,6 +228,11 @@ def get_deduplication_info(
         self.deduplication_spends.update(new_dedup_spends)
         return unique_coin_spends, uint64(cost_saving), unique_additions
 
+
+@dataclasses.dataclass(frozen=True)
+class SingletonFastForward:
+    fast_forward_spends: dict[bytes32, UnspentLineageInfo] = dataclasses.field(default_factory=dict)
+
     async def process_fast_forward_spends(
         self,
         *,


Reviews:
user: login: arvidn
body: I think this is a good improvement. The name "eligible coin" is quite cryptic.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T07:49:23Z
user: login: AmineKhaldi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T10:34:41Z
user: login: arvidn
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T07:01:47Z

Review comments:
diff_hunk: @@ -229,6 +228,11 @@ def get_deduplication_info(
         self.deduplication_spends.update(new_dedup_spends)
         return unique_coin_spends, uint64(cost_saving), unique_additions
 
+
+@dataclasses.dataclass(frozen=True)
path: chia/types/eligible_coin_spends.py
user: login: arvidn
body: I don't understand how this class can be frozen (immutable). Surely you add elements to the `fast_forward_spends` dictionary, which (presumably) requires the class to be mutable.
(I also don't understand how `IdenticalSpendDedup` can be frozen)
created_at: 2025-04-02T07:46:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -229,6 +228,11 @@ def get_deduplication_info(
         self.deduplication_spends.update(new_dedup_spends)
         return unique_coin_spends, uint64(cost_saving), unique_additions
 
+
+@dataclasses.dataclass(frozen=True)
path: chia/types/eligible_coin_spends.py
user: login: AmineKhaldi
body: While the dataclass being frozen prevents reassigning this map, the map itself is and remains mutable.
created_at: 2025-04-02T10:34:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-08T13:47:21Z
body: This pull request has conflicts, please resolve those before we can evaluate the pull request.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-08T15:46:59Z
body: Conflicts have been resolved. A maintainer will review the pull request shortly.
author_association: CONTRIBUTOR