mirror of
https://github.com/Chia-Network/chia-blockchain.git
synced 2026-08-28 18:14:19 -05:00
* Add context handler features to MempoolManager * No need to manually shut_down mempoolmanager * Fix up issues with ordering in full node * Fix dumb programmer issues * Fix up some bugbot issues * Move timing to inside context manager * fix wrong indent
1163 lines
50 KiB
Python
1163 lines
50 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import time
|
|
from collections.abc import AsyncIterator, Awaitable, Callable, Collection
|
|
from concurrent.futures import Executor, ThreadPoolExecutor
|
|
from dataclasses import dataclass, field
|
|
from types import TracebackType
|
|
from typing import TypeVar
|
|
|
|
from chia_rs import (
|
|
ELIGIBLE_FOR_DEDUP,
|
|
ELIGIBLE_FOR_FF,
|
|
MEMPOOL_MODE,
|
|
BLSCache,
|
|
CoinRecord,
|
|
ConsensusConstants,
|
|
SpendBundle,
|
|
SpendBundleConditions,
|
|
check_time_locks,
|
|
get_flags_for_height_and_constants,
|
|
supports_fast_forward,
|
|
validate_clvm_and_signature,
|
|
)
|
|
from chia_rs.sized_bytes import bytes32
|
|
from chia_rs.sized_ints import uint32, uint64
|
|
from chiabip158 import PyBIP158
|
|
from typing_extensions import Self
|
|
|
|
from chia.consensus.block_record import BlockRecordProtocol
|
|
from chia.full_node.bitcoin_fee_estimator import create_bitcoin_fee_estimator
|
|
from chia.full_node.fee_estimation import FeeBlockInfo, MempoolInfo, MempoolItemInfo
|
|
from chia.full_node.fee_estimator_interface import FeeEstimatorInterface
|
|
from chia.full_node.mempool import MEMPOOL_ITEM_FEE_LIMIT, Mempool, MempoolRemoveInfo, MempoolRemoveReason
|
|
from chia.full_node.pending_tx_cache import ConflictTxCache, PendingTxCache
|
|
from chia.types.blockchain_format.coin import Coin
|
|
from chia.types.clvm_cost import CLVMCost
|
|
from chia.types.fee_rate import FeeRate
|
|
from chia.types.generator_types import NewBlockGenerator
|
|
from chia.types.mempool_inclusion_status import MempoolInclusionStatus
|
|
from chia.types.mempool_item import BundleCoinSpend, MempoolItem, UnspentLineageInfo
|
|
from chia.util.db_wrapper import SQLITE_INT_MAX
|
|
from chia.util.errors import Err, ValidationError
|
|
from chia.util.inline_executor import InlineExecutor
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# mempool items replacing existing ones must increase the total fee at least by
|
|
# this amount. 0.00001 XCH
|
|
MEMPOOL_MIN_FEE_INCREASE = uint64(10000000)
|
|
|
|
|
|
@dataclass
|
|
class TimelockConditions:
|
|
assert_height: uint32 = uint32(0)
|
|
assert_seconds: uint64 = uint64(0)
|
|
assert_before_height: uint32 | None = None
|
|
assert_before_seconds: uint64 | None = None
|
|
|
|
|
|
@dataclass
|
|
class LineageInfoCache:
|
|
_fun: Callable[[bytes32], Awaitable[UnspentLineageInfo | None]]
|
|
_cache: dict[bytes32, UnspentLineageInfo | None] = field(default_factory=dict)
|
|
|
|
async def get_unspent_lineage_info(self, puzzle_hash: bytes32) -> UnspentLineageInfo | None:
|
|
# we rely on KeyError to distinguish between a stored
|
|
# None value and a missing entry
|
|
try:
|
|
return self._cache[puzzle_hash]
|
|
except KeyError:
|
|
pass
|
|
|
|
ret = await self._fun(puzzle_hash)
|
|
self._cache[puzzle_hash] = ret
|
|
return ret
|
|
|
|
|
|
def compute_assert_height(
|
|
removal_coin_records: dict[bytes32, CoinRecord],
|
|
conds: SpendBundleConditions,
|
|
) -> TimelockConditions:
|
|
"""
|
|
Computes the most restrictive height- and seconds assertion in the spend bundle.
|
|
Relative heights and times are resolved using the confirmed heights and
|
|
timestamps from the coin records.
|
|
"""
|
|
|
|
ret = TimelockConditions()
|
|
ret.assert_height = uint32(conds.height_absolute)
|
|
ret.assert_seconds = uint64(conds.seconds_absolute)
|
|
ret.assert_before_height = (
|
|
uint32(conds.before_height_absolute) if conds.before_height_absolute is not None else None
|
|
)
|
|
ret.assert_before_seconds = (
|
|
uint64(conds.before_seconds_absolute) if conds.before_seconds_absolute is not None else None
|
|
)
|
|
|
|
for spend in conds.spends:
|
|
if spend.height_relative is not None:
|
|
h = uint32(removal_coin_records[bytes32(spend.coin_id)].confirmed_block_index + spend.height_relative)
|
|
ret.assert_height = max(ret.assert_height, h)
|
|
|
|
if spend.seconds_relative is not None:
|
|
s = uint64(removal_coin_records[bytes32(spend.coin_id)].timestamp + spend.seconds_relative)
|
|
ret.assert_seconds = max(ret.assert_seconds, s)
|
|
|
|
if spend.before_height_relative is not None:
|
|
h = uint32(
|
|
removal_coin_records[bytes32(spend.coin_id)].confirmed_block_index + spend.before_height_relative
|
|
)
|
|
if ret.assert_before_height is not None:
|
|
ret.assert_before_height = min(ret.assert_before_height, h)
|
|
else:
|
|
ret.assert_before_height = h
|
|
|
|
if spend.before_seconds_relative is not None:
|
|
s = uint64(removal_coin_records[bytes32(spend.coin_id)].timestamp + spend.before_seconds_relative)
|
|
if ret.assert_before_seconds is not None:
|
|
ret.assert_before_seconds = min(ret.assert_before_seconds, s)
|
|
else:
|
|
ret.assert_before_seconds = s
|
|
|
|
return ret
|
|
|
|
|
|
@dataclass
|
|
class SpendBundleAddInfo:
|
|
cost: uint64 | None
|
|
status: MempoolInclusionStatus
|
|
removals: list[MempoolRemoveInfo]
|
|
error: Err | None
|
|
|
|
|
|
@dataclass
|
|
class NewPeakInfo:
|
|
spend_bundle_ids: list[bytes32]
|
|
removals: list[MempoolRemoveInfo]
|
|
|
|
|
|
# For block overhead cost calculation
|
|
QUOTE_BYTES = 2
|
|
QUOTE_EXECUTION_COST = 20
|
|
|
|
|
|
def is_atom_canonical(clvm_buffer: bytes, offset: int) -> tuple[int, bool]:
|
|
b = clvm_buffer[offset]
|
|
if (b & 0b11000000) == 0b10000000:
|
|
# 6 bits length prefix
|
|
mask = 0b00111111
|
|
prefix_len = 0
|
|
min_value = 1
|
|
elif (b & 0b11100000) == 0b11000000:
|
|
# 5 + 8 bits length prefix
|
|
mask = 0b00011111
|
|
prefix_len = 1
|
|
min_value = 1 << 6
|
|
elif (b & 0b11110000) == 0b11100000:
|
|
# 4 + 8 + 8 bits length prefix
|
|
mask = 0b00001111
|
|
prefix_len = 2
|
|
min_value = 1 << (5 + 8)
|
|
elif (b & 0b11111000) == 0b11110000:
|
|
# 3 + 8 + 8 + 8 bits length prefix
|
|
mask = 0b00000111
|
|
prefix_len = 3
|
|
min_value = 1 << (4 + 8 + 8)
|
|
elif (b & 0b11111100) == 0b11111000:
|
|
# 2 + 8 + 8 + 8 + 8 bits length prefix
|
|
mask = 0b00000011
|
|
prefix_len = 4
|
|
min_value = 1 << (3 + 8 + 8 + 8)
|
|
elif (b & 0b11111110) == 0b11111100:
|
|
# 1 + 8 + 8 + 8 + 8 + 8 bits length prefix
|
|
mask = 0b00000001
|
|
prefix_len = 5
|
|
min_value = 1 << (2 + 8 + 8 + 8 + 8)
|
|
|
|
atom_len = b & mask
|
|
for i in range(prefix_len):
|
|
atom_len <<= 8
|
|
offset += 1
|
|
atom_len |= clvm_buffer[offset]
|
|
|
|
return 1 + prefix_len + atom_len, atom_len >= min_value
|
|
|
|
|
|
def is_clvm_canonical(clvm_buffer: bytes) -> bool:
|
|
"""
|
|
checks whether the CLVM serialization is all canonical representation.
|
|
atoms can be serialized in more than one way by using more bytes than
|
|
necessary to encode the length prefix. This functions ensures that all atoms are
|
|
encoded with the shortest representation. back-references are not allowed
|
|
and will make this function return false
|
|
"""
|
|
assert clvm_buffer != b""
|
|
|
|
offset = 0
|
|
tokens_left = 1
|
|
while True:
|
|
b = clvm_buffer[offset]
|
|
|
|
# pair
|
|
if b == 0xFF:
|
|
tokens_left += 1
|
|
offset += 1
|
|
continue
|
|
|
|
# back references cannot be considered canonical, since they may be
|
|
# encoded in many different ways
|
|
if b == 0xFE:
|
|
return False
|
|
|
|
# small atom or NIL
|
|
if b <= 0x80:
|
|
tokens_left -= 1
|
|
offset += 1
|
|
else:
|
|
atom_len, canonical = is_atom_canonical(clvm_buffer, offset)
|
|
if not canonical:
|
|
return False
|
|
tokens_left -= 1
|
|
offset += atom_len
|
|
|
|
if tokens_left == 0:
|
|
break
|
|
|
|
# if there's garbage at the end, it's not canonical
|
|
return offset == len(clvm_buffer)
|
|
|
|
|
|
def check_removals(
|
|
removals: dict[bytes32, CoinRecord],
|
|
bundle_coin_spends: dict[bytes32, BundleCoinSpend],
|
|
*,
|
|
get_items_by_coin_ids: Callable[[list[bytes32]], list[MempoolItem]],
|
|
) -> tuple[Err | None, list[MempoolItem]]:
|
|
"""
|
|
This function checks for double spends, unknown spends and conflicting transactions in mempool.
|
|
Returns Error (if any), the set of existing MempoolItems with conflicting spends (if any).
|
|
Note that additions are not checked for duplicates, because having duplicate additions requires also
|
|
having duplicate removals.
|
|
"""
|
|
conflicts = set()
|
|
for coin_id, coin_bcs in bundle_coin_spends.items():
|
|
# 1. Checks if it's been spent already
|
|
if removals[coin_id].spent and not coin_bcs.supports_fast_forward:
|
|
return Err.DOUBLE_SPEND, []
|
|
|
|
# 2. Checks if there's a mempool conflict
|
|
conflicting_items = get_items_by_coin_ids([coin_id])
|
|
for item in conflicting_items:
|
|
if item in conflicts:
|
|
continue
|
|
conflict_bcs = item.bundle_coin_spends.get(coin_id)
|
|
if conflict_bcs is None:
|
|
# Check if this is an item that spends an older ff singleton
|
|
# version with a latest version that matches our coin ID.
|
|
conflict_bcs = next(
|
|
(
|
|
bcs
|
|
for bcs in item.bundle_coin_spends.values()
|
|
if bcs.latest_singleton_lineage is not None and bcs.latest_singleton_lineage.coin_id == coin_id
|
|
),
|
|
None,
|
|
)
|
|
# We're not expected to get here but let's handle it gracefully
|
|
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, []
|
|
# 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:
|
|
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:
|
|
conflicts.add(item)
|
|
|
|
# if the spend we're adding is DEDUP, but there's a conflicting spend
|
|
# that isn't DEDUP, we cannot merge them, so that's a conflict
|
|
elif coin_bcs.eligible_for_dedup and not conflict_bcs.eligible_for_dedup:
|
|
conflicts.add(item)
|
|
|
|
# if the spend we're adding is DEDUP but the existing spend has a
|
|
# different solution, we cannot merge them, so that's a conflict
|
|
elif coin_bcs.eligible_for_dedup and bytes(coin_bcs.coin_spend.solution) != bytes(
|
|
conflict_bcs.coin_spend.solution
|
|
):
|
|
conflicts.add(item)
|
|
|
|
if len(conflicts) > 0:
|
|
return Err.MEMPOOL_CONFLICT, list(conflicts)
|
|
return None, []
|
|
|
|
|
|
class MempoolManager:
|
|
pool: Executor
|
|
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[UnspentLineageInfo | None]]
|
|
nonzero_fee_minimum_fpc: int
|
|
mempool_max_total_cost: int
|
|
# a cache of MempoolItems that conflict with existing items in the pool
|
|
_conflict_cache: ConflictTxCache
|
|
# cache of MempoolItems with height conditions making them not valid yet
|
|
_pending_cache: PendingTxCache
|
|
seen_cache_size: int
|
|
# the peak is only ever set to a transaction block. We use it to validate
|
|
# timelocks against, so it must have a timestamp.
|
|
peak: BlockRecordProtocol | None
|
|
mempool: Mempool
|
|
_worker_queue_size: int
|
|
max_block_clvm_cost: uint64
|
|
max_tx_clvm_cost: uint64
|
|
|
|
def __init__(
|
|
self,
|
|
get_coin_records: Callable[[Collection[bytes32]], Awaitable[list[CoinRecord]]],
|
|
get_unspent_lineage_info_for_puzzle_hash: Callable[[bytes32], Awaitable[UnspentLineageInfo | None]],
|
|
consensus_constants: ConsensusConstants,
|
|
*,
|
|
single_threaded: bool = False,
|
|
max_tx_clvm_cost: uint64 | None = None,
|
|
):
|
|
self.constants: ConsensusConstants = consensus_constants
|
|
|
|
# Keep track of seen spend_bundles
|
|
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
|
|
# spends.
|
|
self.nonzero_fee_minimum_fpc = 5
|
|
|
|
# We need to deduct the block overhead, which consists of the wrapping
|
|
# quote opcode's bytes cost as well as its execution cost.
|
|
BLOCK_OVERHEAD = QUOTE_BYTES * self.constants.COST_PER_BYTE + QUOTE_EXECUTION_COST
|
|
|
|
self.max_block_clvm_cost = uint64(self.constants.MAX_BLOCK_COST_CLVM - BLOCK_OVERHEAD)
|
|
self.max_tx_clvm_cost = (
|
|
max_tx_clvm_cost if max_tx_clvm_cost is not None else uint64(self.constants.MAX_BLOCK_COST_CLVM // 2)
|
|
)
|
|
self.mempool_max_total_cost = int(self.constants.MAX_BLOCK_COST_CLVM * self.constants.MEMPOOL_BLOCK_BUFFER)
|
|
|
|
# Transactions that were unable to enter mempool, used for retry. (they were invalid)
|
|
self._conflict_cache = ConflictTxCache(self.constants.MAX_BLOCK_COST_CLVM * 1, 1000)
|
|
self._pending_cache = PendingTxCache(self.constants.MAX_BLOCK_COST_CLVM * 1, 1000)
|
|
self.seen_cache_size = 10000
|
|
self._worker_queue_size = 0
|
|
if single_threaded:
|
|
self.pool = InlineExecutor()
|
|
else:
|
|
self.pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="mempool-")
|
|
|
|
# The mempool will correspond to a certain peak
|
|
self.peak: BlockRecordProtocol | None = None
|
|
self.fee_estimator: FeeEstimatorInterface = create_bitcoin_fee_estimator(self.max_block_clvm_cost)
|
|
mempool_info = MempoolInfo(
|
|
CLVMCost(uint64(self.mempool_max_total_cost)),
|
|
FeeRate(uint64(self.nonzero_fee_minimum_fpc)),
|
|
CLVMCost(uint64(self.max_block_clvm_cost)),
|
|
)
|
|
self.mempool: Mempool = Mempool(mempool_info, self.fee_estimator)
|
|
|
|
@classmethod
|
|
@contextlib.asynccontextmanager
|
|
async def managed(
|
|
cls,
|
|
get_coin_records: Callable[[Collection[bytes32]], Awaitable[list[CoinRecord]]],
|
|
get_unspent_lineage_info_for_puzzle_hash: Callable[[bytes32], Awaitable[UnspentLineageInfo | None]],
|
|
consensus_constants: ConsensusConstants,
|
|
single_threaded: bool = False,
|
|
max_tx_clvm_cost: uint64 | None = None,
|
|
) -> AsyncIterator[Self]:
|
|
self = cls(
|
|
get_coin_records,
|
|
get_unspent_lineage_info_for_puzzle_hash,
|
|
consensus_constants,
|
|
single_threaded=single_threaded,
|
|
max_tx_clvm_cost=max_tx_clvm_cost,
|
|
)
|
|
try:
|
|
yield self
|
|
finally:
|
|
self.shut_down()
|
|
|
|
def shut_down(self) -> None:
|
|
self.pool.shutdown(wait=True)
|
|
self.mempool.close()
|
|
|
|
def __enter__(self) -> Self:
|
|
return self
|
|
|
|
def __exit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc: BaseException | None,
|
|
traceback: TracebackType | None,
|
|
) -> None:
|
|
self.shut_down()
|
|
|
|
# TODO: remove this, use create_generator() instead
|
|
def create_bundle_from_mempool(self, last_tb_header_hash: bytes32) -> tuple[SpendBundle, list[Coin]] | None:
|
|
"""
|
|
Returns aggregated spendbundle that can be used for creating new block,
|
|
additions and removals in that spend_bundle
|
|
"""
|
|
if self.peak is None or self.peak.header_hash != last_tb_header_hash:
|
|
return None
|
|
return self.mempool.create_bundle_from_mempool_items(self.constants, self.peak.height)
|
|
|
|
def create_block_generator(self, last_tb_header_hash: bytes32, timeout: float) -> NewBlockGenerator | None:
|
|
"""
|
|
Returns a block generator program, the aggregate signature and all additions and removals, for a new block
|
|
"""
|
|
if self.peak is None or self.peak.header_hash != last_tb_header_hash:
|
|
return None
|
|
return self.mempool.create_block_generator(self.constants, self.peak.height, timeout)
|
|
|
|
def create_block_generator2(self, last_tb_header_hash: bytes32, timeout: float) -> NewBlockGenerator | None:
|
|
"""
|
|
Returns a block generator program, the aggregate signature and all additions, for a new block
|
|
"""
|
|
if self.peak is None or self.peak.header_hash != last_tb_header_hash:
|
|
return None
|
|
return self.mempool.create_block_generator2(self.constants, self.peak.height, timeout)
|
|
|
|
def get_filter(self) -> bytes:
|
|
all_transactions: set[bytes32] = set()
|
|
byte_array_list = []
|
|
for key in self.mempool.all_item_ids():
|
|
if key not in all_transactions:
|
|
all_transactions.add(key)
|
|
byte_array_list.append(bytearray(key))
|
|
|
|
tx_filter: PyBIP158 = PyBIP158(byte_array_list)
|
|
return bytes(tx_filter.GetEncoded())
|
|
|
|
def is_fee_enough(self, fees: uint64, cost: uint64) -> bool:
|
|
"""
|
|
Determines whether any of the pools can accept a transaction with a given fees
|
|
and cost.
|
|
"""
|
|
if cost == 0:
|
|
return False
|
|
fees_per_cost = fees / cost
|
|
if not self.mempool.at_full_capacity(cost):
|
|
return True
|
|
if fees_per_cost < self.nonzero_fee_minimum_fpc:
|
|
return False
|
|
min_fee_rate = self.mempool.get_min_fee_rate(cost)
|
|
return min_fee_rate is not None and fees_per_cost > min_fee_rate
|
|
|
|
def add_and_maybe_pop_seen(self, spend_name: bytes32) -> None:
|
|
self.seen_bundle_hashes[spend_name] = spend_name
|
|
while len(self.seen_bundle_hashes) > self.seen_cache_size:
|
|
first_in = next(iter(self.seen_bundle_hashes.keys()))
|
|
self.seen_bundle_hashes.pop(first_in)
|
|
|
|
def seen(self, bundle_hash: bytes32) -> bool:
|
|
"""Return true if we saw this spendbundle recently"""
|
|
return bundle_hash in self.seen_bundle_hashes
|
|
|
|
def remove_seen(self, bundle_hash: bytes32) -> None:
|
|
if bundle_hash in self.seen_bundle_hashes:
|
|
self.seen_bundle_hashes.pop(bundle_hash)
|
|
|
|
async def pre_validate_spendbundle(
|
|
self, spend_bundle: SpendBundle, spend_bundle_id: bytes32 | None = None, bls_cache: BLSCache | None = None
|
|
) -> SpendBundleConditions:
|
|
"""
|
|
Errors are included within the cached_result.
|
|
This runs in another process so we don't block the main thread
|
|
"""
|
|
|
|
if spend_bundle.coin_spends == []:
|
|
raise ValidationError(Err.INVALID_SPEND_BUNDLE, "Empty SpendBundle")
|
|
|
|
assert self.peak is not None
|
|
|
|
self._worker_queue_size += 1
|
|
try:
|
|
flags = get_flags_for_height_and_constants(self.peak.height, self.constants)
|
|
sbc, new_cache_entries, duration = await asyncio.get_running_loop().run_in_executor(
|
|
self.pool,
|
|
validate_clvm_and_signature,
|
|
spend_bundle,
|
|
self.max_tx_clvm_cost,
|
|
self.constants,
|
|
flags | MEMPOOL_MODE,
|
|
)
|
|
# validate_clvm_and_signature raises a ValueError with an error code
|
|
except ValueError as e:
|
|
# Convert that to a ValidationError
|
|
if len(e.args) > 1:
|
|
error = Err(e.args[1])
|
|
raise ValidationError(error)
|
|
else:
|
|
raise ValidationError(Err.UNKNOWN) # pragma: no cover
|
|
finally:
|
|
self._worker_queue_size -= 1
|
|
|
|
if sbc.num_atoms > sbc.cost * 60_000_000 / self.constants.MAX_BLOCK_COST_CLVM:
|
|
raise ValidationError(Err.INVALID_SPEND_BUNDLE, "too many atoms")
|
|
|
|
if sbc.num_pairs > sbc.cost * 60_000_000 / self.constants.MAX_BLOCK_COST_CLVM:
|
|
raise ValidationError(Err.INVALID_SPEND_BUNDLE, "too many pairs")
|
|
|
|
if bls_cache is not None:
|
|
bls_cache.update(new_cache_entries)
|
|
|
|
if spend_bundle_id is None:
|
|
spend_bundle_id = spend_bundle.name()
|
|
|
|
log.log(
|
|
logging.DEBUG if duration < 2 else logging.WARNING,
|
|
f"pre_validate_spendbundle took {duration:0.4f} seconds "
|
|
f"for {spend_bundle_id} (queue-size: {self._worker_queue_size})",
|
|
)
|
|
return sbc
|
|
|
|
async def add_spend_bundle(
|
|
self,
|
|
new_spend: SpendBundle,
|
|
conds: SpendBundleConditions,
|
|
spend_name: bytes32,
|
|
first_added_height: uint32,
|
|
get_coin_records: Callable[[Collection[bytes32]], Awaitable[list[CoinRecord]]] | None = None,
|
|
get_unspent_lineage_info_for_puzzle_hash: Callable[[bytes32], Awaitable[UnspentLineageInfo | None]]
|
|
| None = None,
|
|
) -> SpendBundleAddInfo:
|
|
"""
|
|
Validates and adds to mempool a new_spend with the given
|
|
SpendBundleConditions, and spend_name, and the current mempool. The mempool
|
|
should be locked during this call (blockchain lock). If there are mempool
|
|
conflicts, the conflicting spends might be removed (if the new spend is
|
|
a superset of the previous). Otherwise, the new spend might be
|
|
added to the potential pool.
|
|
|
|
Args:
|
|
new_spend: spend bundle to validate and add
|
|
conds: SpendBundleConditions resulting from running the clvm in the spend bundle's coin spends
|
|
spend_name: hash of the spend bundle data, passed in as an optimization
|
|
|
|
Returns:
|
|
Optional[uint64]: cost of the entire transaction, None iff status is FAILED
|
|
MempoolInclusionStatus: SUCCESS (should add to pool), FAILED (cannot add), and PENDING (can add later)
|
|
list[MempoolRemoveInfo]: conflicting mempool items which were removed, if no Err
|
|
Optional[Err]: Err is set iff status is FAILED
|
|
"""
|
|
|
|
# Skip if already added
|
|
existing_item = self.mempool.get_item_by_id(spend_name)
|
|
if existing_item is not None:
|
|
return SpendBundleAddInfo(existing_item.cost, MempoolInclusionStatus.SUCCESS, [], None)
|
|
|
|
if get_coin_records is None:
|
|
get_coin_records = self.get_coin_records
|
|
|
|
if get_unspent_lineage_info_for_puzzle_hash is None:
|
|
get_unspent_lineage_info_for_puzzle_hash = self.get_unspent_lineage_info_for_puzzle_hash
|
|
|
|
err, item, remove_items = await self.validate_spend_bundle(
|
|
new_spend,
|
|
conds,
|
|
spend_name,
|
|
first_added_height,
|
|
get_coin_records,
|
|
get_unspent_lineage_info_for_puzzle_hash,
|
|
)
|
|
if err is None:
|
|
# No error, immediately add to mempool, after removing conflicting TXs.
|
|
assert item is not None
|
|
conflict = self.mempool.remove_from_pool(remove_items, MempoolRemoveReason.CONFLICT)
|
|
info = self.mempool.add_to_pool(item)
|
|
if info.error is not None:
|
|
return SpendBundleAddInfo(item.cost, MempoolInclusionStatus.FAILED, [], info.error)
|
|
return SpendBundleAddInfo(item.cost, MempoolInclusionStatus.SUCCESS, [*info.removals, conflict], None)
|
|
elif err is Err.MEMPOOL_CONFLICT and item is not None:
|
|
# The transaction has a conflict with another item in the
|
|
# mempool, put it aside and re-try it later
|
|
self._conflict_cache.add(item)
|
|
return SpendBundleAddInfo(item.cost, MempoolInclusionStatus.PENDING, [], err)
|
|
elif item is not None:
|
|
# The transasction has a height assertion and is not yet valid.
|
|
# remember it to try it again later
|
|
self._pending_cache.add(item)
|
|
return SpendBundleAddInfo(item.cost, MempoolInclusionStatus.PENDING, [], err)
|
|
else:
|
|
# Cannot add to the mempool or pending pool.
|
|
return SpendBundleAddInfo(None, MempoolInclusionStatus.FAILED, [], err)
|
|
|
|
async def validate_spend_bundle(
|
|
self,
|
|
new_spend: SpendBundle,
|
|
conds: SpendBundleConditions,
|
|
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[UnspentLineageInfo | None]],
|
|
) -> tuple[Err | None, MempoolItem | None, list[bytes32]]:
|
|
"""
|
|
Validates new_spend with the given SpendBundleConditions, and
|
|
spend_name, and the current mempool. The mempool should
|
|
be locked during this call (blockchain lock).
|
|
|
|
Args:
|
|
new_spend: spend bundle to validate
|
|
conds: result of running the clvm transaction
|
|
spend_name: hash of the spend bundle data, passed in as an optimization
|
|
first_added_height: The block height that `new_spend` first entered this node's mempool.
|
|
Used to estimate how long a spend has taken to be included on the chain.
|
|
This value could differ node to node. Not preserved across full_node restarts.
|
|
|
|
Returns:
|
|
Optional[Err]: Err is set if we cannot add to the mempool, None if we will immediately add to mempool
|
|
Optional[MempoolItem]: the item to add (to mempool or pending pool)
|
|
list[bytes32]: conflicting mempool items to remove, if no Err
|
|
"""
|
|
start_time = time.monotonic()
|
|
if self.peak is None:
|
|
return Err.MEMPOOL_NOT_INITIALIZED, None, []
|
|
|
|
cost = conds.cost
|
|
|
|
removal_names: set[bytes32] = set()
|
|
additions_dict: dict[bytes32, Coin] = {}
|
|
addition_amount: int = 0
|
|
|
|
# Map of coin ID to SpendConditions
|
|
spend_conditions = {bytes32(spend.coin_id): spend for spend in conds.spends}
|
|
|
|
# if this happens, the SpendBundle doesn't match the
|
|
# SpendBundleConditions.
|
|
assert len(new_spend.coin_spends) == len(spend_conditions)
|
|
|
|
bundle_coin_spends: dict[bytes32, BundleCoinSpend] = {}
|
|
for coin_spend in new_spend.coin_spends:
|
|
coin_id = coin_spend.coin.name()
|
|
removal_names.add(coin_id)
|
|
|
|
# if this coin_id isn't found, the SpendBundle doesn't match the
|
|
# SpendBundleConditions.
|
|
spend_conds = spend_conditions.pop(coin_id)
|
|
|
|
if bool(spend_conds.flags & ELIGIBLE_FOR_DEDUP) and not is_clvm_canonical(bytes(coin_spend.solution)):
|
|
return Err.INVALID_COIN_SOLUTION, None, []
|
|
|
|
lineage_info = None
|
|
if 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.
|
|
# So treat this as a normal spend, which requires the exact coin
|
|
# to exist and be unspent.
|
|
# Singletons that were created before the optimization of using
|
|
# spent_index will also fail this test, and such spends will
|
|
# fall back to be treated as non-FF spends.
|
|
lineage_info = await get_unspent_lineage_info_for_puzzle_hash(spend_conds.puzzle_hash)
|
|
|
|
spend_additions = []
|
|
for puzzle_hash, amount, _ in spend_conds.create_coin:
|
|
child_coin = Coin(coin_id, puzzle_hash, uint64(amount))
|
|
spend_additions.append(child_coin)
|
|
additions_dict[child_coin.name()] = child_coin
|
|
addition_amount += amount
|
|
|
|
bundle_coin_spends[coin_id] = BundleCoinSpend(
|
|
coin_spend=coin_spend,
|
|
eligible_for_dedup=bool(spend_conds.flags & ELIGIBLE_FOR_DEDUP),
|
|
additions=spend_additions,
|
|
cost=uint64(spend_conds.condition_cost + spend_conds.execution_cost),
|
|
latest_singleton_lineage=lineage_info,
|
|
)
|
|
|
|
# fast forward spends are only allowed when bundled with other, non-FF, spends
|
|
# in order to evict an FF spend, it must be associated with a normal
|
|
# spend that can be included in a block or invalidated some other way
|
|
if all([s.supports_fast_forward for s in bundle_coin_spends.values()]):
|
|
return Err.INVALID_SPEND_BUNDLE, None, []
|
|
|
|
removal_record_dict: dict[bytes32, CoinRecord] = {}
|
|
removal_amount: int = 0
|
|
removal_records = await get_coin_records(removal_names)
|
|
for record in removal_records:
|
|
removal_record_dict[record.coin.name()] = record
|
|
|
|
for name in removal_names:
|
|
if name not in removal_record_dict and name not in additions_dict:
|
|
return Err.UNKNOWN_UNSPENT, None, []
|
|
if name in additions_dict:
|
|
removal_coin = additions_dict[name]
|
|
# The timestamp and block-height of this coin being spent needs
|
|
# to be consistent with what we use to check time-lock
|
|
# conditions (below). All spends (including ephemeral coins) are
|
|
# spent simultaneously. Ephemeral coins with an
|
|
# ASSERT_SECONDS_RELATIVE 0 condition are still OK to spend in
|
|
# the same block.
|
|
assert self.peak.timestamp is not None
|
|
removal_record = CoinRecord(
|
|
removal_coin,
|
|
uint32(self.peak.height + 1),
|
|
uint32(0),
|
|
False,
|
|
self.peak.timestamp,
|
|
)
|
|
removal_record_dict[name] = removal_record
|
|
else:
|
|
removal_record = removal_record_dict[name]
|
|
removal_amount += removal_record.coin.amount
|
|
|
|
fees = uint64(removal_amount - addition_amount)
|
|
|
|
if cost == 0:
|
|
return Err.UNKNOWN, None, []
|
|
|
|
if cost > self.max_tx_clvm_cost:
|
|
return Err.BLOCK_COST_EXCEEDS_MAX, None, []
|
|
|
|
# this is not very likely to happen, but it's here to ensure SQLite
|
|
# never runs out of precision in its computation of fees.
|
|
# sqlite's integers are signed int64, so the max value they can
|
|
# represent is 2^63-1
|
|
if fees > MEMPOOL_ITEM_FEE_LIMIT or SQLITE_INT_MAX - self.mempool.total_mempool_fees() <= fees:
|
|
return Err.INVALID_BLOCK_FEE_AMOUNT, None, []
|
|
|
|
fees_per_cost: float = fees / cost
|
|
# If pool is at capacity check the fee, if not then accept even without the fee
|
|
if self.mempool.at_full_capacity(cost):
|
|
if fees_per_cost < self.nonzero_fee_minimum_fpc:
|
|
return Err.INVALID_FEE_TOO_CLOSE_TO_ZERO, None, []
|
|
min_fee_rate = self.mempool.get_min_fee_rate(cost)
|
|
if min_fee_rate is None:
|
|
return Err.INVALID_COST_RESULT, None, []
|
|
if fees_per_cost <= min_fee_rate:
|
|
return Err.INVALID_FEE_LOW_FEE, None, []
|
|
|
|
# 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
|
|
)
|
|
|
|
# If we have a mempool conflict, continue, since we still want to keep around the TX in the pending pool.
|
|
if fail_reason is not None and fail_reason is not Err.MEMPOOL_CONFLICT:
|
|
return fail_reason, None, []
|
|
|
|
# Verify conditions, create hash_key list for aggsig check
|
|
for spend in conds.spends:
|
|
coin_record: CoinRecord = removal_record_dict[bytes32(spend.coin_id)]
|
|
# Check that the revealed removal puzzles actually match the puzzle hash
|
|
if spend.puzzle_hash != coin_record.coin.puzzle_hash:
|
|
log.warning("Mempool rejecting transaction because of wrong puzzle_hash")
|
|
log.warning(f"{spend.puzzle_hash.hex()} != {coin_record.coin.puzzle_hash.hex()}")
|
|
return Err.WRONG_PUZZLE_HASH, None, []
|
|
|
|
# the height and time we pass in here represent the previous transaction
|
|
# block's height and timestamp. In the mempool, the most recent peak
|
|
# block we've received will be the previous transaction block, from the
|
|
# point-of-view of the next block to be farmed. Therefore we pass in the
|
|
# current peak's height and timestamp
|
|
assert self.peak.timestamp is not None
|
|
tl_error_rust: int | None = check_time_locks(
|
|
removal_record_dict,
|
|
conds,
|
|
self.peak.height,
|
|
self.peak.timestamp,
|
|
)
|
|
tl_error: Err | None = None
|
|
if tl_error_rust is not None:
|
|
tl_error = Err(tl_error_rust)
|
|
|
|
timelocks: TimelockConditions = compute_assert_height(removal_record_dict, conds)
|
|
|
|
if timelocks.assert_before_height is not None and timelocks.assert_before_height <= timelocks.assert_height:
|
|
# returning None as the "potential" means it failed. We won't store it
|
|
# in the pending cache
|
|
return Err.IMPOSSIBLE_HEIGHT_ABSOLUTE_CONSTRAINTS, None, [] # MempoolInclusionStatus.FAILED
|
|
if timelocks.assert_before_seconds is not None and timelocks.assert_before_seconds <= timelocks.assert_seconds:
|
|
return Err.IMPOSSIBLE_SECONDS_ABSOLUTE_CONSTRAINTS, None, [] # MempoolInclusionStatus.FAILED
|
|
|
|
potential = MempoolItem(
|
|
new_spend.aggregated_signature,
|
|
uint64(fees),
|
|
conds,
|
|
spend_name,
|
|
first_added_height,
|
|
timelocks.assert_height,
|
|
timelocks.assert_before_height,
|
|
timelocks.assert_before_seconds,
|
|
bundle_coin_spends,
|
|
)
|
|
|
|
if tl_error:
|
|
if tl_error is Err.ASSERT_HEIGHT_ABSOLUTE_FAILED or tl_error is Err.ASSERT_HEIGHT_RELATIVE_FAILED:
|
|
return tl_error, potential, [] # MempoolInclusionStatus.PENDING
|
|
else:
|
|
return tl_error, None, [] # MempoolInclusionStatus.FAILED
|
|
|
|
if fail_reason is Err.MEMPOOL_CONFLICT:
|
|
log.debug(f"Replace attempted. number of MempoolItems: {len(conflicts)}")
|
|
if not can_replace(conflicts, potential):
|
|
return Err.MEMPOOL_CONFLICT, potential, []
|
|
|
|
duration = time.monotonic() - start_time
|
|
|
|
log.log(
|
|
logging.DEBUG if duration < 2 else logging.WARNING,
|
|
f"add_spendbundle {spend_name} took {duration:0.2f} seconds. "
|
|
f"Cost: {cost} ({round(100.0 * cost / self.constants.MAX_BLOCK_COST_CLVM, 3)}% of max block cost)",
|
|
)
|
|
|
|
if duration > 2:
|
|
log.warning("validating spend took too long, rejecting")
|
|
return Err.INVALID_SPEND_BUNDLE, None, []
|
|
|
|
return None, potential, [item.name for item in conflicts]
|
|
|
|
def get_spendbundle(self, bundle_hash: bytes32) -> SpendBundle | None:
|
|
"""Returns a full SpendBundle if it's inside one the mempools"""
|
|
item: MempoolItem | None = self.mempool.get_item_by_id(bundle_hash)
|
|
if item is not None:
|
|
return item.to_spend_bundle()
|
|
return None
|
|
|
|
def get_mempool_item(self, bundle_hash: bytes32, include_pending: bool = False) -> MempoolItem | None:
|
|
"""
|
|
Returns the MempoolItem in the mempool that matches the provided spend bundle hash (id)
|
|
or None if not found.
|
|
|
|
If include_pending is specified, also check the PENDING cache.
|
|
"""
|
|
item = self.mempool.get_item_by_id(bundle_hash)
|
|
if not item and include_pending:
|
|
# no async lock needed since we're not mutating the pending_cache
|
|
item = self._pending_cache.get(bundle_hash)
|
|
if not item and include_pending:
|
|
item = self._conflict_cache.get(bundle_hash)
|
|
|
|
return item
|
|
|
|
async def new_peak(self, new_peak: BlockRecordProtocol | None, spent_coins: list[bytes32] | None) -> NewPeakInfo:
|
|
"""
|
|
Called when a new peak is available, we try to recreate a mempool for the new tip.
|
|
new_peak should always be the most recent *transaction* block of the chain. Since
|
|
the mempool cannot traverse the chain to find the most recent transaction block,
|
|
we wouldn't be able to detect, and correctly update the mempool, if we saw a
|
|
non-transaction block on a fork. self.peak must always be set to a transaction
|
|
block.
|
|
"""
|
|
if new_peak is None:
|
|
return NewPeakInfo([], [])
|
|
# we're only interested in transaction blocks
|
|
if new_peak.is_transaction_block is False:
|
|
return NewPeakInfo([], [])
|
|
if self.peak == new_peak:
|
|
return NewPeakInfo([], [])
|
|
assert new_peak.timestamp is not None
|
|
self.fee_estimator.new_block_height(new_peak.height)
|
|
included_items: list[MempoolItemInfo] = []
|
|
new_peak_start = time.monotonic()
|
|
|
|
expired = self.mempool.new_tx_block(new_peak.height, new_peak.timestamp)
|
|
mempool_item_removals: list[MempoolRemoveInfo] = [expired]
|
|
|
|
use_optimization: bool = self.peak is not None and new_peak.prev_transaction_block_hash == self.peak.header_hash
|
|
self.peak = new_peak
|
|
|
|
lineage_cache = LineageInfoCache(self.get_unspent_lineage_info_for_puzzle_hash)
|
|
|
|
if use_optimization and spent_coins is not None:
|
|
# We don't reinitialize a mempool, just kick removed items
|
|
# transactions in the mempool may be spending multiple coins,
|
|
# when looking up transactions by all coin IDs, we're likely to
|
|
# find the same transaction multiple times. We put them in a set
|
|
# to deduplicate
|
|
spendbundle_ids_to_remove: set[bytes32] = set()
|
|
|
|
# rebasing a fast forward spend is more expensive than to just
|
|
# evict the item. So, any FF spend we may need to rebase, defer
|
|
# them until after we've gone through all spends
|
|
deferred_ff_items: set[tuple[bytes32, MempoolItem]] = set()
|
|
|
|
for spend in spent_coins:
|
|
items = self.mempool.get_items_by_coin_id(spend)
|
|
for item in items:
|
|
# this is a property, compute it once
|
|
item_name = item.name
|
|
|
|
# if we've already decided to remove this mempool item
|
|
# because of some other coin, we don't need to do any more
|
|
# work
|
|
if item_name in spendbundle_ids_to_remove:
|
|
continue
|
|
|
|
bcs = item.bundle_coin_spends.get(spend)
|
|
if bcs is not None and bcs.latest_singleton_lineage is None:
|
|
# this is a regular coin spend that's now made it into
|
|
# a block and we just evict its mempool item
|
|
included_items.append(MempoolItemInfo(item.cost, item.fee, item.height_added_to_mempool))
|
|
self.remove_seen(item_name)
|
|
spendbundle_ids_to_remove.add(item_name)
|
|
continue
|
|
|
|
deferred_ff_items.add((spend, item))
|
|
|
|
# fast forward spends are indexed under the latest singleton coin ID
|
|
# if it's spent, we need to update the index in the mempool. This
|
|
# list lets us perform a bulk update
|
|
# new_coin_id, current_coin_id, mempool item name
|
|
spends_to_update: list[tuple[bytes32, bytes32, bytes32]] = []
|
|
|
|
for spend, item in deferred_ff_items:
|
|
item_name = item.spend_bundle_name
|
|
if item_name in spendbundle_ids_to_remove:
|
|
continue
|
|
# there may be multiple matching spends in the mempool
|
|
# item, for the same singleton
|
|
found_matches = 0
|
|
for bcs in item.bundle_coin_spends.values():
|
|
if bcs.latest_singleton_lineage is None or bcs.latest_singleton_lineage.coin_id != spend:
|
|
continue
|
|
found_matches += 1
|
|
|
|
# TODO: in the future, we could pass this new coin ID
|
|
# into new_peak() and avoid this DB lookup
|
|
lineage_info = await lineage_cache.get_unspent_lineage_info(bcs.coin_spend.coin.puzzle_hash)
|
|
if lineage_info is None:
|
|
# this singleton no longer has an unspent coin with
|
|
# this puzzle-hash. FF is not longer available and we
|
|
# just need to evict this mempool item
|
|
self.remove_seen(item_name)
|
|
spendbundle_ids_to_remove.add(item_name)
|
|
break
|
|
|
|
spends_to_update.append((lineage_info.coin_id, spend, item_name))
|
|
bcs.latest_singleton_lineage = lineage_info
|
|
|
|
if found_matches == 0: # pragma: no cover
|
|
# We are not expected to get here. this is all
|
|
# defensive to get rid of the spend bundle or patch
|
|
# it up
|
|
log.warning(
|
|
f"MempoolItem indexed as spending coin: {spend}, "
|
|
f"but spend is not found in item: {item_name}. Evicting mempool item"
|
|
)
|
|
# we don't expect this to happen, so evict the
|
|
# item as a precaution
|
|
spendbundle_ids_to_remove.add(item_name)
|
|
|
|
if len(spends_to_update) > 0:
|
|
self.mempool.update_spend_index(spends_to_update)
|
|
|
|
mempool_item_removals.append(
|
|
self.mempool.remove_from_pool(list(spendbundle_ids_to_remove), MempoolRemoveReason.BLOCK_INCLUSION)
|
|
)
|
|
else:
|
|
log.warning(
|
|
"updating the mempool using the slow-path. "
|
|
f"peak: {self.peak.header_hash.hex()} "
|
|
f"new-peak-prev: {new_peak.prev_transaction_block_hash} "
|
|
f"coins: {'not set' if spent_coins is None else 'set'}"
|
|
)
|
|
old_pool = self.mempool
|
|
self.mempool = Mempool(old_pool.mempool_info, old_pool.fee_estimator)
|
|
self.seen_bundle_hashes = {}
|
|
|
|
# in order to make this a bit quicker, we look-up all the spends in
|
|
# a single query, rather than one at a time.
|
|
coin_records: dict[bytes32, CoinRecord] = {}
|
|
|
|
removals: set[bytes32] = set()
|
|
for item in old_pool.all_items():
|
|
removals.update(item.bundle_coin_spends)
|
|
|
|
for record in await self.get_coin_records(removals):
|
|
name = record.coin.name()
|
|
coin_records[name] = record
|
|
|
|
async def local_get_coin_records(names: Collection[bytes32]) -> list[CoinRecord]:
|
|
ret: list[CoinRecord] = []
|
|
for name in names:
|
|
r = coin_records.get(name)
|
|
if r is not None:
|
|
ret.append(r)
|
|
return ret
|
|
|
|
for item in old_pool.all_items():
|
|
info = await self.add_spend_bundle(
|
|
item.to_spend_bundle(),
|
|
item.conds,
|
|
item.spend_bundle_name,
|
|
item.height_added_to_mempool,
|
|
local_get_coin_records,
|
|
lineage_cache.get_unspent_lineage_info,
|
|
)
|
|
# Only add to `seen` if inclusion worked, so it can be resubmitted in case of a reorg
|
|
if info.status == MempoolInclusionStatus.SUCCESS:
|
|
self.add_and_maybe_pop_seen(item.spend_bundle_name)
|
|
# If the spend bundle was confirmed or conflicting (can no longer be in mempool), it won't be
|
|
# successfully added to the new mempool.
|
|
if info.status == MempoolInclusionStatus.FAILED and info.error == Err.DOUBLE_SPEND:
|
|
# Item was in mempool, but after the new block it's a double spend.
|
|
# Item is most likely included in the block.
|
|
included_items.append(MempoolItemInfo(item.cost, item.fee, item.height_added_to_mempool))
|
|
|
|
old_pool.close()
|
|
|
|
potential_txs = self._pending_cache.drain(new_peak.height)
|
|
potential_txs.update(self._conflict_cache.drain())
|
|
txs_added = []
|
|
for item in potential_txs.values():
|
|
info = await self.add_spend_bundle(
|
|
item.to_spend_bundle(),
|
|
item.conds,
|
|
item.spend_bundle_name,
|
|
item.height_added_to_mempool,
|
|
self.get_coin_records,
|
|
lineage_cache.get_unspent_lineage_info,
|
|
)
|
|
if info.status == MempoolInclusionStatus.SUCCESS:
|
|
txs_added.append(item.spend_bundle_name)
|
|
mempool_item_removals.extend(info.removals)
|
|
log.info(
|
|
f"Size of mempool: {self.mempool.size()} spends, "
|
|
f"cost: {self.mempool.total_mempool_cost()} "
|
|
f"minimum fee rate (in FPC) to get in for 5M cost tx: {self.mempool.get_min_fee_rate(5000000)}"
|
|
)
|
|
self.mempool.fee_estimator.new_block(FeeBlockInfo(new_peak.height, included_items))
|
|
duration = time.monotonic() - new_peak_start
|
|
log.log(logging.WARNING if duration > 1 else logging.INFO, f"new_peak() took {duration:0.2f} seconds")
|
|
return NewPeakInfo(txs_added, mempool_item_removals)
|
|
|
|
def get_items_not_in_filter(self, mempool_filter: PyBIP158, limit: int = 100) -> list[MempoolItem]:
|
|
items: list[MempoolItem] = []
|
|
|
|
assert limit > 0
|
|
|
|
# Send 100 with the highest fee per cost
|
|
for item in self.mempool.items_by_feerate():
|
|
if len(items) >= limit:
|
|
return items
|
|
if mempool_filter.Match(bytearray(item.spend_bundle_name)):
|
|
continue
|
|
items.append(item)
|
|
|
|
return items
|
|
|
|
|
|
T = TypeVar("T", uint32, uint64)
|
|
|
|
|
|
def optional_min(a: T | None, b: T | None) -> T | None:
|
|
return min((v for v in [a, b] if v is not None), default=None)
|
|
|
|
|
|
def optional_max(a: T | None, b: T | None) -> T | None:
|
|
return max((v for v in [a, b] if v is not None), default=None)
|
|
|
|
|
|
def can_replace(conflicting_items: list[MempoolItem], new_item: MempoolItem) -> bool:
|
|
"""
|
|
This function implements the mempool replacement rules. Given a Mempool item
|
|
we're attempting to insert into the mempool (new_item) and the set of existing
|
|
mempool items that conflict with it, this function answers the question whether
|
|
the existing items can be replaced by the new one.
|
|
"""
|
|
|
|
conflicting_fees = 0
|
|
conflicting_cost = 0
|
|
assert_height: uint32 | None = None
|
|
assert_before_height: uint32 | None = None
|
|
assert_before_seconds: uint64 | None = None
|
|
# we don't allow replacing mempool items with new ones that remove
|
|
# eligibility for dedup and fast-forward. Doing so could be abused by
|
|
# denying such spends from operating as intended
|
|
# collect all coins that are eligible for dedup and FF in the existing items
|
|
existing_ff_spends: set[bytes32] = set()
|
|
existing_dedup_spends: set[bytes32] = set()
|
|
|
|
for item in conflicting_items:
|
|
conflicting_fees += item.fee
|
|
conflicting_cost += item.cost
|
|
|
|
# All coins spent in all conflicting items must also be spent in the new item. (superset rule). This is
|
|
# important because otherwise there exists an attack. A user spends coin A. An attacker replaces the
|
|
# bundle with AB with a higher fee. An attacker then replaces the bundle with just B with a higher
|
|
# fee than AB therefore kicking out A altogether. The better way to solve this would be to keep a cache
|
|
# of booted transactions like A, and retry them after they get removed from mempool due to a conflict.
|
|
for coin_id, bcs in item.bundle_coin_spends.items():
|
|
if coin_id not in new_item.bundle_coin_spends:
|
|
log.debug("Rejecting conflicting tx as it does not spend conflicting coin %s", coin_id)
|
|
return False
|
|
if bcs.supports_fast_forward:
|
|
existing_ff_spends.add(bytes32(coin_id))
|
|
if bcs.eligible_for_dedup:
|
|
existing_dedup_spends.add(bytes32(coin_id))
|
|
|
|
assert_height = optional_max(assert_height, item.assert_height)
|
|
assert_before_height = optional_min(assert_before_height, item.assert_before_height)
|
|
assert_before_seconds = optional_min(assert_before_seconds, item.assert_before_seconds)
|
|
|
|
# New item must have higher fee per cost
|
|
conflicting_fees_per_cost = conflicting_fees / conflicting_cost
|
|
if new_item.fee_per_cost <= conflicting_fees_per_cost:
|
|
log.debug(
|
|
f"Rejecting conflicting tx due to not increasing fees per cost "
|
|
f"({new_item.fee_per_cost} <= {conflicting_fees_per_cost})"
|
|
)
|
|
return False
|
|
|
|
# New item must increase the total fee at least by a certain amount
|
|
fee_increase = new_item.fee - conflicting_fees
|
|
if fee_increase < MEMPOOL_MIN_FEE_INCREASE:
|
|
log.debug(f"Rejecting conflicting tx due to low fee increase ({fee_increase})")
|
|
return False
|
|
|
|
# New item may not have a different effective height/time lock (time-lock rule)
|
|
if new_item.assert_height != assert_height:
|
|
log.debug(
|
|
"Rejecting conflicting tx due to changing ASSERT_HEIGHT constraints %s -> %s",
|
|
assert_height,
|
|
new_item.assert_height,
|
|
)
|
|
return False
|
|
|
|
if new_item.assert_before_height != assert_before_height:
|
|
log.debug(
|
|
"Rejecting conflicting tx due to changing ASSERT_BEFORE_HEIGHT constraints %s -> %s",
|
|
assert_before_height,
|
|
new_item.assert_before_height,
|
|
)
|
|
return False
|
|
|
|
if new_item.assert_before_seconds != assert_before_seconds:
|
|
log.debug(
|
|
"Rejecting conflicting tx due to changing ASSERT_BEFORE_SECONDS constraints %s -> %s",
|
|
assert_before_seconds,
|
|
new_item.assert_before_seconds,
|
|
)
|
|
return False
|
|
|
|
if len(existing_ff_spends) > 0 or len(existing_dedup_spends) > 0:
|
|
for coin_id, bcs in new_item.bundle_coin_spends.items():
|
|
if not bcs.supports_fast_forward and coin_id in existing_ff_spends:
|
|
log.debug("Rejecting conflicting tx due to changing ELIGIBLE_FOR_FF of coin spend %s", coin_id)
|
|
return False
|
|
|
|
if not bcs.eligible_for_dedup and coin_id in existing_dedup_spends:
|
|
log.debug("Rejecting conflicting tx due to changing ELIGIBLE_FOR_DEDUP of coin spend %s", coin_id)
|
|
return False
|
|
|
|
log.info(f"Replacing conflicting tx in mempool. New tx fee: {new_item.fee}, old tx fees: {conflicting_fees}")
|
|
return True
|