if a dedup spend doesn't make it into the block, restore the dedup state (#21115)

* if a dedup spend doesn't make it into the block, restore the dedup state

* review comments
This commit is contained in:
Arvid Norberg
2026-07-16 10:16:18 -07:00
committed by GitHub
parent a9b0d2eb1e
commit c95e936a01
4 changed files with 117 additions and 30 deletions
+57 -2
View File
@@ -7,6 +7,7 @@ from collections.abc import Callable
import pytest
from chia_rs import (
ELIGIBLE_FOR_DEDUP,
ENABLE_KECCAK_OPS_OUTSIDE_GUARD,
AugSchemeMPL,
CoinSpend,
@@ -3473,8 +3474,6 @@ def test_max_spends_per_block(old: bool) -> None:
@pytest.mark.parametrize("old", [True, False])
def test_max_spends_per_block_with_dedup(old: bool) -> None:
from chia_rs import ELIGIBLE_FOR_DEDUP
max_cost = uint64(11_000_000_000)
fee_estimator = create_bitcoin_fee_estimator(max_cost)
mempool_info = MempoolInfo(
@@ -3514,6 +3513,62 @@ def test_max_spends_per_block_with_dedup(old: bool) -> None:
assert len(generator.removals) > MAX_SPENDS_PER_BLOCK // 2
@pytest.mark.parametrize("old", [True, False])
def test_skipped_item_does_not_leak_dedup_state(old: bool) -> None:
"""
an item that is processed (and thus registers a shared
dedup coin) but ultimately dropped must not leave that coin registered in
the dedup state. Otherwise a later item spending the same coin gets
"deduplicated" against a spend that never makes it into the block, dropping
the coin from the block entirely.
"""
max_cost = uint64(11_000_000_000)
fee_estimator = create_bitcoin_fee_estimator(max_cost)
mempool_info = MempoolInfo(
CLVMCost(uint64(max_cost * 10)),
FeeRate(uint64(1000000)),
CLVMCost(max_cost),
)
mempool = Mempool(mempool_info, fee_estimator)
shared_coin = make_coin(0)
# The "big" item registers the shared dedup coin but has more spends than a
# block can hold, so it gets skipped. Its high fee gives it the highest
# priority, so it's the first item processed (and the first to touch the
# shared coin).
big_item = mk_item(
[shared_coin, *[make_coin(i + 1) for i in range(MAX_SPENDS_PER_BLOCK)]],
cost=1,
fee=1_000_000_000,
flags=[ELIGIBLE_FOR_DEDUP],
)
info = mempool.add_to_pool(big_item)
assert info.error is None
# The "small" item spends the same shared dedup coin plus one unique coin.
# It's small enough to fit and is processed after the big item.
unique_coin = make_coin(MAX_SPENDS_PER_BLOCK + 1)
small_item = mk_item(
[shared_coin, unique_coin],
cost=1,
fee=0,
flags=[ELIGIBLE_FOR_DEDUP, 0],
)
info = mempool.add_to_pool(small_item)
assert info.error is None
create_block = mempool.create_block_generator if old else mempool.create_block_generator2
generator = create_block(test_constants, uint32(0), 30.0)
assert generator is not None
removals = set(generator.removals)
# The small item was included, so every coin it spends must be in the block.
# In particular the shared coin must not have been deduplicated away against
# the skipped big item.
assert removals == {shared_coin, unique_coin}
def test_keccak() -> None:
# the keccak operator is 62. The assemble() function doesn't support it
# (yet)
@@ -22,6 +22,7 @@ from chia_rs import (
check_time_locks,
get_conditions_from_spendbundle,
run_block_generator2,
supports_fast_forward,
)
from chia_rs.sized_bytes import bytes32
from chia_rs.sized_ints import uint8, uint32, uint64
@@ -1684,12 +1685,16 @@ def test_dedup_info_nothing_to_do() -> None:
sb = spend_bundle_from_conditions(conditions, TEST_COIN, sig)
mempool_item = mempool_item_from_spendbundle(sb)
dedup_coin_spends = IdenticalSpendDedup()
unique_coin_spends, cost_saving, unique_additions = dedup_coin_spends.get_deduplication_info(
unique_coin_spends, cost_saving, unique_additions, dedup_state_update = dedup_coin_spends.get_deduplication_info(
bundle_coin_spends=mempool_item.bundle_coin_spends
)
assert unique_coin_spends == sb.coin_spends
assert cost_saving == 0
assert unique_additions == [Coin(TEST_COIN_ID, IDENTITY_PUZZLE_HASH, uint64(1))]
assert dedup_state_update == {}
# get_deduplication_info must not mutate the dedup state on its own
assert dedup_coin_spends == IdenticalSpendDedup()
dedup_coin_spends.update_deduplication_spends(dedup_state_update)
assert dedup_coin_spends == IdenticalSpendDedup()
@@ -1704,7 +1709,7 @@ def test_dedup_info_eligible_1st_time() -> None:
assert mempool_item.conds is not None
dedup_coin_spends = IdenticalSpendDedup()
solution = SerializedProgram.to(conditions)
unique_coin_spends, cost_saving, unique_additions = dedup_coin_spends.get_deduplication_info(
unique_coin_spends, cost_saving, unique_additions, dedup_state_update = dedup_coin_spends.get_deduplication_info(
bundle_coin_spends=mempool_item.bundle_coin_spends
)
assert unique_coin_spends == sb.coin_spends
@@ -1714,6 +1719,10 @@ def test_dedup_info_eligible_1st_time() -> None:
Coin(TEST_COIN_ID, IDENTITY_PUZZLE_HASH, uint64(TEST_COIN_AMOUNT - 1)),
}
expected_cost = mempool_item.bundle_coin_spends[TEST_COIN_ID].cost
# The update is returned but not applied until explicitly committed
assert dedup_coin_spends == IdenticalSpendDedup()
assert dedup_state_update == {TEST_COIN_ID: DedupCoinSpend(solution=solution, cost=expected_cost)}
dedup_coin_spends.update_deduplication_spends(dedup_state_update)
assert dedup_coin_spends == IdenticalSpendDedup(
{TEST_COIN_ID: DedupCoinSpend(solution=solution, cost=expected_cost)}
)
@@ -1752,7 +1761,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 = dedup_coin_spends.get_deduplication_info(
unique_coin_spends, cost_saving, unique_additions, dedup_state_update = dedup_coin_spends.get_deduplication_info(
bundle_coin_spends=mempool_item.bundle_coin_spends
)
# Only the eligible one that we encountered more than once gets deduplicated
@@ -1760,8 +1769,11 @@ def test_dedup_info_eligible_2nd_time_and_another_1st_time() -> None:
assert cost_saving == test_coin_cost
assert unique_additions == [Coin(TEST_COIN_ID2, IDENTITY_PUZZLE_HASH, TEST_COIN_AMOUNT2)]
# The coin we encountered a second time is already in the map
# The coin we encountered for the first time gets added with its solution and cost
# The coin we encountered for the first time is only reported in the update,
# not committed until we explicitly do so
test_coin2_cost = mempool_item.bundle_coin_spends[TEST_COIN_ID2].cost
assert dedup_state_update == {TEST_COIN_ID2: DedupCoinSpend(solution=second_solution, cost=test_coin2_cost)}
dedup_coin_spends.update_deduplication_spends(dedup_state_update)
expected_dedup_coin_spends = IdenticalSpendDedup(
{
TEST_COIN_ID: DedupCoinSpend(solution=initial_solution, cost=test_coin_cost),
@@ -1801,13 +1813,16 @@ 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 = dedup_coin_spends.get_deduplication_info(
unique_coin_spends, cost_saving, unique_additions, dedup_state_update = dedup_coin_spends.get_deduplication_info(
bundle_coin_spends=mempool_item.bundle_coin_spends
)
assert unique_coin_spends == sb3.coin_spends
assert cost_saving == test_coin_cost + test_coin2_cost
assert unique_additions == [Coin(TEST_COIN_ID3, IDENTITY_PUZZLE_HASH, TEST_COIN_AMOUNT3)]
# TEST_COIN_ID3 is non-eligible, so it doesn't end up in this map
# Both eligible coins were already known, so there's nothing new to commit
# (TEST_COIN_ID3 is non-eligible, so it never ends up in the map)
assert dedup_state_update == {}
dedup_coin_spends.update_deduplication_spends(dedup_state_update)
expected_dedup_coin_spends = IdenticalSpendDedup(
{
TEST_COIN_ID: DedupCoinSpend(initial_solution, test_coin_cost),
@@ -2465,8 +2480,6 @@ class TestCoins:
def make_singleton_spend(
launcher_id: bytes32, parent_parent_id: bytes32 = bytes32([3] * 32), child_amount: int = 1
) -> CoinSpend:
from chia_rs import supports_fast_forward
from chia.wallet.lineage_proof import LineageProof
from chia.wallet.puzzles.singleton_top_layer_v1_1 import puzzle_for_singleton, solution_for_singleton
+17 -4
View File
@@ -117,11 +117,17 @@ class IdenticalSpendDedup:
def get_deduplication_info(
self, *, bundle_coin_spends: dict[bytes32, BundleCoinSpend]
) -> tuple[list[CoinSpend], uint64, list[Coin]]:
) -> tuple[list[CoinSpend], uint64, list[Coin], dict[bytes32, DedupCoinSpend]]:
"""
Checks all coin spends of a mempool item for deduplication eligibility and
provides the caller with the necessary information that allows it to perform
identical spend aggregation on that mempool item if possible
identical spend aggregation on that mempool item if possible.
This does not mutate the deduplication state. The returned state update
must be explicitly committed by the caller via
`update_deduplication_spends()` once the item is confirmed to be included,
so that items which are processed but ultimately dropped don't leave stale
deduplication entries behind.
Args:
bundle_coin_spends: the mempool item's coin spends data
@@ -130,6 +136,9 @@ class IdenticalSpendDedup:
list[CoinSpend]: list of unique coin spends in this mempool item
uint64: the cost we're saving by deduplicating eligible coins
list[Coin]: list of unique additions in this mempool item
dict[bytes32, DedupCoinSpend]: the deduplication state update to
commit via `update_deduplication_spends()` if the item ends up
included
Raises:
ValueError to skip the mempool item we're currently in, if it's
@@ -162,9 +171,13 @@ class IdenticalSpendDedup:
# different solutions are rejected in check_removals().
raise SkipDedup("Solution is different from what we're deduplicating on")
cost_saving += dedup_coin_spend.cost
# Update the eligible coin spends data
return unique_coin_spends, uint64(cost_saving), unique_additions, new_dedup_spends
def update_deduplication_spends(self, new_dedup_spends: dict[bytes32, DedupCoinSpend]) -> None:
self.deduplication_spends.update(new_dedup_spends)
return unique_coin_spends, uint64(cost_saving), unique_additions
def copy(self) -> IdenticalSpendDedup:
return IdenticalSpendDedup(deduplication_spends=dict(self.deduplication_spends))
@dataclasses.dataclass(frozen=True)
+22 -16
View File
@@ -26,6 +26,7 @@ from chia_rs.sized_ints import uint32, uint64
from chia.consensus.default_constants import DEFAULT_CONSTANTS
from chia.full_node.eligible_coin_spends import (
DedupCoinSpend,
IdenticalSpendDedup,
SingletonFastForward,
SkipDedup,
@@ -634,13 +635,14 @@ class Mempool:
unique_coin_spends.append(spend_data.coin_spend)
unique_additions.extend(spend_data.additions)
ff_state_update: dict[bytes32, UnspentLineageInfo] = {}
dedup_state_update: dict[bytes32, DedupCoinSpend] = {}
cost_saving = 0
else:
bundle_coin_spends, ff_state_update = singleton_ff.process_fast_forward_spends(
mempool_item=item, prev_tx_height=prev_tx_height, constants=constants
)
unique_coin_spends, cost_saving, unique_additions = dedup_coin_spends.get_deduplication_info(
bundle_coin_spends=bundle_coin_spends
unique_coin_spends, cost_saving, unique_additions, dedup_state_update = (
dedup_coin_spends.get_deduplication_info(bundle_coin_spends=bundle_coin_spends)
)
item_cost = cost - cost_saving
log.info(
@@ -666,6 +668,7 @@ class Mempool:
continue
break
singleton_ff.update_fast_forward_spends(ff_state_update)
dedup_coin_spends.update_deduplication_spends(dedup_state_update)
coin_spends.extend(unique_coin_spends)
additions.extend(unique_additions)
sigs.append(item.aggregated_signature)
@@ -714,9 +717,10 @@ class Mempool:
dedup_coin_spends = IdenticalSpendDedup()
singleton_ff = SingletonFastForward()
# Fast forward state committed so far from accepted batches, used to
# rollback on batch rejection.
# Fast forward and dedup state committed so far from accepted batches,
# used to rollback on batch rejection.
committed_ff = singleton_ff.copy()
committed_dedup = dedup_coin_spends.copy()
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 priority DESC, seq ASC")
@@ -749,8 +753,10 @@ class Mempool:
bundle_coin_spends, ff_state_update = singleton_ff.process_fast_forward_spends(
mempool_item=item, prev_tx_height=prev_tx_height, constants=constants
)
unique_coin_spends, cost_saving, unique_additions = dedup_coin_spends.get_deduplication_info(
bundle_coin_spends=bundle_coin_spends
# This `dedup_state_update` is only committed later on via
# `update_deduplication_spends` if the item gets batched.
unique_coin_spends, cost_saving, unique_additions, dedup_state_update = (
dedup_coin_spends.get_deduplication_info(bundle_coin_spends=bundle_coin_spends)
)
new_fee_sum = fee_sum + fee
if new_fee_sum > DEFAULT_CONSTANTS.MAX_COIN_AMOUNT:
@@ -776,9 +782,10 @@ class Mempool:
block_cost = builder.cost()
if added:
# Update the checkpoint to include the fast forward
# state from all the items in this accepted batch.
# Update the checkpoint to include the fast forward and
# dedup state from all the items in this accepted batch.
committed_ff = singleton_ff.copy()
committed_dedup = dedup_coin_spends.copy()
added_spends += batch_spends
additions.extend(batch_additions)
removals.extend([cs.coin for sb in batch_transactions for cs in sb.coin_spends])
@@ -793,29 +800,28 @@ class Mempool:
else:
log.info(f"Skipping transaction batch cumulative cost: {block_cost} batch cost: {batch_cost}")
skipped_items += 1
# Restore FF state
# Restore FF and dedup state
singleton_ff = committed_ff.copy()
dedup_coin_spends = committed_dedup.copy()
# Reset the batch
batch_cost = 0
batch_transactions = []
batch_additions = []
batch_spends = 0
# Reprocess the current item against the correct fast
# forward state.
# forward and dedup state.
bundle_coin_spends, ff_state_update = singleton_ff.process_fast_forward_spends(
mempool_item=item, prev_tx_height=prev_tx_height, constants=constants
)
unique_coin_spends = []
unique_additions = []
for spend_data in bundle_coin_spends.values():
unique_coin_spends.append(spend_data.coin_spend)
unique_additions.extend(spend_data.additions)
cost_saving = uint64(0)
unique_coin_spends, cost_saving, unique_additions, dedup_state_update = (
dedup_coin_spends.get_deduplication_info(bundle_coin_spends=bundle_coin_spends)
)
if done:
break
singleton_ff.update_fast_forward_spends(ff_state_update)
dedup_coin_spends.update_deduplication_spends(dedup_state_update)
batch_cost += cost - cost_saving
batch_transactions.append(SpendBundle(unique_coin_spends, item.aggregated_signature))
batch_spends += len(unique_coin_spends)