option to disable fast forward

This commit is contained in:
arvidn
2026-08-17 12:30:34 +02:00
parent ab5512dccf
commit 2881767849
5 changed files with 162 additions and 49 deletions
@@ -261,6 +261,7 @@ async def instantiate_mempool_manager(
block_timestamp: uint64 = TEST_TIMESTAMP,
constants: ConsensusConstants = DEFAULT_CONSTANTS,
max_tx_clvm_cost: uint64 | None = None,
fast_forward: bool = True,
) -> AsyncGenerator[MempoolManager, None]:
async with MempoolManager.managed(
get_coin_records,
@@ -269,6 +270,7 @@ async def instantiate_mempool_manager(
InlineExecutor(),
max_tx_clvm_cost=max_tx_clvm_cost,
validation_timeout=10,
fast_forward=fast_forward,
) as mempool_manager:
test_block_record = create_test_block_record(height=block_height, timestamp=block_timestamp)
await mempool_manager.new_peak(test_block_record, None)
@@ -2489,13 +2491,14 @@ def make_singleton_spend(
@asynccontextmanager
async def setup_mempool(coins: TestCoins) -> AsyncGenerator[MempoolManager, None]:
async def setup_mempool(coins: TestCoins, *, fast_forward: bool = True) -> AsyncGenerator[MempoolManager, None]:
async with MempoolManager.managed(
coins.get_coin_records,
coins.get_unspent_lineage_info,
DEFAULT_CONSTANTS,
InlineExecutor(),
validation_timeout=10,
fast_forward=fast_forward,
) as mempool_manager:
test_block_record = create_test_block_record(height=uint32(5000000), timestamp=uint64(12345678))
await mempool_manager.new_peak(test_block_record, None)
@@ -3042,8 +3045,9 @@ async def test_spending_singleton_to_invalidate_existing_ff_spends() -> None:
@pytest.mark.parametrize("flags", [ELIGIBLE_FOR_DEDUP, ELIGIBLE_FOR_FF, ELIGIBLE_FOR_FF | ELIGIBLE_FOR_DEDUP])
@pytest.mark.parametrize("old", [True, False])
@pytest.mark.parametrize("fast_forward", [True, False])
@pytest.mark.anyio
async def test_check_removals_with_block_creation(flags: int, old: bool) -> None:
async def test_check_removals_with_block_creation(flags: int, old: bool, fast_forward: bool) -> None:
LAUNCHER_ID = bytes32([1] * 32)
PARENT_PARENT = bytes32([2] * 32)
singleton_spend = make_singleton_spend(LAUNCHER_ID, PARENT_PARENT)
@@ -3051,7 +3055,7 @@ async def test_check_removals_with_block_creation(flags: int, old: bool) -> None
coins=[singleton_spend.coin, TEST_COIN], lineage={singleton_spend.coin.puzzle_hash: singleton_spend.coin}
)
async with setup_mempool(coins) as mempool_manager:
async with setup_mempool(coins, fast_forward=fast_forward) as mempool_manager:
sb1 = SpendBundle([singleton_spend], G2Element())
sb1_conds = make_test_conds(
spend_ids=[(singleton_spend.coin, 0)],
@@ -3108,6 +3112,7 @@ class CheckRemovalsCase:
bundle_coin_spends: dict[bytes32, BundleCoinSpend] = dataclasses.field(default_factory=dict)
conflicting_mempool_items: dict[bytes32, list[MempoolItem]] = dataclasses.field(default_factory=dict)
expected_result: tuple[Err | None, list[MempoolItem]] = dataclasses.field(default_factory=lambda: (None, []))
fast_forward: bool = True
marks: Marks = ()
@@ -3135,6 +3140,13 @@ class CheckRemovalsCase:
bundle_coin_spends={TEST_COIN_ID: mk_bcs(mk_coin_spend(TEST_COIN), ELIGIBLE_FOR_FF)},
expected_result=(None, []),
),
CheckRemovalsCase(
id="Already spent FF coin rejected when fast-forward disabled",
removals={TEST_COIN_ID: make_coin_record(TEST_COIN, spent_block_index=1)},
bundle_coin_spends={TEST_COIN_ID: mk_bcs(mk_coin_spend(TEST_COIN), ELIGIBLE_FOR_FF)},
expected_result=(Err.DOUBLE_SPEND, []),
fast_forward=False,
),
CheckRemovalsCase(
id="FF coin, non FF mempool conflict",
removals={TEST_COIN_ID: TEST_COIN_RECORD},
@@ -3156,6 +3168,14 @@ class CheckRemovalsCase:
conflicting_mempool_items={TEST_COIN_ID: [mk_item([TEST_COIN], flags=[ELIGIBLE_FOR_FF])]},
expected_result=(None, []),
),
CheckRemovalsCase(
id="FF coin, FF mempool conflict rejected when fast-forward disabled",
removals={TEST_COIN_ID: TEST_COIN_RECORD},
bundle_coin_spends={TEST_COIN_ID: mk_bcs(mk_coin_spend(TEST_COIN), ELIGIBLE_FOR_FF)},
conflicting_mempool_items={TEST_COIN_ID: [mk_item([TEST_COIN], flags=[ELIGIBLE_FOR_FF])]},
expected_result=(Err.MEMPOOL_CONFLICT, [mk_item([TEST_COIN], flags=[ELIGIBLE_FOR_FF])]),
fast_forward=False,
),
CheckRemovalsCase(
id="Dedup coin, Dedup mempool conflict",
removals={TEST_COIN_ID: TEST_COIN_RECORD},
@@ -3208,6 +3228,23 @@ class CheckRemovalsCase:
},
expected_result=(Err.MEMPOOL_CONFLICT, [mk_item([TEST_COIN])]),
),
CheckRemovalsCase(
id="Both FF and non FF coins, FF conflict also rejected when fast-forward disabled",
removals={TEST_COIN_ID: TEST_COIN_RECORD, TEST_COIN_ID2: TEST_COIN_RECORD2},
bundle_coin_spends={
TEST_COIN_ID: mk_bcs(mk_coin_spend(TEST_COIN)),
TEST_COIN_ID2: mk_bcs(mk_coin_spend(TEST_COIN2), ELIGIBLE_FOR_FF),
},
conflicting_mempool_items={
TEST_COIN_ID: [mk_item([TEST_COIN])],
TEST_COIN_ID2: [mk_item([TEST_COIN2], flags=[ELIGIBLE_FOR_FF])],
},
expected_result=(
Err.MEMPOOL_CONFLICT,
[mk_item([TEST_COIN]), mk_item([TEST_COIN2], flags=[ELIGIBLE_FOR_FF])],
),
fast_forward=False,
),
CheckRemovalsCase(
id="Two FF coins, only one with non FF conflict",
removals={TEST_COIN_ID: TEST_COIN_RECORD, TEST_COIN_ID2: TEST_COIN_RECORD2},
@@ -3221,6 +3258,23 @@ class CheckRemovalsCase:
},
expected_result=(Err.MEMPOOL_CONFLICT, [mk_item([TEST_COIN2])]),
),
CheckRemovalsCase(
id="Two FF coins both conflict when fast-forward disabled",
removals={TEST_COIN_ID: TEST_COIN_RECORD, TEST_COIN_ID2: TEST_COIN_RECORD2},
bundle_coin_spends={
TEST_COIN_ID: mk_bcs(mk_coin_spend(TEST_COIN), ELIGIBLE_FOR_FF),
TEST_COIN_ID2: mk_bcs(mk_coin_spend(TEST_COIN2), ELIGIBLE_FOR_FF),
},
conflicting_mempool_items={
TEST_COIN_ID: [mk_item([TEST_COIN], flags=[ELIGIBLE_FOR_FF])],
TEST_COIN_ID2: [mk_item([TEST_COIN2])],
},
expected_result=(
Err.MEMPOOL_CONFLICT,
[mk_item([TEST_COIN], flags=[ELIGIBLE_FOR_FF]), mk_item([TEST_COIN2])],
),
fast_forward=False,
),
CheckRemovalsCase(
id="Conflicting items are added to conflicts only once",
removals={TEST_COIN_ID: TEST_COIN_RECORD, TEST_COIN_ID2: TEST_COIN_RECORD2},
@@ -3248,6 +3302,7 @@ def test_check_removals(case: CheckRemovalsCase) -> None:
bundle_coin_spends=case.bundle_coin_spends,
removals=case.removals,
get_items_by_coin_ids=test_get_items_by_coin_ids,
fast_forward=case.fast_forward,
)
expected_err, expected_conflicts = case.expected_result
err, conflicts = result
@@ -331,14 +331,17 @@ async def prepare_and_test_singleton(
@pytest.mark.anyio
async def test_singleton_fast_forward_solo() -> None:
@pytest.mark.parametrize("fast_forward", [True, False])
async def test_singleton_fast_forward_solo(fast_forward: bool) -> None:
"""
We don't allow a spend bundle with *only* fast forward spends, since those
are difficult to evict from the mempool. They would always be valid as long as
the singleton exists.
the singleton exists. When fast-forward is disabled, a solo spend of the
current unspent singleton is treated as a normal spend (and succeeds); a
subsequent re-spend of that spent coin is rejected as a double spend.
"""
SINGLETON_AMOUNT = uint64(1337)
async with sim_and_client() as (sim, sim_client):
async with sim_and_client(fast_forward=fast_forward) as (sim, sim_client):
singleton, eve_coin_spend, inner_puzzle, _ = await prepare_and_test_singleton(
sim, sim_client, True, SINGLETON_AMOUNT
)
@@ -347,40 +350,53 @@ async def test_singleton_fast_forward_solo() -> None:
inner_conditions: list[list[Any]] = [
[ConditionOpcode.CREATE_COIN, inner_puzzle_hash, SINGLETON_AMOUNT],
]
singleton_coin_spend, _ = make_singleton_coin_spend(eve_coin_spend, singleton, inner_puzzle, inner_conditions)
# spending the eve coin is not eligible for fast forward, so we need to make this spend first, to test FF
await make_and_send_spend_bundle(sim, sim_client, [singleton_coin_spend], aggsig=G2Element())
unspent_lineage_info = await sim_client.service.coin_store.get_unspent_lineage_info_for_puzzle_hash(
singleton_puzzle_hash
)
singleton_child, _ = await get_singleton_and_remaining_coins(sim)
assert singleton_child.amount == SINGLETON_AMOUNT
assert unspent_lineage_info == UnspentLineageInfo(
coin_id=singleton_child.name(),
parent_id=eve_coin_spend.coin.name(),
parent_parent_id=eve_coin_spend.coin.parent_coin_info,
)
inner_conditions = [[ConditionOpcode.CREATE_COIN, inner_puzzle_hash, SINGLETON_AMOUNT]]
# this is a FF spend that isn't combined with any other spend. It's not allowed
# This spends the current unspent singleton. With FF enabled it is marked
# FF-eligible and rejected as a solo FF bundle. With FF disabled it is a
# normal spend of an unspent coin and is accepted.
singleton_coin_spend, _ = make_singleton_coin_spend(eve_coin_spend, singleton, inner_puzzle, inner_conditions)
status, error = await sim_client.push_tx(SpendBundle([singleton_coin_spend], G2Element()))
assert error is Err.INVALID_SPEND_BUNDLE
assert status == MempoolInclusionStatus.FAILED
if fast_forward:
assert status == MempoolInclusionStatus.FAILED
assert error is Err.INVALID_SPEND_BUNDLE
# Lineage is unchanged because the spend was not farmed
unspent_lineage_info = await sim_client.service.coin_store.get_unspent_lineage_info_for_puzzle_hash(
singleton_puzzle_hash
)
singleton_child, _ = await get_singleton_and_remaining_coins(sim)
assert singleton_child.amount == SINGLETON_AMOUNT
assert unspent_lineage_info == UnspentLineageInfo(
coin_id=singleton_child.name(),
parent_id=eve_coin_spend.coin.name(),
parent_parent_id=eve_coin_spend.coin.parent_coin_info,
)
# Retrying the same solo FF spend is still rejected
status, error = await sim_client.push_tx(SpendBundle([singleton_coin_spend], G2Element()))
assert status == MempoolInclusionStatus.FAILED
assert error is Err.INVALID_SPEND_BUNDLE
else:
assert status == MempoolInclusionStatus.SUCCESS
assert error is None
await sim.farm_block()
# Re-spending the now-spent singleton is a double spend
status, error = await sim_client.push_tx(SpendBundle([singleton_coin_spend], G2Element()))
assert status == MempoolInclusionStatus.FAILED
assert error is Err.DOUBLE_SPEND
@pytest.mark.anyio
@pytest.mark.parametrize("is_eligible_for_ff", [True, False])
async def test_singleton_fast_forward_different_block(is_eligible_for_ff: bool) -> None:
@pytest.mark.parametrize("fast_forward", [True, False])
async def test_singleton_fast_forward_different_block(is_eligible_for_ff: bool, fast_forward: bool) -> None:
"""
This tests uses the `is_eligible_for_ff` parameter to cover both when a
singleton is eligible for fast forward and when it's not, as we attempt to
spend an earlier version of it, in a different block, and watch it either
get properly fast forwarded to the latest unspent (when it's eligible) or
get correctly rejected as a double spend (when it's not eligible)
get correctly rejected as a double spend (when it's not eligible). The
`fast_forward` setting must also be enabled for the FF exception to apply.
"""
SINGLETON_AMOUNT = uint64(1337)
async with sim_and_client() as (sim, sim_client):
async with sim_and_client(fast_forward=fast_forward) as (sim, sim_client):
singleton, eve_coin_spend, inner_puzzle, remaining_coin = await prepare_and_test_singleton(
sim, sim_client, is_eligible_for_ff, SINGLETON_AMOUNT
)
@@ -434,7 +450,7 @@ async def test_singleton_fast_forward_different_block(is_eligible_for_ff: bool)
signing_coin=singleton,
aggsig=sig,
)
if is_eligible_for_ff:
if is_eligible_for_ff and fast_forward:
# Instead of rejecting this as double spend, we perform a fast forward,
# spending the singleton child as a result, and creating the latest
# version which is the grandchild in this scenario
@@ -448,21 +464,23 @@ async def test_singleton_fast_forward_different_block(is_eligible_for_ff: bool)
coin_id=singleton_grandchild.name(), parent_id=singleton_child.name(), parent_parent_id=singleton.name()
)
else:
# As this singleton is not eligible for fast forward, attempting to
# spend one of its earlier versions is considered a double spend
# Not eligible for FF, or FF disabled in the mempool: spending an
# earlier version is a double spend
assert status == MempoolInclusionStatus.FAILED
assert error == Err.DOUBLE_SPEND
@pytest.mark.anyio
async def test_singleton_fast_forward_same_block() -> None:
@pytest.mark.parametrize("fast_forward", [True, False])
async def test_singleton_fast_forward_same_block(fast_forward: bool) -> None:
"""
This tests covers sending multiple transactions that spend an already spent
singleton version, all in the same block, to make sure they get properly
fast forwarded and chained down to a latest unspent version
fast forwarded and chained down to a latest unspent version. When
fast-forward is disabled, those re-spends are rejected as double spends.
"""
SINGLETON_AMOUNT = uint64(1337)
async with sim_and_client() as (sim, sim_client):
async with sim_and_client(fast_forward=fast_forward) as (sim, sim_client):
singleton, eve_coin_spend, inner_puzzle, remaining_coin = await prepare_and_test_singleton(
sim, sim_client, True, SINGLETON_AMOUNT
)
@@ -494,7 +512,7 @@ async def test_singleton_fast_forward_same_block() -> None:
coin_id=singleton_child.name(), parent_id=singleton.name(), parent_parent_id=eve_coin_spend.coin.name()
)
# Now let's send 3 arbitrary spends of the already spent singleton in
# one block. They should all properly fast forward
# one block. They should all properly fast forward when enabled.
sk = AugSchemeMPL.key_gen(b"a" * 32)
g1 = sk.get_g1()
@@ -513,8 +531,15 @@ async def test_singleton_fast_forward_same_block() -> None:
)
remaining_coin_spend = CoinSpend(remaining_coin, IDENTITY_PUZZLE, remaining_spend_solution)
status, error = await sim_client.push_tx(SpendBundle([singleton_coin_spend, remaining_coin_spend], aggsig))
assert error is None
assert status == MempoolInclusionStatus.SUCCESS
if fast_forward:
assert error is None
assert status == MempoolInclusionStatus.SUCCESS
else:
assert status == MempoolInclusionStatus.FAILED
assert error == Err.DOUBLE_SPEND
if not fast_forward:
return
# Farm a block to process all these spend bundles
await sim.farm_block()
@@ -530,13 +555,16 @@ async def test_singleton_fast_forward_same_block() -> None:
@pytest.mark.anyio
async def test_mempool_items_immutability_on_ff() -> None:
@pytest.mark.parametrize("fast_forward", [True, False])
async def test_mempool_items_immutability_on_ff(fast_forward: bool) -> None:
"""
This tests processing singleton fast forward spends for mempool items using
modified copies, without altering those original mempool items.
modified copies, without altering those original mempool items. When
fast-forward is disabled, the re-spend of the earlier singleton version is
rejected as a double spend.
"""
SINGLETON_AMOUNT = uint64(1337)
async with sim_and_client() as (sim, sim_client):
async with sim_and_client(fast_forward=fast_forward) as (sim, sim_client):
singleton, eve_coin_spend, inner_puzzle, remaining_coin = await prepare_and_test_singleton(
sim, sim_client, True, SINGLETON_AMOUNT
)
@@ -583,6 +611,11 @@ async def test_mempool_items_immutability_on_ff() -> None:
sb = SpendBundle([remaining_coin_spend, singleton_coin_spend], sig)
sb_name = sb.name()
status, error = await sim_client.push_tx(sb)
if not fast_forward:
assert status == MempoolInclusionStatus.FAILED
assert error == Err.DOUBLE_SPEND
return
assert status == MempoolInclusionStatus.SUCCESS
assert error is None
original_item = copy.copy(sim_client.service.mempool_manager.get_mempool_item(sb_name))
@@ -606,13 +639,14 @@ async def test_mempool_items_immutability_on_ff() -> None:
@pytest.mark.anyio
async def test_double_spend_ff_spend_no_latest_unspent() -> None:
@pytest.mark.parametrize("fast_forward", [True, False])
async def test_double_spend_ff_spend_no_latest_unspent(fast_forward: bool) -> None:
"""
This test covers the scenario where we receive a spend bundle with a
singleton fast forward spend that has currently no unspent coin.
"""
singleton_amount = uint64(1337)
async with sim_and_client() as (sim, sim_client):
async with sim_and_client(fast_forward=fast_forward) as (sim, sim_client):
# Prepare a singleton spend
singleton, eve_coin_spend, inner_puzzle, _ = await prepare_and_test_singleton(
sim, sim_client, True, singleton_amount=singleton_amount
+12 -3
View File
@@ -59,9 +59,13 @@ and is designed so that you could test with it and then swap in a real rpc clien
@asynccontextmanager
async def sim_and_client(
db_path: Path | None = None, defaults: ConsensusConstants = DEFAULT_CONSTANTS, pass_prefarm: bool = True
db_path: Path | None = None,
defaults: ConsensusConstants = DEFAULT_CONSTANTS,
pass_prefarm: bool = True,
*,
fast_forward: bool = True,
) -> AsyncIterator[tuple[SpendSim, SimClient]]:
async with SpendSim.managed(db_path, defaults) as sim:
async with SpendSim.managed(db_path, defaults, fast_forward=fast_forward) as sim:
client: SimClient = SimClient(sim)
if pass_prefarm:
await sim.farm_block()
@@ -156,7 +160,11 @@ class SpendSim:
@classmethod
@contextlib.asynccontextmanager
async def managed(
cls, db_path: Path | None = None, defaults: ConsensusConstants = DEFAULT_CONSTANTS
cls,
db_path: Path | None = None,
defaults: ConsensusConstants = DEFAULT_CONSTANTS,
*,
fast_forward: bool = True,
) -> AsyncIterator[Self]:
self = cls()
if db_path is None:
@@ -175,6 +183,7 @@ class SpendSim:
defaults,
InlineExecutor(),
validation_timeout=10,
fast_forward=fast_forward,
) as self.mempool_manager:
# Load the next data if there is any
async with self.db_wrapper.writer_maybe_transaction() as conn:
+1
View File
@@ -313,6 +313,7 @@ class FullNode:
consensus_constants=self.constants,
pool=self.pool,
validation_timeout=self.config.get("block_creation_timeout", 2.0),
fast_forward=self.config.get("fast-forward", True),
) as self._mempool_manager:
# Transactions go into this queue from the server, and get sent to respond_transaction
self._transaction_queue = TransactionQueue(
+19 -5
View File
@@ -229,6 +229,7 @@ def check_removals(
bundle_coin_spends: dict[bytes32, BundleCoinSpend],
*,
get_items_by_coin_ids: Callable[[list[bytes32]], list[MempoolItem]],
fast_forward: bool = True,
) -> tuple[Err | None, list[MempoolItem]]:
"""
This function checks for double spends, unknown spends and conflicting transactions in mempool.
@@ -238,8 +239,9 @@ def check_removals(
"""
conflicts = set()
for coin_id, coin_bcs in bundle_coin_spends.items():
supports_ff = fast_forward and coin_bcs.supports_fast_forward
# 1. Checks if it's been spent already
if removals[coin_id].spent and not coin_bcs.supports_fast_forward:
if removals[coin_id].spent and not supports_ff:
return Err.DOUBLE_SPEND, []
# 2. Checks if there's a mempool conflict
@@ -263,14 +265,15 @@ def check_removals(
if conflict_bcs is None:
log.warning(f"Coin ID {coin_id} expected but not found in mempool item {item.name}")
return Err.INVALID_SPEND_BUNDLE, []
conflict_supports_ff = fast_forward and conflict_bcs.supports_fast_forward
# if the spend we're adding to the mempool is not DEDUP nor FF, it's
# just a regular conflict
if not coin_bcs.supports_fast_forward and not coin_bcs.eligible_for_dedup:
if not supports_ff and not coin_bcs.eligible_for_dedup:
conflicts.add(item)
# if the spend we're adding is FF, but there's a conflicting spend
# that isn't FF, they can't be chained, so that's a conflict
elif coin_bcs.supports_fast_forward and not conflict_bcs.supports_fast_forward:
elif supports_ff and not conflict_supports_ff:
conflicts.add(item)
# if the spend we're adding is DEDUP, but there's a conflicting spend
@@ -312,6 +315,10 @@ class MempoolManager:
max_block_clvm_cost: uint64
max_tx_clvm_cost: uint64
validation_timeout: float
# When False, singleton fast-forward is disabled: spends are not marked FF
# eligible and the double-spend / mempool-conflict exceptions for FF do not
# apply. Controlled by full_node config key "fast-forward" (default True).
fast_forward: bool
def __init__(
self,
@@ -322,6 +329,7 @@ class MempoolManager:
*,
validation_timeout: float,
max_tx_clvm_cost: uint64 | None = None,
fast_forward: bool = True,
):
self.constants: ConsensusConstants = consensus_constants
@@ -357,6 +365,7 @@ class MempoolManager:
self._worker_queue_size = 0
self.validation_timeout = validation_timeout
self.pool = pool
self.fast_forward = fast_forward
# The mempool will correspond to a certain peak
self.peak: BlockRecordProtocol | None = None
@@ -379,6 +388,7 @@ class MempoolManager:
*,
validation_timeout: float,
max_tx_clvm_cost: uint64 | None = None,
fast_forward: bool = True,
) -> AsyncIterator[Self]:
self = cls(
get_coin_records,
@@ -387,6 +397,7 @@ class MempoolManager:
pool,
max_tx_clvm_cost=max_tx_clvm_cost,
validation_timeout=validation_timeout,
fast_forward=fast_forward,
)
try:
yield self
@@ -677,7 +688,7 @@ class MempoolManager:
return Err.INVALID_COIN_SOLUTION, None, []
lineage_info = None
if bool(spend_conds.flags & ELIGIBLE_FOR_FF) and supports_fast_forward(coin_spend):
if self.fast_forward and bool(spend_conds.flags & ELIGIBLE_FOR_FF) and supports_fast_forward(coin_spend):
# Make sure the fast forward spend still has a version that is
# still unspent, because if the singleton has been spent in a
# non-FF spend, this fast forward spend will never become valid.
@@ -768,7 +779,10 @@ class MempoolManager:
# Check removals against UnspentDB + DiffStore + Mempool + SpendBundle
# Use this information later when constructing a block
fail_reason, conflicts = check_removals(
removal_record_dict, bundle_coin_spends, get_items_by_coin_ids=self.mempool.get_items_by_coin_ids
removal_record_dict,
bundle_coin_spends,
get_items_by_coin_ids=self.mempool.get_items_by_coin_ids,
fast_forward=self.fast_forward,
)
# If we have a mempool conflict, continue, since we still want to keep around the TX in the pending pool.