Skip to content

Commit de0b0d8

Browse files
authored
CHIA-2381 Validate fast forward spends before adding their spend bundle 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.
1 parent ad75591 commit de0b0d8

File tree

9 files changed

+109
-15
lines changed

9 files changed

+109
-15
lines changed

benchmarks/mempool-long-lived.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from chia.types.coin_record import CoinRecord
1818
from chia.types.coin_spend import CoinSpend
1919
from chia.types.condition_opcodes import ConditionOpcode
20+
from chia.types.eligible_coin_spends import UnspentLineageInfo
2021
from chia.types.spend_bundle import SpendBundle
2122
from chia.util.ints import uint32, uint64
2223

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

94+
# We currently don't need to keep track of these for our purpose
95+
async def get_unspent_lineage_info_for_puzzle_hash(_: bytes32) -> Optional[UnspentLineageInfo]:
96+
assert False
97+
9398
timestamp = uint64(1631794488)
9499

95-
mempool = MempoolManager(get_coin_record, DEFAULT_CONSTANTS, single_threaded=True)
100+
mempool = MempoolManager(
101+
get_coin_record, get_unspent_lineage_info_for_puzzle_hash, DEFAULT_CONSTANTS, single_threaded=True
102+
)
96103

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

benchmarks/mempool.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,12 @@ async def get_unspent_lineage_info_for_puzzle_hash(_: bytes32) -> Optional[Unspe
166166
else:
167167
print("\n== Multi-threaded")
168168

169-
mempool = MempoolManager(get_coin_records, DEFAULT_CONSTANTS, single_threaded=single_threaded)
169+
mempool = MempoolManager(
170+
get_coin_records,
171+
get_unspent_lineage_info_for_puzzle_hash,
172+
DEFAULT_CONSTANTS,
173+
single_threaded=single_threaded,
174+
)
170175

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

199-
mempool = MempoolManager(get_coin_records, DEFAULT_CONSTANTS, single_threaded=single_threaded)
204+
mempool = MempoolManager(
205+
get_coin_records,
206+
get_unspent_lineage_info_for_puzzle_hash,
207+
DEFAULT_CONSTANTS,
208+
single_threaded=single_threaded,
209+
)
200210

201211
height = start_height
202212
rec = fake_block_record(height, timestamp)

chia/_tests/core/mempool/test_mempool_fee_estimator.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ async def test_basics() -> None:
6363
async def test_fee_increase() -> None:
6464
async with DBConnection(db_version=2) as db_wrapper:
6565
coin_store = await CoinStore.create(db_wrapper)
66-
mempool_manager = MempoolManager(coin_store.get_coin_records, test_constants)
66+
mempool_manager = MempoolManager(
67+
coin_store.get_coin_records, coin_store.get_unspent_lineage_info_for_puzzle_hash, test_constants
68+
)
6769
assert test_constants.MAX_BLOCK_COST_CLVM == mempool_manager.constants.MAX_BLOCK_COST_CLVM
6870
btc_fee_estimator: BitcoinFeeEstimator = mempool_manager.mempool.fee_estimator # type: ignore
6971
fee_tracker = btc_fee_estimator.get_tracker()

chia/_tests/core/mempool/test_mempool_manager.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ async def zero_calls_get_coin_records(coin_ids: Collection[bytes32]) -> list[Coi
103103
return []
104104

105105

106+
async def zero_calls_get_unspent_lineage_info_for_puzzle_hash(_puzzle_hash: bytes32) -> Optional[UnspentLineageInfo]:
107+
assert False # pragma no cover
108+
109+
106110
async def get_coin_records_for_test_coins(coin_ids: Collection[bytes32]) -> list[CoinRecord]:
107111
test_coin_records = {
108112
TEST_COIN_ID: TEST_COIN_RECORD,
@@ -140,7 +144,12 @@ async def instantiate_mempool_manager(
140144
constants: ConsensusConstants = DEFAULT_CONSTANTS,
141145
max_tx_clvm_cost: Optional[uint64] = None,
142146
) -> MempoolManager:
143-
mempool_manager = MempoolManager(get_coin_records, constants, max_tx_clvm_cost=max_tx_clvm_cost)
147+
mempool_manager = MempoolManager(
148+
get_coin_records,
149+
zero_calls_get_unspent_lineage_info_for_puzzle_hash,
150+
constants,
151+
max_tx_clvm_cost=max_tx_clvm_cost,
152+
)
144153
test_block_record = create_test_block_record(height=block_height, timestamp=block_timestamp)
145154
await mempool_manager.new_peak(test_block_record, None)
146155
invariant_check_mempool(mempool_manager.mempool)
@@ -427,18 +436,18 @@ def make_bundle_spends_map_and_fee(
427436
eligibility_and_additions[coin_id] = EligibilityAndAdditions(
428437
is_eligible_for_dedup=bool(spend.flags & ELIGIBLE_FOR_DEDUP),
429438
spend_additions=spend_additions,
430-
is_eligible_for_ff=bool(spend.flags & ELIGIBLE_FOR_FF),
439+
ff_puzzle_hash=bytes32(spend.puzzle_hash) if bool(spend.flags & ELIGIBLE_FOR_FF) else None,
431440
)
432441
for coin_spend in spend_bundle.coin_spends:
433442
coin_id = coin_spend.coin.name()
434443
removals_amount += coin_spend.coin.amount
435444
eligibility_info = eligibility_and_additions.get(
436-
coin_id, EligibilityAndAdditions(is_eligible_for_dedup=False, spend_additions=[], is_eligible_for_ff=False)
445+
coin_id, EligibilityAndAdditions(is_eligible_for_dedup=False, spend_additions=[], ff_puzzle_hash=None)
437446
)
438447
bundle_coin_spends[coin_id] = BundleCoinSpend(
439448
coin_spend=coin_spend,
440449
eligible_for_dedup=eligibility_info.is_eligible_for_dedup,
441-
eligible_for_fast_forward=eligibility_info.is_eligible_for_ff,
450+
eligible_for_fast_forward=eligibility_info.ff_puzzle_hash is not None,
442451
additions=eligibility_info.spend_additions,
443452
)
444453
fee = uint64(removals_amount - additions_amount)

chia/_tests/core/mempool/test_singleton_fast_forward.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,3 +692,52 @@ async def test_mempool_items_immutability_on_ff() -> None:
692692
sb_filter = PyBIP158(bytearray(original_filter))
693693
items_not_in_sb_filter = sim_client.service.mempool_manager.get_items_not_in_filter(sb_filter)
694694
assert len(items_not_in_sb_filter) == 0
695+
696+
697+
@pytest.mark.anyio
698+
async def test_double_spend_ff_spend_no_latest_unspent() -> None:
699+
"""
700+
This test covers the scenario where we receive a spend bundle with a
701+
singleton fast forward spend that has currently no unspent coin.
702+
"""
703+
test_amount = uint64(1337)
704+
async with sim_and_client() as (sim, sim_client):
705+
# Prepare a singleton spend
706+
singleton, eve_coin_spend, inner_puzzle, _ = await prepare_and_test_singleton(
707+
sim, sim_client, True, start_amount=test_amount, singleton_amount=test_amount
708+
)
709+
singleton_name = singleton.name()
710+
singleton_puzzle_hash = eve_coin_spend.coin.puzzle_hash
711+
inner_puzzle_hash = inner_puzzle.get_tree_hash()
712+
sk = AugSchemeMPL.key_gen(b"9" * 32)
713+
g1 = sk.get_g1()
714+
sig = AugSchemeMPL.sign(sk, b"foobar", g1)
715+
inner_conditions: list[list[Any]] = [
716+
[ConditionOpcode.AGG_SIG_UNSAFE, bytes(g1), b"foobar"],
717+
[ConditionOpcode.CREATE_COIN, inner_puzzle_hash, test_amount],
718+
]
719+
singleton_coin_spend, _ = make_singleton_coin_spend(eve_coin_spend, singleton, inner_puzzle, inner_conditions)
720+
# Get its current latest unspent info
721+
unspent_lineage_info = await sim_client.service.coin_store.get_unspent_lineage_info_for_puzzle_hash(
722+
singleton_puzzle_hash
723+
)
724+
assert unspent_lineage_info == UnspentLineageInfo(
725+
coin_id=singleton_name,
726+
coin_amount=test_amount,
727+
parent_id=eve_coin_spend.coin.name(),
728+
parent_amount=eve_coin_spend.coin.amount,
729+
parent_parent_id=eve_coin_spend.coin.parent_coin_info,
730+
)
731+
# Let's remove this latest unspent coin from the coin store
732+
async with sim_client.service.coin_store.db_wrapper.writer_maybe_transaction() as conn:
733+
await conn.execute("DELETE FROM coin_record WHERE coin_name = ?", (unspent_lineage_info.coin_id,))
734+
# This singleton no longer has a latest unspent coin
735+
unspent_lineage_info = await sim_client.service.coin_store.get_unspent_lineage_info_for_puzzle_hash(
736+
singleton_puzzle_hash
737+
)
738+
assert unspent_lineage_info is None
739+
# Let's attempt to spend this singleton and get get it fast forwarded
740+
status, error = await make_and_send_spend_bundle(sim, sim_client, [singleton_coin_spend], aggsig=sig)
741+
# It fails validation because it doesn't currently have a latest unspent
742+
assert status == MempoolInclusionStatus.FAILED
743+
assert error == Err.DOUBLE_SPEND

chia/_tests/util/spend_sim.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ async def managed(
165165
async with DBWrapper2.managed(database=uri, uri=True, reader_count=1, db_version=2) as self.db_wrapper:
166166
self.coin_store = await CoinStore.create(self.db_wrapper)
167167
self.hint_store = await HintStore.create(self.db_wrapper)
168-
self.mempool_manager = MempoolManager(self.coin_store.get_coin_records, defaults)
168+
self.mempool_manager = MempoolManager(
169+
self.coin_store.get_coin_records, self.coin_store.get_unspent_lineage_info_for_puzzle_hash, defaults
170+
)
169171
self.defaults = defaults
170172

171173
# Load the next data if there is any

chia/full_node/full_node.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ async def manage(self) -> AsyncIterator[None]:
274274

275275
self._mempool_manager = MempoolManager(
276276
get_coin_records=self.coin_store.get_coin_records,
277+
get_unspent_lineage_info_for_puzzle_hash=self.coin_store.get_unspent_lineage_info_for_puzzle_hash,
277278
consensus_constants=self.constants,
278279
single_threaded=single_threaded,
279280
)

chia/full_node/mempool_manager.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ class MempoolManager:
129129
constants: ConsensusConstants
130130
seen_bundle_hashes: dict[bytes32, bytes32]
131131
get_coin_records: Callable[[Collection[bytes32]], Awaitable[list[CoinRecord]]]
132+
get_unspent_lineage_info_for_puzzle_hash: Callable[[bytes32], Awaitable[Optional[UnspentLineageInfo]]]
132133
nonzero_fee_minimum_fpc: int
133134
mempool_max_total_cost: int
134135
# a cache of MempoolItems that conflict with existing items in the pool
@@ -145,6 +146,7 @@ class MempoolManager:
145146
def __init__(
146147
self,
147148
get_coin_records: Callable[[Collection[bytes32]], Awaitable[list[CoinRecord]]],
149+
get_unspent_lineage_info_for_puzzle_hash: Callable[[bytes32], Awaitable[Optional[UnspentLineageInfo]]],
148150
consensus_constants: ConsensusConstants,
149151
*,
150152
single_threaded: bool = False,
@@ -156,6 +158,7 @@ def __init__(
156158
self.seen_bundle_hashes: dict[bytes32, bytes32] = {}
157159

158160
self.get_coin_records = get_coin_records
161+
self.get_unspent_lineage_info_for_puzzle_hash = get_unspent_lineage_info_for_puzzle_hash
159162

160163
# The fee per cost must be above this amount to consider the fee "nonzero", and thus able to kick out other
161164
# transactions. This prevents spam. This is equivalent to 0.055 XCH per block, or about 0.00005 XCH for two
@@ -349,6 +352,7 @@ async def add_spend_bundle(
349352
spend_name,
350353
first_added_height,
351354
get_coin_records,
355+
self.get_unspent_lineage_info_for_puzzle_hash,
352356
)
353357
if err is None:
354358
# No error, immediately add to mempool, after removing conflicting TXs.
@@ -379,6 +383,7 @@ async def validate_spend_bundle(
379383
spend_name: bytes32,
380384
first_added_height: uint32,
381385
get_coin_records: Callable[[Collection[bytes32]], Awaitable[list[CoinRecord]]],
386+
get_unspent_lineage_info_for_puzzle_hash: Callable[[bytes32], Awaitable[Optional[UnspentLineageInfo]]],
382387
) -> tuple[Optional[Err], Optional[MempoolItem], list[bytes32]]:
383388
"""
384389
Validates new_spend with the given NPCResult, and spend_name, and the current mempool. The mempool should
@@ -423,7 +428,7 @@ async def validate_spend_bundle(
423428
eligibility_and_additions[coin_id] = EligibilityAndAdditions(
424429
is_eligible_for_dedup=is_eligible_for_dedup,
425430
spend_additions=spend_additions,
426-
is_eligible_for_ff=is_eligible_for_ff,
431+
ff_puzzle_hash=bytes32(spend.puzzle_hash) if is_eligible_for_ff else None,
427432
)
428433
removal_names_from_coin_spends: set[bytes32] = set()
429434
fast_forward_coin_ids: set[bytes32] = set()
@@ -433,14 +438,20 @@ async def validate_spend_bundle(
433438
removal_names_from_coin_spends.add(coin_id)
434439
eligibility_info = eligibility_and_additions.get(
435440
coin_id,
436-
EligibilityAndAdditions(is_eligible_for_dedup=False, spend_additions=[], is_eligible_for_ff=False),
441+
EligibilityAndAdditions(is_eligible_for_dedup=False, spend_additions=[], ff_puzzle_hash=None),
437442
)
438-
mark_as_fast_forward = eligibility_info.is_eligible_for_ff and supports_fast_forward(coin_spend)
443+
mark_as_fast_forward = eligibility_info.ff_puzzle_hash is not None and supports_fast_forward(coin_spend)
444+
if mark_as_fast_forward:
445+
# Make sure the fast forward spend still has a version that is
446+
# still unspent, because if the singleton has been melted, the
447+
# fast forward spend will never become valid.
448+
assert eligibility_info.ff_puzzle_hash is not None
449+
if await get_unspent_lineage_info_for_puzzle_hash(eligibility_info.ff_puzzle_hash) is None:
450+
return Err.DOUBLE_SPEND, None, []
451+
fast_forward_coin_ids.add(coin_id)
439452
# We are now able to check eligibility of both dedup and fast forward
440453
if not (eligibility_info.is_eligible_for_dedup or mark_as_fast_forward):
441454
non_eligible_coin_ids.append(coin_id)
442-
if mark_as_fast_forward:
443-
fast_forward_coin_ids.add(coin_id)
444455
bundle_coin_spends[coin_id] = BundleCoinSpend(
445456
coin_spend=coin_spend,
446457
eligible_for_dedup=eligibility_info.is_eligible_for_dedup,

chia/types/eligible_coin_spends.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
class EligibilityAndAdditions:
2525
is_eligible_for_dedup: bool
2626
spend_additions: list[Coin]
27-
is_eligible_for_ff: bool
27+
# This is the spend puzzle hash. It's set to `None` if the spend is not
28+
# eligible for fast forward. When the spend is eligible, we use its puzzle
29+
# hash to check if the singleton has an unspent coin or not.
30+
ff_puzzle_hash: Optional[bytes32] = None
2831

2932

3033
def run_for_cost(

0 commit comments

Comments
 (0)