Skip to content
Open
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
9 changes: 8 additions & 1 deletion hathor/conf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from hathor.checkpoint import Checkpoint
from hathor.consensus.consensus_settings import ConsensusSettings, PowSettings
from hathor.feature_activation.settings import Settings as FeatureActivationSettings
from hathorlib.conf.settings import HathorSettings as LibSettings
from hathorlib.conf.settings import FeatureSetting, HathorSettings as LibSettings

DECIMAL_PLACES = 2

Expand All @@ -32,6 +32,13 @@
class HathorSettings(LibSettings):
model_config = ConfigDict(extra='forbid')

# Fee rate settings for shielded outputs
FEE_PER_AMOUNT_SHIELDED_OUTPUT: int = 1
FEE_PER_FULL_SHIELDED_OUTPUT: int = 2

# Used to enable shielded transactions.
ENABLE_SHIELDED_TRANSACTIONS: FeatureSetting = FeatureSetting.DISABLED

# Block checkpoints
CHECKPOINTS: list[Checkpoint] = []

Expand Down
21 changes: 20 additions & 1 deletion hathor/consensus/consensus.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from hathor.consensus.transaction_consensus import TransactionConsensusAlgorithmFactory
from hathor.execution_manager import non_critical_code
from hathor.feature_activation.feature import Feature
from hathor.feature_activation.utils import Features
from hathor.nanocontracts.exception import NCInvalidSignature
from hathor.nanocontracts.execution import NCBlockExecutor, NCConsensusBlockExecutor
from hathor.profiler import get_cpu_profiler
Expand Down Expand Up @@ -456,6 +457,9 @@ def _feature_activation_rules(self, tx: Transaction, new_best_block: Block) -> b
case Feature.OPCODES_V2:
if not self._opcodes_v2_activation_rule(tx, new_best_block):
return False
case Feature.SHIELDED_TRANSACTIONS:
if not self._shielded_activation_rule(tx, is_active):
return False
case (
Feature.INCREASE_MAX_MERKLE_PATH_LENGTH
| Feature.NOP_FEATURE_1
Expand Down Expand Up @@ -506,6 +510,16 @@ def _fee_tokens_activation_rule(self, tx: Transaction, is_active: bool) -> bool:

return True

def _shielded_activation_rule(self, tx: Transaction, is_active: bool) -> bool:
"""Check whether a tx became invalid because the reorg changed the shielded feature activation state."""
if is_active:
return True

if tx.has_shielded_outputs():
return False

return True

def _checkdatasig_count_rule(self, tx: Transaction) -> bool:
"""Check whether a tx became invalid because of the count checkdatasig feature."""
from hathor.verification.vertex_verifier import VertexVerifier
Expand All @@ -530,7 +544,12 @@ def _opcodes_v2_activation_rule(self, tx: Transaction, new_best_block: Block) ->
# We check all txs regardless of the feature state, because this rule
# already prohibited mempool txs before the block feature activation.

params = VerificationParams.default_for_mempool(best_block=new_best_block)
features = Features.from_vertex(
settings=self._settings,
feature_service=self.feature_service,
vertex=new_best_block,
)
params = VerificationParams.default_for_mempool(best_block=new_best_block, features=features)

# Any exception in the inputs verification will be considered
# a fail and the tx will be removed from the mempool.
Expand Down
15 changes: 15 additions & 0 deletions hathor/dag_builder/default_filler.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ def calculate_balance(self, node: DAGNode) -> dict[str, int]:

return balance

def _account_for_shielded_fee(self, node: DAGNode) -> None:
"""Subtract shielded output fees from the node's HTR balance."""
fee = 0
for txout in node.outputs:
if txout is None:
continue
_, _, attrs = txout
if attrs.get('full-shielded'):
fee += self._settings.FEE_PER_FULL_SHIELDED_OUTPUT
elif attrs.get('shielded'):
fee += self._settings.FEE_PER_AMOUNT_SHIELDED_OUTPUT
if fee > 0:
node.balances['HTR'] = node.balances.get('HTR', 0) - fee

def balance_node_inputs_and_outputs(self, node: DAGNode) -> None:
"""Balance the inputs and outputs of a node."""
balance = self.calculate_balance(node)
Expand Down Expand Up @@ -222,6 +236,7 @@ def run(self) -> None:
continue

self.fill_parents(node)
self._account_for_shielded_fee(node)
self.balance_node_inputs_and_outputs(node)

case DAGNodeType.OnChainBlueprint:
Expand Down
8 changes: 7 additions & 1 deletion hathor/dag_builder/tokenizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,13 @@ def tokenize(content: str) -> Iterator[Token]:
index = int(key[4:-1])
amount = int(parts[2])
token = parts[3]
attrs = parts[4:]
raw_attrs = parts[4:]
attrs: dict[str, str | int] = {}
for a in raw_attrs:
if a.startswith('[') and a.endswith(']'):
attrs[a[1:-1]] = 1
else:
attrs[a] = 1
yield (TokenType.OUTPUT, (name, index, amount, token, attrs))
else:
value = ' '.join(parts[2:])
Expand Down
68 changes: 67 additions & 1 deletion hathor/dag_builder/vertex_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,18 @@ def _create_vertex_txout(
*,
token_creation: bool = False
) -> tuple[list[bytes], list[TxOutput]]:
"""Create TxOutput objects for a node."""
"""Create TxOutput objects for a node. Shielded outputs are skipped here."""
tokens: list[bytes] = []
outputs: list[TxOutput] = []

for txout in node.outputs:
assert txout is not None
amount, token_name, attrs = txout

# Skip shielded outputs — they are handled by add_shielded_outputs_header_if_needed
if attrs.get('shielded') or attrs.get('full-shielded'):
continue

if token_name == 'HTR':
index = 0
elif token_creation:
Expand Down Expand Up @@ -330,6 +335,8 @@ def add_headers_if_needed(self, node: DAGNode, vertex: BaseTransaction) -> None:
"""Add the configured headers."""
self.add_nano_header_if_needed(node, vertex)
self.add_fee_header_if_needed(node, vertex)
self.add_shielded_outputs_header_if_needed(node, vertex)
self._add_or_augment_shielded_fee(node, vertex)

def add_nano_header_if_needed(self, node: DAGNode, vertex: BaseTransaction) -> None:
if 'nc_id' not in node.attrs:
Expand Down Expand Up @@ -456,6 +463,65 @@ def add_fee_header_if_needed(self, node: DAGNode, vertex: BaseTransaction) -> No
)
vertex.headers.append(fee_header)

