Skip to content

Commit

Permalink
CHIA-2381 Validate fast forward spends before adding their spend bund…
Browse files Browse the repository at this point in the history
…le to the mempool (#19272)

Validate fast forward spends before adding their spend bundle to the mempool.

Make sure that all fast forward spends of a spend bundle would still have unspent coins.
  • Loading branch information
AmineKhaldi authored Feb 17, 2025
1 parent ad75591 commit de0b0d8
Show file tree
Hide file tree
Showing 9 changed files with 109 additions and 15 deletions.
9 changes: 8 additions & 1 deletion benchmarks/mempool-long-lived.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from chia.types.coin_record import CoinRecord
from chia.types.coin_spend import CoinSpend
from chia.types.condition_opcodes import ConditionOpcode
from chia.types.eligible_coin_spends import UnspentLineageInfo
from chia.types.spend_bundle import SpendBundle
from chia.util.ints import uint32, uint64

Expand Down Expand Up @@ -90,9 +91,15 @@ async def get_coin_record(coin_ids: Collection[bytes32]) -> list[CoinRecord]:
ret.append(r)
return ret

# We currently don't need to keep track of these for our purpose
async def get_unspent_lineage_info_for_puzzle_hash(_: bytes32) -> Optional[UnspentLineageInfo]:
assert False

timestamp = uint64(1631794488)

mempool = MempoolManager(get_coin_record, DEFAULT_CONSTANTS, single_threaded=True)
mempool = MempoolManager(
get_coin_record, get_unspent_lineage_info_for_puzzle_hash, DEFAULT_CONSTANTS, single_threaded=True
)

print("\nrunning add_spend_bundle() + new_peak()")

Expand Down
14 changes: 12 additions & 2 deletions benchmarks/mempool.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,12 @@ async def get_unspent_lineage_info_for_puzzle_hash(_: bytes32) -> Optional[Unspe
else:
print("\n== Multi-threaded")

mempool = MempoolManager(get_coin_records, DEFAULT_CONSTANTS, single_threaded=single_threaded)
mempool = MempoolManager(
get_coin_records,
get_unspent_lineage_info_for_puzzle_hash,
DEFAULT_CONSTANTS,
single_threaded=single_threaded,
)

height = start_height
rec = fake_block_record(height, timestamp)
Expand Down Expand Up @@ -196,7 +201,12 @@ async def add_spend_bundles(spend_bundles: list[SpendBundle]) -> None:
print(f" time: {stop - start:0.4f}s")
print(f" per call: {(stop - start) / total_bundles * 1000:0.2f}ms")

mempool = MempoolManager(get_coin_records, DEFAULT_CONSTANTS, single_threaded=single_threaded)
mempool = MempoolManager(
get_coin_records,
get_unspent_lineage_info_for_puzzle_hash,
DEFAULT_CONSTANTS,
single_threaded=single_threaded,
)

height = start_height
rec = fake_block_record(height, timestamp)
Expand Down
4 changes: 3 additions & 1 deletion chia/_tests/core/mempool/test_mempool_fee_estimator.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ async def test_basics() -> None:
async def test_fee_increase() -> None:
async with DBConnection(db_version=2) as db_wrapper:
coin_store = await CoinStore.create(db_wrapper)
mempool_manager = MempoolManager(coin_store.get_coin_records, test_constants)
mempool_manager = MempoolManager(
coin_store.get_coin_records, coin_store.get_unspent_lineage_info_for_puzzle_hash, test_constants
)
assert test_constants.MAX_BLOCK_COST_CLVM == mempool_manager.constants.MAX_BLOCK_COST_CLVM
btc_fee_estimator: BitcoinFeeEstimator = mempool_manager.mempool.fee_estimator # type: ignore
fee_tracker = btc_fee_estimator.get_tracker()
Expand Down
17 changes: 13 additions & 4 deletions chia/_tests/core/mempool/test_mempool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ async def zero_calls_get_coin_records(coin_ids: Collection[bytes32]) -> list[Coi
return []


async def zero_calls_get_unspent_lineage_info_for_puzzle_hash(_puzzle_hash: bytes32) -> Optional[UnspentLineageInfo]:
assert False # pragma no cover


async def get_coin_records_for_test_coins(coin_ids: Collection[bytes32]) -> list[CoinRecord]:
test_coin_records = {
TEST_COIN_ID: TEST_COIN_RECORD,
Expand Down Expand Up @@ -140,7 +144,12 @@ async def instantiate_mempool_manager(
constants: ConsensusConstants = DEFAULT_CONSTANTS,
max_tx_clvm_cost: Optional[uint64] = None,
) -> MempoolManager:
mempool_manager = MempoolManager(get_coin_records, constants, max_tx_clvm_cost=max_tx_clvm_cost)
mempool_manager = MempoolManager(
get_coin_records,
zero_calls_get_unspent_lineage_info_for_puzzle_hash,
constants,
max_tx_clvm_cost=max_tx_clvm_cost,
)
test_block_record = create_test_block_record(height=block_height, timestamp=block_timestamp)
await mempool_manager.new_peak(test_block_record, None)
invariant_check_mempool(mempool_manager.mempool)
Expand Down Expand Up @@ -427,18 +436,18 @@ def make_bundle_spends_map_and_fee(
eligibility_and_additions[coin_id] = EligibilityAndAdditions(
is_eligible_for_dedup=bool(spend.flags & ELIGIBLE_FOR_DEDUP),
spend_additions=spend_additions,
is_eligible_for_ff=bool(spend.flags & ELIGIBLE_FOR_FF),
ff_puzzle_hash=bytes32(spend.puzzle_hash) if bool(spend.flags & ELIGIBLE_FOR_FF) else None,
)
for coin_spend in spend_bundle.coin_spends:
coin_id = coin_spend.coin.name()
removals_amount += coin_spend.coin.amount
eligibility_info = eligibility_and_additions.get(
coin_id, EligibilityAndAdditions(is_eligible_for_dedup=False, spend_additions=[], is_eligible_for_ff=False)
coin_id, EligibilityAndAdditions(is_eligible_for_dedup=False, spend_additions=[], ff_puzzle_hash=None)
)
bundle_coin_spends[coin_id] = BundleCoinSpend(
coin_spend=coin_spend,
eligible_for_dedup=eligibility_info.is_eligible_for_dedup,
eligible_for_fast_forward=eligibility_info.is_eligible_for_ff,
eligible_for_fast_forward=eligibility_info.ff_puzzle_hash is not None,
additions=eligibility_info.spend_additions,
)
fee = uint64(removals_amount - additions_amount)
Expand Down
49 changes: 49 additions & 0 deletions chia/_tests/core/mempool/test_singleton_fast_forward.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,3 +692,52 @@ async def test_mempool_items_immutability_on_ff() -> None:
sb_filter = PyBIP158(bytearray(original_filter))
items_not_in_sb_filter = sim_client.service.mempool_manager.get_items_not_in_filter(sb_filter)
assert len(items_not_in_sb_filter) == 0


@pytest.mark.anyio
async def test_double_spend_ff_spend_no_latest_unspent() -> None:
"""
This test covers the scenario where we receive a spend bundle with a
singleton fast forward spend that has currently no unspent coin.
"""
test_amount = uint64(1337)
async with sim_and_client() as (sim, sim_client):
# Prepare a singleton spend
singleton, eve_coin_spend, inner_puzzle, _ = await prepare_and_test_singleton(
sim, sim_client, True, start_amount=test_amount, singleton_amount=test_amount
)
singleton_name = singleton.name()
singleton_puzzle_hash = eve_coin_spend.coin.puzzle_hash
inner_puzzle_hash = inner_puzzle.get_tree_hash()
sk = AugSchemeMPL.key_gen(b"9" * 32)
g1 = sk.get_g1()
sig = AugSchemeMPL.sign(sk, b"foobar", g1)
inner_conditions: list[list[Any]] = [
[ConditionOpcode.AGG_SIG_UNSAFE, bytes(g1), b"foobar"],
[ConditionOpcode.CREATE_COIN, inner_puzzle_hash, test_amount],
]
singleton_coin_spend, _ = make_singleton_coin_spend(eve_coin_spend, singleton, inner_puzzle, inner_conditions)
# Get its current latest unspent info
unspent_lineage_info = await sim_client.service.coin_store.get_unspent_lineage_info_for_puzzle_hash(
singleton_puzzle_hash
)
assert unspent_lineage_info == UnspentLineageInfo(
coin_id=singleton_name,
coin_amount=test_amount,
parent_id=eve_coin_spend.coin.name(),
parent_amount=eve_coin_spend.coin.amount,
parent_parent_id=eve_coin_spend.coin.parent_coin_info,
)
# Let's remove this latest unspent coin from the coin store
async with sim_client.service.coin_store.db_wrapper.writer_maybe_transaction() as conn:
await conn.execute("DELETE FROM coin_record WHERE coin_name = ?", (unspent_lineage_info.coin_id,))
# This singleton no longer has a latest unspent coin
unspent_lineage_info = await sim_client.service.coin_store.get_unspent_lineage_info_for_puzzle_hash(
singleton_puzzle_hash
)
assert unspent_lineage_info is None
# Let's attempt to spend this singleton and get get it fast forwarded
status, error = await make_and_send_spend_bundle(sim, sim_client, [singleton_coin_spend], aggsig=sig)
# It fails validation because it doesn't currently have a latest unspent
assert status == MempoolInclusionStatus.FAILED
assert error == Err.DOUBLE_SPEND
4 changes: 3 additions & 1 deletion chia/_tests/util/spend_sim.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,9 @@ async def managed(
async with DBWrapper2.managed(database=uri, uri=True, reader_count=1, db_version=2) as self.db_wrapper:
self.coin_store = await CoinStore.create(self.db_wrapper)
self.hint_store = await HintStore.create(self.db_wrapper)
self.mempool_manager = MempoolManager(self.coin_store.get_coin_records, defaults)
self.mempool_manager = MempoolManager(
self.coin_store.get_coin_records, self.coin_store.get_unspent_lineage_info_for_puzzle_hash, defaults
)
self.defaults = defaults

# Load the next data if there is any
Expand Down
1 change: 1 addition & 0 deletions chia/full_node/full_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ async def manage(self) -> AsyncIterator[None]:

self._mempool_manager = MempoolManager(
get_coin_records=self.coin_store.get_coin_records,
get_unspent_lineage_info_for_puzzle_hash=self.coin_store.get_unspent_lineage_info_for_puzzle_hash,
consensus_constants=self.constants,
single_threaded=single_threaded,
)
Expand Down
21 changes: 16 additions & 5 deletions chia/full_node/mempool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class MempoolManager:
constants: ConsensusConstants
seen_bundle_hashes: dict[bytes32, bytes32]
get_coin_records: Callable[[Collection[bytes32]], Awaitable[list[CoinRecord]]]
get_unspent_lineage_info_for_puzzle_hash: Callable[[bytes32], Awaitable[Optional[UnspentLineageInfo]]]
nonzero_fee_minimum_fpc: int
mempool_max_total_cost: int
# a cache of MempoolItems that conflict with existing items in the pool
Expand All @@ -145,6 +146,7 @@ class MempoolManager:
def __init__(
self,
get_coin_records: Callable[[Collection[bytes32]], Awaitable[list[CoinRecord]]],
get_unspent_lineage_info_for_puzzle_hash: Callable[[bytes32], Awaitable[Optional[UnspentLineageInfo]]],
consensus_constants: ConsensusConstants,
*,
single_threaded: bool = False,
Expand All @@ -156,6 +158,7 @@ def __init__(
self.seen_bundle_hashes: dict[bytes32, bytes32] = {}

self.get_coin_records = get_coin_records
self.get_unspent_lineage_info_for_puzzle_hash = get_unspent_lineage_info_for_puzzle_hash

# The fee per cost must be above this amount to consider the fee "nonzero", and thus able to kick out other
# transactions. This prevents spam. This is equivalent to 0.055 XCH per block, or about 0.00005 XCH for two
Expand Down Expand Up @@ -349,6 +352,7 @@ async def add_spend_bundle(
spend_name,
first_added_height,
get_coin_records,
self.get_unspent_lineage_info_for_puzzle_hash,
)
if err is None:
# No error, immediately add to mempool, after removing conflicting TXs.
Expand Down Expand Up @@ -379,6 +383,7 @@ async def validate_spend_bundle(
spend_name: bytes32,
first_added_height: uint32,
get_coin_records: Callable[[Collection[bytes32]], Awaitable[list[CoinRecord]]],
get_unspent_lineage_info_for_puzzle_hash: Callable[[bytes32], Awaitable[Optional[UnspentLineageInfo]]],
) -> tuple[Optional[Err], Optional[MempoolItem], list[bytes32]]:
"""
Validates new_spend with the given NPCResult, and spend_name, and the current mempool. The mempool should
Expand Down Expand Up @@ -423,7 +428,7 @@ async def validate_spend_bundle(
eligibility_and_additions[coin_id] = EligibilityAndAdditions(
is_eligible_for_dedup=is_eligible_for_dedup,
spend_additions=spend_additions,
is_eligible_for_ff=is_eligible_for_ff,
ff_puzzle_hash=bytes32(spend.puzzle_hash) if is_eligible_for_ff else None,
)
removal_names_from_coin_spends: set[bytes32] = set()
fast_forward_coin_ids: set[bytes32] = set()
Expand All @@ -433,14 +438,20 @@ async def validate_spend_bundle(
removal_names_from_coin_spends.add(coin_id)
eligibility_info = eligibility_and_additions.get(
coin_id,
EligibilityAndAdditions(is_eligible_for_dedup=False, spend_additions=[], is_eligible_for_ff=False),
EligibilityAndAdditions(is_eligible_for_dedup=False, spend_additions=[], ff_puzzle_hash=None),
)
mark_as_fast_forward = eligibility_info.is_eligible_for_ff and supports_fast_forward(coin_spend)
mark_as_fast_forward = eligibility_info.ff_puzzle_hash is not None and supports_fast_forward(coin_spend)
if mark_as_fast_forward:
# Make sure the fast forward spend still has a version that is
# still unspent, because if the singleton has been melted, the
# fast forward spend will never become valid.
assert eligibility_info.ff_puzzle_hash is not None
if await get_unspent_lineage_info_for_puzzle_hash(eligibility_info.ff_puzzle_hash) is None:
return Err.DOUBLE_SPEND, None, []
fast_forward_coin_ids.add(coin_id)
# We are now able to check eligibility of both dedup and fast forward
if not (eligibility_info.is_eligible_for_dedup or mark_as_fast_forward):
non_eligible_coin_ids.append(coin_id)
if mark_as_fast_forward:
fast_forward_coin_ids.add(coin_id)
bundle_coin_spends[coin_id] = BundleCoinSpend(
coin_spend=coin_spend,
eligible_for_dedup=eligibility_info.is_eligible_for_dedup,
Expand Down
5 changes: 4 additions & 1 deletion chia/types/eligible_coin_spends.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
class EligibilityAndAdditions:
is_eligible_for_dedup: bool
spend_additions: list[Coin]
is_eligible_for_ff: bool
# This is the spend puzzle hash. It's set to `None` if the spend is not
# eligible for fast forward. When the spend is eligible, we use its puzzle
# hash to check if the singleton has an unspent coin or not.
ff_puzzle_hash: Optional[bytes32] = None


def run_for_cost(
Expand Down

0 comments on commit de0b0d8

Please sign in to comment.