Skip to content

Commit 6358bab

Browse files
authored
[CHIA-2383] improve FF mempool eviction (#19355)
* evict FF spends from the mempool if they become invalid with a new peak * optimize fast-forward rebase in mempool new_peak() * clean up tests, also test reversing spent coins in new_peak() * extend mempool invariant check in tests * fix typo in comment
1 parent 92cd6ee commit 6358bab

File tree

5 files changed

+444
-16
lines changed

5 files changed

+444
-16
lines changed

chia/_tests/core/mempool/test_mempool_manager.py

Lines changed: 328 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
import dataclasses
44
import logging
5-
from collections.abc import Awaitable, Collection
6-
from typing import Any, Callable, ClassVar, Optional
5+
from collections.abc import Awaitable, Collection, Sequence
6+
from typing import Any, Callable, ClassVar, Optional, Union
77

88
import pytest
99
from chia_rs import (
@@ -208,15 +208,22 @@ def make_test_conds(
208208
before_seconds_relative: Optional[int] = None,
209209
before_seconds_absolute: Optional[int] = None,
210210
cost: int = 0,
211-
spend_ids: list[tuple[bytes32, int]] = [(TEST_COIN_ID, 0)],
211+
spend_ids: Sequence[tuple[Union[bytes32, Coin], int]] = [(TEST_COIN_ID, 0)],
212212
) -> SpendBundleConditions:
213+
spend_info: list[tuple[bytes32, bytes32, bytes32, uint64, int]] = []
214+
for coin, flags in spend_ids:
215+
if isinstance(coin, Coin):
216+
spend_info.append((coin.name(), coin.parent_coin_info, coin.puzzle_hash, coin.amount, flags))
217+
else:
218+
spend_info.append((coin, IDENTITY_PUZZLE_HASH, IDENTITY_PUZZLE_HASH, TEST_COIN_AMOUNT, flags))
219+
213220
return SpendBundleConditions(
214221
[
215222
SpendConditions(
216-
spend_id,
217-
IDENTITY_PUZZLE_HASH,
218-
IDENTITY_PUZZLE_HASH,
219-
TEST_COIN_AMOUNT,
223+
coin_id,
224+
parent_id,
225+
puzzle_hash,
226+
amount,
220227
None if height_relative is None else uint32(height_relative),
221228
None if seconds_relative is None else uint64(seconds_relative),
222229
None if before_height_relative is None else uint32(before_height_relative),
@@ -233,7 +240,7 @@ def make_test_conds(
233240
[],
234241
flags,
235242
)
236-
for spend_id, flags in spend_ids
243+
for coin_id, parent_id, puzzle_hash, amount, flags in spend_info
237244
],
238245
0,
239246
uint32(height_absolute),
@@ -2181,3 +2188,316 @@ async def test_height_added_to_mempool(optimized_path: bool) -> None:
21812188
mempool_item = mempool_manager.get_mempool_item(sb_name)
21822189
assert mempool_item is not None
21832190
assert mempool_item.height_added_to_mempool == original_height
2191+
2192+
2193+
# This is a test utility to provide a simple view of the coin table for the
2194+
# mempool manager.
2195+
class TestCoins:
2196+
coin_records: dict[bytes32, CoinRecord]
2197+
lineage_info: dict[bytes32, UnspentLineageInfo]
2198+
2199+
def __init__(self, coins: list[Coin], lineage: dict[bytes32, Coin]) -> None:
2200+
self.coin_records = {}
2201+
for c in coins:
2202+
self.coin_records[c.name()] = CoinRecord(c, uint32(0), uint32(0), False, TEST_TIMESTAMP)
2203+
self.lineage_info = {}
2204+
for ph, c in lineage.items():
2205+
self.lineage_info[ph] = UnspentLineageInfo(
2206+
c.name(), c.amount, c.parent_coin_info, uint64(1337), bytes32([42] * 32)
2207+
)
2208+
2209+
def spend_coin(self, coin_id: bytes32, height: uint32 = uint32(10)) -> None:
2210+
self.coin_records[coin_id] = dataclasses.replace(self.coin_records[coin_id], spent_block_index=height)
2211+
2212+
def update_lineage(self, puzzle_hash: bytes32, coin: Optional[Coin]) -> None:
2213+
if coin is None:
2214+
self.lineage_info.pop(puzzle_hash)
2215+
else:
2216+
assert coin.puzzle_hash == puzzle_hash
2217+
prev = self.lineage_info[puzzle_hash]
2218+
self.lineage_info[puzzle_hash] = UnspentLineageInfo(
2219+
coin.name(), coin.amount, coin.parent_coin_info, prev.coin_amount, prev.coin_id
2220+
)
2221+
2222+
async def get_coin_records(self, coin_ids: Collection[bytes32]) -> list[CoinRecord]:
2223+
ret = []
2224+
for coin_id in coin_ids:
2225+
rec = self.coin_records.get(coin_id)
2226+
if rec is not None:
2227+
ret.append(rec)
2228+
2229+
return ret
2230+
2231+
async def get_unspent_lineage_info(self, ph: bytes32) -> Optional[UnspentLineageInfo]:
2232+
return self.lineage_info.get(ph)
2233+
2234+
2235+
# creates a CoinSpend of a made up
2236+
def make_singleton_spend(launcher_id: bytes32, parent_parent_id: bytes32 = bytes32([3] * 32)) -> CoinSpend:
2237+
from chia_rs import supports_fast_forward
2238+
2239+
from chia.wallet.lineage_proof import LineageProof
2240+
from chia.wallet.puzzles.singleton_top_layer_v1_1 import (
2241+
puzzle_for_singleton,
2242+
solution_for_singleton,
2243+
)
2244+
2245+
singleton_puzzle = SerializedProgram.from_program(puzzle_for_singleton(launcher_id, Program.to(1)))
2246+
2247+
PARENT_COIN = Coin(parent_parent_id, singleton_puzzle.get_tree_hash(), uint64(1))
2248+
COIN = Coin(PARENT_COIN.name(), singleton_puzzle.get_tree_hash(), uint64(1))
2249+
2250+
lineage_proof = LineageProof(parent_parent_id, IDENTITY_PUZZLE_HASH, uint64(1))
2251+
2252+
inner_solution = Program.to([[ConditionOpcode.CREATE_COIN, IDENTITY_PUZZLE_HASH, uint64(1)]])
2253+
singleton_solution = SerializedProgram.from_program(
2254+
solution_for_singleton(lineage_proof, uint64(1), inner_solution)
2255+
)
2256+
2257+
ret = CoinSpend(COIN, singleton_puzzle, singleton_solution)
2258+
2259+
# we make sure the spend actually supports fast forward
2260+
assert supports_fast_forward(ret)
2261+
assert ret.coin.puzzle_hash == ret.puzzle_reveal.get_tree_hash()
2262+
return ret
2263+
2264+
2265+
async def setup_mempool(coins: TestCoins) -> MempoolManager:
2266+
mempool_manager = MempoolManager(
2267+
coins.get_coin_records,
2268+
coins.get_unspent_lineage_info,
2269+
DEFAULT_CONSTANTS,
2270+
)
2271+
test_block_record = create_test_block_record(height=uint32(10), timestamp=uint64(12345678))
2272+
await mempool_manager.new_peak(test_block_record, None)
2273+
return mempool_manager
2274+
2275+
2276+
# adds a new peak to the memepool manager with the specified coin IDs spent
2277+
async def advance_mempool(
2278+
mempool: MempoolManager, spent_coins: list[bytes32], *, use_optimization: bool = True
2279+
) -> None:
2280+
br = mempool.peak
2281+
assert br is not None
2282+
2283+
if use_optimization:
2284+
next_height = uint32(br.height + 1)
2285+
else:
2286+
next_height = uint32(br.height + 2)
2287+
2288+
assert br.timestamp is not None
2289+
prev_block_hash = br.header_hash
2290+
br = create_test_block_record(height=next_height, timestamp=uint64(br.timestamp + 10))
2291+
2292+
if use_optimization:
2293+
assert prev_block_hash == br.prev_transaction_block_hash
2294+
else:
2295+
assert prev_block_hash != br.prev_transaction_block_hash
2296+
2297+
await mempool.new_peak(br, spent_coins)
2298+
invariant_check_mempool(mempool.mempool)
2299+
2300+
2301+
@pytest.mark.anyio
2302+
@pytest.mark.parametrize("spend_singleton", [True, False])
2303+
@pytest.mark.parametrize("spend_plain", [True, False])
2304+
@pytest.mark.parametrize("use_optimization", [True, False])
2305+
@pytest.mark.parametrize("reverse_spend_order", [True, False])
2306+
async def test_new_peak_ff_eviction(
2307+
spend_singleton: bool, spend_plain: bool, use_optimization: bool, reverse_spend_order: bool
2308+
) -> None:
2309+
LAUNCHER_ID = bytes32([1] * 32)
2310+
singleton_spend = make_singleton_spend(LAUNCHER_ID)
2311+
2312+
coin_spend = make_spend(
2313+
TEST_COIN,
2314+
IDENTITY_PUZZLE,
2315+
Program.to([[ConditionOpcode.CREATE_COIN, IDENTITY_PUZZLE_HASH, 1336]]),
2316+
)
2317+
bundle = SpendBundle([singleton_spend, coin_spend], G2Element())
2318+
2319+
coins = TestCoins([singleton_spend.coin, TEST_COIN], {singleton_spend.coin.puzzle_hash: singleton_spend.coin})
2320+
2321+
mempool_manager = await setup_mempool(coins)
2322+
2323+
bundle_add_info = await mempool_manager.add_spend_bundle(
2324+
bundle,
2325+
make_test_conds(spend_ids=[(singleton_spend.coin, ELIGIBLE_FOR_FF), (TEST_COIN, 0)], cost=1000000),
2326+
bundle.name(),
2327+
first_added_height=uint32(1),
2328+
)
2329+
2330+
assert bundle_add_info.status == MempoolInclusionStatus.SUCCESS
2331+
item = mempool_manager.get_mempool_item(bundle.name())
2332+
assert item is not None
2333+
assert item.bundle_coin_spends[singleton_spend.coin.name()].eligible_for_fast_forward
2334+
assert item.bundle_coin_spends[singleton_spend.coin.name()].latest_singleton_coin == singleton_spend.coin.name()
2335+
2336+
spent_coins: list[bytes32] = []
2337+
2338+
if spend_singleton:
2339+
# pretend that we melted the singleton, the FF spend
2340+
coins.update_lineage(singleton_spend.coin.puzzle_hash, None)
2341+
coins.spend_coin(singleton_spend.coin.name(), uint32(11))
2342+
spent_coins.append(singleton_spend.coin.name())
2343+
2344+
if spend_plain:
2345+
# pretend that we spend singleton, the FF spend
2346+
coins.spend_coin(coin_spend.coin.name(), uint32(11))
2347+
spent_coins.append(coin_spend.coin.name())
2348+
2349+
assert bundle_add_info.status == MempoolInclusionStatus.SUCCESS
2350+
invariant_check_mempool(mempool_manager.mempool)
2351+
2352+
if reverse_spend_order:
2353+
spent_coins.reverse()
2354+
2355+
await advance_mempool(mempool_manager, spent_coins, use_optimization=use_optimization)
2356+
2357+
# make sure the mempool item is evicted
2358+
if spend_singleton or spend_plain:
2359+
assert mempool_manager.get_mempool_item(bundle.name()) is None
2360+
else:
2361+
item = mempool_manager.get_mempool_item(bundle.name())
2362+
assert item is not None
2363+
assert item.bundle_coin_spends[singleton_spend.coin.name()].eligible_for_fast_forward
2364+
assert item.bundle_coin_spends[singleton_spend.coin.name()].latest_singleton_coin == singleton_spend.coin.name()
2365+
2366+
2367+
@pytest.mark.anyio
2368+
@pytest.mark.parametrize("use_optimization", [True, False])
2369+
async def test_multiple_ff(use_optimization: bool) -> None:
2370+
# create two different singleton spends of the same singleton, that support
2371+
# fast forward. Then update the latest singleton coin and ensure both
2372+
# entries in the mempool are updated accordingly
2373+
2374+
PARENT_PARENT1 = bytes32([4] * 32)
2375+
PARENT_PARENT2 = bytes32([5] * 32)
2376+
PARENT_PARENT3 = bytes32([6] * 32)
2377+
2378+
# two different spends of the same singleton. both can be fast-forwarded
2379+
LAUNCHER_ID = bytes32([1] * 32)
2380+
singleton_spend1 = make_singleton_spend(LAUNCHER_ID, PARENT_PARENT1)
2381+
singleton_spend2 = make_singleton_spend(LAUNCHER_ID, PARENT_PARENT2)
2382+
2383+
# in the next block, this will be the latest singleton coin
2384+
singleton_spend3 = make_singleton_spend(LAUNCHER_ID, PARENT_PARENT3)
2385+
2386+
coin_spend = make_spend(
2387+
TEST_COIN,
2388+
IDENTITY_PUZZLE,
2389+
Program.to([[ConditionOpcode.CREATE_COIN, IDENTITY_PUZZLE_HASH, 1336]]),
2390+
)
2391+
bundle = SpendBundle([singleton_spend1, singleton_spend2, coin_spend], G2Element())
2392+
2393+
# the singleton puzzle hash resulves to the most recent singleton coin, number 2
2394+
# pretend that coin1 is spent
2395+
singleton_ph = singleton_spend2.coin.puzzle_hash
2396+
coins = TestCoins([singleton_spend1.coin, singleton_spend2.coin, TEST_COIN], {singleton_ph: singleton_spend2.coin})
2397+
2398+
mempool_manager = await setup_mempool(coins)
2399+
2400+
bundle_add_info = await mempool_manager.add_spend_bundle(
2401+
bundle,
2402+
make_test_conds(
2403+
spend_ids=[
2404+
(singleton_spend1.coin, ELIGIBLE_FOR_FF),
2405+
(singleton_spend2.coin, ELIGIBLE_FOR_FF),
2406+
(TEST_COIN, 0),
2407+
],
2408+
cost=1000000,
2409+
),
2410+
bundle.name(),
2411+
first_added_height=uint32(1),
2412+
)
2413+
assert bundle_add_info.status == MempoolInclusionStatus.SUCCESS
2414+
invariant_check_mempool(mempool_manager.mempool)
2415+
2416+
item = mempool_manager.get_mempool_item(bundle.name())
2417+
assert item is not None
2418+
assert item.bundle_coin_spends[singleton_spend1.coin.name()].eligible_for_fast_forward
2419+
assert item.bundle_coin_spends[singleton_spend2.coin.name()].eligible_for_fast_forward
2420+
assert not item.bundle_coin_spends[coin_spend.coin.name()].eligible_for_fast_forward
2421+
2422+
# spend the singleton coin2 and make coin3 the latest version
2423+
coins.update_lineage(singleton_ph, singleton_spend3.coin)
2424+
coins.spend_coin(singleton_spend2.coin.name(), uint32(11))
2425+
2426+
await advance_mempool(mempool_manager, [singleton_spend2.coin.name()], use_optimization=use_optimization)
2427+
2428+
# we can still fast-forward the singleton spends, the bundle should still be valid
2429+
item = mempool_manager.get_mempool_item(bundle.name())
2430+
assert item is not None
2431+
spend = item.bundle_coin_spends[singleton_spend1.coin.name()]
2432+
assert spend.latest_singleton_coin == singleton_spend3.coin.name()
2433+
spend = item.bundle_coin_spends[singleton_spend2.coin.name()]
2434+
assert spend.latest_singleton_coin == singleton_spend3.coin.name()
2435+
2436+
2437+
@pytest.mark.anyio
2438+
@pytest.mark.parametrize("use_optimization", [True, False])
2439+
async def test_advancing_ff(use_optimization: bool) -> None:
2440+
# add a FF spend under coin1, advance it twice
2441+
# the second time we have to search for it with a linear search, because
2442+
# it's filed under the original coin
2443+
2444+
PARENT_PARENT1 = bytes32([4] * 32)
2445+
PARENT_PARENT2 = bytes32([5] * 32)
2446+
PARENT_PARENT3 = bytes32([6] * 32)
2447+
2448+
# two different spends of the same singleton. both can be fast-forwarded
2449+
LAUNCHER_ID = bytes32([1] * 32)
2450+
spend_a = make_singleton_spend(LAUNCHER_ID, PARENT_PARENT1)
2451+
spend_b = make_singleton_spend(LAUNCHER_ID, PARENT_PARENT2)
2452+
spend_c = make_singleton_spend(LAUNCHER_ID, PARENT_PARENT3)
2453+
2454+
coin_spend = make_spend(
2455+
TEST_COIN,
2456+
IDENTITY_PUZZLE,
2457+
Program.to([[ConditionOpcode.CREATE_COIN, IDENTITY_PUZZLE_HASH, 1336]]),
2458+
)
2459+
bundle = SpendBundle([spend_a, coin_spend], G2Element())
2460+
2461+
# the singleton puzzle hash resulves to the most recent singleton coin, number 2
2462+
# pretend that coin1 is spent
2463+
singleton_ph = spend_a.coin.puzzle_hash
2464+
coins = TestCoins([spend_a.coin, spend_b.coin, spend_c.coin, TEST_COIN], {singleton_ph: spend_a.coin})
2465+
2466+
mempool_manager = await setup_mempool(coins)
2467+
2468+
bundle_add_info = await mempool_manager.add_spend_bundle(
2469+
bundle,
2470+
make_test_conds(spend_ids=[(spend_a.coin, ELIGIBLE_FOR_FF), (TEST_COIN, 0)], cost=1000000),
2471+
bundle.name(),
2472+
first_added_height=uint32(1),
2473+
)
2474+
assert bundle_add_info.status == MempoolInclusionStatus.SUCCESS
2475+
invariant_check_mempool(mempool_manager.mempool)
2476+
2477+
item = mempool_manager.get_mempool_item(bundle.name())
2478+
assert item is not None
2479+
spend = item.bundle_coin_spends[spend_a.coin.name()]
2480+
assert spend.eligible_for_fast_forward
2481+
assert spend.latest_singleton_coin == spend_a.coin.name()
2482+
2483+
coins.update_lineage(singleton_ph, spend_b.coin)
2484+
coins.spend_coin(spend_a.coin.name(), uint32(11))
2485+
2486+
await advance_mempool(mempool_manager, [spend_a.coin.name()])
2487+
2488+
item = mempool_manager.get_mempool_item(bundle.name())
2489+
assert item is not None
2490+
spend = item.bundle_coin_spends[spend_a.coin.name()]
2491+
assert spend.eligible_for_fast_forward
2492+
assert spend.latest_singleton_coin == spend_b.coin.name()
2493+
2494+
coins.update_lineage(singleton_ph, spend_c.coin)
2495+
coins.spend_coin(spend_b.coin.name(), uint32(12))
2496+
2497+
await advance_mempool(mempool_manager, [spend_b.coin.name()], use_optimization=use_optimization)
2498+
2499+
item = mempool_manager.get_mempool_item(bundle.name())
2500+
assert item is not None
2501+
spend = item.bundle_coin_spends[spend_a.coin.name()]
2502+
assert spend.eligible_for_fast_forward
2503+
assert spend.latest_singleton_coin == spend_c.coin.name()

chia/_tests/util/misc.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,20 @@ def invariant_check_mempool(mempool: Mempool) -> None:
482482
val = cursor.fetchone()
483483
assert (mempool._total_cost, mempool._total_fee) == val
484484

485+
with mempool._db_conn as conn:
486+
cursor = conn.execute("SELECT coin_id, tx FROM spends")
487+
for coin_id, item_id in cursor.fetchall():
488+
item = mempool._items.get(item_id)
489+
assert item is not None
490+
# item is expected to contain a spend of coin_id, but it might be a
491+
# fast-forward spend, in which case the dictionary won't help us,
492+
# but we'll have to do a linear search
493+
if coin_id in item.bundle_coin_spends:
494+
assert item.bundle_coin_spends[coin_id].coin_spend.coin.name() == coin_id
495+
continue
496+
497+
assert any(map(lambda i: i.latest_singleton_coin == coin_id, item.bundle_coin_spends.values()))
498+
485499

486500
async def wallet_height_at_least(wallet_node: WalletNode, h: uint32) -> bool:
487501
height = await wallet_node.wallet_state_manager.blockchain.get_finished_sync_up_to()

0 commit comments

Comments
 (0)