Skip to content

fix(nano): non-idempotent indexes handling #1313

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion hathor/consensus/block_consensus.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ def _nc_execute_calls(self, block: Block, *, is_reorg: bool) -> None:
# Update metadata.
self.nc_update_metadata(tx, runner)

# Update indexes. This must be after metadata is updated.
assert tx.storage is not None
assert tx.storage.indexes is not None
tx.storage.indexes.handle_contract_execution(tx)

# We only emit events when the nc is successfully executed.
assert self.context.nc_events is not None
last_call_info = runner.get_last_call_info()
Expand Down Expand Up @@ -685,7 +690,7 @@ def remove_first_block_markers(self, block: Block) -> None:
if meta.nc_execution is NCExecutionState.SUCCESS:
assert tx.storage is not None
assert tx.storage.indexes is not None
tx.storage.indexes.nc_update_remove(tx)
tx.storage.indexes.handle_contract_unexecution(tx)
meta.nc_execution = NCExecutionState.PENDING
meta.nc_calls = None
meta.first_block = None
Expand Down
26 changes: 13 additions & 13 deletions hathor/indexes/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,14 +206,16 @@ def _manually_initialize(self, tx_storage: 'TransactionStorage') -> None:
def update(self, tx: BaseTransaction) -> None:
""" This is the new update method that indexes should use instead of add_tx/del_tx
"""
self.nc_update_add(tx)

# XXX: this _should_ be here, but it breaks some tests, for now this is done explicitly in hathor.manager
# self.mempool_tips.update(tx)
if self.utxo:
self.utxo.update(tx)

def nc_update_add(self, tx: BaseTransaction) -> None:
def handle_contract_execution(self, tx: BaseTransaction) -> None:
"""
Update indexes according to a Nano Contract execution.
Must be called only once for each time a contract is executed.
"""
from hathor.conf.settings import HATHOR_TOKEN_UID
from hathor.nanocontracts.runner.types import (
NCSyscallRecord,
Expand All @@ -223,13 +225,9 @@ def nc_update_add(self, tx: BaseTransaction) -> None:
from hathor.nanocontracts.types import ContractId
from hathor.transaction.nc_execution_state import NCExecutionState

if not tx.is_nano_contract():
return

meta = tx.get_metadata()
if meta.nc_execution != NCExecutionState.SUCCESS:
return

assert tx.is_nano_contract()
assert meta.nc_execution is NCExecutionState.SUCCESS
assert meta.nc_calls
first_call = meta.nc_calls[0]
nc_syscalls: list[NCSyscallRecord] = []
Expand Down Expand Up @@ -280,7 +278,11 @@ def nc_update_add(self, tx: BaseTransaction) -> None:
case _:
assert_never(syscall)

def nc_update_remove(self, tx: BaseTransaction) -> None:
def handle_contract_unexecution(self, tx: BaseTransaction) -> None:
"""
Update indexes according to a Nano Contract unexecution, which happens when a reorg unconfirms a nano tx.
Must be called only once for each time a contract is unexecuted.
"""
from hathor.conf.settings import HATHOR_TOKEN_UID
from hathor.nanocontracts.runner.types import (
NCSyscallRecord,
Expand All @@ -289,10 +291,8 @@ def nc_update_remove(self, tx: BaseTransaction) -> None:
)
from hathor.nanocontracts.types import NC_INITIALIZE_METHOD, ContractId

if not tx.is_nano_contract():
return

meta = tx.get_metadata()
assert tx.is_nano_contract()
assert meta.nc_execution is NCExecutionState.SUCCESS
assert meta.nc_calls
first_call = meta.nc_calls[0]
Expand Down
2 changes: 1 addition & 1 deletion hathor/transaction/nc_execution_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@
@unique
class NCExecutionState(StrEnum):
PENDING = auto() # aka, not even tried to execute it
SUCCESS = auto() # execution was sucessful
SUCCESS = auto() # execution was successful
FAILURE = auto() # execution failed and the transaction is voided
SKIPPED = auto() # execution was skipped, usually because the transaction was voided
72 changes: 72 additions & 0 deletions tests/nanocontracts/test_indexes2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright 2025 Hathor Labs
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from hathor.conf.settings import HATHOR_TOKEN_UID
from hathor.nanocontracts import Blueprint, Context, public
from hathor.nanocontracts.types import ContractId, VertexId
from hathor.nanocontracts.utils import derive_child_token_id
from hathor.transaction import Transaction
from hathor.transaction.nc_execution_state import NCExecutionState
from hathor.transaction.util import get_deposit_amount
from tests.dag_builder.builder import TestDAGBuilder
from tests.nanocontracts.blueprints.unittest import BlueprintTestCase


class MyBlueprint(Blueprint):
@public(allow_deposit=True)
def initialize(self, ctx: Context, amount: int) -> None:
self.syscall.create_token(token_name='token a', token_symbol='TKA', amount=amount)


class TestIndexes2(BlueprintTestCase):
def setUp(self) -> None:
super().setUp()

assert self.manager.tx_storage.indexes is not None
assert self.manager.tx_storage.indexes.tokens is not None
self.tokens_index = self.manager.tx_storage.indexes.tokens

self.blueprint_id = self.gen_random_blueprint_id()
self.dag_builder = TestDAGBuilder.from_manager(self.manager)
self.register_blueprint_class(self.blueprint_id, MyBlueprint)

def test_indexes_tx_affected_twice(self) -> None:
amount = 10000
artifacts = self.dag_builder.build_from_str(f'''
blockchain genesis b[1..11]
b10 < dummy

tx1.nc_id = "{self.blueprint_id.hex()}"
tx1.nc_method = initialize({amount})
tx1.nc_deposit = 1000 HTR
tx1 <-- b11 # Confirming tx1 means it's affected in the consensus

tx1.out[0] <<< tx2 # Spending tx1 means it's affected in the consensus for a second time
b11 < tx2
''')
artifacts.propagate_with(self.manager)

tx1, = artifacts.get_typed_vertices(['tx1'], Transaction)
tka = derive_child_token_id(ContractId(VertexId(tx1.hash)), 'TKA')

tka_token_info = self.tokens_index.get_token_info(tka)
htr_token_info = self.tokens_index.get_token_info(HATHOR_TOKEN_UID)

assert tx1.get_metadata().nc_execution is NCExecutionState.SUCCESS
assert tka_token_info.get_total() == amount
assert htr_token_info.get_total() == (
self._settings.GENESIS_TOKENS
+ 11 * self._settings.INITIAL_TOKENS_PER_BLOCK
- get_deposit_amount(self._settings, amount)
)