From f12e5841c5b6fbb0a9f2a5a8ec3ef822dd5b3bd9 Mon Sep 17 00:00:00 2001 From: Jan Segre Date: Thu, 16 Apr 2026 17:04:12 +0200 Subject: [PATCH] feat(nano): execute standalone transfer headers --- .../nanocontracts/execution/block_executor.py | 191 ++++++++++++++---- .../execution/consensus_block_executor.py | 40 +++- .../execution/dry_run_block_executor.py | 12 +- hathor/nanocontracts/sorter/random_sorter.py | 44 ++-- hathor/nanocontracts/sorter/types.py | 4 +- hathor_tests/nanocontracts/test_consensus.py | 99 +++++++++ 6 files changed, 329 insertions(+), 61 deletions(-) diff --git a/hathor/nanocontracts/execution/block_executor.py b/hathor/nanocontracts/execution/block_executor.py index 9d52fda35d..247c51e70e 100644 --- a/hathor/nanocontracts/execution/block_executor.py +++ b/hathor/nanocontracts/execution/block_executor.py @@ -18,14 +18,14 @@ import hashlib import traceback +from collections import defaultdict from dataclasses import dataclass from typing import TYPE_CHECKING, Callable, Iterator, Mapping, TypeAlias, cast from hathor.feature_activation.utils import Features -from hathor.nanocontracts.exception import NCFail +from hathor.nanocontracts.exception import NCFail, NCInsufficientFunds from hathor.nanocontracts.nano_runtime_version import NanoRuntimeVersion from hathor.transaction import Block, Transaction -from hathorlib.nanocontracts.runner.call_info import CallInfo from hathorlib.nanocontracts.runner.runner import MAX_SEQNUM_JUMP_SIZE from hathorlib.nanocontracts.types import Address, BlueprintId, ContractId, NCRawArgs, VertexId @@ -48,25 +48,30 @@ class NCTxExecutionSuccess: """Result type for successful NC execution.""" tx: Transaction - runner: 'Runner' + block_storage: 'NCBlockStorage' + runner: 'Runner | None' = None @dataclass(slots=True, frozen=True) class NCTxExecutionFailure: """Result type for failed NC execution. - `call_info` is None for cyclic-dependency failures, which never run a Runner. + `runner` is None for cyclic-dependency failures and failed transfer-header executions. """ tx: Transaction - call_info: CallInfo | None + block_storage: 'NCBlockStorage | None' + runner: 'Runner | None' exception: NCFail traceback: str + persist_block_storage: bool = False @dataclass(slots=True, frozen=True) class NCTxExecutionSkipped: """Result type for skipped NC execution (voided transactions).""" tx: Transaction + block_storage: 'NCBlockStorage | None' = None + persist_block_storage: bool = False NCTxExecutionResult: TypeAlias = NCTxExecutionSuccess | NCTxExecutionFailure | NCTxExecutionSkipped @@ -136,7 +141,7 @@ def get_token_description(self, token_id: 'TokenUid') -> 'TokenDescription': class NCBlockExecutor: """ - Pure execution of nano contract transactions in a block. + Pure execution of nano contract and transfer-header transactions in a block. This class contains the core NC execution logic without any side effects. It yields execution events that can be processed by a caller to apply @@ -197,15 +202,17 @@ def execute_block( parent_root_id = parent_meta.nc_block_root_id assert parent_root_id is not None - nc_calls: list[Transaction] = [] + stateful_txs: list[Transaction] = [] for tx in block.iter_transactions_in_this_block(): - if not tx.is_nano_contract(): + if not tx.is_nano_contract() and not tx.has_transfer_header(): continue - nc_calls.append(tx) + stateful_txs.append(tx) - sorted_txs = self._nc_calls_sorter(block, nc_calls) if nc_calls else None - nc_sorted_calls = sorted_txs.sorted if sorted_txs else tuple() - nc_cyclic_txs = sorted_txs.cyclic if sorted_txs else tuple() + sorted_txs = self._nc_calls_sorter(block, stateful_txs) if stateful_txs else None + stateful_sorted_txs = sorted_txs.sorted if sorted_txs else tuple() + cyclic_txs = sorted_txs.cyclic if sorted_txs else tuple() + nc_sorted_calls = tuple(tx for tx in stateful_sorted_txs if tx.is_nano_contract()) + nc_cyclic_txs = tuple(tx for tx in cyclic_txs if tx.is_nano_contract()) block_storage = self._nc_storage_factory.get_block_storage(parent_root_id) assert block_storage.get_root_id() == parent_root_id features = Features.from_vertex(settings=self._settings, feature_service=self._feature_service, vertex=block) @@ -218,7 +225,7 @@ def execute_block( nc_cyclic_fails=nc_cyclic_txs, ) - for tx in nc_cyclic_txs: + for tx in cyclic_txs: yield NCBeginTransaction(tx=tx, rng_seed=b'') try: # Dummy raise just to create an exception context and convert @@ -227,7 +234,8 @@ def execute_block( except NCFail as e: yield NCTxExecutionFailure( tx=tx, - call_info=None, + block_storage=None, + runner=None, exception=e, traceback=traceback.format_exc(), ) @@ -235,10 +243,13 @@ def execute_block( seed_hasher = hashlib.sha256(block.hash) - for tx in nc_sorted_calls: + current_root_id = parent_root_id + + for tx in stateful_sorted_txs: + tx_block_storage = self._nc_storage_factory.get_block_storage(current_root_id) # Compute RNG seed for this transaction seed_hasher.update(tx.hash) - seed_hasher.update(block_storage.get_root_id()) + seed_hasher.update(current_root_id) rng_seed = seed_hasher.digest() yield NCBeginTransaction(tx=tx, rng_seed=rng_seed) @@ -247,17 +258,30 @@ def execute_block( result = self.execute_transaction( runtime_version=features.nano_runtime_version, tx=tx, - block_storage=block_storage, + block_storage=tx_block_storage, rng_seed=rng_seed, should_skip=should_skip, ) yield result + match result: + case NCTxExecutionSuccess(block_storage=result_block_storage): + current_root_id = result_block_storage.get_root_id() + case NCTxExecutionFailure(block_storage=result_block_storage, persist_block_storage=True): + assert result_block_storage is not None + current_root_id = result_block_storage.get_root_id() + case NCTxExecutionSkipped(block_storage=result_block_storage, persist_block_storage=True): + assert result_block_storage is not None + current_root_id = result_block_storage.get_root_id() + case _: + pass + yield NCEndTransaction(tx=tx) # Compute final root ID without committing - final_root_id = block_storage.get_root_id() - if not nc_sorted_calls: + final_root_id = current_root_id + block_storage = self._nc_storage_factory.get_block_storage(final_root_id) + if not stateful_sorted_txs: assert final_root_id == parent_root_id yield NCEndBlock( block=block, @@ -288,14 +312,31 @@ def execute_transaction( """ if should_skip(tx): # Skip transactions based on the caller-provided predicate. - # Check if seqnum needs to be updated. - nc_header = tx.get_nano_header() - nc_address = Address(nc_header.nc_address) - seqnum = block_storage.get_address_seqnum(nc_address) - if nc_header.nc_seqnum > seqnum: - block_storage.set_address_seqnum(nc_address, nc_header.nc_seqnum) + if tx.is_nano_contract(): + nc_header = tx.get_nano_header() + nc_address = Address(nc_header.nc_address) + seqnum = block_storage.get_address_seqnum(nc_address) + if nc_header.nc_seqnum > seqnum: + block_storage.set_address_seqnum(nc_address, nc_header.nc_seqnum) + return NCTxExecutionSkipped(tx=tx, block_storage=block_storage, persist_block_storage=True) return NCTxExecutionSkipped(tx=tx) + if not tx.is_nano_contract(): + try: + self._verify_transfer_header_balances(block_storage, tx) + self._verify_transfer_header_seqnums(block_storage, tx) + self._apply_transfer_header_diffs(block_storage, self._get_transfer_header_diffs(tx)) + self._apply_transfer_header_seqnums(block_storage, tx) + except NCFail as e: + return NCTxExecutionFailure( + tx=tx, + block_storage=None, + runner=None, + exception=e, + traceback=traceback.format_exc(), + ) + return NCTxExecutionSuccess(tx=tx, block_storage=block_storage) + runner = self._runner_factory.create( runtime_version=runtime_version, block_storage=block_storage, @@ -303,9 +344,6 @@ def execute_transaction( ) try: - assert isinstance(tx, Transaction) - - # Check seqnum. nano_header = tx.get_nano_header() if nano_header.is_creating_a_new_contract(): @@ -314,18 +352,12 @@ def execute_transaction( contract_id = ContractId(VertexId(nano_header.nc_id)) assert nano_header.nc_seqnum >= 0 - current_seqnum = runner.block_storage.get_address_seqnum( - Address(nano_header.nc_address) - ) + current_seqnum = runner.block_storage.get_address_seqnum(Address(nano_header.nc_address)) diff = nano_header.nc_seqnum - current_seqnum if diff <= 0 or diff > MAX_SEQNUM_JUMP_SIZE: - # Fail execution if seqnum is invalid. runner._last_call_info = runner._build_call_info(contract_id) - # TODO: Set the seqnum in this case? raise NCFail(f'invalid seqnum (diff={diff})') - runner.block_storage.set_address_seqnum( - Address(nano_header.nc_address), nano_header.nc_seqnum - ) + runner.block_storage.set_address_seqnum(Address(nano_header.nc_address), nano_header.nc_seqnum) vertex_metadata = tx.get_metadata() assert vertex_metadata.first_block is not None, ( @@ -348,19 +380,102 @@ def execute_transaction( # and at this point no tokens pending creation, so we can validate the balances self._verify_transparent_balance_after_execution(tx, block_storage, runner) except NCFail as e: + self._ensure_runner_has_last_call_info(tx, runner) runner.discard_pending_changes() return NCTxExecutionFailure( tx=tx, - call_info=runner.get_last_call_info(), + block_storage=block_storage, + runner=runner, exception=e, traceback=traceback.format_exc(), + persist_block_storage=True, ) # Commit is intentionally outside the NCFail handling path. # A failure here indicates critical state corruption and must propagate. runner.commit_pending_changes() - return NCTxExecutionSuccess(tx=tx, runner=runner) + return NCTxExecutionSuccess(tx=tx, block_storage=block_storage, runner=runner) + + def _get_transfer_header_diffs(self, tx: Transaction) -> dict[tuple['Address', 'TokenUid'], int]: + from hathor.nanocontracts.types import Address, TokenUid + + diffs: defaultdict[tuple[Address, TokenUid], int] = defaultdict(int) + if not tx.has_transfer_header(): + return dict(diffs) + + transfer_header = tx.get_transfer_header() + for txin in transfer_header.inputs: + token_uid = TokenUid(tx.get_token_uid(txin.token_index)) + input_address = transfer_header.addresses[txin.address_index] + diffs[(Address(input_address.address), token_uid)] -= txin.amount + + for txout in transfer_header.outputs: + token_uid = TokenUid(tx.get_token_uid(txout.token_index)) + diffs[(Address(txout.address), token_uid)] += txout.amount + + return dict(diffs) + + def _verify_transfer_header_balances( + self, + block_storage: 'NCBlockStorage', + tx: Transaction, + ) -> None: + transfer_header_diffs = self._get_transfer_header_diffs(tx) + for (address, token_uid), diff in transfer_header_diffs.items(): + if diff >= 0: + continue + + balance = block_storage.get_address_balance(address, token_uid) + if balance + diff < 0: + raise NCInsufficientFunds( + f'insufficient transfer-header balance for address={address.hex()} ' + f'token={token_uid.hex()}: available={balance} required={-diff}' + ) + + def _verify_transfer_header_seqnums(self, block_storage: 'NCBlockStorage', tx: Transaction) -> None: + if not tx.has_transfer_header(): + return + + transfer_header = tx.get_transfer_header() + for input_address in transfer_header.addresses: + current_seqnum = block_storage.get_address_seqnum(Address(input_address.address)) + diff = input_address.seqnum - current_seqnum + if diff <= 0 or diff > MAX_SEQNUM_JUMP_SIZE: + raise NCFail(f'invalid transfer-header seqnum (diff={diff})') + + def _apply_transfer_header_diffs( + self, + block_storage: 'NCBlockStorage', + transfer_header_diffs: dict[tuple['Address', 'TokenUid'], int], + ) -> None: + from hathor.nanocontracts.types import Amount + + for (address, token_uid), diff in transfer_header_diffs.items(): + if diff == 0: + continue + block_storage.add_address_balance(address, Amount(diff), token_uid) + + def _apply_transfer_header_seqnums(self, block_storage: 'NCBlockStorage', tx: Transaction) -> None: + if not tx.has_transfer_header(): + return + + transfer_header = tx.get_transfer_header() + for input_address in transfer_header.addresses: + block_storage.set_address_seqnum(Address(input_address.address), input_address.seqnum) + + def _ensure_runner_has_last_call_info(self, tx: Transaction, runner: 'Runner') -> None: + from hathor.nanocontracts.types import ContractId, VertexId + + if runner._last_call_info is not None: + return + + nano_header = tx.get_nano_header() + if nano_header.is_creating_a_new_contract(): + contract_id = ContractId(VertexId(tx.hash)) + else: + contract_id = ContractId(VertexId(nano_header.nc_id)) + runner._last_call_info = runner._build_call_info(contract_id) def _verify_transparent_balance_after_execution( self, diff --git a/hathor/nanocontracts/execution/consensus_block_executor.py b/hathor/nanocontracts/execution/consensus_block_executor.py index f724e7188c..735329c590 100644 --- a/hathor/nanocontracts/execution/consensus_block_executor.py +++ b/hathor/nanocontracts/execution/consensus_block_executor.py @@ -247,9 +247,13 @@ def _apply_effect( # as that is added by the executor itself after a failure assert NC_EXECUTION_FAIL_ID not in tx_meta.voided_by - case NCTxExecutionSuccess(tx=tx, runner=runner): + case NCTxExecutionSuccess(tx=tx, block_storage=block_storage, runner=runner): from hathor.nanocontracts.runner.call_info import CallType + if runner is None: + block_storage.commit() + return + tx_meta = tx.get_metadata() tx_meta.nc_execution = NCExecutionState.SUCCESS @@ -262,6 +266,7 @@ def _apply_effect( # for the block_storage. This ensures we will have a clean database with # no orphan nodes. runner.commit() + block_storage.commit() # Derive call_info, nc_calls_records, and events from runner call_info = runner.get_last_call_info() @@ -297,7 +302,28 @@ def _apply_effect( # Save logs self._nc_log_storage.save_logs(tx, call_info.nc_logger.__entries__, None) - case NCTxExecutionFailure(tx=tx, call_info=call_info, exception=exception, traceback=tb): + case NCTxExecutionFailure( + tx=tx, + block_storage=block_storage, + runner=runner, + exception=exception, + traceback=tb, + persist_block_storage=persist_block_storage, + ): + if persist_block_storage: + assert block_storage is not None + block_storage.commit() + + if runner is None and not tx.is_nano_contract(): + self.log.info( + 'transfer-header execution failed', + tx=tx.hash.hex(), + error=repr(exception), + cause=repr(exception.__cause__), + ) + on_failure(tx) + return + # Log the failure kwargs: dict[str, Any] = {} if tx.name: @@ -315,10 +341,16 @@ def _apply_effect( on_failure(tx) # Save logs with exception info - log_entries = call_info.nc_logger.__entries__ if call_info is not None else [] + failure_call_info = runner.get_last_call_info() if runner is not None else None + log_entries = failure_call_info.nc_logger.__entries__ if failure_call_info is not None else [] self._nc_log_storage.save_logs(tx, log_entries, (exception, tb)) - case NCTxExecutionSkipped(tx=tx): + case NCTxExecutionSkipped(tx=tx, block_storage=block_storage, persist_block_storage=persist_block_storage): + if persist_block_storage: + assert block_storage is not None + block_storage.commit() + if not tx.is_nano_contract(): + return tx_meta = tx.get_metadata() tx_meta.nc_execution = NCExecutionState.SKIPPED context.save(tx) diff --git a/hathor/nanocontracts/execution/dry_run_block_executor.py b/hathor/nanocontracts/execution/dry_run_block_executor.py index ff4f934385..a2ee936626 100644 --- a/hathor/nanocontracts/execution/dry_run_block_executor.py +++ b/hathor/nanocontracts/execution/dry_run_block_executor.py @@ -128,15 +128,22 @@ def should_skip(tx: Transaction) -> bool: current_rng_seed = rng_seed case NCTxExecutionSuccess(tx=tx, runner=runner): + if runner is None: + current_rng_seed = None + continue tx_result = self._build_success_result( tx, current_rng_seed, runner, include_changes ) transactions.append(tx_result) current_rng_seed = None - case NCTxExecutionFailure(tx=tx, call_info=call_info, exception=exception, traceback=tb): + case NCTxExecutionFailure(tx=tx, runner=runner, exception=exception, traceback=tb): # Mark this tx as voided for subsequent transactions voided_in_block.add(tx.hash) + if not tx.is_nano_contract(): + current_rng_seed = None + continue + call_info = runner.get_last_call_info() if runner is not None else None tx_result = self._build_failure_result( tx, current_rng_seed, call_info, exception, tb, include_changes ) @@ -146,6 +153,9 @@ def should_skip(tx: Transaction) -> bool: case NCTxExecutionSkipped(tx=tx): # Also mark skipped txs as voided (propagate through chain) voided_in_block.add(tx.hash) + if not tx.is_nano_contract(): + current_rng_seed = None + continue tx_result = DryRunTxResult( tx_hash=tx.hash, rng_seed=current_rng_seed if current_rng_seed is not None else b'', diff --git a/hathor/nanocontracts/sorter/random_sorter.py b/hathor/nanocontracts/sorter/random_sorter.py index ce7ce96c8d..1686d0e8c2 100644 --- a/hathor/nanocontracts/sorter/random_sorter.py +++ b/hathor/nanocontracts/sorter/random_sorter.py @@ -27,23 +27,23 @@ from hathor.types import Address, VertexId -def random_nc_calls_sorter(block: Block, nc_calls: list[Transaction]) -> SortedTransactions: - sorter = NCBlockSorter.create_from_block(block, nc_calls) +def random_nc_calls_sorter(block: Block, txs: list[Transaction]) -> SortedTransactions: + sorter = NCBlockSorter.create_from_block(block, txs) seed = hashlib.sha256(block.hash).digest() order, stuck = sorter.generate_random_topological_order(seed) - tx_by_id = dict((tx.hash, tx) for tx in nc_calls) + tx_by_id = dict((tx.hash, tx) for tx in txs) assert all((tx_id in order or tx_id in stuck) for tx_id in tx_by_id) - sorted_calls = tuple(tx_by_id[_id] for _id in order) + sorted_txs = tuple(tx_by_id[_id] for _id in order) cycle_failed: list[Transaction] = [] for id_ in stuck: - # `stuck` may also contain dummy seqnum nodes and non-NC funds transactions that got dragged into - # the cycle's reachable set; only NC transactions in the current block should be added. + # `stuck` may also contain dummy seqnum nodes and non-stateful funds transactions that got dragged into + # the cycle's reachable set; only stateful transactions in the current block should be added. if tx := tx_by_id.get(id_): cycle_failed.append(tx) - return SortedTransactions(sorted=sorted_calls, cyclic=tuple(cycle_failed)) + return SortedTransactions(sorted=sorted_txs, cyclic=tuple(cycle_failed)) @dataclass(slots=True, kw_only=True) @@ -62,8 +62,9 @@ def copy(self) -> 'SorterNode': class NCBlockSorter: """This class is responsible for sorting a list of Nano cryptocurrency - transactions to be executed by the consensus algorithm. The transactions - are sorted in topological order, ensuring proper dependency management. + and transfer-header transactions to be executed by the consensus algorithm. + The transactions are sorted in topological order, ensuring proper dependency + management. Algorithm: @@ -81,9 +82,9 @@ def __init__(self, nc_hashes: set[VertexId]) -> None: self._nc_hashes = nc_hashes @classmethod - def create_from_block(cls, block: Block, nc_calls: list[Transaction]) -> Self: - """Create a Sorter instance from the nano transactions confirmed by a block.""" - nc_hashes = set(tx.hash for tx in nc_calls) + def create_from_block(cls, block: Block, txs: list[Transaction]) -> Self: + """Create a sorter instance from the stateful transactions confirmed by a block.""" + nc_hashes = set(tx.hash for tx in txs) sorter = cls(nc_hashes) sorter._block = block @@ -104,10 +105,9 @@ def create_from_block(cls, block: Block, nc_calls: list[Transaction]) -> Self: grouped_txs: defaultdict[Address, defaultdict[int, list[Transaction]]] = defaultdict(lambda: defaultdict(list)) dummy_nodes = 0 - for tx in nc_calls: - assert tx.is_nano_contract() - nano_header = tx.get_nano_header() - grouped_txs[nano_header.nc_address][nano_header.nc_seqnum].append(tx) + for tx in txs: + for address, seqnum in cls._iter_seqnums(tx): + grouped_txs[address][seqnum].append(tx) for _address, txs_by_seqnum in grouped_txs.items(): sorted_by_seqnum = sorted(txs_by_seqnum.items()) @@ -131,6 +131,18 @@ def create_from_block(cls, block: Block, nc_calls: list[Transaction]) -> Self: return sorter + @staticmethod + def _iter_seqnums(tx: Transaction) -> set[tuple[Address, int]]: + ret: set[tuple[Address, int]] = set() + if tx.is_nano_contract(): + nano_header = tx.get_nano_header() + ret.add((Address(nano_header.nc_address), nano_header.nc_seqnum)) + if tx.has_transfer_header(): + transfer_header = tx.get_transfer_header() + for input_address in transfer_header.addresses: + ret.add((Address(input_address.address), input_address.seqnum)) + return ret + def copy(self) -> NCBlockSorter: """Copy the sorter. It is useful if one wants to call get_random_topological_order() multiple times.""" if self._dirty: diff --git a/hathor/nanocontracts/sorter/types.py b/hathor/nanocontracts/sorter/types.py index 9a01fa8ef2..a202e74b37 100644 --- a/hathor/nanocontracts/sorter/types.py +++ b/hathor/nanocontracts/sorter/types.py @@ -25,12 +25,12 @@ class SortedTransactions: class NCSorterCallable(Protocol): - def __call__(self, block: Block, nc_calls: list[Transaction]) -> SortedTransactions: + def __call__(self, block: Block, txs: list[Transaction]) -> SortedTransactions: """ Return the sorted execution order plus any transactions that must fail due to cyclic dependencies. `SortedTransactions.cyclic` is empty in the normal case. A non-empty tuple means the sorter detected a cycle in the dependency graph; the caller must mark those transactions as failed - NC executions and skip executing them. + stateful executions and skip executing them. """ ... diff --git a/hathor_tests/nanocontracts/test_consensus.py b/hathor_tests/nanocontracts/test_consensus.py index e4c5168624..038eef7c3f 100644 --- a/hathor_tests/nanocontracts/test_consensus.py +++ b/hathor_tests/nanocontracts/test_consensus.py @@ -29,6 +29,7 @@ from hathor_tests.dag_builder.builder import TestDAGBuilder from hathor_tests.simulation.base import SimulatorTestCase from hathor_tests.utils import add_blocks_unlock_reward, add_custom_tx, create_tokens, gen_custom_base_tx +from hathorlib.conf.settings import FeatureSetting settings = HathorSettings() @@ -99,6 +100,9 @@ class NCAddressBalanceConsensusTestCase(SimulatorTestCase): def setUp(self) -> None: super().setUp() self.blueprint_id = b'z' * 32 + self.simulator.settings = self.simulator.settings.model_copy( + update={'ENABLE_TRANSFER_HEADER': FeatureSetting.ENABLED} + ) self.manager = self.simulator.create_peer() self.manager.allow_mining_without_peers() self.manager.blueprint_service.register_blueprint(self.blueprint_id, AddressBalanceBlueprint) @@ -138,6 +142,101 @@ def test_address_balance_execution_with_transfer_from_contract_is_successful(sel caller = tx_contract.get_nano_header().nc_address assert self._get_address_balance(caller) == 7 + def test_standalone_transfer_header_execution_updates_state(self) -> None: + artifacts = self.dag_builder.build_from_str(f''' + blockchain genesis b[1..33] + b10 < dummy + + tx_init.nc_id = "{self.blueprint_id.hex()}" + tx_init.nc_method = initialize("00") + tx_init.nc_address = wallet_contract + tx_init.nc_seqnum = 0 + tx_init.nc_deposit = 20 HTR + + tx_seed.nc_id = tx_init + tx_seed.nc_method = transfer_to_caller(9) + tx_seed.nc_address = wallet_sender + tx_seed.nc_seqnum = 0 + + tx_transfer.transfer_seqnum = 1 + tx_transfer.nc_transfer_input = 4 HTR wallet_sender + tx_transfer.nc_transfer_output = 4 HTR wallet_receiver + + tx_init <-- b30 + tx_seed <-- b31 + tx_transfer <-- b32 + ''') + + artifacts.propagate_with(self.manager) + + tx_transfer = artifacts.get_typed_vertex('tx_transfer', Transaction) + b31, b32 = artifacts.get_typed_vertices(['b31', 'b32'], Block) + assert not tx_transfer.is_nano_contract() + assert tx_transfer.get_metadata().nc_execution is None + + transfer_header = tx_transfer.get_transfer_header() + sender = transfer_header.addresses[0].address + receiver = transfer_header.outputs[0].address + + assert self._get_address_balance(sender, block=b31) == 9 + assert self._get_address_balance(receiver, block=b31) == 0 + assert self._get_address_balance(sender, block=b32) == 5 + assert self._get_address_balance(receiver, block=b32) == 4 + + def test_reorg_discards_losing_chain_transfer_header_state(self) -> None: + artifacts = self.dag_builder.build_from_str(f''' + blockchain genesis b[1..33] + blockchain b31 a[32..34] + b10 < dummy + + tx_init.nc_id = "{self.blueprint_id.hex()}" + tx_init.nc_method = initialize("00") + tx_init.nc_address = wallet_contract + tx_init.nc_seqnum = 0 + tx_init.nc_deposit = 20 HTR + + tx_seed.nc_id = tx_init + tx_seed.nc_method = transfer_to_caller(10) + tx_seed.nc_address = wallet_sender + tx_seed.nc_seqnum = 0 + + tx_header_b.transfer_seqnum = 1 + tx_header_b.nc_transfer_input = 4 HTR wallet_sender + tx_header_b.nc_transfer_output = 4 HTR wallet_receiver_b + + tx_header_a.transfer_seqnum = 1 + tx_header_a.nc_transfer_input = 7 HTR wallet_sender + tx_header_a.nc_transfer_output = 7 HTR wallet_receiver_a + tx_header_a.weight = 30 + + tx_init <-- b30 + tx_seed <-- b31 + tx_header_b <-- b32 + + b33 < a32 + tx_header_a <-- a32 + ''') + + artifacts.propagate_with(self.manager) + + tx_header_a = artifacts.get_typed_vertex('tx_header_a', Transaction) + tx_header_b = artifacts.get_typed_vertex('tx_header_b', Transaction) + b33, a34 = artifacts.get_typed_vertices(['b33', 'a34'], Block) + + sender = tx_header_a.get_transfer_header().addresses[0].address + receiver_a = tx_header_a.get_transfer_header().outputs[0].address + receiver_b = tx_header_b.get_transfer_header().outputs[0].address + + assert b33.get_metadata().voided_by == {b33.hash} + assert a34.get_metadata().voided_by is None + assert self._get_address_balance(receiver_b) == 0 + assert self._get_address_balance(sender) == 3 + assert self._get_address_balance(receiver_a) == 7 + + best_block = self.manager.tx_storage.get_best_block() + block_storage = self.manager.get_nc_block_storage(best_block) + assert block_storage.get_address_seqnum(Address(sender)) == 1 + class NCConsensusTestCase(SimulatorTestCase): __test__ = True