def _add_or_augment_shielded_fee(self, node: DAGNode, vertex: BaseTransaction) -> None:
"""Add or augment a FeeHeader with the shielded output fee."""
if not isinstance(vertex, Transaction):
return

from hathor.verification.transaction_verifier import TransactionVerifier
shielded_fee = TransactionVerifier.calculate_shielded_fee(self._settings, vertex)
if shielded_fee == 0:
return

# Look for an existing FeeHeader
existing_fee_header: FeeHeader | None = None
for header in vertex.headers:
if isinstance(header, FeeHeader):
existing_fee_header = header
break

if existing_fee_header is not None:
# Augment the existing FeeHeader: find HTR entry and add shielded fee
new_fees: list[FeeHeaderEntry] = []
found_htr = False
for entry in existing_fee_header.fees:
if entry.token_index == 0 and not found_htr:
# Augment the HTR fee entry
new_fees.append(FeeHeaderEntry(token_index=0, amount=entry.amount + shielded_fee))
found_htr = True
else:
new_fees.append(entry)
if not found_htr:
new_fees.append(FeeHeaderEntry(token_index=0, amount=shielded_fee))
existing_fee_header.fees = new_fees
else:
# Create a new FeeHeader with just the shielded fee
fee_header = FeeHeader(
settings=vertex._settings,
tx=vertex,
fees=[FeeHeaderEntry(token_index=0, amount=shielded_fee)],
)
vertex.headers.append(fee_header)

def add_shielded_outputs_header_if_needed(self, node: DAGNode, vertex: BaseTransaction) -> None:
"""Collect outputs with [shielded] or [full-shielded] attrs into a ShieldedOutputsHeader."""
# TODO: For each output with [shielded] or [full-shielded] attrs, generate an
# ephemeral keypair for ECDH recovery, derive Pedersen commitments and range proofs
# using hathor.crypto.shielded, create AmountShieldedOutput/FullShieldedOutput, and
# attach as ShieldedOutputsHeader. For full-shielded: also create asset commitments
# and surjection proofs.
return

def _get_recipient_pubkey_from_script(self, script: bytes) -> bytes | None:
"""Extract the recipient's compressed public key from a P2PKH script.

Looks up the address in all wallets to find the corresponding public key.
Returns None if the public key cannot be determined.
"""
# TODO: Parse P2PKH script to get address, look up in wallets, extract
# compressed public key using extract_key_bytes from hathor.crypto.shielded.ecdh.
return None

def create_vertex_on_chain_blueprint(self, node: DAGNode) -> OnChainBlueprint:
"""Create an OnChainBlueprint given a node."""
block_parents, txs_parents = self._create_vertex_parents(node)
Expand Down
1 change: 1 addition & 0 deletions hathor/feature_activation/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ class Feature(StrEnum):
NANO_CONTRACTS = 'NANO_CONTRACTS'
FEE_TOKENS = 'FEE_TOKENS'
OPCODES_V2 = 'OPCODES_V2'
SHIELDED_TRANSACTIONS = 'SHIELDED_TRANSACTIONS'
3 changes: 3 additions & 0 deletions hathor/feature_activation/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class Features:
nanocontracts: bool
fee_tokens: bool
opcodes_version: OpcodesVersion
shielded_transactions: bool

@staticmethod
def from_vertex(*, settings: HathorSettings, feature_service: FeatureService, vertex: Vertex) -> Features:
Expand All @@ -47,6 +48,7 @@ def from_vertex(*, settings: HathorSettings, feature_service: FeatureService, ve
Feature.NANO_CONTRACTS: settings.ENABLE_NANO_CONTRACTS,
Feature.FEE_TOKENS: settings.ENABLE_FEE_BASED_TOKENS,
Feature.OPCODES_V2: settings.ENABLE_OPCODES_V2,
Feature.SHIELDED_TRANSACTIONS: settings.ENABLE_SHIELDED_TRANSACTIONS,
}

feature_is_active: dict[Feature, bool] = {
Expand All @@ -61,6 +63,7 @@ def from_vertex(*, settings: HathorSettings, feature_service: FeatureService, ve
nanocontracts=feature_is_active[Feature.NANO_CONTRACTS],
fee_tokens=feature_is_active[Feature.FEE_TOKENS],
opcodes_version=opcodes_version,
shielded_transactions=feature_is_active[Feature.SHIELDED_TRANSACTIONS],
)


Expand Down
14 changes: 12 additions & 2 deletions hathor/indexes/utxo_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,12 @@ def _update_executed(self, tx: BaseTransaction) -> None:
# remove all inputs
for tx_input in tx.inputs:
spent_tx = tx.get_spent_tx(tx_input)
spent_tx_output = spent_tx.outputs[tx_input.index]
# Use resolve_spent_output for shielded-aware lookup
resolved = spent_tx.resolve_spent_output(tx_input.index)
if not isinstance(resolved, TxOutput):
# Shielded outputs don't have public value/token for the UTXO index
continue
spent_tx_output = resolved
log_it = log.new(tx_id=spent_tx.hash_hex, index=tx_input.index)
if _should_skip_output(spent_tx_output):
log_it.debug('ignore input')
Expand Down Expand Up @@ -184,7 +189,12 @@ def _update_voided(self, tx: BaseTransaction) -> None:
# re-add inputs that aren't voided
for tx_input in tx.inputs:
spent_tx = tx.get_spent_tx(tx_input)
spent_tx_output = spent_tx.outputs[tx_input.index]
# Use resolve_spent_output for shielded-aware lookup
resolved = spent_tx.resolve_spent_output(tx_input.index)
if not isinstance(resolved, TxOutput):
# Shielded outputs don't have public value/token for the UTXO index
continue
spent_tx_output = resolved
log_it = log.new(tx_id=spent_tx.hash_hex, index=tx_input.index)
if _should_skip_output(spent_tx_output):
log_it.debug('ignore input')
Expand Down
20 changes: 16 additions & 4 deletions hathor/nanocontracts/vertex_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@


def _get_txin_output(vertex: BaseTransaction, txin: TxInput) -> TxOutput | None:
"""Return the output that txin points to."""
"""Return the output that txin points to.

Returns None for shielded outputs (they don't have TxOutput fields)
or when storage is unavailable.
"""
from hathor.transaction.storage.exceptions import TransactionDoesNotExist

if vertex.storage is None:
Expand All @@ -40,10 +44,18 @@ def _get_txin_output(vertex: BaseTransaction, txin: TxInput) -> TxOutput | None:
except TransactionDoesNotExist:
assert False, f'missing dependency: {txin.tx_id.hex()}'

assert len(vertex2.outputs) > txin.index, 'invalid output index'
# Use resolve_spent_output for shielded-aware lookup
try:
resolved = vertex2.resolve_spent_output(txin.index)
except IndexError:
return None

# Only return TxOutput; shielded outputs lack value/token_data for TxOutputData
from hathor.transaction import TxOutput as _TxOutput
if not isinstance(resolved, _TxOutput):
return None

txin_output = vertex2.outputs[txin.index]
return txin_output
return resolved


@dataclass(frozen=True, slots=True, kw_only=True)
Expand Down
4 changes: 4 additions & 0 deletions hathor/p2p/sync_v2/transaction_streaming_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,17 @@ def __init__(self,
# it will be correctly enabled when doing a full validation anyway.
# We can also set the `nc_block_root_id` to `None` because we only call `verify_basic`,
# which doesn't need it.
# XXX: Default to shielded_transactions=False since shielded txs cannot exist
# before the feature is activated. The correct value will be computed when doing
# a full validation anyway.
self.verification_params = VerificationParams(
nc_block_root_id=None,
features=Features(
count_checkdatasig_op=False,
nanocontracts=False,
fee_tokens=False,
opcodes_version=OpcodesVersion.V1,
shielded_transactions=False,
)
)

Expand Down
Loading
Loading