From 41ab9aa58a468fd9db0d85731257c5534433881c Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 12:48:56 +0100 Subject: [PATCH 01/55] fix(fees): align Studio with consensus v0.6 economics Mirror cap-priced round quotes, configured ladder semantics, appeal profit reserves, exact 2.5x rewards, typed appeal funding, immutable top-up schedules, live appeal overlay pricing, latest appeal ABIs, decision guards, and surplus refunds. Expose the canonical quote over RPC so cross-stack tests no longer maintain a second Studio formula. --- .env.example | 2 + backend/protocol_rpc/endpoints.py | 100 +++- .../fastapi_endpoint_generator.py | 1 + backend/protocol_rpc/fees.py | 415 ++++++++++++--- backend/protocol_rpc/rpc_methods.py | 14 +- backend/protocol_rpc/transactions_parser.py | 41 ++ backend/protocol_rpc/types.py | 1 + docker-compose.yml | 2 + tests/unit/test_studio_fees.py | 487 +++++++++++++----- tests/unit/test_transactions_parser.py | 83 ++- 10 files changed, 954 insertions(+), 192 deletions(-) diff --git a/.env.example b/.env.example index a077ccce6..e1078140c 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,8 @@ GENVM_WEB_DEBUG="0" GENLAYER_STUDIO_GEN_PER_TIME_UNIT='1000000000000000' # 0.001 GEN per time unit GENLAYER_STUDIO_STORAGE_UNIT_PRICE='1' GENLAYER_STUDIO_RECEIPT_GAS_PRICE='1' +# Combined developer + DAO share of gross time-unit fees (15% by default). +GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS='1500' GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS='210000' GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS='100000' GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES='1024' diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index a5ff3945d..7ee0dfc02 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -52,6 +52,7 @@ FeeValidationError, StudioFeePolicy, apply_fee_top_up, + calculate_round_fees, create_fee_accounting, get_leader_rounds, normalize_fees_distribution, @@ -61,7 +62,11 @@ studio_fee_config, validate_transaction_fee_deposit, ) -from backend.consensus.history import completed_consensus_round_index +from backend.consensus.history import ( + actual_leader_rotations_by_round, + completed_consensus_round_index, + completed_consensus_rounds, +) from backend.errors.errors import InvalidAddressError, InvalidTransactionError from backend.database_handler.errors import ContractNotFoundError @@ -70,7 +75,6 @@ TransactionsProcessor, ) - logger = logging.getLogger(__name__) TRANSACTION_NOT_FOUND_MESSAGE = "Transaction not found" from backend.node.base import Node, _genvm_debug_mode, get_simulator_chain_id @@ -364,6 +368,24 @@ def get_studio_fee_config() -> dict[str, Any]: return studio_fee_config(StudioFeePolicy.from_env()) +def sim_calculate_round_fees( + fees_distribution: dict[str, Any], + num_of_validators: int = 5, + round: int = 0, +) -> str: + """Expose Studio's canonical fee quote for cross-stack conformance tests.""" + try: + quote = calculate_round_fees( + fees_distribution, + int(num_of_validators), + int(round), + StudioFeePolicy.from_env(), + ) + except FeeValidationError as exc: + raise InvalidTransactionError(str(exc)) from exc + return str(quote) + + ####### ADMIN ACCESS CONTROL ####### def require_admin_access(func): """ @@ -2053,15 +2075,79 @@ def _handle_appeal_or_top_up_and_submit( fee_accounting = (tx.get("data") or {}).get(FEE_ACCOUNTING_KEY) if fee_accounting is not None: + status = str(tx.get("status") or "") + if status not in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.UNDETERMINED.value, + TransactionStatus.LEADER_TIMEOUT.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, + } or bool(tx.get("appealed")): + raise InvalidTransactionError("CanNotAppeal") + consensus_history = tx.get("consensus_history") + expected_decision_id = decoded_rollup_transaction.data.expected_decision_id + current_round = _current_fee_round(consensus_history) + # Studio has no on-chain DecisionRecord, so derive the equivalent + # monotonic ordinal from its alternating normal/appeal round history. + # Round 0 -> decision 1; rounds 1/2 -> decision 2; rounds 3/4 -> 3. + current_decision_id = 1 + ((current_round + 1) // 2) + if ( + expected_decision_id is not None + and int(expected_decision_id) != current_decision_id + ): + raise InvalidTransactionError("CanNotAppeal") + session = getattr(accounts_manager, "session", None) + validator_count = ( + ValidatorsRegistry(session).count_validators() + if session is not None + else int( + fee_accounting.get("num_of_initial_validators") + or tx.get("num_of_initial_validators") + or 5 + ) + ) + normal_leader_count = sum( + 1 + for index, _entry in enumerate( + completed_consensus_rounds(consensus_history) + ) + if index % 2 == 0 + ) + replacement_rotations = None + if status == TransactionStatus.LEADER_TIMEOUT.value: + rotations = normalize_fees_distribution( + fee_accounting.get("fees_distribution") or {} + )["rotations"] + rotation_index = current_round // 2 + configured = ( + int(rotations[rotation_index]) + if rotation_index < len(rotations) + else int(rotations[-1] if rotations else 0) + ) + used = actual_leader_rotations_by_round(consensus_history).get( + current_round, + 0, + ) + replacement_rotations = max(0, configured - used) + elif status == TransactionStatus.UNDETERMINED.value: + replacement_rotations = max( + 0, + int(tx.get("config_rotation_rounds") or 0), + ) try: updated = record_appeal_bond( fee_accounting, amount=decoded_rollup_transaction.total_spend, appealer=decoded_rollup_transaction.from_address, - current_round=_current_fee_round(tx.get("consensus_history")), - status=str(tx.get("status") or ""), + current_round=current_round, + status=status, fees_distribution=decoded_rollup_transaction.data.fees_distribution, top_up_and_submit=decoded_rollup_transaction.data.top_up_and_submit, + terminal_committee_upper_bound=max( + 0, + validator_count - normal_leader_count, + ), + replacement_rotations=replacement_rotations, + time_unit_overlay_bps=StudioFeePolicy.from_env().time_unit_overlay_bps, ) except FeeValidationError as exc: raise InvalidTransactionError(str(exc)) from exc @@ -2070,6 +2156,12 @@ def _handle_appeal_or_top_up_and_submit( decoded_rollup_transaction.from_address, decoded_rollup_transaction.total_spend, ) + surplus_refund = int(updated["appeal_bonds"][-1]["surplusRefund"]) + if surplus_refund > 0: + accounts_manager.credit_account_balance( + decoded_rollup_transaction.from_address, + surplus_refund, + ) transactions_processor.update_transaction_fee_accounting(tx_id, updated) transactions_processor.set_transaction_appeal(tx_id, True) diff --git a/backend/protocol_rpc/fastapi_endpoint_generator.py b/backend/protocol_rpc/fastapi_endpoint_generator.py index 5d74ab3e5..35ac29df7 100644 --- a/backend/protocol_rpc/fastapi_endpoint_generator.py +++ b/backend/protocol_rpc/fastapi_endpoint_generator.py @@ -490,6 +490,7 @@ def register(func, method_name=None): "sim_getFinalityWindowTime", ) register(endpoints.get_studio_fee_config, "sim_getFeeConfig") + register(endpoints.sim_calculate_round_fees, "sim_calculateRoundFees") register( partial(endpoints.get_contract, accounts_manager), "sim_getConsensusContract" ) diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 6d84752b4..3b0d6b5c0 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -3,7 +3,7 @@ import base64 import copy import os -from dataclasses import dataclass, fields +from dataclasses import dataclass, fields, replace from typing import Any, Callable import rlp @@ -15,7 +15,6 @@ ) from backend.consensus.types import ConsensusRound - VALIDATORS_PER_ROUND = ( 5, 7, @@ -81,7 +80,10 @@ DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION = 200 DEFAULT_PRICE_CAP_HEADROOM_BPS = 12_000 DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM = 10_000 +DEFAULT_TIME_UNIT_OVERLAY_BPS = 1_500 GENVM_UNMETERED_DATA_FEE_BUCKET = (1 << 256) - 1 +SUCCESSFUL_APPEAL_REWARD_NUMERATOR = 5 +SUCCESSFUL_APPEAL_REWARD_DENOMINATOR = 2 class FeeValidationError(ValueError): @@ -152,6 +154,10 @@ class InvalidAppealBond(FeeValidationError): pass +class TopUpCannotExtendSchedule(FeeValidationError): + pass + + class MessageDeclaredBudgetInsufficient(FeeValidationError): pass @@ -209,6 +215,7 @@ class StudioFeePolicy: extra_exec_gas: int = 210_000 max_allocation_tree_depth: int = 5 max_messages_per_tx: int = 0 + time_unit_overlay_bps: int = 0 @classmethod def from_env(cls) -> "StudioFeePolicy": @@ -242,6 +249,10 @@ def from_env(cls) -> "StudioFeePolicy": "GENLAYER_STUDIO_MAX_ALLOCATION_TREE_DEPTH", 5 ), max_messages_per_tx=_env_int("GENLAYER_STUDIO_MAX_MESSAGES_PER_TX", 0), + time_unit_overlay_bps=_env_int( + "GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS", + DEFAULT_TIME_UNIT_OVERLAY_BPS, + ), ) def estimate_propose_receipt_bytes(self, eq_outputs_length: int) -> int: @@ -335,7 +346,15 @@ def to_snapshot(self) -> dict[str, int]: @classmethod def from_snapshot(cls, snapshot: dict[str, Any]) -> "StudioFeePolicy": - return cls(**{field.name: int(snapshot[field.name]) for field in fields(cls)}) + # Snapshots created before the overlay field was introduced remain + # valid and preserve the economics under which they were funded. + defaults = cls() + return cls( + **{ + field.name: int(snapshot.get(field.name, getattr(defaults, field.name))) + for field in fields(cls) + } + ) def _accounting_policy( @@ -441,9 +460,6 @@ def calculate_time_unit_fees_through_round( raise InvalidAppealRounds("InvalidAppealRounds") capped_final_round = min(max(0, int(final_round)), int(fees["appealRounds"]) * 2) - if validator_index + capped_final_round >= len(VALIDATORS_PER_ROUND): - raise InvalidNumOfValidators("InvalidNumOfValidators") - leader_timeunits = int(fees["leaderTimeunitsAllocation"]) validator_timeunits = int(fees["validatorTimeunitsAllocation"]) actual_rotations = actual_leader_rotations_by_round(consensus_history) @@ -468,7 +484,7 @@ def calculate_time_unit_fees_through_round( else: rotations_this_round = 1 total += _calculate_fee_for_round( - VALIDATORS_PER_ROUND[validator_index + offset], + _validators_per_round_safe(offset), rotations_this_round, leader_timeunits, validator_timeunits, @@ -488,16 +504,37 @@ def calculate_round_fees( num_of_validators: int, round: int = 0, policy: StudioFeePolicy | None = None, + *, + enforce_gen_price_cap: bool = True, ) -> int: fees = normalize_fees_distribution(fees_distribution) policy = policy or StudioFeePolicy() if round == 0: - total = _calculate_initial_round_total(fees, num_of_validators) + time_unit_work = _calculate_initial_round_total(fees, num_of_validators) + appeal_profit_reserve = _calculate_appeal_profit_reserve(fees) else: - total = _calculate_appeal_round_total(fees, round) + time_unit_work = _calculate_appeal_round_total(fees, round) + appeal_profit_reserve = 0 - total = _apply_time_unit_price(total, int(fees["maxPriceGenPerTimeUnit"]), policy) + max_price = int(fees["maxPriceGenPerTimeUnit"]) + priced_work = _apply_time_unit_price( + time_unit_work, + max_price, + policy, + enforce_cap=enforce_gen_price_cap, + ) + priced_profit_reserve = _apply_time_unit_price( + appeal_profit_reserve, + max_price, + policy, + enforce_cap=enforce_gen_price_cap, + ) + total = ( + priced_work + + _time_unit_overlay(priced_work, policy.time_unit_overlay_bps) + + priced_profit_reserve + ) _enforce_gas_price_cap( policy.storage_unit_price, int(fees["storageFeeMaxGasPrice"]) ) @@ -584,6 +621,7 @@ def studio_fee_config(policy: StudioFeePolicy | None = None) -> dict[str, Any]: ), "maxAllocationTreeDepth": str(policy.max_allocation_tree_depth), "maxMessagesPerTx": str(policy.max_messages_per_tx), + "timeUnitOverlayBps": str(policy.time_unit_overlay_bps), }, "capabilities": { "messageFees": { @@ -869,13 +907,31 @@ def apply_fee_top_up( raise InsufficientFees("InsufficientFees") primary_amount = amount - incoming_message_fees + updated = copy.deepcopy(accounting) + current = normalize_fees_distribution(updated.get("fees_distribution") or {}) + merged = merge_fees_distribution(current, incoming) if perform_fee_checks: - required_primary = calculate_round_fees(incoming, num_of_validators, 0, policy) - if required_primary > primary_amount: + quote_before = ( + calculate_round_fees( + current, + num_of_validators, + 0, + policy, + enforce_gen_price_cap=False, + ) + if current["rotations"] + else 0 + ) + quote_after = calculate_round_fees( + merged, + num_of_validators, + 0, + policy, + enforce_gen_price_cap=False, + ) + if quote_after - quote_before > primary_amount: raise InsufficientFees("InsufficientFeesForRound") - updated = copy.deepcopy(accounting) - merged = merge_fees_distribution(updated.get("fees_distribution") or {}, incoming) if ( int(merged["executionBudgetPerRound"]) > 0 and int(merged["executionBudgetPerRound"]) @@ -917,75 +973,224 @@ def record_appeal_bond( round: int | None = None, fees_distribution: dict[str, Any] | None = None, top_up_and_submit: bool = False, + terminal_committee_upper_bound: int | None = None, + replacement_rotations: int | None = None, + time_unit_overlay_bps: int | None = None, policy: StudioFeePolicy | None = None, ) -> dict[str, Any]: updated = copy.deepcopy(accounting) policy = _accounting_policy(updated, policy) + if time_unit_overlay_bps is not None: + # Consensus prices the developer/DAO overlay at appeal admission; + # unlike the transaction's GEN multiplier, this split is not locked at + # initial activation. + policy = replace( + policy, + time_unit_overlay_bps=int(time_unit_overlay_bps), + ) amount = int(amount) - min_required = 0 - if status is not None: - min_required = calculate_min_appeal_bond( - updated.get("fees_distribution") or {}, - current_round=current_round, - status=status, - policy=policy, - ) - if amount < min_required: - raise InvalidAppealBond("InvalidAppealBond") + if status is None: + raise InvalidAppealBond("InvalidAppealBond") + charge = calculate_appeal_charge( + updated.get("fees_distribution") or {}, + current_round=current_round, + status=status, + terminal_committee_upper_bound=terminal_committee_upper_bound, + replacement_rotations=replacement_rotations, + policy=policy, + ) + bond = int(charge["bond"]) + funding = int(charge["funding"]) + total_required = bond + funding + if bond <= 0 or amount < total_required: + raise InvalidAppealBond("InvalidAppealBond") - if top_up_and_submit: - updated["primary_fee_budget"] = ( - int(updated.get("primary_fee_budget", 0)) + amount - ) - updated["paid_fee_value"] = int(updated.get("paid_fee_value", 0)) + amount + if bool(charge["extendsSchedule"]): merged = normalize_fees_distribution(updated.get("fees_distribution") or {}) merged["appealRounds"] = int(merged["appealRounds"]) + 1 + rotations = list(merged["rotations"]) + while len(rotations) < int(merged["appealRounds"]) + 1: + rotations.append(int(charge["replacementRotations"])) + merged["rotations"] = rotations updated["fees_distribution"] = merged updated["execution_budget_total"] = int( merged["executionBudgetPerRound"] ) * get_leader_rounds(merged) - updated["appeal_bonds_total"] = int(updated.get("appeal_bonds_total", 0)) + amount + # Bond principal stays in its own custody. Only the typed funding leg is + # spendable fee budget; this prevents topUpAndSubmitAppeal from counting + # the same payment as both bond and primary fees. + updated["primary_fee_budget"] = int(updated.get("primary_fee_budget", 0)) + funding + updated["paid_fee_value"] = int(updated.get("paid_fee_value", 0)) + funding + updated["appeal_funding_total"] = ( + int(updated.get("appeal_funding_total", 0)) + funding + ) + updated["appeal_bonds_total"] = int(updated.get("appeal_bonds_total", 0)) + bond + surplus = amount - total_required + updated["appeal_charge_surplus_refunded"] = ( + int(updated.get("appeal_charge_surplus_refunded", 0)) + surplus + ) updated.setdefault("appeal_bonds", []).append( { "appealer": appealer, - "amount": amount, + "amount": bond, + "submittedAmount": amount, + "funding": funding, + "fundingBreakdown": charge["fundingBreakdown"], + "requiredCharge": total_required, + "surplusRefund": surplus, "round": current_round if round is None else round, "status": status, - "minimumRequired": min_required, + "minimumRequired": bond, "topUpAndSubmit": bool(top_up_and_submit), - "feesDistributionIgnored": fees_distribution is not None - and top_up_and_submit, + "feesDistributionIgnored": fees_distribution is not None, + "extendsSchedule": bool(charge["extendsSchedule"]), } ) _refresh_message_fee_accounting_report_if_present(updated, policy) return updated +def calculate_appeal_charge( + fees_distribution: dict[str, Any], + *, + current_round: int, + status: str, + terminal_committee_upper_bound: int | None = None, + replacement_rotations: int | None = None, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + """Quote the same bond + typed funding split used by Consensus admission.""" + policy = policy or StudioFeePolicy() + fees = normalize_fees_distribution(fees_distribution) + current_round = max(0, int(current_round)) + status_value = str(status).upper() + bond = calculate_min_appeal_bond( + fees, + current_round=current_round, + status=status_value, + leader_timeout_rotations_left=( + replacement_rotations if status_value == "LEADER_TIMEOUT" else None + ), + policy=policy, + ) + + validator_appeal = status_value in {"VALIDATORS_TIMEOUT", "ACCEPTED"} + leader_appeal = status_value in {"LEADER_TIMEOUT", "UNDETERMINED"} + if not validator_appeal and not leader_appeal: + raise InvalidAppealBond("InvalidAppealBond") + + if validator_appeal: + appeal_round = ( + current_round + 1 if current_round % 2 == 0 else current_round + 2 + ) + jury_count = _validators_per_round_safe(current_round + 1) + replacement_rotations = 0 + else: + appeal_round = current_round + 2 + jury_count = 0 + if replacement_rotations is None: + replacement_rotations = ( + _appeal_rotation_allowance(fees, current_round) + if status_value == "LEADER_TIMEOUT" + else int(fees["rotations"][0] if fees["rotations"] else 0) + ) + replacement_rotations = max(0, int(replacement_rotations)) + + pre_funded = appeal_round + (appeal_round & 1) <= int(fees["appealRounds"]) * 2 + taxable_work = 0 + if leader_appeal: + if not pre_funded: + taxable_work = bond + else: + scheduled_replacement = _validators_per_round_safe(appeal_round + 1) + replacement_count = max( + 0, + int( + terminal_committee_upper_bound + if terminal_committee_upper_bound is not None + else scheduled_replacement + ), + ) + if pre_funded: + time_units = max(0, replacement_count - scheduled_replacement) * int( + fees["validatorTimeunitsAllocation"] + ) + else: + time_units = ( + jury_count * int(fees["validatorTimeunitsAllocation"]) + + replacement_count * int(fees["validatorTimeunitsAllocation"]) + + int(fees["leaderTimeunitsAllocation"]) + ) + taxable_work = ( + time_units * policy.gen_per_time_unit + if policy.gen_per_time_unit > 0 + else time_units + ) + + appellant_profit = 0 if pre_funded else successful_appeal_profit(bond) + execution_slots = 0 + if not pre_funded: + execution_slots = replacement_rotations + 2 if leader_appeal else 2 + execution_backing = execution_slots * int(fees["executionBudgetPerRound"]) + overlay = _time_unit_overlay(taxable_work, policy.time_unit_overlay_bps) + funding = taxable_work + overlay + appellant_profit + execution_backing + return { + "bond": bond, + "funding": funding, + "appealRound": appeal_round, + "juryCount": jury_count, + "replacementRotations": replacement_rotations, + "extendsSchedule": not pre_funded, + "fundingBreakdown": { + "bondPrincipal": bond, + "taxableWork": taxable_work, + "overlay": overlay, + "appellantProfit": appellant_profit, + "executionBacking": execution_backing, + }, + } + + def calculate_min_appeal_bond( fees_distribution: dict[str, Any], *, current_round: int, status: str, + leader_timeout_rotations_left: int | None = None, policy: StudioFeePolicy | None = None, ) -> int: policy = policy or StudioFeePolicy() fees = normalize_fees_distribution(fees_distribution) current_round = max(0, int(current_round)) status_value = str(status).upper() - if status_value in {"LEADER_TIMEOUT", "UNDETERMINED"}: + if status_value == "LEADER_TIMEOUT": + rotations_left = ( + _appeal_rotation_allowance(fees, current_round) + if leader_timeout_rotations_left is None + else max(0, int(leader_timeout_rotations_left)) + ) + total = _calculate_fee_for_round( + _validators_per_round_safe(current_round), + rotations_left + 1, + int(fees["leaderTimeunitsAllocation"]), + int(fees["validatorTimeunitsAllocation"]), + ) + return ( + total * policy.gen_per_time_unit if policy.gen_per_time_unit > 0 else total + ) + + if status_value == "UNDETERMINED": target_round = current_round + 2 - if target_round >= len(VALIDATORS_PER_ROUND): - raise InvalidNumOfValidators("InvalidNumOfValidators") rotations = ( int(fees["rotations"][target_round - 1]) if target_round - 1 < len(fees["rotations"]) else 0 ) total = _calculate_fee_for_round( - VALIDATORS_PER_ROUND[target_round], - rotations, + _validators_per_round_safe(target_round), + rotations + 1, int(fees["leaderTimeunitsAllocation"]), int(fees["validatorTimeunitsAllocation"]), ) @@ -995,9 +1200,7 @@ def calculate_min_appeal_bond( if status_value in {"VALIDATORS_TIMEOUT", "ACCEPTED"}: target_round = current_round + 1 - if target_round >= len(VALIDATORS_PER_ROUND): - raise InvalidNumOfValidators("InvalidNumOfValidators") - total = VALIDATORS_PER_ROUND[target_round] * int( + total = _validators_per_round_safe(target_round) * int( fees["validatorTimeunitsAllocation"] ) return ( @@ -1007,6 +1210,19 @@ def calculate_min_appeal_bond( return 0 +def _appeal_rotation_allowance( + fees: dict[str, int | list[int]], + current_round: int, +) -> int: + rotations = fees["rotations"] + if not isinstance(rotations, list) or not rotations: + return 0 + current_normal_index = max(0, int(current_round)) // 2 + if current_normal_index < len(rotations): + return int(rotations[current_normal_index]) + return int(rotations[-1]) + + def fill_message_fee_payload_from_allocation( accounting: dict[str, Any], message: dict[str, Any], @@ -1488,7 +1704,27 @@ def settle_fee_accounting( execution_spent = min( int(updated.get("execution_fee_consumed", 0)), execution_budget ) - primary_spent = min(primary_budget, time_unit_budget + execution_spent) + bond_settlements, bond_payout = _settle_appeal_bonds( + updated, + consensus_history=consensus_history, + cancel=False, + ) + appeal_profit_spent = sum( + max(0, int(item.get("payout", 0)) - int(item.get("amount", 0))) + for item in bond_settlements + if item.get("status") == "successful" + ) + time_unit_overlay_spent = _time_unit_overlay( + time_unit_budget, + policy.time_unit_overlay_bps, + ) + primary_spent = min( + primary_budget, + time_unit_budget + + time_unit_overlay_spent + + appeal_profit_spent + + execution_spent, + ) primary_refund = max( 0, primary_budget - primary_spent - int(updated.get("primary_fee_refunded", 0)) ) @@ -1500,15 +1736,11 @@ def settle_fee_accounting( - int(updated.get("message_fee_refunded", 0)), ) refund = primary_refund + message_refund - bond_settlements, bond_payout = _settle_appeal_bonds( - updated, - consensus_history=consensus_history, - cancel=False, - ) - updated["status"] = "settled" updated["settlement_reason"] = reason updated["primary_fee_spent"] = primary_spent + updated["time_unit_overlay_spent"] = time_unit_overlay_spent + updated["appeal_profit_spent"] = appeal_profit_spent updated["primary_fee_refunded"] = ( int(updated.get("primary_fee_refunded", 0)) + primary_refund ) @@ -1597,6 +1829,12 @@ def merge_fees_distribution( "validatorTimeunitsAllocation" ] merged["appealRounds"] = incoming_fees["appealRounds"] + merged["rotations"] = list(incoming_fees["rotations"]) + elif incoming_fees["rotations"] or int(incoming_fees["appealRounds"]) != 0: + # Consensus treats an established schedule as immutable. Appeals are + # the only operation allowed to append one appeal slot plus its paired + # rotation entry; ordinary top-ups are pure funding. + raise TopUpCannotExtendSchedule("TopUpCannotExtendSchedule") merged["executionBudgetPerRound"] = int(merged["executionBudgetPerRound"]) + int( incoming_fees["executionBudgetPerRound"] @@ -1604,7 +1842,6 @@ def merge_fees_distribution( merged["totalMessageFees"] = int(merged["totalMessageFees"]) + int( incoming_fees["totalMessageFees"] ) - merged["rotations"] = list(merged["rotations"]) + list(incoming_fees["rotations"]) for cap in ( "maxPriceGenPerTimeUnit", "storageFeeMaxGasPrice", @@ -1830,30 +2067,79 @@ def _calculate_appeal_round_total( fees: dict[str, int | list[int]], round: int, ) -> int: - if round >= len(VALIDATORS_PER_ROUND): - raise InvalidNumOfValidators("InvalidNumOfValidators") - rotations = ( int(fees["rotations"][round - 1]) if round - 1 < len(fees["rotations"]) else 0 ) return _calculate_fee_for_round( - VALIDATORS_PER_ROUND[round], - rotations, + _validators_per_round_safe(round), + rotations + 1, int(fees["leaderTimeunitsAllocation"]), int(fees["validatorTimeunitsAllocation"]), ) +def _calculate_appeal_profit_reserve( + fees: dict[str, int | list[int]], +) -> int: + rotations = fees["rotations"] + if not isinstance(rotations, list): + raise InvalidAppealRounds("InvalidAppealRounds") + + reserve = 0 + for appeal_ordinal in range(int(fees["appealRounds"])): + rotations_index = appeal_ordinal + 1 + funded_rotations = ( + int(rotations[rotations_index]) if rotations_index < len(rotations) else 0 + ) + next_normal_bond = _calculate_fee_for_round( + _validators_per_round_safe((appeal_ordinal + 1) * 2), + funded_rotations + 1, + int(fees["leaderTimeunitsAllocation"]), + int(fees["validatorTimeunitsAllocation"]), + ) + reserve += successful_appeal_profit(next_normal_bond) + return reserve + + def _apply_time_unit_price( total: int, max_price: int, policy: StudioFeePolicy, + *, + enforce_cap: bool = True, ) -> int: - if policy.gen_per_time_unit <= 0: - return total - if max_price > 0 and policy.gen_per_time_unit > max_price: + if enforce_cap and max_price > 0 and policy.gen_per_time_unit > max_price: raise MaxPriceExceeded("MaxPriceExceeded") - return total * policy.gen_per_time_unit + # Consensus reserves at the caller's ceiling. The live price is checked + # against that ceiling and locked separately for settlement. + return total * max_price if max_price > 0 else total + + +def _time_unit_overlay(time_unit_work: int, split_bps: int) -> int: + split_bps = int(split_bps) + if time_unit_work <= 0 or split_bps <= 0: + return 0 + if split_bps >= 10_000: + raise FeeValidationError("InvalidTimeUnitOverlayBps") + return time_unit_work * split_bps // (10_000 - split_bps) + + +def successful_appeal_reward(appeal_bond: int) -> int: + appeal_bond = int(appeal_bond) + return ( + appeal_bond + * SUCCESSFUL_APPEAL_REWARD_NUMERATOR + // SUCCESSFUL_APPEAL_REWARD_DENOMINATOR + ) + + +def successful_appeal_profit(appeal_bond: int) -> int: + return successful_appeal_reward(appeal_bond) - int(appeal_bond) + + +def _validators_per_round_safe(round: int) -> int: + index = min(max(0, int(round)), len(VALIDATORS_PER_ROUND) - 1) + return VALIDATORS_PER_ROUND[index] def _enforce_gas_price_cap(actual_price: int, max_price: int) -> None: @@ -1891,10 +2177,11 @@ def _calculate_fees( rotations_index = 1 rotations_this_round = 1 appeal_rounds = int(fees_distribution["appealRounds"]) - if validator_index + (appeal_rounds * 2) >= len(VALIDATORS_PER_ROUND): - raise InvalidNumOfValidators("InvalidNumOfValidators") for offset in range(1, (appeal_rounds * 2) + 1): - round_validators = VALIDATORS_PER_ROUND[validator_index + offset] + # Consensus uses the configured round ladder by absolute round after + # the caller-selected initial committee. Rounds beyond the published + # ladder saturate at its final size. + round_validators = _validators_per_round_safe(offset) if offset % 2 == 0 and rotations_index < len(rotations): rotations_this_round = int(rotations[rotations_index]) + 1 rotations_index += 1 @@ -1976,7 +2263,7 @@ def _settle_appeal_bonds( payout = amount elif outcome in APPEAL_SUCCESS_ROUNDS: status = "successful" - payout = amount * 3 // 2 + payout = successful_appeal_reward(amount) else: status = "forfeited" payout = 0 @@ -2140,6 +2427,8 @@ def _new_fee_accounting( "external_message_events": [], "appeal_bonds": [], "appeal_bonds_total": 0, + "appeal_funding_total": 0, + "appeal_charge_surplus_refunded": 0, "total_refunded": 0, "refunds": [], "top_ups": [ diff --git a/backend/protocol_rpc/rpc_methods.py b/backend/protocol_rpc/rpc_methods.py index 2bbea44b0..bd51e9d23 100644 --- a/backend/protocol_rpc/rpc_methods.py +++ b/backend/protocol_rpc/rpc_methods.py @@ -27,7 +27,6 @@ from backend.protocol_rpc.rpc_decorators import rpc from backend.protocol_rpc.rpc_endpoint_manager import LogPolicy - # --------------------------------------------------------------------------- # Simulator endpoints # --------------------------------------------------------------------------- @@ -298,6 +297,19 @@ def get_fee_config() -> dict: return impl.get_studio_fee_config() +@rpc.method("sim_calculateRoundFees", log_policy=LogPolicy.debug()) +def sim_calculate_round_fees( + fees_distribution: dict, + num_of_validators: int = 5, + round: int = 0, +) -> str: + return impl.sim_calculate_round_fees( + fees_distribution, + num_of_validators, + round, + ) + + @rpc.method("sim_getConsensusContract", log_policy=LogPolicy.debug()) def get_consensus_contract( contract_name: str, diff --git a/backend/protocol_rpc/transactions_parser.py b/backend/protocol_rpc/transactions_parser.py index 1a0f85126..95c8b386a 100644 --- a/backend/protocol_rpc/transactions_parser.py +++ b/backend/protocol_rpc/transactions_parser.py @@ -166,7 +166,39 @@ "type": "function", } +FEE_AWARE_SUBMIT_APPEAL_ABI = { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "internalType": "uint256", + "name": "_expectedDecisionId", + "type": "uint256", + }, + ], + "name": "submitAppeal", + "outputs": [], + "stateMutability": "payable", + "type": "function", +} + +FEE_AWARE_LEGACY_SUBMIT_APPEAL_ABI = { + **FEE_AWARE_SUBMIT_APPEAL_ABI, + "inputs": [FEE_AWARE_SUBMIT_APPEAL_ABI["inputs"][0]], +} + FEE_AWARE_TOP_UP_AND_SUBMIT_APPEAL_ABI = { + **FEE_AWARE_TOP_UP_FEES_ABI, + "inputs": [ + FEE_AWARE_TOP_UP_FEES_ABI["inputs"][0], + FEE_AWARE_SUBMIT_APPEAL_ABI["inputs"][1], + FEE_AWARE_TOP_UP_FEES_ABI["inputs"][1], + ], + "name": "topUpAndSubmitAppeal", +} + +# Keep accepting transactions produced by the currently released SDKs while +# the toolchain migrates to exact DecisionId authority. +FEE_AWARE_LEGACY_TOP_UP_AND_SUBMIT_APPEAL_ABI = { **FEE_AWARE_TOP_UP_FEES_ABI, "name": "topUpAndSubmitAppeal", } @@ -408,6 +440,9 @@ def _to_int(value: bytes) -> int: params = decoded_data["params"] decoded_data = DecodedsubmitAppealDataArgs( tx_id=params["_txId"], + expected_decision_id=params.get( + "_expectedDecisionId" + ), ) elif decoded_data["function"] == "topUpFees": params = decoded_data["params"] @@ -423,6 +458,9 @@ def _to_int(value: bytes) -> int: params = decoded_data["params"] decoded_data = DecodedsubmitAppealDataArgs( tx_id=params["_txId"], + expected_decision_id=params.get( + "_expectedDecisionId" + ), fees_distribution=self._fees_distribution_to_dict( params["_feesDistribution"] ), @@ -681,8 +719,11 @@ def _get_contract_abi(self) -> list: [ FEE_AWARE_ADD_TRANSACTION_ABI, FEE_AWARE_DEPLOY_SALTED_ABI, + FEE_AWARE_SUBMIT_APPEAL_ABI, + FEE_AWARE_LEGACY_SUBMIT_APPEAL_ABI, FEE_AWARE_TOP_UP_FEES_ABI, FEE_AWARE_TOP_UP_AND_SUBMIT_APPEAL_ABI, + FEE_AWARE_LEGACY_TOP_UP_AND_SUBMIT_APPEAL_ABI, ] ) return contract_abi diff --git a/backend/protocol_rpc/types.py b/backend/protocol_rpc/types.py index 05325c2c1..b4c90e60a 100644 --- a/backend/protocol_rpc/types.py +++ b/backend/protocol_rpc/types.py @@ -30,6 +30,7 @@ def to_json(self) -> dict[str]: @dataclass class DecodedsubmitAppealDataArgs: tx_id: str + expected_decision_id: int | None = None fees_distribution: dict | None = None top_up_and_submit: bool = False diff --git a/docker-compose.yml b/docker-compose.yml index edc831a2c..9f5667594 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -136,6 +136,7 @@ services: - GENLAYER_STUDIO_GEN_PER_TIME_UNIT=${GENLAYER_STUDIO_GEN_PER_TIME_UNIT:-1000000000000000} - GENLAYER_STUDIO_STORAGE_UNIT_PRICE=${GENLAYER_STUDIO_STORAGE_UNIT_PRICE:-1} - GENLAYER_STUDIO_RECEIPT_GAS_PRICE=${GENLAYER_STUDIO_RECEIPT_GAS_PRICE:-1} + - GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS=${GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS:-1500} - GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS=${GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS:-210000} - GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS=${GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS:-100000} - GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES=${GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES:-1024} @@ -327,6 +328,7 @@ services: - GENLAYER_STUDIO_GEN_PER_TIME_UNIT=${GENLAYER_STUDIO_GEN_PER_TIME_UNIT:-1000000000000000} - GENLAYER_STUDIO_STORAGE_UNIT_PRICE=${GENLAYER_STUDIO_STORAGE_UNIT_PRICE:-1} - GENLAYER_STUDIO_RECEIPT_GAS_PRICE=${GENLAYER_STUDIO_RECEIPT_GAS_PRICE:-1} + - GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS=${GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS:-1500} - GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS=${GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS:-210000} - GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS=${GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS:-100000} - GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES=${GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES:-1024} diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 837621f19..c31d1e050 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -27,6 +27,7 @@ _simulation_fee_accounting, _validate_fee_envelope, _with_default_simulation_fees, + sim_calculate_round_fees, sim_estimate_transaction_fees, ) from backend.protocol_rpc.fees import ( @@ -63,13 +64,16 @@ SUBMITTED_MESSAGE_ABI_TYPE, DEFAULT_GEN_PER_TIME_UNIT, DEFAULT_PRICE_CAP_HEADROOM_BPS, + DEFAULT_TIME_UNIT_OVERLAY_BPS, DEFAULT_RECEIPT_GAS_PRICE, DEFAULT_STORAGE_UNIT_PRICE, DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND, GENVM_UNMETERED_DATA_FEE_BUCKET, StudioFeePolicy, + TopUpCannotExtendSchedule, TooManyMessages, apply_fee_top_up, + calculate_appeal_charge, calculate_min_appeal_bond, calculate_round_fees, calculate_time_unit_fees_through_round, @@ -92,6 +96,8 @@ required_fee_deposit, settle_fee_accounting, studio_fee_config, + successful_appeal_profit, + successful_appeal_reward, unwind_reveal_message_fees, validate_message_allocations, validate_transaction_fee_deposit, @@ -140,6 +146,18 @@ def _fees_distribution( } +def _top_up_distribution(**overrides): + """Pure-funding top-up shape accepted by Consensus after activation.""" + values = { + "leader_timeunits": 0, + "validator_timeunits": 0, + "appeals": 0, + "rotations": [], + } + values.update(overrides) + return _fees_distribution(**values) + + def _required_env_fee_deposit(fees_distribution, num_of_validators=5): return required_fee_deposit( fees_distribution, @@ -292,17 +310,28 @@ def _allocation( (5, 5, [3, 3, 3, 3, 3, 3], 338700), (5, 5, [0, 2, 4, 0, 0, 2], 213700), (23, 0, [0], 4700), - (23, 1, [0, 0], 19300), - (23, 2, [0, 2, 4], 143700), + (23, 1, [0, 0], 8500), + (23, 2, [0, 2, 4], 39300), (1537, 0, [0], 307500), + (1537, 1, [0, 0], 311300), ], ) def test_calculate_round_fees_matches_consensus_budget_cases( validators, appeals, rotations, expected ): fees_distribution = _fees_distribution(appeals=appeals, rotations=rotations) + configured_sizes = [5, 7, 11, 13, 23, 25, 47, 49, 95, 97, 191] + appeal_profit_reserve = sum( + successful_appeal_profit( + (rotations[appeal_ordinal + 1] + 1) + * (100 + configured_sizes[(appeal_ordinal + 1) * 2] * 200) + ) + for appeal_ordinal in range(appeals) + ) - assert calculate_round_fees(fees_distribution, validators) == expected + assert calculate_round_fees(fees_distribution, validators) == ( + expected + appeal_profit_reserve + ) def test_calculate_round_fees_rejects_invalid_validator_count(): @@ -320,17 +349,41 @@ def test_calculate_round_fees_rejects_invalid_appeal_rounds(): def test_calculate_round_fees_applies_gen_per_time_unit_multiplier(): - fees_distribution = _fees_distribution() + fees_distribution = _fees_distribution(max_price_gen_per_time_unit=12) policy = StudioFeePolicy(gen_per_time_unit=10) - assert calculate_round_fees(fees_distribution, 5, policy=policy) == 11000 + # Consensus reserves at the submitted cap (12), while validating that the + # live price (10) does not exceed it. + assert calculate_round_fees(fees_distribution, 5, policy=policy) == 13200 assert ( calculate_round_fees( - _fees_distribution(execution_budget_per_round=50), + _fees_distribution( + execution_budget_per_round=50, + max_price_gen_per_time_unit=12, + ), 5, policy=policy, ) - == 11050 + == 13250 + ) + + +def test_calculate_round_fees_reserves_profit_and_grosses_up_only_work(): + fees_distribution = _fees_distribution( + appeals=1, + rotations=[0, 0], + max_price_gen_per_time_unit=10, + ) + policy = StudioFeePolicy( + gen_per_time_unit=8, + time_unit_overlay_bps=DEFAULT_TIME_UNIT_OVERLAY_BPS, + ) + + work = 4_900 * 10 + profit_reserve = successful_appeal_profit(2_300) * 10 + overlay = work * 1_500 // 8_500 + assert calculate_round_fees(fees_distribution, 5, policy=policy) == ( + work + overlay + profit_reserve ) @@ -365,7 +418,21 @@ def test_calculate_round_fees_adds_execution_budget_per_leader_round(): ) assert get_leader_rounds(fees_distribution) == 6 - assert calculate_round_fees(fees_distribution, 5) == 10900 + assert calculate_round_fees(fees_distribution, 5) == 21250 + + +def test_sim_calculate_round_fees_exposes_decimal_canonical_quote(monkeypatch): + monkeypatch.setenv("GENLAYER_STUDIO_GEN_PER_TIME_UNIT", "1") + monkeypatch.setenv("GENLAYER_STUDIO_STORAGE_UNIT_PRICE", "0") + monkeypatch.setenv("GENLAYER_STUDIO_RECEIPT_GAS_PRICE", "0") + monkeypatch.setenv("GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS", "1500") + fees_distribution = _fees_distribution( + appeals=1, + rotations=[0, 0], + max_price_gen_per_time_unit=1, + ) + + assert sim_calculate_round_fees(fees_distribution, 5, 0) == "9214" def test_time_unit_fees_through_round_refunds_unused_appeal_budget(): @@ -373,7 +440,7 @@ def test_time_unit_fees_through_round_refunds_unused_appeal_budget(): assert get_leader_rounds_through_round(fees_distribution, 0) == 1 assert calculate_time_unit_fees_through_round(fees_distribution, 5, 0) == 1100 - assert calculate_round_fees(fees_distribution, 5) == 12300 + assert calculate_round_fees(fees_distribution, 5) == 22800 def test_time_unit_fees_through_round_uses_actual_rotation_history(): @@ -432,6 +499,7 @@ def test_studio_fee_policy_env_defaults_to_fee_enabled(monkeypatch): assert policy.gen_per_time_unit == DEFAULT_GEN_PER_TIME_UNIT assert policy.storage_unit_price == DEFAULT_STORAGE_UNIT_PRICE assert policy.receipt_gas_price == DEFAULT_RECEIPT_GAS_PRICE + assert policy.time_unit_overlay_bps == DEFAULT_TIME_UNIT_OVERLAY_BPS assert policy.fee_accounting_enabled() is True @@ -559,7 +627,11 @@ def test_studio_fee_config_exposes_default_nonzero_fee_policy(): assert distribution["receiptFeeMaxGasPrice"] == 2 assert ( fee_value - == (1100 * DEFAULT_GEN_PER_TIME_UNIT) + distribution["executionBudgetPerRound"] + == ( + 1100 + * (DEFAULT_GEN_PER_TIME_UNIT * DEFAULT_PRICE_CAP_HEADROOM_BPS // 10_000) + ) + + distribution["executionBudgetPerRound"] ) config = studio_fee_config(policy) @@ -570,6 +642,7 @@ def test_studio_fee_config_exposes_default_nonzero_fee_policy(): assert config["policy"]["messageFeeParamsBudgetFloor"] == str( policy.message_fee_params_budget_floor() ) + assert config["policy"]["timeUnitOverlayBps"] == "0" assert config["capabilities"]["messageFees"]["mode1"] == { "accounting": True, "genvmExecution": False, @@ -1621,13 +1694,14 @@ def test_genvm_fee_context_sets_message_bucket_independently(): def test_genvm_message_fee_allocation_maps_studio_nodes(): fee_params = _encode_internal_fee_params(leader_timeunits=6) + fees_distribution = _fees_distribution( + total_message_fees=60, + max_price_gen_per_time_unit=11, + storage_fee_max_gas_price=12, + receipt_fee_max_gas_price=13, + ) accounting = create_fee_accounting( - fees_distribution=_fees_distribution( - total_message_fees=60, - max_price_gen_per_time_unit=11, - storage_fee_max_gas_price=12, - receipt_fee_max_gas_price=13, - ), + fees_distribution=fees_distribution, message_allocations=[ _allocation( budget=60, @@ -1637,7 +1711,7 @@ def test_genvm_message_fee_allocation_maps_studio_nodes(): ) ], num_of_validators=5, - submitted_value=1160, + submitted_value=required_fee_deposit(fees_distribution, 5), user_value=0, ) @@ -2412,9 +2486,7 @@ def test_top_up_refreshes_message_fee_report_after_budget_increase(): topped_up = apply_fee_top_up( recorded, - fees_distribution=_fees_distribution( - leader_timeunits=0, - validator_timeunits=0, + fees_distribution=_top_up_distribution( total_message_fees=25, ), amount=25, @@ -3611,7 +3683,7 @@ def test_settle_fee_accounting_caps_execution_spend_to_actual_round_budget(): accounting = create_fee_accounting( fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=12800, + submitted_value=required_fee_deposit(fees_distribution, 5), user_value=0, sender="0x1111111111111111111111111111111111111111", ) @@ -3627,14 +3699,15 @@ def test_settle_fee_accounting_caps_execution_spend_to_actual_round_budget(): assert settled["execution_fee_consumed"] == 300 assert settled["actual_final_round"] == 0 assert settled["primary_fee_spent"] == 1200 - assert refund == 11600 + assert refund == 22100 def test_settle_fee_accounting_uses_actual_round_for_primary_refund(): + fees_distribution = _fees_distribution(appeals=2, rotations=[0, 0, 0]) accounting = create_fee_accounting( - fees_distribution=_fees_distribution(appeals=2, rotations=[0, 0, 0]), + fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=12300, + submitted_value=required_fee_deposit(fees_distribution, 5), user_value=0, sender="0x1111111111111111111111111111111111111111", ) @@ -3647,7 +3720,7 @@ def test_settle_fee_accounting_uses_actual_round_for_primary_refund(): assert settled["primary_fee_spent"] == 1100 assert settled["actual_final_round"] == 0 - assert refund == 11200 + assert refund == 21700 def test_settle_fee_accounting_charges_half_leader_allocation_for_timeout_round(): @@ -4858,10 +4931,11 @@ def test_consume_message_fees_rejects_allocation_overrun_and_fee_param_mismatch( def test_record_appeal_bond_validates_minimum_and_keeps_bond_separate(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) accounting = create_fee_accounting( - fees_distribution=_fees_distribution(), + fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=1100, + submitted_value=required_fee_deposit(fees_distribution, 5), user_value=0, ) @@ -4893,16 +4967,17 @@ def test_record_appeal_bond_validates_minimum_and_keeps_bond_separate(): assert updated["appeal_bonds_total"] == 1400 assert updated["paid_fee_value"] == accounting["paid_fee_value"] - assert updated["primary_fee_budget"] == 1100 + assert updated["primary_fee_budget"] == accounting["primary_fee_budget"] assert updated["top_ups"] == accounting["top_ups"] assert updated["appeal_bonds"][0]["minimumRequired"] == 1400 def test_settle_fee_accounting_pays_successful_appeal_bond_plus_profit(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) accounting = create_fee_accounting( - fees_distribution=_fees_distribution(appeals=1, rotations=[0, 0]), + fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=4900, + submitted_value=required_fee_deposit(fees_distribution, 5), user_value=0, ) recorded = record_appeal_bond( @@ -4926,8 +5001,9 @@ def test_settle_fee_accounting_pays_successful_appeal_bond_plus_profit(): consensus_history=consensus_history, ) - assert refund == 2300 - assert settled["appeal_bonds_payout_total"] == 2100 + assert refund == 3650 + assert settled["appeal_bonds_payout_total"] == 3500 + assert settled["appeal_profit_spent"] == 2100 assert settled["appeal_bond_settlements"] == [ { "bondIndex": 0, @@ -4935,7 +5011,7 @@ def test_settle_fee_accounting_pays_successful_appeal_bond_plus_profit(): "amount": 1400, "round": 0, "status": "successful", - "payout": 2100, + "payout": 3500, "outcomeRound": 1, "outcome": "Leader Appeal Successful", } @@ -4943,10 +5019,11 @@ def test_settle_fee_accounting_pays_successful_appeal_bond_plus_profit(): def test_settle_fee_accounting_explicitly_forfeits_failed_appeal_bond(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) accounting = create_fee_accounting( - fees_distribution=_fees_distribution(appeals=1, rotations=[0, 0]), + fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=4900, + submitted_value=required_fee_deposit(fees_distribution, 5), user_value=0, ) recorded = record_appeal_bond( @@ -4970,17 +5047,18 @@ def test_settle_fee_accounting_explicitly_forfeits_failed_appeal_bond(): consensus_history=consensus_history, ) - assert refund == 2300 + assert refund == 5750 assert settled["appeal_bonds_payout_total"] == 0 assert settled["appeal_bond_settlements"][0]["status"] == "forfeited" assert settled["appeal_bond_settlements"][0]["bond_forfeited"] == 1400 def test_cancel_fee_accounting_returns_unadjudicated_appeal_bond(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) accounting = create_fee_accounting( - fees_distribution=_fees_distribution(), + fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=1100, + submitted_value=required_fee_deposit(fees_distribution, 5), user_value=0, ) recorded = record_appeal_bond( @@ -4993,27 +5071,33 @@ def test_cancel_fee_accounting_returns_unadjudicated_appeal_bond(): canceled, refund = cancel_fee_accounting(recorded) - assert refund == 1100 + assert refund == accounting["primary_fee_budget"] assert canceled["appeal_bonds_payout_total"] == 1400 assert canceled["appeal_bond_settlements"][0]["status"] == "returned" assert canceled["appeal_bond_settlements"][0]["payout"] == 1400 -def test_top_up_and_submit_appeal_skips_generic_top_up_bookkeeping(): +def test_top_up_and_submit_appeal_records_only_typed_funding_in_fee_budget(): + fees_distribution = _fees_distribution( + max_price_gen_per_time_unit=100, + storage_fee_max_gas_price=80, + receipt_fee_max_gas_price=60, + ) accounting = create_fee_accounting( - fees_distribution=_fees_distribution( - max_price_gen_per_time_unit=100, - storage_fee_max_gas_price=80, - receipt_fee_max_gas_price=60, - ), + fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=1100, + submitted_value=required_fee_deposit(fees_distribution, 5), user_value=0, ) + charge = calculate_appeal_charge( + accounting["fees_distribution"], + current_round=0, + status="ACCEPTED", + ) updated = record_appeal_bond( accounting, - amount=1400, + amount=charge["bond"] + charge["funding"], appealer="0x1111111111111111111111111111111111111111", current_round=0, status="ACCEPTED", @@ -5026,8 +5110,10 @@ def test_top_up_and_submit_appeal_skips_generic_top_up_bookkeeping(): top_up_and_submit=True, ) - assert updated["paid_fee_value"] == accounting["paid_fee_value"] + 1400 - assert updated["primary_fee_budget"] == accounting["primary_fee_budget"] + 1400 + assert updated["paid_fee_value"] == accounting["paid_fee_value"] + charge["funding"] + assert updated["primary_fee_budget"] == ( + accounting["primary_fee_budget"] + charge["funding"] + ) assert updated["top_ups"] == accounting["top_ups"] assert updated["message_fee_budget"] == accounting["message_fee_budget"] assert updated["fees_distribution"]["appealRounds"] == 1 @@ -5037,6 +5123,7 @@ def test_top_up_and_submit_appeal_skips_generic_top_up_bookkeeping(): assert updated["fees_distribution"]["receiptFeeMaxGasPrice"] == 60 assert updated["appeal_bonds"][0]["topUpAndSubmit"] is True assert updated["appeal_bonds"][0]["feesDistributionIgnored"] is True + assert updated["appeal_bonds"][0]["extendsSchedule"] is True def test_accepted_appeal_bond_uses_active_timeunit_price(): @@ -5050,11 +5137,52 @@ def test_accepted_appeal_bond_uses_active_timeunit_price(): assert bond == 1400 * 10**15 +def test_leader_timeout_bond_matches_fee_simulator_configured_round_vector(): + bond = calculate_min_appeal_bond( + _fees_distribution(), + current_round=0, + status="LEADER_TIMEOUT", + ) + + assert bond == 100 + 5 * 200 + + +def test_leader_appeal_bonds_count_the_mandatory_attempt(): + fees_distribution = _fees_distribution( + appeals=1, + rotations=[0, 0], + ) + + assert ( + calculate_min_appeal_bond( + fees_distribution, + current_round=0, + status="UNDETERMINED", + ) + == 100 + 11 * 200 + ) + assert ( + calculate_min_appeal_bond( + fees_distribution, + current_round=0, + status="LEADER_TIMEOUT", + ) + == 100 + 5 * 200 + ) + + +def test_successful_appeal_return_matches_fee_simulator_exact_integer_math(): + bond = 10**30 + 1 + + assert successful_appeal_reward(bond) == bond * 5 // 2 + assert successful_appeal_profit(bond) == bond * 5 // 2 - bond + + def test_timeout_appeal_bond_bypasses_stale_max_price_cap(): policy = StudioFeePolicy(gen_per_time_unit=100) fees_distribution = _fees_distribution( appeals=2, - rotations=[0, 1, 1], + rotations=[1, 1, 1], max_price_gen_per_time_unit=10, ) @@ -5068,14 +5196,14 @@ def test_timeout_appeal_bond_bypasses_stale_max_price_cap(): policy=policy, ) - assert bond == 230_000 + assert bond == 220_000 def test_top_up_and_submit_appeal_bypasses_stale_cap_without_rewriting_policy(): submission_policy = StudioFeePolicy(gen_per_time_unit=1) fees_distribution = _fees_distribution( appeals=2, - rotations=[0, 1, 1], + rotations=[1, 1, 1], max_price_gen_per_time_unit=10, ) accounting = create_fee_accounting( @@ -5096,10 +5224,11 @@ def test_top_up_and_submit_appeal_bypasses_stale_cap_without_rewriting_policy(): top_up_and_submit=True, ) - assert updated["primary_fee_budget"] == accounting["primary_fee_budget"] + 2_300 - assert updated["fees_distribution"]["appealRounds"] == 3 + assert updated["primary_fee_budget"] == accounting["primary_fee_budget"] + assert updated["fees_distribution"]["appealRounds"] == 2 assert updated["fees_distribution"]["maxPriceGenPerTimeUnit"] == 10 - assert updated["appeal_bonds"][0]["minimumRequired"] == 2_300 + assert updated["appeal_bonds"][0]["minimumRequired"] == 2_200 + assert updated["appeal_bonds"][0]["surplusRefund"] == 100 assert updated["appeal_bonds"][0]["feesDistributionIgnored"] is True @@ -5111,9 +5240,14 @@ def test_top_up_and_submit_appeal_only_bumps_appeal_capacity_and_fee_pot(): user_value=0, ) + charge = calculate_appeal_charge( + accounting["fees_distribution"], + current_round=0, + status="ACCEPTED", + ) updated = record_appeal_bond( accounting, - amount=1400, + amount=charge["bond"] + charge["funding"], appealer="0x1111111111111111111111111111111111111111", current_round=0, status="ACCEPTED", @@ -5121,13 +5255,55 @@ def test_top_up_and_submit_appeal_only_bumps_appeal_capacity_and_fee_pot(): top_up_and_submit=True, ) - assert updated["primary_fee_budget"] == 2500 + assert updated["primary_fee_budget"] == 1100 + charge["funding"] assert updated["fees_distribution"]["appealRounds"] == 1 assert updated["fees_distribution"]["totalMessageFees"] == 0 assert updated["message_fee_budget"] == 0 assert updated["appeal_bonds"][0]["feesDistributionIgnored"] is True +def test_appeal_admission_uses_current_overlay_with_locked_gen_price(): + submission_policy = StudioFeePolicy( + gen_per_time_unit=3, + time_unit_overlay_bps=500, + ) + current_overlay_bps = 2_000 + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(max_price_gen_per_time_unit=3), + num_of_validators=5, + submitted_value=required_fee_deposit( + _fees_distribution(max_price_gen_per_time_unit=3), + 5, + submission_policy, + ), + user_value=0, + policy=submission_policy, + ) + admission_policy = StudioFeePolicy( + gen_per_time_unit=3, + time_unit_overlay_bps=current_overlay_bps, + ) + charge = calculate_appeal_charge( + accounting["fees_distribution"], + current_round=0, + status="ACCEPTED", + policy=admission_policy, + ) + + updated = record_appeal_bond( + accounting, + amount=charge["bond"] + charge["funding"], + appealer="0x1111111111111111111111111111111111111111", + current_round=0, + status="ACCEPTED", + time_unit_overlay_bps=current_overlay_bps, + ) + + breakdown = updated["appeal_bonds"][0]["fundingBreakdown"] + assert breakdown["taxableWork"] == 3_700 * 3 + assert breakdown["overlay"] == breakdown["taxableWork"] * 2_000 // 8_000 + + def test_top_up_and_submit_appeal_refreshes_recommended_fee_preset(): policy = StudioFeePolicy(gen_per_time_unit=1, receipt_gas_price=0) accounting = create_fee_accounting( @@ -5145,9 +5321,15 @@ def test_top_up_and_submit_appeal_refreshes_recommended_fee_preset(): assert recorded["recommended_fee_preset"]["distribution"]["appealRounds"] == 0 + charge = calculate_appeal_charge( + recorded["fees_distribution"], + current_round=0, + status="ACCEPTED", + policy=policy, + ) updated = record_appeal_bond( recorded, - amount=1_400, + amount=charge["bond"] + charge["funding"], appealer="0x1111111111111111111111111111111111111111", current_round=0, status="ACCEPTED", @@ -5622,7 +5804,8 @@ def test_message_dispatch_creates_mode1_child_fee_accounting_from_pending_metada monkeypatch.setenv("GENLAYER_STUDIO_RECEIPT_GAS_PRICE", "0") policy = StudioFeePolicy.from_env() fee_params = _encode_internal_fee_params() - fees_distribution = _fees_distribution(total_message_fees=55) + child_budget = 64 + fees_distribution = _fees_distribution(total_message_fees=child_budget) accounting = create_fee_accounting( fees_distribution=fees_distribution, num_of_validators=5, @@ -5640,7 +5823,7 @@ def test_message_dispatch_creates_mode1_child_fee_accounting_from_pending_metada on="accepted", value=7, fee_params=fee_params, - declared_budget=55, + declared_budget=child_budget, call_key="0x" + "12" * 32, ) @@ -5653,25 +5836,25 @@ def test_message_dispatch_creates_mode1_child_fee_accounting_from_pending_metada assert nonce == 3 assert value == 7 assert data["calldata"] == b"\x12\x34" - assert data["fee_value"] == 55 + assert data["fee_value"] == child_budget assert data["user_value"] == 7 assert data["fees_distribution"]["totalMessageFees"] == 0 assert data[FEE_ACCOUNTING_KEY]["source"] == "internal_message" - assert data[FEE_ACCOUNTING_KEY]["paid_fee_value"] == 55 + assert data[FEE_ACCOUNTING_KEY]["paid_fee_value"] == child_budget assert data[FEE_ACCOUNTING_KEY]["message_fee_budget"] == 0 assert data["message_allocations_count"] == 0 assert len(internal_messages) == 1 serialized_child = json.loads(internal_messages[0]["data"]) - assert serialized_child["fee_value"] == 55 + assert serialized_child["fee_value"] == child_budget assert serialized_child["user_value"] == 7 assert serialized_child["calldata"] == base64.b64encode(b"\x12\x34").decode("utf-8") assert processor.updated_hash == context.transaction.hash - assert processor.updated_fee_accounting["message_fee_consumed"] == 55 + assert processor.updated_fee_accounting["message_fee_consumed"] == child_budget assert processor.updated_fee_accounting["allocation_consumed"] == {} assert processor.updated_fee_accounting["message_consumption_events"][-1] == { - "consumed": 55, - "internalConsumed": 55, + "consumed": child_budget, + "internalConsumed": child_budget, "externalReimbursed": 0, "remaining": 0, } @@ -5690,7 +5873,8 @@ def test_message_dispatch_fills_mode2_child_fee_accounting_from_allocation_subtr child_recipient = "0x3333333333333333333333333333333333333333" root_call_key = "0x" + "12" * 32 child_call_key = "0x" + "34" * 32 - root_budget = 113 + child_budget = 67 + root_budget = 132 fees_distribution = _fees_distribution(total_message_fees=root_budget) accounting = create_fee_accounting( fees_distribution=fees_distribution, @@ -5705,7 +5889,7 @@ def test_message_dispatch_fills_mode2_child_fee_accounting_from_allocation_subtr parent_index=0, recipient=child_recipient, call_key=child_call_key, - budget=57, + budget=child_budget, fee_params=child_fee_params, ), ], @@ -5734,9 +5918,9 @@ def test_message_dispatch_fills_mode2_child_fee_accounting_from_allocation_subtr child_accounting = child_data[FEE_ACCOUNTING_KEY] assert child_data["fee_value"] == root_budget assert child_data["fees_distribution"]["leaderTimeunitsAllocation"] == 6 - assert child_data["fees_distribution"]["totalMessageFees"] == 57 + assert child_data["fees_distribution"]["totalMessageFees"] == child_budget assert child_data["message_allocations_count"] == 1 - assert child_accounting["message_fee_budget"] == 57 + assert child_accounting["message_fee_budget"] == child_budget assert child_accounting["message_allocations"] == [ { "messageType": 1, @@ -5744,7 +5928,7 @@ def test_message_dispatch_fills_mode2_child_fee_accounting_from_allocation_subtr "parentIndex": NODE_ROOT_SENTINEL, "recipient": child_recipient, "callKey": child_call_key, - "budget": 57, + "budget": child_budget, "feeParams": "0x" + child_fee_params.hex(), } ] @@ -5834,7 +6018,7 @@ def test_apply_fee_top_up_extends_budgets(): updated = apply_fee_top_up( accounting, - fees_distribution=_fees_distribution(total_message_fees=55), + fees_distribution=_top_up_distribution(total_message_fees=55), amount=1155, sender="0x1111111111111111111111111111111111111111", perform_fee_checks=False, @@ -5845,7 +6029,7 @@ def test_apply_fee_top_up_extends_budgets(): assert updated["message_fee_budget"] == 55 -def test_apply_fee_top_up_locks_timeunit_and_appeal_round_policy(): +def test_apply_fee_top_up_rejects_schedule_extension_after_activation(): initial_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) incoming_distribution = _fees_distribution( leader_timeunits=999, @@ -5860,16 +6044,12 @@ def test_apply_fee_top_up_locks_timeunit_and_appeal_round_policy(): user_value=0, ) - updated = apply_fee_top_up( - accounting, - fees_distribution=incoming_distribution, - amount=calculate_round_fees(incoming_distribution, 5), - ) - - assert updated["fees_distribution"]["leaderTimeunitsAllocation"] == 100 - assert updated["fees_distribution"]["validatorTimeunitsAllocation"] == 200 - assert updated["fees_distribution"]["appealRounds"] == 1 - assert updated["fees_distribution"]["rotations"] == [0, 0, 2, 2, 2, 2] + with pytest.raises(TopUpCannotExtendSchedule): + apply_fee_top_up( + accounting, + fees_distribution=incoming_distribution, + amount=calculate_round_fees(incoming_distribution, 5), + ) def test_apply_fee_top_up_rejects_underfunded_message_bucket(): @@ -5883,9 +6063,7 @@ def test_apply_fee_top_up_rejects_underfunded_message_bucket(): with pytest.raises(InsufficientFees): apply_fee_top_up( accounting, - fees_distribution=_fees_distribution( - leader_timeunits=0, - validator_timeunits=0, + fees_distribution=_top_up_distribution( total_message_fees=55, ), amount=54, @@ -5903,7 +6081,7 @@ def test_apply_fee_top_up_rejects_underfunded_primary_delta(): with pytest.raises(InsufficientFees): apply_fee_top_up( accounting, - fees_distribution=_fees_distribution(), + fees_distribution=_top_up_distribution(execution_budget_per_round=1100), amount=1099, ) @@ -5920,9 +6098,7 @@ def test_apply_fee_top_up_adds_message_bucket_without_mutating_allocations(): updated = apply_fee_top_up( accounting, - fees_distribution=_fees_distribution( - leader_timeunits=0, - validator_timeunits=0, + fees_distribution=_top_up_distribution( total_message_fees=25, ), amount=25, @@ -5953,7 +6129,7 @@ def test_apply_fee_top_up_checks_execution_budget_floor_after_merge(): updated = apply_fee_top_up( accounting, - fees_distribution=_fees_distribution(execution_budget_per_round=1), + fees_distribution=_top_up_distribution(execution_budget_per_round=1), amount=1101, policy=policy, ) @@ -5974,29 +6150,28 @@ def test_apply_fee_top_up_checks_execution_budget_floor_after_merge(): with pytest.raises(BudgetTooLow): apply_fee_top_up( underfunded, - fees_distribution=_fees_distribution(execution_budget_per_round=1), + fees_distribution=_top_up_distribution(execution_budget_per_round=1), amount=1101, policy=policy, ) def test_apply_fee_top_up_only_raises_existing_price_caps(): + fees_distribution = _fees_distribution( + max_price_gen_per_time_unit=100, + storage_fee_max_gas_price=80, + receipt_fee_max_gas_price=60, + ) accounting = create_fee_accounting( - fees_distribution=_fees_distribution( - max_price_gen_per_time_unit=100, - storage_fee_max_gas_price=80, - receipt_fee_max_gas_price=60, - ), + fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=1100, + submitted_value=required_fee_deposit(fees_distribution, 5), user_value=0, ) unchanged = apply_fee_top_up( accounting, - fees_distribution=_fees_distribution( - leader_timeunits=0, - validator_timeunits=0, + fees_distribution=_top_up_distribution( max_price_gen_per_time_unit=90, storage_fee_max_gas_price=0, receipt_fee_max_gas_price=60, @@ -6010,14 +6185,12 @@ def test_apply_fee_top_up_only_raises_existing_price_caps(): raised = apply_fee_top_up( unchanged, - fees_distribution=_fees_distribution( - leader_timeunits=0, - validator_timeunits=0, + fees_distribution=_top_up_distribution( max_price_gen_per_time_unit=120, storage_fee_max_gas_price=85, receipt_fee_max_gas_price=70, ), - amount=0, + amount=22_000, ) assert raised["fees_distribution"]["maxPriceGenPerTimeUnit"] == 120 @@ -6033,9 +6206,7 @@ def test_apply_fee_top_up_only_raises_existing_price_caps(): still_uncapped = apply_fee_top_up( uncapped, - fees_distribution=_fees_distribution( - leader_timeunits=0, - validator_timeunits=0, + fees_distribution=_top_up_distribution( max_price_gen_per_time_unit=120, storage_fee_max_gas_price=85, receipt_fee_max_gas_price=70, @@ -6048,6 +6219,33 @@ def test_apply_fee_top_up_only_raises_existing_price_caps(): assert still_uncapped["fees_distribution"]["receiptFeeMaxGasPrice"] == 0 +def test_apply_fee_top_up_uses_locked_cap_after_live_gen_price_rises(): + submission_policy = StudioFeePolicy(gen_per_time_unit=1) + fees_distribution = _fees_distribution(max_price_gen_per_time_unit=10) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit( + fees_distribution, + 5, + submission_policy, + ), + user_value=0, + policy=submission_policy, + ) + + updated = apply_fee_top_up( + accounting, + fees_distribution=_top_up_distribution(execution_budget_per_round=1), + amount=1, + policy=StudioFeePolicy(gen_per_time_unit=20), + ) + + assert updated["fees_distribution"]["maxPriceGenPerTimeUnit"] == 10 + assert updated["fees_distribution"]["executionBudgetPerRound"] == 1 + assert updated["primary_fee_budget"] == accounting["primary_fee_budget"] + 1 + + class _FakeAccountsManager: def __init__(self, balance=0): self.balance = balance @@ -6095,7 +6293,7 @@ def _decoded_top_up(tx_id, *, amount, fees_distribution=None): to_address="0x0000000000000000000000000000000000000000", data=DecodedTopUpFeesDataArgs( tx_id=tx_id, - fees_distribution=fees_distribution or _fees_distribution(), + fees_distribution=fees_distribution or _top_up_distribution(), ), type="2", nonce=0, @@ -6107,6 +6305,7 @@ def _decoded_appeal( tx_id, *, amount, + expected_decision_id=None, fees_distribution=None, top_up_and_submit=False, ): @@ -6115,6 +6314,7 @@ def _decoded_appeal( to_address="0x0000000000000000000000000000000000000000", data=DecodedsubmitAppealDataArgs( tx_id=tx_id, + expected_decision_id=expected_decision_id, fees_distribution=fees_distribution, top_up_and_submit=top_up_and_submit, ), @@ -6155,6 +6355,16 @@ def _env_fee_accounting(fees_distribution=None): ) +def _env_appeal_charge(accounting, *, status="ACCEPTED", current_round=0): + return calculate_appeal_charge( + accounting["fees_distribution"], + current_round=current_round, + status=status, + terminal_committee_upper_bound=5, + policy=StudioFeePolicy.from_env(), + ) + + def test_top_up_fees_endpoint_updates_accounting_and_debits_sender(): tx = _fee_accounted_tx() accounts = _FakeAccountsManager(balance=0) @@ -6193,12 +6403,14 @@ def test_top_up_fees_endpoint_rejects_final_decided_transaction_status(status): assert transactions.updated_fee_accounting is None -def test_submit_appeal_endpoint_records_bond_without_expanding_fee_pot(): +def test_submit_appeal_endpoint_records_separate_bond_and_typed_funding(): accounting = _env_fee_accounting() tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) accounts = _FakeAccountsManager(balance=0) transactions = _FakeTransactionsProcessor(tx) - appeal_bond = 1400 * DEFAULT_GEN_PER_TIME_UNIT + charge = _env_appeal_charge(accounting) + appeal_bond = charge["bond"] + submitted = appeal_bond + charge["funding"] class _MsgHandler: def __init__(self): @@ -6213,22 +6425,24 @@ def send_message(self, log_event, log_to_terminal=True): accounts_manager=accounts, transactions_processor=transactions, msg_handler=handler, - decoded_rollup_transaction=_decoded_appeal(tx["hash"], amount=appeal_bond), + decoded_rollup_transaction=_decoded_appeal(tx["hash"], amount=submitted), ) updated = transactions.updated_fee_accounting assert tx_id == tx["hash"] assert updated["appeal_bonds_total"] == appeal_bond - assert updated["primary_fee_budget"] == accounting["primary_fee_budget"] + assert updated["primary_fee_budget"] == ( + accounting["primary_fee_budget"] + charge["funding"] + ) assert updated["appeal_bonds"][0]["minimumRequired"] == appeal_bond assert updated["appeal_bonds"][0]["topUpAndSubmit"] is False assert transactions.appeal_updates == [(tx["hash"], True)] assert len(handler.events) == 1 assert accounts.credits == [ - ("0x1111111111111111111111111111111111111111", appeal_bond) + ("0x1111111111111111111111111111111111111111", submitted) ] assert accounts.debits == [ - ("0x1111111111111111111111111111111111111111", appeal_bond) + ("0x1111111111111111111111111111111111111111", submitted) ] @@ -6236,7 +6450,8 @@ def test_top_up_and_submit_appeal_endpoint_expands_capacity_only(): accounting = _env_fee_accounting() tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) transactions = _FakeTransactionsProcessor(tx) - appeal_bond = 1400 * DEFAULT_GEN_PER_TIME_UNIT + charge = _env_appeal_charge(accounting) + submitted = charge["bond"] + charge["funding"] class _MsgHandler: def send_message(self, log_event, log_to_terminal=True): @@ -6248,7 +6463,7 @@ def send_message(self, log_event, log_to_terminal=True): msg_handler=_MsgHandler(), decoded_rollup_transaction=_decoded_appeal( tx["hash"], - amount=appeal_bond, + amount=submitted, fees_distribution=_fees_distribution(total_message_fees=55), top_up_and_submit=True, ), @@ -6256,9 +6471,11 @@ def send_message(self, log_event, log_to_terminal=True): updated = transactions.updated_fee_accounting assert tx_id == tx["hash"] - assert updated["paid_fee_value"] == accounting["paid_fee_value"] + appeal_bond + assert updated["paid_fee_value"] == ( + accounting["paid_fee_value"] + charge["funding"] + ) assert updated["primary_fee_budget"] == ( - accounting["primary_fee_budget"] + appeal_bond + accounting["primary_fee_budget"] + charge["funding"] ) assert updated["message_fee_budget"] == 0 assert updated["fees_distribution"]["appealRounds"] == 1 @@ -6269,8 +6486,10 @@ def send_message(self, log_event, log_to_terminal=True): def test_submit_appeal_endpoint_rejects_bond_below_required_minimum(): - tx = _fee_accounted_tx(status="ACCEPTED", accounting=_env_fee_accounting()) + accounting = _env_fee_accounting() + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) transactions = _FakeTransactionsProcessor(tx) + charge = _env_appeal_charge(accounting) class _MsgHandler: def send_message(self, log_event, log_to_terminal=True): @@ -6283,7 +6502,33 @@ def send_message(self, log_event, log_to_terminal=True): msg_handler=_MsgHandler(), decoded_rollup_transaction=_decoded_appeal( tx["hash"], - amount=(1400 * DEFAULT_GEN_PER_TIME_UNIT) - 1, + amount=charge["bond"] + charge["funding"] - 1, + ), + ) + + assert transactions.updated_fee_accounting is None + assert transactions.appeal_updates == [] + + +def test_submit_appeal_endpoint_rejects_stale_decision_id_before_charging(): + accounting = _env_fee_accounting() + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) + transactions = _FakeTransactionsProcessor(tx) + charge = _env_appeal_charge(accounting) + + class _MsgHandler: + def send_message(self, log_event, log_to_terminal=True): + pass + + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + _handle_appeal_or_top_up_and_submit( + accounts_manager=_FakeAccountsManager(balance=5000), + transactions_processor=transactions, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal( + tx["hash"], + amount=charge["bond"] + charge["funding"], + expected_decision_id=2, ), ) diff --git a/tests/unit/test_transactions_parser.py b/tests/unit/test_transactions_parser.py index 8c102d4fa..862b1dbdd 100644 --- a/tests/unit/test_transactions_parser.py +++ b/tests/unit/test_transactions_parser.py @@ -359,11 +359,19 @@ def _fee_aware_call_data(parser, function_name: str, params: tuple) -> bytes: return selector + parser.web3.codec.encode([input_type], [params]) -def _contract_call_data(parser, function_name: str, params: list) -> bytes: +def _contract_call_data( + parser, + function_name: str, + params: list, + *, + input_count: int | None = None, +) -> bytes: abi_entry = next( entry for entry in parser._get_contract_abi() - if entry["type"] == "function" and entry["name"] == function_name + if entry["type"] == "function" + and entry["name"] == function_name + and (input_count is None or len(entry["inputs"]) == input_count) ) input_types = [ parser._canonical_abi_type(abi_input) for abi_input in abi_entry["inputs"] @@ -512,7 +520,12 @@ def test_decode_signed_transaction_fee_top_up_calls( nonce=8, to=bytes.fromhex("0000000000000000000000000000000000000000"), value=1400, - data=_contract_call_data(parser, function_name, [tx_id, fees_distribution]), + data=_contract_call_data( + parser, + function_name, + [tx_id, fees_distribution], + input_count=2, + ), ) decoded = parser.decode_signed_transaction(raw) @@ -579,3 +592,67 @@ def test_decode_signed_transaction_submit_appeal_uses_value_as_bond(monkeypatch) assert decoded.data.tx_id == tx_id assert decoded.data.fees_distribution is None assert decoded.data.top_up_and_submit is False + + +def test_decode_latest_submit_appeal_preserves_expected_decision_id(monkeypatch): + monkeypatch.setattr( + "backend.protocol_rpc.transactions_parser.Account.recover_transaction", + lambda raw: "0x3333333333333333333333333333333333333333", + ) + consensus_service = Mock() + consensus_service.web3 = Web3() + consensus_service.load_contract = Mock(return_value={"abi": []}) + parser = TransactionParser(consensus_service) + tx_id = b"\x56" * 32 + + raw = _build_eip1559_raw( + nonce=10, + to=bytes.fromhex("0000000000000000000000000000000000000000"), + value=1400, + data=_contract_call_data( + parser, + "submitAppeal", + [tx_id, 7], + input_count=2, + ), + ) + + decoded = parser.decode_signed_transaction(raw) + + assert decoded is not None + assert decoded.data.tx_id == tx_id + assert decoded.data.expected_decision_id == 7 + assert decoded.data.top_up_and_submit is False + + +def test_decode_latest_top_up_and_submit_appeal_preserves_decision_id(monkeypatch): + monkeypatch.setattr( + "backend.protocol_rpc.transactions_parser.Account.recover_transaction", + lambda raw: "0x3333333333333333333333333333333333333333", + ) + consensus_service = Mock() + consensus_service.web3 = Web3() + consensus_service.load_contract = Mock(return_value={"abi": []}) + parser = TransactionParser(consensus_service) + tx_id = b"\x78" * 32 + fees_distribution = (11, 22, 2, 333, 44, 55, [3, 4, 5], 66, 77, 88) + + raw = _build_eip1559_raw( + nonce=11, + to=bytes.fromhex("0000000000000000000000000000000000000000"), + value=2800, + data=_contract_call_data( + parser, + "topUpAndSubmitAppeal", + [tx_id, 9, fees_distribution], + input_count=3, + ), + ) + + decoded = parser.decode_signed_transaction(raw) + + assert decoded is not None + assert decoded.fee_value == 2800 + assert decoded.data.expected_decision_id == 9 + assert decoded.data.top_up_and_submit is True + assert decoded.data.fees_distribution["rotations"] == [3, 4, 5] From 8d43de4e48a9062424f05d39d3a90b1906a6abed Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 12:58:02 +0100 Subject: [PATCH 02/55] test(fees): derive settlement deposit from quote --- tests/db-sqlalchemy/accounts_manager_test.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/db-sqlalchemy/accounts_manager_test.py b/tests/db-sqlalchemy/accounts_manager_test.py index c9267b023..c97692731 100644 --- a/tests/db-sqlalchemy/accounts_manager_test.py +++ b/tests/db-sqlalchemy/accounts_manager_test.py @@ -10,7 +10,11 @@ from backend.database_handler.errors import AccountNotFoundError from backend.database_handler.models import Transactions from backend.database_handler.transactions_processor import TransactionsProcessor -from backend.protocol_rpc.fees import FEE_ACCOUNTING_KEY, create_fee_accounting +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + create_fee_accounting, + required_fee_deposit, +) @pytest.fixture @@ -256,10 +260,12 @@ def test_settle_tx_fee_accounting_once_uses_actual_final_round_for_refund( ): sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" tx_hash = "0x" + "ef" * 32 + fees_distribution = _fees_distribution(appeals=2, rotations=[0, 0, 0]) + submitted_value = required_fee_deposit(fees_distribution, 5) accounting = create_fee_accounting( - fees_distribution=_fees_distribution(appeals=2, rotations=[0, 0, 0]), + fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=12300, + submitted_value=submitted_value, user_value=0, sender=sender, ) @@ -275,8 +281,8 @@ def test_settle_tx_fee_accounting_once_uses_actual_final_round_for_refund( session.flush() session.expire_all() - assert refund == 11200 - assert accounts_manager.get_account_balance(sender) == 11200 + assert refund == submitted_value - 1100 + assert accounts_manager.get_account_balance(sender) == submitted_value - 1100 tx = session.query(Transactions).filter_by(hash=tx_hash).one() fee_accounting = tx.data[FEE_ACCOUNTING_KEY] assert fee_accounting["actual_final_round"] == 0 From 521367932d6d421c84a7a20e6f2137dbd1585b14 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 13:12:40 +0100 Subject: [PATCH 03/55] chore(genvm): replace dead source pin --- third_party/genvm/version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/genvm/version b/third_party/genvm/version index 07f057afb..beaff3cff 100644 --- a/third_party/genvm/version +++ b/third_party/genvm/version @@ -1 +1 @@ -fix/vm-fatal-errors:8a4816ebea2d2c1f210c6167778e94330fb3d46a +v0.6-dev:d07f21dab9c4061d5422b73e2535101e06fd5962 From de7d032a8617d3762f73bb5dd94b498bd84dc784 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 13:22:45 +0100 Subject: [PATCH 04/55] test(fees): cover timeout rotation multiplier --- tests/unit/test_studio_fees.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index c31d1e050..15092cb3b 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -5143,8 +5143,15 @@ def test_leader_timeout_bond_matches_fee_simulator_configured_round_vector(): current_round=0, status="LEADER_TIMEOUT", ) + bond_with_two_rotations_left = calculate_min_appeal_bond( + _fees_distribution(), + current_round=0, + status="LEADER_TIMEOUT", + leader_timeout_rotations_left=2, + ) assert bond == 100 + 5 * 200 + assert bond_with_two_rotations_left == 3 * (100 + 5 * 200) def test_leader_appeal_bonds_count_the_mandatory_attempt(): From d9fe46d543b2e384922300ab050b7debd4b3022e Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 21:19:45 +0100 Subject: [PATCH 05/55] fix(fees): align studio with consensus v0.6 --- .env.example | 20 +- backend/consensus/base.py | 458 ++- backend/consensus/decisions.py | 2 +- backend/consensus/history.py | 60 +- backend/consensus/utils.py | 14 +- backend/database_handler/accounts_manager.py | 43 +- backend/node/genvm/base.py | 16 +- backend/node/types.py | 7 + backend/protocol_rpc/endpoints.py | 199 +- .../fastapi_endpoint_generator.py | 9 + backend/protocol_rpc/fees.py | 2093 +++++++++-- backend/protocol_rpc/rpc_methods.py | 18 + docker-compose.yml | 30 +- hardhat/contracts/v2_contracts/FeeManager.sol | 7 +- .../FeeManager.RoundFeesRotations.test.js | 36 + tests/consensus/test_payable_scenarios.py | 8 +- tests/db-sqlalchemy/accounts_manager_test.py | 82 + .../consensus/test_decisions_revealing.py | 29 +- .../unit/consensus/test_eth_send_emission.py | 21 + tests/unit/test_consensus_voting.py | 32 +- tests/unit/test_node_state_proxy_metrics.py | 21 +- tests/unit/test_studio_fees.py | 3049 +++++++++++++++-- tests/unit/test_timeunit_consumption.py | 20 + 23 files changed, 5731 insertions(+), 543 deletions(-) create mode 100644 hardhat/test/fees/FeeManager.RoundFeesRotations.test.js diff --git a/.env.example b/.env.example index e1078140c..7d472805e 100644 --- a/.env.example +++ b/.env.example @@ -51,11 +51,25 @@ GENVM_WEB_DEBUG="0" ######################################## # Studio Fee Accounting # Set all three price values to 0 to run Studio in gasless mode. -GENLAYER_STUDIO_GEN_PER_TIME_UNIT='1000000000000000' # 0.001 GEN per time unit -GENLAYER_STUDIO_STORAGE_UNIT_PRICE='1' -GENLAYER_STUDIO_RECEIPT_GAS_PRICE='1' +GENLAYER_STUDIO_GEN_PER_TIME_UNIT='1' # v0.6 neutral time-unit multiplier +GENLAYER_STUDIO_STORAGE_UNIT_PRICE='250000000' # v0.6 default: 0.25 gwei +GENLAYER_STUDIO_RECEIPT_GAS_PRICE='250000000' # v0.6 default: 0.25 gwei # Combined developer + DAO share of gross time-unit fees (15% by default). GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS='1500' +# v0.6 Idleness phase bounds (PHASE_TIMEOUT_BOUNDS=30,600,30,600). +GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS='30' +GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS='600' +GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS='30' +GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS='600' +# Governance-controlled external allocation gas-limit floor; 0 disables it. +GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT='0' +# Messages has an independent hard protocol ceiling (20 maximum); the optional +# FeeManager cap may lower it further when non-zero. +GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES='20' +GENLAYER_STUDIO_MAX_MESSAGES_PER_TX='0' +# Governance-controlled receipt/output admission caps; 0 disables them. +GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES='0' +GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES='0' GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS='210000' GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS='100000' GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES='1024' diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 91e0601a1..f32a0b15d 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -55,15 +55,22 @@ ) from backend.protocol_rpc.fees import ( FEE_ACCOUNTING_KEY, + VALIDATORS_PER_ROUND, FeeValidationError, StudioFeePolicy, + activate_fee_accounting, consume_message_fees, create_child_fee_accounting, derive_external_message_call_key, + discard_active_message_generation, fill_message_fee_payload_from_allocation, + mark_message_effects_delivered, + message_novelty_mask, + prepare_reveal_message_generation, record_external_message_execution_fees, - record_reveal_message_fees, + stamp_receipt_execution_policy, unwind_reveal_message_fees, + validate_receipt_admission_caps, ) from backend.rollup.consensus_service import ConsensusService @@ -1579,6 +1586,33 @@ def _external_message_value_for_phase( ) +def _use_balance_obligation_total( + pending_transactions: Iterable[PendingTransaction], +) -> tuple[int, int]: + fee_budget = 0 + value = 0 + for pending_transaction in pending_transactions: + if pending_transaction.is_eth_send or not pending_transaction.use_balance: + continue + fee_budget += max(0, int(pending_transaction.declared_budget or 0)) + value += max(0, int(pending_transaction.value or 0)) + return fee_budget, value + + +def _use_balance_obligation_for_phase( + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> int: + return sum( + max(0, int(pending_transaction.declared_budget or 0)) + + max(0, int(pending_transaction.value or 0)) + for pending_transaction in pending_transactions + if not pending_transaction.is_eth_send + and pending_transaction.use_balance + and pending_transaction.on == on + ) + + def _apply_external_message_freeze_check( context: TransactionContext, leader_receipt: Receipt, @@ -1586,7 +1620,15 @@ def _apply_external_message_freeze_check( if leader_receipt.execution_result != ExecutionResultStatus.SUCCESS: return - declared_value = _external_message_value_total(leader_receipt.pending_transactions) + pending_transactions = _novel_pending_transactions( + context, + leader_receipt.pending_transactions, + ) + external_value = _external_message_value_total(pending_transactions) + use_balance_fee, use_balance_value = _use_balance_obligation_total( + pending_transactions + ) + declared_value = external_value + use_balance_fee + use_balance_value if declared_value <= 0: return @@ -1598,9 +1640,13 @@ def _apply_external_message_freeze_check( if declared_value <= available: return + error_name = ( + "ExternalMessageFreezeExceeded" + if use_balance_fee + use_balance_value == 0 + else "ContractMessageFreezeExceeded" + ) error_message = ( - "ExternalMessageFreezeExceeded: " - f"declaredValue={declared_value}, availableLimit={available}" + f"{error_name}: declaredValue={declared_value}, availableLimit={available}" ) leader_receipt.execution_result = ExecutionResultStatus.ERROR leader_receipt.result = bytes([ResultCode.VM_ERROR]) + error_message.encode("utf-8") @@ -1609,13 +1655,26 @@ def _apply_external_message_freeze_check( leader_receipt.pending_transactions = [] leader_receipt.genvm_result = { **(leader_receipt.genvm_result or {}), - "error_code": "EXTERNAL_MESSAGE_FREEZE_EXCEEDED", + "error_code": ( + "EXTERNAL_MESSAGE_FREEZE_EXCEEDED" + if use_balance_fee + use_balance_value == 0 + else "CONTRACT_MESSAGE_FREEZE_EXCEEDED" + ), "error_description": error_message, "external_message_freeze": { "declaredValue": declared_value, "availableLimit": available, "balance": balance, "reservedExternal": other_reserved, + **( + { + "externalValue": external_value, + "useBalanceFee": use_balance_fee, + "useBalanceValue": use_balance_value, + } + if use_balance_fee + use_balance_value > 0 + else {} + ), }, } @@ -1642,8 +1701,10 @@ def _remaining_external_freeze_after_phase( if on == "finalized": return pending_freeze - return pending_freeze + _external_message_value_for_phase( - pending_transactions, "finalized" + return ( + pending_freeze + + _external_message_value_for_phase(pending_transactions, "finalized") + + _use_balance_obligation_for_phase(pending_transactions, "finalized") ) @@ -1687,6 +1748,10 @@ def _external_message_pending_freeze_total(context: TransactionContext) -> int: _receipt_pending_transactions(receipt), "finalized", ) + total += _use_balance_obligation_for_phase_from_raw( + _receipt_pending_transactions(receipt), + "finalized", + ) return total @@ -1767,6 +1832,57 @@ def _pending_transaction_external_value( return int(pending_transaction.get("value", 0) or 0) +def _use_balance_obligation_for_phase_from_raw( + pending_transactions: Iterable[Any], + on: Literal["accepted", "finalized"], +) -> int: + total = 0 + for pending_transaction in pending_transactions: + if isinstance(pending_transaction, PendingTransaction): + if ( + pending_transaction.is_eth_send + or not pending_transaction.use_balance + or pending_transaction.on != on + ): + continue + total += max(0, int(pending_transaction.declared_budget or 0)) + total += max(0, int(pending_transaction.value or 0)) + continue + + if not isinstance(pending_transaction, dict): + continue + message_type = pending_transaction.get( + "message_type", pending_transaction.get("messageType") + ) + if message_type in {0, "0", "External", "external"}: + continue + if not bool( + pending_transaction.get( + "use_balance", pending_transaction.get("useBalance", False) + ) + ): + continue + pending_on = pending_transaction.get("on") + if pending_on is None and "onAcceptance" in pending_transaction: + pending_on = ( + "accepted" if pending_transaction.get("onAcceptance") else "finalized" + ) + if pending_on != on: + continue + total += max( + 0, + int( + pending_transaction.get( + "declared_budget", + pending_transaction.get("declaredBudget", 0), + ) + or 0 + ), + ) + total += max(0, int(pending_transaction.get("value", 0) or 0)) + return total + + def _pending_transaction_with_value( pending_transaction: PendingTransaction, value: int, @@ -1804,7 +1920,14 @@ def _debit_internal_message_value_for_phase( pending_transactions: list[PendingTransaction], on: Literal["accepted", "finalized"], ) -> bool: - internal_value = _internal_message_value_for_phase(pending_transactions, on) + internal_value = sum( + int(pending_transaction.value or 0) + for pending_transaction in pending_transactions + if not pending_transaction.is_eth_send + and not pending_transaction.use_balance + and pending_transaction.on == on + and int(pending_transaction.value or 0) > 0 + ) if internal_value <= 0: return True @@ -1833,6 +1956,40 @@ def _debit_internal_message_value_for_phase( return debited +def _debit_use_balance_funding_for_phase( + context: TransactionContext, + pending_transactions: list[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> bool: + obligation = _use_balance_obligation_for_phase(pending_transactions, on) + if obligation <= 0: + return True + + available = _internal_message_value_cap(context, pending_transactions, on) + if obligation > available: + _log_message_value_debit_failure( + context, + on, + obligation, + "useBalance internal", + "Emitting the child without contract-funded value or fees.", + ) + return False + + debited = context.accounts_manager.debit_account_balance( + context.transaction.to_address, obligation + ) + if not debited: + _log_message_value_debit_failure( + context, + on, + obligation, + "useBalance internal", + "Emitting the child without contract-funded value or fees.", + ) + return debited + + def _internal_message_value_cap( context: TransactionContext, pending_transactions: list[PendingTransaction], @@ -1888,9 +2045,19 @@ def _adjust_unbacked_message_values( *, external_value_backed: bool, internal_value_backed: bool, + use_balance_funding_backed: bool, ) -> list[PendingTransaction]: adjusted_pending_transactions = [] for pending_transaction in pending_transactions: + if ( + pending_transaction.on == on + and pending_transaction.use_balance + and not use_balance_funding_backed + ): + unfunded = _pending_transaction_with_value(pending_transaction, 0) + unfunded.declared_budget = 0 + adjusted_pending_transactions.append(unfunded) + continue value = int(pending_transaction.value or 0) if pending_transaction.on == on and value > 0: if pending_transaction.is_eth_send and not external_value_backed: @@ -1915,11 +2082,14 @@ def _apply_message_value_withdrawals_for_phase( external_value_backed = _debit_external_message_value_for_phase( context, pending_list, on ) + use_balance_funding_backed = _debit_use_balance_funding_for_phase( + context, pending_list, on + ) internal_value_backed = _debit_internal_message_value_for_phase( context, pending_list, on ) - if external_value_backed and internal_value_backed: + if external_value_backed and internal_value_backed and use_balance_funding_backed: return pending_list return _adjust_unbacked_message_values( @@ -1927,6 +2097,7 @@ def _apply_message_value_withdrawals_for_phase( on, external_value_backed=external_value_backed, internal_value_backed=internal_value_backed, + use_balance_funding_backed=use_balance_funding_backed, ) @@ -1956,6 +2127,51 @@ async def handle(self, context): ) await EffectExecutor(context).execute(pre_effects) + # Consensus reserves the time-unit pool at the user's cap during + # submission, then locks live execution prices only when the queued + # transaction first activates. A live GEN price above the cap cancels + # with a full value/fee refund; storage and receipt prices simply lock. + fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + if fee_accounting: + activated_accounting, should_cancel = activate_fee_accounting( + fee_accounting, + StudioFeePolicy.from_env(), + selection_pool_count=( + len(context.validators_snapshot.nodes) + if context.validators_snapshot is not None + else None + ), + ) + context.transactions_processor.update_transaction_fee_accounting( + context.transaction.hash, + activated_accounting, + ) + context.transaction.data = { + **(context.transaction.data or {}), + FEE_ACCOUNTING_KEY: activated_accounting, + } + if should_cancel: + value_sender = context.transaction.from_address + if value_sender: + context.accounts_manager.refund_tx_value( + context.transaction.hash, + value_sender, + ) + fee_sender = activated_accounting.get("sender") or value_sender + if fee_sender: + context.accounts_manager.cancel_tx_fee_accounting_once( + context.transaction.hash, + fee_sender, + "activation_gen_price_cap_exceeded", + ) + await ConsensusAlgorithm.dispatch_transaction_status_update( + context.transactions_processor, + context.transaction.hash, + TransactionStatus.CANCELED, + context.msg_handler, + ) + return None + # Log executing message (unless appeal) if ( not context.transaction.appeal_leader_timeout @@ -2185,7 +2401,11 @@ def leader_timing_callback(step_name: str): leader_budget_seconds = _slot_budget_seconds(context.transaction, "leader") leader_deadline = asyncio.get_running_loop().time() + leader_budget_seconds - def _build_leader_timeout_receipt(leader_dict: dict) -> Receipt: + def _build_leader_timeout_receipt( + leader_dict: dict, + *, + detail: str | None = None, + ) -> Receipt: timeout_ms = int(leader_budget_seconds * 1000) return Receipt( result=bytes([ResultCode.VM_ERROR]) + b"timeout", @@ -2198,7 +2418,8 @@ def _build_leader_timeout_receipt(leader_dict: dict) -> Receipt: vote=None, genvm_result={ "stdout": "", - "stderr": f"Leader execution exceeded {leader_budget_seconds:.3f}s", + "stderr": detail + or f"Leader execution exceeded {leader_budget_seconds:.3f}s", "error_code": "CONSENSUS_LEADER_EXEC_TIMEOUT", "raw_error": { "causes": ["LEADER_EXEC_TIMEOUT"], @@ -2295,6 +2516,28 @@ def _build_leader_timeout_receipt(leader_dict: dict) -> Receipt: context.consensus_data.leader_receipt[0].result[0] == ResultCode.VM_ERROR and context.consensus_data.leader_receipt[0].result[1:] == b"timeout" ) + if not leader_receipt_timed_out: + try: + proposal_policy = stamp_receipt_execution_policy( + context.consensus_data.leader_receipt[0], + (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY), + StudioFeePolicy.from_env(), + ) + validate_receipt_admission_caps( + context.consensus_data.leader_receipt[0], + proposal_policy, + ) + except FeeValidationError as exc: + # The on-chain proposal/reveal call reverts on these caps. + # Studio advances to the equivalent leader-timeout path + # instead of accepting a receipt Consensus would never store. + context.consensus_data.leader_receipt = [ + _build_leader_timeout_receipt( + context.leader, + detail=str(exc), + ) + ] + leader_receipt_timed_out = True # Post-execution decision next_state, post_effects = decide_post_proposal( @@ -2576,6 +2819,7 @@ def _is_quorum_reached(votes_so_far: list[str], total_votes: int) -> bool: Vote.AGREE.value, Vote.DISAGREE.value, Vote.TIMEOUT.value, + Vote.DETERMINISTIC_VIOLATION.value, Vote.IDLE.value, ) outcomes = { @@ -2914,14 +3158,19 @@ async def handle(self, context): executor = EffectExecutor(context) await executor.execute(pre_effects) - # Impure: triggered transaction processing (needs DB reads for nonce/accounts) - # Cumulative: child emission happens on every acceptance round (including appeal re-acceptance) + # Impure: triggered transaction processing (needs DB reads for nonce/accounts). + # Consensus tombstones each tx-scoped logical message occurrence, so a + # successful appeal may re-reveal it but cannot charge or emit it twice. if execution_success: + novel_pending_transactions = _novel_pending_transactions( + context, + leader_receipt.pending_transactions, + ) internal_messages_data, insert_transactions_data = _get_messages_data( context, _apply_message_value_withdrawals_for_phase( context, - leader_receipt.pending_transactions, + novel_pending_transactions, "accepted", ), "accepted", @@ -2937,6 +3186,11 @@ async def handle(self, context): _emit_messages( context, insert_transactions_data, rollup_receipt, "accepted" ) + _mark_message_phase_delivered( + context, + leader_receipt.pending_transactions, + "accepted", + ) # Execute post-effects (status update + appeal cleanup) await executor.execute(post_effects) @@ -3090,11 +3344,15 @@ async def handle(self, context): finalized_state=accepted_state, ) + novel_pending_transactions = _novel_pending_transactions( + context, + leader_receipt.pending_transactions, + ) internal_messages_data, insert_transactions_data = _get_messages_data( context, _apply_message_value_withdrawals_for_phase( context, - leader_receipt.pending_transactions, + novel_pending_transactions, "finalized", ), "finalized", @@ -3110,6 +3368,11 @@ async def handle(self, context): _emit_messages( context, insert_transactions_data, rollup_receipt, "finalized" ) + _mark_message_phase_delivered( + context, + leader_receipt.pending_transactions, + "finalized", + ) await executor.execute(post_effects) @@ -3134,7 +3397,9 @@ def _get_messages_data( insert_transactions_data = [] internal_messages_data = [] message_fee_payloads = [] - parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + parent_fee_accounting = (getattr(context.transaction, "data", None) or {}).get( + FEE_ACCOUNTING_KEY + ) reveal_recorded = bool( parent_fee_accounting and parent_fee_accounting.get("message_fees_recorded_at_reveal") @@ -3278,7 +3543,7 @@ def _parent_message_fee_payload( on: Literal["accepted", "finalized"], ) -> dict[str, Any]: payload = _pending_transaction_fee_payload(pending_transaction, on) - if pending_transaction.is_eth_send: + if pending_transaction.is_eth_send or pending_transaction.use_balance: return payload try: @@ -3302,8 +3567,12 @@ def _attach_child_fee_accounting( message=message_payload, parent_fees_distribution=parent_fee_accounting.get("fees_distribution"), message_allocations=message_payload.get("allocationSubtree") or [], - sender=context.transaction.origin_address - or context.transaction.from_address, + sender=( + context.transaction.to_address + if pending_transaction.use_balance + else context.transaction.origin_address + or context.transaction.from_address + ), policy=StudioFeePolicy.from_env(), ) except FeeValidationError as exc: @@ -3358,6 +3627,7 @@ def _record_parent_message_fee_consumption( parent_fee_accounting, message_fee_payloads, reveal_recorded, + _message_execution_fee_recipient(context), ) context.transaction.data = dict(context.transaction.data or {}) context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting @@ -3366,20 +3636,65 @@ def _record_parent_message_fee_consumption( ) +def _message_execution_fee_recipient(context: TransactionContext) -> str | None: + """Best Studio analogue of Consensus' tx.origin execution recipient. + + Acceptance processing happens inside the leader's reveal transaction on + chain. Studio does not expose a separate public external-message flush EOA, + so finalization uses the leader attached to the accepted receipt as the + observable executor instead of incorrectly reimbursing the transaction + sender. + """ + + receipt_sources = [ + getattr(context, "consensus_data", None), + getattr(context.transaction, "consensus_data", None), + ] + for source in receipt_sources: + if source is None: + continue + leader_receipts = ( + source.get("leader_receipt", []) + if isinstance(source, dict) + else getattr(source, "leader_receipt", []) + ) + if isinstance(leader_receipts, dict): + leader_receipts = [leader_receipts] + if not leader_receipts: + continue + receipt = leader_receipts[0] + node_config = ( + receipt.get("node_config", {}) + if isinstance(receipt, dict) + else getattr(receipt, "node_config", {}) + ) + address = ( + node_config.get("address") + if isinstance(node_config, dict) + else getattr(node_config, "address", None) + ) + if address: + return str(address) + return None + + def _consume_parent_message_fee_payloads( parent_fee_accounting: dict[str, Any], message_fee_payloads: list[dict[str, Any]], reveal_recorded: bool, + executor: str | None = None, ) -> dict[str, Any]: try: if reveal_recorded: return record_external_message_execution_fees( parent_fee_accounting, message_fee_payloads, + executor=executor, ) return consume_message_fees( parent_fee_accounting, message_fee_payloads, + external_executor=executor, ) except FeeValidationError as exc: raise RuntimeError(str(exc)) from exc @@ -3408,8 +3723,9 @@ def _sync_reveal_message_fee_accounting( return try: - updated_accounting = record_reveal_message_fees( + updated_accounting = prepare_reveal_message_generation( parent_fee_accounting, + context.transaction.hash, message_fee_payloads, ) except FeeValidationError as exc: @@ -3434,7 +3750,7 @@ def _reveal_message_fee_payloads( pending_transaction, pending_transaction.on, ) - if not pending_transaction.is_eth_send: + if not pending_transaction.is_eth_send and not pending_transaction.use_balance: message_payload = fill_message_fee_payload_from_allocation( parent_fee_accounting, message_payload, @@ -3443,6 +3759,67 @@ def _reveal_message_fee_payloads( return message_fee_payloads +def _novel_pending_transactions( + context: TransactionContext, + pending_transactions: Iterable[Any], +) -> list[PendingTransaction]: + pending_list = [ + _coerce_pending_transaction(pending_transaction) + for pending_transaction in pending_transactions + ] + parent_fee_accounting = (getattr(context.transaction, "data", None) or {}).get( + FEE_ACCOUNTING_KEY + ) + if not parent_fee_accounting or not pending_list: + return pending_list + + payloads = _reveal_message_fee_payloads(parent_fee_accounting, pending_list) + try: + novelty = message_novelty_mask( + parent_fee_accounting, + context.transaction.hash, + payloads, + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + return [ + pending_transaction + for pending_transaction, is_novel in zip(pending_list, novelty) + if is_novel + ] + + +def _mark_message_phase_delivered( + context: TransactionContext, + pending_transactions: Iterable[Any], + on: Literal["accepted", "finalized"], +) -> None: + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + if not parent_fee_accounting: + return + payloads = _reveal_message_fee_payloads( + parent_fee_accounting, + pending_transactions, + ) + if not payloads: + return + try: + updated_accounting = mark_message_effects_delivered( + parent_fee_accounting, + context.transaction.hash, + payloads, + on, + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting + context.transactions_processor.update_transaction_fee_accounting( + context.transaction.hash, + updated_accounting, + ) + + def _unwind_discarded_reveal_message_fee_accounting( context: TransactionContext, ) -> None: @@ -3450,6 +3827,18 @@ def _unwind_discarded_reveal_message_fee_accounting( if not parent_fee_accounting: return + if isinstance(parent_fee_accounting.get("active_message_generation"), dict): + updated_accounting = discard_active_message_generation( + parent_fee_accounting, + ) + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting + context.transactions_processor.update_transaction_fee_accounting( + context.transaction.hash, + updated_accounting, + ) + return + prior_receipts = _leader_receipts_from_consensus_data( context.transaction.consensus_data ) @@ -3530,10 +3919,27 @@ def _pending_transaction_fee_payload( "declaredBudget": pending_transaction.declared_budget, "allocationSubtree": pending_transaction.allocation_subtree, "callKey": call_key, + "useBalance": pending_transaction.use_balance, "gasUsed": pending_transaction.gas_used, } +def _child_config_rotation_rounds(parent_rotations: int, data: dict[str, Any]) -> int: + """Clamp a triggered child's runtime rotations to its funded schedule.""" + + fees = data.get("fees_distribution") + if not isinstance(fees, dict): + return max(0, int(parent_rotations)) + rotations = fees.get("rotations") + if not isinstance(rotations, list): + rotations = fees.get("rotationsList") + if not isinstance(rotations, list): + return max(0, int(parent_rotations)) + if not rotations: + return 0 + return min(max(0, int(parent_rotations)), *(max(0, int(v)) for v in rotations)) + + def _emit_messages( context: TransactionContext, insert_transactions_data: list, @@ -3561,10 +3967,16 @@ def _emit_messages( type=insert_transaction_data[2], nonce=insert_transaction_data[3], leader_only=leader_only, # Backward compat - num_of_initial_validators=context.transaction.num_of_initial_validators, + # Consensus creates every internal child at the protocol's + # round-0 committee size. The child's minimum fee floor is priced + # on the same basis, independent of the parent's current size. + num_of_initial_validators=VALIDATORS_PER_ROUND[0], triggered_by_hash=context.transaction.hash, transaction_hash=transaction_hash, - config_rotation_rounds=context.transaction.config_rotation_rounds, + config_rotation_rounds=_child_config_rotation_rounds( + context.transaction.config_rotation_rounds, + insert_transaction_data[1], + ), sim_config=( context.transaction.sim_config.to_dict() if context.transaction.sim_config diff --git a/backend/consensus/decisions.py b/backend/consensus/decisions.py index 27929d1cd..31a33c8b8 100644 --- a/backend/consensus/decisions.py +++ b/backend/consensus/decisions.py @@ -34,7 +34,6 @@ ) from backend.consensus.types import ConsensusResult, ConsensusRound - # ── UndeterminedState ────────────────────────────────────────────── @@ -658,6 +657,7 @@ def decide_revealing( elif consensus_result in ( ConsensusResult.MAJORITY_DISAGREE, ConsensusResult.NO_MAJORITY, + ConsensusResult.DETERMINISTIC_VIOLATION, ): if rotation_count >= config_rotation_rounds: return "undetermined", effects diff --git a/backend/consensus/history.py b/backend/consensus/history.py index ccee039fe..53404e68e 100644 --- a/backend/consensus/history.py +++ b/backend/consensus/history.py @@ -16,6 +16,25 @@ ConsensusRound.LEADER_ROTATION_APPEAL.value, } +# Studio records the verdict and execution of an appeal as one history item. +# Consensus, however, advances the fee round according to the appeal shape: +# validator appeals occupy the next odd round (skipping an even gap when +# chained), while leader/leader-timeout appeals create an appeal bookkeeping +# round and execute again in the following even round. Fee accounting must +# preserve those on-chain round numbers even though the UI history is compact. +LEADER_APPEAL_CONSENSUS_ROUNDS = { + ConsensusRound.LEADER_APPEAL_SUCCESSFUL.value, + ConsensusRound.LEADER_APPEAL_FAILED.value, + ConsensusRound.LEADER_TIMEOUT_APPEAL_SUCCESSFUL.value, + ConsensusRound.LEADER_TIMEOUT_APPEAL_FAILED.value, +} +VALIDATOR_APPEAL_CONSENSUS_ROUNDS = { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_APPEAL_FAILED.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATORS_TIMEOUT_APPEAL_FAILED.value, +} + def is_completed_consensus_round(entry: dict[str, Any]) -> bool: return str(entry.get("consensus_round") or "") not in NON_ROUND_CONSENSUS_EVENTS @@ -37,7 +56,32 @@ def completed_consensus_rounds( def completed_consensus_round_index(consensus_history: dict[str, Any] | None) -> int: - return max(0, len(completed_consensus_rounds(consensus_history)) - 1) + entries = logical_fee_round_entries(consensus_history) + return entries[-1][0] if entries else 0 + + +def _next_logical_fee_round(previous_round: int | None, outcome: str) -> int: + if previous_round is None: + return 0 + if outcome in LEADER_APPEAL_CONSENSUS_ROUNDS: + return previous_round + 2 + if outcome in VALIDATOR_APPEAL_CONSENSUS_ROUNDS: + return previous_round + (1 if previous_round % 2 == 0 else 2) + return previous_round + 1 + + +def logical_fee_round_entries( + consensus_history: dict[str, Any] | None, +) -> list[tuple[int, dict[str, Any]]]: + """Return compact Studio history entries keyed by on-chain fee round.""" + logical_entries: list[tuple[int, dict[str, Any]]] = [] + previous_round: int | None = None + for entry in completed_consensus_rounds(consensus_history): + outcome = str(entry.get("consensus_round") or "") + logical_round = _next_logical_fee_round(previous_round, outcome) + logical_entries.append((logical_round, entry)) + previous_round = logical_round + return logical_entries def actual_leader_rotations_by_round( @@ -51,7 +95,7 @@ def actual_leader_rotations_by_round( rotations: dict[int, int] = {} pending_rotations = 0 - round_index = 0 + previous_round: int | None = None for entry in results: if not isinstance(entry, dict): continue @@ -59,9 +103,10 @@ def actual_leader_rotations_by_round( if event in NON_ROUND_CONSENSUS_EVENTS: pending_rotations += 1 continue + round_index = _next_logical_fee_round(previous_round, event) rotations[round_index] = pending_rotations pending_rotations = 0 - round_index += 1 + previous_round = round_index return rotations @@ -159,6 +204,7 @@ def _empty_pending_time_units() -> dict[str, int | str | bool]: def _record_round( per_round: list[dict[str, int | str]], *, + round_index: int | None = None, consensus_round: str, leader_timeunits: int, validator_timeunits: int, @@ -166,7 +212,7 @@ def _record_round( ) -> tuple[int, int]: per_round.append( _round_entry( - round_index=len(per_round), + round_index=len(per_round) if round_index is None else round_index, consensus_round=consensus_round, leader_timeunits=leader_timeunits, validator_timeunits=validator_timeunits, @@ -210,6 +256,7 @@ def _consume_history_time_units( pending = _empty_pending_time_units() leader_timeunits_used = 0 validator_timeunits_used = 0 + previous_round: int | None = None for entry in results: if not isinstance(entry, dict): @@ -234,8 +281,10 @@ def _consume_history_time_units( max_validator_timeunits, int(pending["max_validator_timeunits"]) ) pending = _empty_pending_time_units() + logical_round = _next_logical_fee_round(previous_round, consensus_round) leader_used, validator_used = _record_round( per_round, + round_index=logical_round, consensus_round=consensus_round, leader_timeunits=leader_timeunits, validator_timeunits=validator_timeunits, @@ -243,6 +292,7 @@ def _consume_history_time_units( ) leader_timeunits_used += leader_used validator_timeunits_used += validator_used + previous_round = logical_round return per_round, leader_timeunits_used, validator_timeunits_used, pending @@ -289,8 +339,10 @@ def time_unit_consumption( validator_timeunits_used += validator_used if pending["has_rotation"]: + trailing_round = int(per_round[-1]["round"]) + 1 if per_round else 0 leader_used, validator_used = _record_round( per_round, + round_index=trailing_round, consensus_round=str(pending["consensus_round"]), leader_timeunits=int(pending["leader_timeunits"]), validator_timeunits=int(pending["validator_timeunits"]), diff --git a/backend/consensus/utils.py b/backend/consensus/utils.py index fc689df5f..407bbfc9e 100644 --- a/backend/consensus/utils.py +++ b/backend/consensus/utils.py @@ -6,7 +6,7 @@ def determine_consensus_from_votes(votes_list: list[str]) -> ConsensusResult: """ Determine consensus from a list of votes using actual majority (>50%). - IDLE votes count as DISAGREE (couldn't participate = didn't agree). + Studio's local IDLE sentinel is revealed on-chain as a Timeout ballot. Args: votes_list: List of vote strings @@ -20,16 +20,20 @@ def determine_consensus_from_votes(votes_list: list[str]) -> ConsensusResult: agree_count = votes_list.count(Vote.AGREE.value) disagree_count = votes_list.count(Vote.DISAGREE.value) timeout_count = votes_list.count(Vote.TIMEOUT.value) + deterministic_violation_count = votes_list.count(Vote.DETERMINISTIC_VIOLATION.value) idle_count = votes_list.count(Vote.IDLE.value) - # IDLE = couldn't participate → counts as disagree - effective_disagree = disagree_count + idle_count + # RevealingState classifies local IDLE as the protocol's Timeout vote for + # the rollup. The local decision must use that same classified ballot. + effective_timeout = timeout_count + idle_count if agree_count > majority: return ConsensusResult.MAJORITY_AGREE - elif effective_disagree > majority: + elif disagree_count > majority: return ConsensusResult.MAJORITY_DISAGREE - elif timeout_count > majority: + elif effective_timeout > majority: return ConsensusResult.TIMEOUT + elif deterministic_violation_count > majority: + return ConsensusResult.DETERMINISTIC_VIOLATION else: return ConsensusResult.NO_MAJORITY diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index 6169b5171..dff206302 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -203,8 +203,9 @@ def cancel_tx_fee_accounting_once( data[FEE_ACCOUNTING_KEY] = updated transaction.data = data if refund > 0: - self.credit_account_balance(sender_address, refund) + self._credit_fee_refund_settlements(updated, sender_address, refund) if not was_terminal: + self._credit_external_message_fee_payouts(updated) self._credit_appeal_bond_payouts(updated) return refund @@ -234,15 +235,44 @@ def settle_tx_fee_accounting_once( actual_final_round=_infer_final_round(transaction.consensus_history), num_of_validators=transaction.num_of_initial_validators, consensus_history=transaction.consensus_history, + execution_mode=( + transaction.execution_mode.value + if hasattr(transaction.execution_mode, "value") + else str(transaction.execution_mode or "NORMAL") + ), ) data[FEE_ACCOUNTING_KEY] = updated transaction.data = data if refund > 0: - self.credit_account_balance(sender_address, refund) + self._credit_fee_refund_settlements(updated, sender_address, refund) if not was_terminal: + self._credit_external_message_fee_payouts(updated) self._credit_appeal_bond_payouts(updated) return refund + def _credit_fee_refund_settlements( + self, + accounting: dict, + fallback_recipient: str, + total_refund: int, + ) -> None: + settlements = accounting.get("fee_refund_settlements") or [] + credited = 0 + for settlement in settlements: + if not isinstance(settlement, dict): + continue + recipient = settlement.get("recipient") + amount = int(settlement.get("amount", 0) or 0) + if recipient and amount > 0: + self.credit_account_balance(recipient, amount) + credited += amount + remainder = max(0, int(total_refund) - credited) + if remainder > 0: + self.credit_account_balance( + accounting.get("sender") or fallback_recipient, + remainder, + ) + def _credit_appeal_bond_payouts(self, accounting: dict) -> None: for payout in accounting.get("appeal_bond_settlements") or []: if not isinstance(payout, dict): @@ -252,6 +282,15 @@ def _credit_appeal_bond_payouts(self, accounting: dict) -> None: if amount > 0 and appealer: self.credit_account_balance(appealer, amount) + def _credit_external_message_fee_payouts(self, accounting: dict) -> None: + for payout in accounting.get("external_message_fee_payouts") or []: + if not isinstance(payout, dict): + continue + recipient = payout.get("recipient") + amount = int(payout.get("amount", 0) or 0) + if amount > 0 and recipient: + self.credit_account_balance(recipient, amount) + def _infer_final_round(consensus_history: dict | None) -> int: return completed_consensus_round_index(consensus_history) diff --git a/backend/node/genvm/base.py b/backend/node/genvm/base.py index c46cbbadc..62fe6b0ac 100644 --- a/backend/node/genvm/base.py +++ b/backend/node/genvm/base.py @@ -70,7 +70,9 @@ "genPerTimeUnit": "0", } -INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = "(uint256,uint256,uint256,uint256,uint256[])" +INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = ( + "(uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)" +) INTERNAL_MESSAGE_FEE_PARAMS_WITH_CAPS_ABI_TYPE = ( "(uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)" ) @@ -337,6 +339,9 @@ def _emission_internal_fee_params(emission: dict) -> bytes: appeal_rounds, int(value.get("execution_budget_per_round", 0)), rotations, + int(value.get("max_price_gen_per_time_unit", 0)), + int(value.get("storage_fee_max_gas_price", 0)), + int(value.get("receipt_fee_max_gas_price", 0)), ) ], ) @@ -412,6 +417,9 @@ def _canonical_internal_fee_params_from_genvm(fee_params: bytes) -> bytes: int(decoded[2]), int(decoded[3]), [int(rotation) for rotation in decoded[4]], + int(decoded[5]), + int(decoded[6]), + int(decoded[7]), ) ], ) @@ -621,6 +629,9 @@ def provide_result( declared_budget=_emission_int(emission, "declaredBudget"), call_key=_emission_hex(emission, "callKey"), allocation_subtree=_emission_allocation_subtree(emission), + use_balance=bool( + _emission_value(emission, "useBalance") or False + ), ) ) case "InternalDeployMessage": @@ -636,6 +647,9 @@ def provide_result( declared_budget=_emission_int(emission, "declaredBudget"), call_key=_emission_hex(emission, "callKey"), allocation_subtree=_emission_allocation_subtree(emission), + use_balance=bool( + _emission_value(emission, "useBalance") or False + ), ) ) case "ExternalMessage": diff --git a/backend/node/types.py b/backend/node/types.py index 4ad98f1a8..3e0e6cdc3 100644 --- a/backend/node/types.py +++ b/backend/node/types.py @@ -181,6 +181,7 @@ class PendingTransaction: call_key: str = "0x" + ("0" * 64) allocation_subtree: list[dict] = field(default_factory=list) gas_used: int = 0 + use_balance: bool = False def is_deploy(self) -> bool: return self.code is not None @@ -197,6 +198,7 @@ def to_dict(self): "call_key": self.call_key, "allocation_subtree": self.allocation_subtree, "gas_used": self.gas_used, + "use_balance": self.use_balance, } elif self.code is None: return { @@ -209,6 +211,7 @@ def to_dict(self): "call_key": self.call_key, "allocation_subtree": self.allocation_subtree, "gas_used": self.gas_used, + "use_balance": self.use_balance, } else: return { @@ -222,6 +225,7 @@ def to_dict(self): "call_key": self.call_key, "allocation_subtree": self.allocation_subtree, "gas_used": self.gas_used, + "use_balance": self.use_balance, } @classmethod @@ -240,6 +244,7 @@ def from_dict(cls, input: dict) -> "PendingTransaction": call_key=input.get("call_key", "0x" + ("0" * 64)), allocation_subtree=input.get("allocation_subtree", []), gas_used=_int_from_serialized(input.get("gas_used"), 0), + use_balance=bool(input.get("use_balance", False)), ) elif "code" in input: return cls( @@ -254,6 +259,7 @@ def from_dict(cls, input: dict) -> "PendingTransaction": call_key=input.get("call_key", "0x" + ("0" * 64)), allocation_subtree=input.get("allocation_subtree", []), gas_used=_int_from_serialized(input.get("gas_used"), 0), + use_balance=bool(input.get("use_balance", False)), ) else: return cls( @@ -268,6 +274,7 @@ def from_dict(cls, input: dict) -> "PendingTransaction": call_key=input.get("call_key", "0x" + ("0" * 64)), allocation_subtree=input.get("allocation_subtree", []), gas_used=_int_from_serialized(input.get("gas_used"), 0), + use_balance=bool(input.get("use_balance", False)), ) diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 7ee0dfc02..f12aa04e2 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -53,9 +53,11 @@ StudioFeePolicy, apply_fee_top_up, calculate_round_fees, + decode_internal_message_fee_params, create_fee_accounting, get_leader_rounds, normalize_fees_distribution, + min_message_primary_fees, record_appeal_bond, record_execution_fee_consumption, required_fee_deposit, @@ -65,7 +67,7 @@ from backend.consensus.history import ( actual_leader_rotations_by_round, completed_consensus_round_index, - completed_consensus_rounds, + logical_fee_round_entries, ) from backend.errors.errors import InvalidAddressError, InvalidTransactionError from backend.database_handler.errors import ContractNotFoundError @@ -386,6 +388,44 @@ def sim_calculate_round_fees( return str(quote) +def sim_min_message_primary_fees(fee_params: str) -> str: + """Quote the child primary-fee floor from canonical v0.6 ABI bytes.""" + try: + decoded = decode_internal_message_fee_params(fee_params) + quote = min_message_primary_fees(decoded, StudioFeePolicy.from_env()) + except FeeValidationError as exc: + raise InvalidTransactionError(str(exc)) from exc + return str(quote) + + +def sim_estimate_propose_receipt_gas(eq_outputs_length: int = 0) -> dict[str, str]: + """Expose the deterministic v0.6 proposed-receipt metering formula.""" + policy = StudioFeePolicy.from_env() + receipt_bytes = policy.estimate_propose_receipt_bytes(int(eq_outputs_length)) + gas = policy.estimate_propose_receipt_gas(receipt_bytes) + return { + "receiptBytes": str(receipt_bytes), + "gas": str(gas), + "fee": str(gas * policy.receipt_gas_price), + } + + +def sim_estimate_message_reveal_gas( + message_bytes: int, + message_count: int, +) -> dict[str, str]: + """Expose the deterministic v0.6 revealed-message write formula.""" + policy = StudioFeePolicy.from_env() + gas = policy.estimate_consensus_message_reveal_gas( + int(message_bytes), + int(message_count), + ) + return { + "gas": str(gas), + "fee": str(gas * policy.receipt_gas_price), + } + + ####### ADMIN ACCESS CONTROL ####### def require_admin_access(func): """ @@ -1884,6 +1924,35 @@ def _fee_metadata(decoded_rollup_transaction: DecodedRollupTransaction) -> dict: return metadata +def _funded_max_rotations( + decoded_rollup_transaction: DecodedRollupTransaction, + requested_max_rotations: int, +) -> int: + """Mirror Consensus' submission-time rotation-capacity clamp. + + Consensus seeds every normal round from one transaction-level + ``initialRotations`` value, then clamps that value to the smallest funded + entry in ``feesDistribution.rotations``. Studio must persist the same + effective value or it can execute attempts that the submitted fee + schedule never reserved. + """ + + data = decoded_rollup_transaction.data + if ( + data is None + or isinstance(data, (DecodedsubmitAppealDataArgs, DecodedTopUpFeesDataArgs)) + or not hasattr(data, "args") + or data.args is None + or data.args.fees_distribution is None + ): + return int(requested_max_rotations) + + rotations = normalize_fees_distribution(data.args.fees_distribution)["rotations"] + if not rotations: + return int(requested_max_rotations) + return min(int(requested_max_rotations), *(int(value) for value in rotations)) + + # `DebugMode` is ordered least- to most-permissive, and the manager gates # `reroute_to` on `debug_mode >= Safe` (implementation/src/manager/run.rs). # Comparing positions states that rule directly instead of enumerating the @@ -2030,6 +2099,8 @@ def _handle_top_up_fees( if status in { TransactionStatus.ACCEPTED.value, TransactionStatus.UNDETERMINED.value, + TransactionStatus.LEADER_TIMEOUT.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, TransactionStatus.FINALIZED.value, TransactionStatus.CANCELED.value, }: @@ -2073,56 +2144,75 @@ def _handle_appeal_or_top_up_and_submit( if tx is None: raise NotFoundError(message=TRANSACTION_NOT_FOUND_MESSAGE, data={"hash": tx_id}) + status = str(tx.get("status") or "") + if status not in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.UNDETERMINED.value, + TransactionStatus.LEADER_TIMEOUT.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, + } or bool(tx.get("appealed")): + raise InvalidTransactionError("CanNotAppeal") + appeal_window_started = tx.get("timestamp_awaiting_finalization") + if appeal_window_started is not None: + finality_window = int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) + failed_reduction = float( + os.environ.get("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0") + ) + failed_reduction = min(1.0, max(0.0, failed_reduction)) + appeal_deadline = ( + float(appeal_window_started) + + float(tx.get("appeal_processing_time") or 0) + + finality_window + * ((1.0 - failed_reduction) ** int(tx.get("appeal_failed") or 0)) + ) + # Consensus rejects at `block.timestamp >= appealDeadline`; do the + # same at the RPC admission boundary instead of relying on the + # asynchronous finalization worker to win the race first. + if time.time() >= appeal_deadline: + raise InvalidTransactionError("CanNotAppeal") + consensus_history = tx.get("consensus_history") + expected_decision_id = decoded_rollup_transaction.data.expected_decision_id + current_round = _current_fee_round(consensus_history) + # Studio has no on-chain DecisionRecord, so derive the equivalent + # monotonic ordinal from its alternating normal/appeal round history. + # Round 0 -> decision 1; rounds 1/2 -> decision 2; rounds 3/4 -> 3. + current_decision_id = 1 + ((current_round + 1) // 2) + if ( + expected_decision_id is not None + and int(expected_decision_id) != current_decision_id + ): + raise InvalidTransactionError("CanNotAppeal") + fee_accounting = (tx.get("data") or {}).get(FEE_ACCOUNTING_KEY) if fee_accounting is not None: - status = str(tx.get("status") or "") - if status not in { - TransactionStatus.ACCEPTED.value, - TransactionStatus.UNDETERMINED.value, - TransactionStatus.LEADER_TIMEOUT.value, - TransactionStatus.VALIDATORS_TIMEOUT.value, - } or bool(tx.get("appealed")): - raise InvalidTransactionError("CanNotAppeal") - consensus_history = tx.get("consensus_history") - expected_decision_id = decoded_rollup_transaction.data.expected_decision_id - current_round = _current_fee_round(consensus_history) - # Studio has no on-chain DecisionRecord, so derive the equivalent - # monotonic ordinal from its alternating normal/appeal round history. - # Round 0 -> decision 1; rounds 1/2 -> decision 2; rounds 3/4 -> 3. - current_decision_id = 1 + ((current_round + 1) // 2) - if ( - expected_decision_id is not None - and int(expected_decision_id) != current_decision_id - ): - raise InvalidTransactionError("CanNotAppeal") session = getattr(accounts_manager, "session", None) + frozen_pool_count = fee_accounting.get("selection_pool_count") validator_count = ( - ValidatorsRegistry(session).count_validators() - if session is not None - else int( - fee_accounting.get("num_of_initial_validators") - or tx.get("num_of_initial_validators") - or 5 - ) - ) - normal_leader_count = sum( - 1 - for index, _entry in enumerate( - completed_consensus_rounds(consensus_history) + int(frozen_pool_count) + if frozen_pool_count is not None + else ( + ValidatorsRegistry(session).count_validators() + if session is not None + else int( + fee_accounting.get("num_of_initial_validators") + or tx.get("num_of_initial_validators") + or 5 + ) ) - if index % 2 == 0 ) + normal_leader_count = _normal_leader_count(consensus_history) replacement_rotations = None + leader_timeout_live_seats = None if status == TransactionStatus.LEADER_TIMEOUT.value: - rotations = normalize_fees_distribution( - fee_accounting.get("fees_distribution") or {} - )["rotations"] - rotation_index = current_round // 2 - configured = ( - int(rotations[rotation_index]) - if rotation_index < len(rotations) - else int(rotations[-1] if rotations else 0) - ) + live_validators = tx.get("leader_timeout_validators") + if isinstance(live_validators, list): + leader_timeout_live_seats = len(live_validators) + # Consensus prices the live `rotationsLeft` stored on the timed-out + # round. Studio's equivalent is the submission-clamped transaction + # capacity minus rotations already consumed in this logical round; + # the per-round fee schedule may be larger and is not the runtime + # counter. + configured = max(0, int(tx.get("config_rotation_rounds") or 0)) used = actual_leader_rotations_by_round(consensus_history).get( current_round, 0, @@ -2147,6 +2237,7 @@ def _handle_appeal_or_top_up_and_submit( validator_count - normal_leader_count, ), replacement_rotations=replacement_rotations, + leader_timeout_live_seats=leader_timeout_live_seats, time_unit_overlay_bps=StudioFeePolicy.from_env().time_unit_overlay_bps, ) except FeeValidationError as exc: @@ -2188,6 +2279,18 @@ def _current_fee_round(consensus_history: dict | None) -> int: return completed_consensus_round_index(consensus_history) +def _normal_leader_count(consensus_history: dict | None) -> int: + # Consensus excludes every prior *normal-round* leader from the terminal + # replacement committee. Studio compacts a leader appeal's hidden appeal + # round and replay into one history entry, so raw list parity is not a safe + # way to identify normal rounds after the first leader appeal. + return sum( + 1 + for logical_round, _entry in logical_fee_round_entries(consensus_history) + if logical_round % 2 == 0 + ) + + def _simulation_fee_accounting( params: dict, *, @@ -2264,7 +2367,11 @@ def _effective_simulation_fee_accounting_for_genvm( ) fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) execution_budget_per_round = int(fees["executionBudgetPerRound"]) - floor = policy.message_fee_params_budget_floor() + # Consensus admits against the proposal-only receipt floor, while GenVM + # reserves proposal/reveal/nondeterministic-output start costs before user + # code. Estimation must temporarily satisfy the latter or it cannot run far + # enough to recommend a realistic budget. + floor = policy.genvm_start_budget_floor() if execution_budget_per_round <= 0 or execution_budget_per_round >= floor: return accounting @@ -2363,6 +2470,10 @@ def send_raw_transaction( genlayer_transaction = transactions_parser.get_genlayer_transaction( decoded_rollup_transaction ) + genlayer_transaction.max_rotations = _funded_max_rotations( + decoded_rollup_transaction, + genlayer_transaction.max_rotations, + ) _reject_genvm_executor_selector_unless_deploy( sim_config, is_deploy=genlayer_transaction.type == TransactionType.DEPLOY_CONTRACT, diff --git a/backend/protocol_rpc/fastapi_endpoint_generator.py b/backend/protocol_rpc/fastapi_endpoint_generator.py index 35ac29df7..2c98e3205 100644 --- a/backend/protocol_rpc/fastapi_endpoint_generator.py +++ b/backend/protocol_rpc/fastapi_endpoint_generator.py @@ -491,6 +491,15 @@ def register(func, method_name=None): ) register(endpoints.get_studio_fee_config, "sim_getFeeConfig") register(endpoints.sim_calculate_round_fees, "sim_calculateRoundFees") + register(endpoints.sim_min_message_primary_fees, "sim_minMessagePrimaryFees") + register( + endpoints.sim_estimate_propose_receipt_gas, + "sim_estimateProposeReceiptGas", + ) + register( + endpoints.sim_estimate_message_reveal_gas, + "sim_estimateMessageRevealGas", + ) register( partial(endpoints.get_contract, accounts_manager), "sim_getConsensusContract" ) diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 3b0d6b5c0..90248c2cd 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -8,10 +8,14 @@ import rlp from eth_abi import decode, encode +from eth_hash.auto import keccak from backend.consensus.history import ( + LEADER_APPEAL_CONSENSUS_ROUNDS, + NON_ROUND_CONSENSUS_EVENTS, + VALIDATOR_APPEAL_CONSENSUS_ROUNDS, actual_leader_rotations_by_round, - completed_consensus_rounds, + logical_fee_round_entries, ) from backend.consensus.types import ConsensusRound @@ -39,12 +43,18 @@ MIN_RECEIPT_BYTES = 512 PROPOSE_RECEIPT_SLOTS = 7 MESSAGE_REVEAL_LENGTH_SLOTS = 32 +MAX_ALLOCATED_MESSAGES_CAP = 20 NONDET_OUTPUT_LENGTH_BYTES = 32 NODE_ROOT_SENTINEL = (1 << 256) - 1 -CALL_KEY_WILDCARD = "0x" + ("0" * 64) +MAX_CONTRIBUTION_SEGMENTS = 64 +EMPTY_CALL_KEY = "0x" + ("0" * 64) +# Consensus reserves keccak256("") as the allocation wildcard. bytes32(0) +# remains the call key for an empty method name and must not match every call. +CALL_KEY_WILDCARD = "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" MESSAGE_TYPE_EXTERNAL = 0 MESSAGE_TYPE_INTERNAL = 1 FEE_ACCOUNTING_KEY = "fee_accounting" +FEE_POLICY_SNAPSHOT_KEY = "fee_policy_snapshot" APPEAL_SUCCESS_ROUNDS = { ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, @@ -62,25 +72,34 @@ ConsensusRound.LEADER_TIMEOUT.value: (1, 2), } -INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = "(uint256,uint256,uint256,uint256,uint256[])" +INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = ( + "(uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)" +) EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = "(uint256,uint256)" MESSAGE_ALLOCATION_NODE_ABI_TYPE = ( "(uint8,bool,uint256,address,bytes32,uint256,bytes)[]" ) SUBMITTED_MESSAGE_ABI_TYPE = ( - "(uint8,address,uint256,bytes,bool,uint256,bytes,uint256,bytes,bytes32)[]" + "(uint8,address,uint256,bytes,bool,uint256,bytes,uint256,bytes,bytes32,bool)[]" ) WEI_PER_GEN = 10**18 -DEFAULT_GEN_PER_TIME_UNIT = WEI_PER_GEN // 1_000 -DEFAULT_STORAGE_UNIT_PRICE = 1 -DEFAULT_RECEIPT_GAS_PRICE = 1 -DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND = 500_000 +# Keep the standalone Studio policy identical to the v0.6 deployment defaults. +# The multiplier is deliberately the neutral value 1 while the storage and +# receipt gas prices use the deployment's 0.25 gwei fallback price. +DEFAULT_GEN_PER_TIME_UNIT = 1 +DEFAULT_STORAGE_UNIT_PRICE = 250_000_000 +DEFAULT_RECEIPT_GAS_PRICE = 250_000_000 +DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND = 100_000_000 DEFAULT_LEADER_TIMEUNITS_ALLOCATION = 100 DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION = 200 DEFAULT_PRICE_CAP_HEADROOM_BPS = 12_000 DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM = 10_000 DEFAULT_TIME_UNIT_OVERLAY_BPS = 1_500 +DEFAULT_MIN_PROPOSE_TIMEUNITS = 30 +DEFAULT_MAX_PROPOSE_TIMEUNITS = 600 +DEFAULT_MIN_COMMIT_TIMEUNITS = 30 +DEFAULT_MAX_COMMIT_TIMEUNITS = 600 GENVM_UNMETERED_DATA_FEE_BUCKET = (1 << 256) - 1 SUCCESSFUL_APPEAL_REWARD_NUMERATOR = 5 SUCCESSFUL_APPEAL_REWARD_DENOMINATOR = 2 @@ -110,6 +129,14 @@ class MaxPriceExceeded(FeeValidationError): pass +class FeeValueMustBeNonZero(FeeValidationError): + pass + + +class PhaseTimeoutOutOfBounds(FeeValidationError): + pass + + class MessageAllocationsNotEqualBudget(FeeValidationError): pass @@ -200,6 +227,18 @@ class TooManyMessages(FeeValidationError): pass +class EqOutputsTooLarge(FeeValidationError): + pass + + +class SubmittedMessagesTooLarge(FeeValidationError): + pass + + +class MessageEffectDescriptorMismatch(FeeValidationError): + pass + + @dataclass(frozen=True) class StudioFeePolicy: gen_per_time_unit: int = 0 @@ -214,8 +253,43 @@ class StudioFeePolicy: receipt_wrapper_bytes: int = 1_024 extra_exec_gas: int = 210_000 max_allocation_tree_depth: int = 5 + max_allocated_messages: int = MAX_ALLOCATED_MESSAGES_CAP max_messages_per_tx: int = 0 + min_external_gas_limit: int = 0 + max_eq_outputs_bytes: int = 0 + max_submitted_messages_bytes: int = 0 time_unit_overlay_bps: int = 0 + enforce_v06_submission_config: bool = False + # Pure/test policies retain the permissive contract-initializer bounds. + # from_env() below uses the v0.6 deployment defaults (30..600). + min_propose_timeunits: int = 1 + max_propose_timeunits: int = (1 << 128) - 1 + min_commit_timeunits: int = 1 + max_commit_timeunits: int = (1 << 128) - 1 + + def __post_init__(self) -> None: + if not 0 <= int(self.max_allocated_messages) <= MAX_ALLOCATED_MESSAGES_CAP: + raise ValueError( + "invalid max allocated messages: " + f"{self.max_allocated_messages} (protocol cap " + f"{MAX_ALLOCATED_MESSAGES_CAP})" + ) + for minimum, maximum, label in ( + ( + self.min_propose_timeunits, + self.max_propose_timeunits, + "propose", + ), + ( + self.min_commit_timeunits, + self.max_commit_timeunits, + "commit", + ), + ): + if int(minimum) <= 0 or int(maximum) < int(minimum): + raise ValueError( + f"invalid {label} timeunit bounds: {minimum}..{maximum}" + ) @classmethod def from_env(cls) -> "StudioFeePolicy": @@ -248,11 +322,39 @@ def from_env(cls) -> "StudioFeePolicy": max_allocation_tree_depth=_env_int( "GENLAYER_STUDIO_MAX_ALLOCATION_TREE_DEPTH", 5 ), + max_allocated_messages=_env_int( + "GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES", + MAX_ALLOCATED_MESSAGES_CAP, + ), max_messages_per_tx=_env_int("GENLAYER_STUDIO_MAX_MESSAGES_PER_TX", 0), + min_external_gas_limit=_env_int( + "GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT", 0 + ), + max_eq_outputs_bytes=_env_int("GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES", 0), + max_submitted_messages_bytes=_env_int( + "GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES", 0 + ), time_unit_overlay_bps=_env_int( "GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS", DEFAULT_TIME_UNIT_OVERLAY_BPS, ), + enforce_v06_submission_config=True, + min_propose_timeunits=_env_int( + "GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS", + DEFAULT_MIN_PROPOSE_TIMEUNITS, + ), + max_propose_timeunits=_env_int( + "GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS", + DEFAULT_MAX_PROPOSE_TIMEUNITS, + ), + min_commit_timeunits=_env_int( + "GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS", + DEFAULT_MIN_COMMIT_TIMEUNITS, + ), + max_commit_timeunits=_env_int( + "GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS", + DEFAULT_MAX_COMMIT_TIMEUNITS, + ), ) def estimate_propose_receipt_bytes(self, eq_outputs_length: int) -> int: @@ -288,10 +390,12 @@ def estimate_consensus_message_reveal_gas( message_bytes: int, message_count: int, ) -> int: - return self.estimate_receipt_gas( - measured_exec_gas=0, - calldata_length=message_bytes, - slots_changed=message_count, + return ( + self.fixed_message_reveal_gas + + self.intrinsic_gas + + self.bootloader_overhead + + (max(0, int(message_bytes)) * self.calldata_gas_per_byte) + + (max(0, int(message_count)) * self.gas_per_changed_slot) ) def estimate_receipt_gas( @@ -320,7 +424,19 @@ def message_fee_params_budget_floor(self) -> int: def minimum_execution_budget_per_round(self) -> int: if self.receipt_gas_price <= 0: return 0 - message_receipt_start_gas = ( + # FeeManager.messageFeeParamsBudgetFloor() prices only a minimum-size + # proposed receipt. Message reveal and nondeterministic-output charges + # remain ordinary per-round consumption, not admission requirements. + return ( + self.estimate_propose_receipt_gas(MIN_RECEIPT_BYTES) + * self.receipt_gas_price + ) + + def genvm_start_budget_floor(self) -> int: + """Budget GenVM must reserve before contract code begins executing.""" + if self.receipt_gas_price <= 0: + return 0 + start_gas = ( self.fixed_propose_receipt_gas + self.intrinsic_gas + self.bootloader_overhead @@ -329,10 +445,9 @@ def minimum_execution_budget_per_round(self) -> int: + self.intrinsic_gas + self.bootloader_overhead + (MESSAGE_REVEAL_LENGTH_SLOTS * self.gas_per_changed_slot) + + self.estimate_nondet_output_start_gas() ) - return ( - message_receipt_start_gas + self.estimate_nondet_output_start_gas() - ) * self.receipt_gas_price + return start_gas * self.receipt_gas_price def fee_accounting_enabled(self) -> bool: return ( @@ -372,6 +487,61 @@ def _accounting_policy( return StudioFeePolicy() +def _live_policy_for_accounting( + accounting: dict[str, Any] | None, + override: StudioFeePolicy | None = None, +) -> StudioFeePolicy: + """Return the governance policy that applies to work performed now. + + Before activation, the submission snapshot is also the best available + representation of the live configuration (and keeps pure callers/tests + independent of process environment). Once prices are locked, Consensus + continues reading every non-price metering parameter and admission cap + live from FeeManager/Messages. + """ + if override is not None: + return override + if accounting and accounting.get("activation_prices_locked"): + return StudioFeePolicy.from_env() + return _accounting_policy(accounting) + + +def execution_policy_for_accounting( + accounting: dict[str, Any] | None, + live_policy: StudioFeePolicy | None = None, +) -> StudioFeePolicy: + """Combine live metering configuration with activation-locked prices.""" + live_policy = _live_policy_for_accounting(accounting, live_policy) + if not accounting or not accounting.get("activation_prices_locked"): + return live_policy + + locked_policy = _accounting_policy(accounting) + return replace( + live_policy, + gen_per_time_unit=int(locked_policy.gen_per_time_unit), + storage_unit_price=int(locked_policy.storage_unit_price), + receipt_gas_price=int(locked_policy.receipt_gas_price), + ) + + +def stamp_receipt_execution_policy( + receipt: Any, + accounting: dict[str, Any] | None, + live_policy: StudioFeePolicy | None = None, +) -> StudioFeePolicy: + """Persist the exact proposal-time metering inputs on a Studio receipt.""" + policy = execution_policy_for_accounting(accounting, live_policy) + genvm_result = _receipt_genvm_result(receipt) + if genvm_result is None: + genvm_result = {} + if isinstance(receipt, dict): + receipt["genvm_result"] = genvm_result + else: + setattr(receipt, "genvm_result", genvm_result) + genvm_result[FEE_POLICY_SNAPSHOT_KEY] = policy.to_snapshot() + return policy + + def _env_int(name: str, default: int) -> int: raw = os.getenv(name) if raw is None or raw == "": @@ -460,8 +630,19 @@ def calculate_time_unit_fees_through_round( raise InvalidAppealRounds("InvalidAppealRounds") capped_final_round = min(max(0, int(final_round)), int(fees["appealRounds"]) * 2) - leader_timeunits = int(fees["leaderTimeunitsAllocation"]) - validator_timeunits = int(fees["validatorTimeunitsAllocation"]) + max_price = int(fees["maxPriceGenPerTimeUnit"]) + if policy.gen_per_time_unit > 0: + if max_price > 0 and policy.gen_per_time_unit > max_price: + raise MaxPriceExceeded("MaxPriceExceeded") + leader_timeunits = ( + int(fees["leaderTimeunitsAllocation"]) * policy.gen_per_time_unit + ) + validator_timeunits = ( + int(fees["validatorTimeunitsAllocation"]) * policy.gen_per_time_unit + ) + else: + leader_timeunits = int(fees["leaderTimeunitsAllocation"]) + validator_timeunits = int(fees["validatorTimeunitsAllocation"]) actual_rotations = actual_leader_rotations_by_round(consensus_history) round_outcomes = _round_outcomes(consensus_history) total = _calculate_fee_for_round( @@ -491,28 +672,631 @@ def calculate_time_unit_fees_through_round( leader_multiplier=_round_leader_multiplier(round_outcomes.get(offset)), ) - max_price = int(fees["maxPriceGenPerTimeUnit"]) - if policy.gen_per_time_unit > 0: - if max_price > 0 and policy.gen_per_time_unit > max_price: - raise MaxPriceExceeded("MaxPriceExceeded") - total *= policy.gen_per_time_unit return total +def _calculate_settled_time_unit_fees( + fees_distribution: dict[str, Any], + num_of_validators: int, + final_round: int, + policy: StudioFeePolicy, + consensus_history: dict[str, Any] | None, + execution_mode: str = "NORMAL", +) -> tuple[int, list[dict[str, Any]]]: + """Aggregate the fee-funded round payouts made by FeesProcessor. + + Submission quotes reserve every configured seat and leader attempt. At + settlement Consensus pays by round type instead: a bare leader timeout + pays only half a leader allocation, appeal rounds never pay a leader, and + a successful appeal retroactively skips the appealed round. Prior leader + rotations pay the non-leader committee plus half of the rotated leader. + """ + fees = normalize_fees_distribution(fees_distribution) + _validator_index(num_of_validators) + logical_entries = logical_fee_round_entries(consensus_history) + entries = {round_index: entry for round_index, entry in logical_entries} + if not entries: + return ( + calculate_time_unit_fees_through_round( + fees, + num_of_validators, + final_round, + policy, + consensus_history=consensus_history, + ), + [], + ) + + capped_final_round = min( + max(0, int(final_round)), + int(fees["appealRounds"]) * 2, + max(entries), + ) + outcomes = { + round_index: str(entry.get("consensus_round") or "") + for round_index, entry in entries.items() + } + previous_completed_round: dict[int, int] = { + logical_entries[index][0]: logical_entries[index - 1][0] + for index in range(1, len(logical_entries)) + } + skipped_rounds = set() + for round_index, outcome in outcomes.items(): + if outcome not in APPEAL_SUCCESS_ROUNDS: + continue + predecessor = previous_completed_round.get(round_index) + if predecessor is None: + continue + # A chained validator appeal is separated by an empty logical round. + # Consensus leaves the preceding appeal round's fee type intact; only + # a directly appealed execution round is retroactively skipped. + if outcomes.get(predecessor) in ( + VALIDATOR_APPEAL_CONSENSUS_ROUNDS | LEADER_APPEAL_CONSENSUS_ROUNDS + ): + continue + # A successful leader replay does not erase a raw deterministic- + # violation predecessor: those validators correctly established the + # separate fault and retain their fee treatment on-chain. + if ( + outcome == ConsensusRound.LEADER_APPEAL_SUCCESSFUL.value + and _fee_alignment_result(entries.get(predecessor)) + == "deterministic_violation" + ): + continue + skipped_rounds.add(predecessor) + validator_appeal_outcomes = { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_APPEAL_FAILED.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATORS_TIMEOUT_APPEAL_FAILED.value, + } + leader_appeal_outcomes = APPEAL_SUCCESS_ROUNDS.union(APPEAL_FAILED_ROUNDS) - ( + validator_appeal_outcomes + ) + rotations_by_round = actual_leader_rotations_by_round(consensus_history) + attempts_by_round = _consensus_attempt_entries_by_round(consensus_history) + outcomes = _round_outcomes(consensus_history) + if policy.gen_per_time_unit > 0: + leader_timeunits = ( + int(fees["leaderTimeunitsAllocation"]) * policy.gen_per_time_unit + ) + validator_timeunits = ( + int(fees["validatorTimeunitsAllocation"]) * policy.gen_per_time_unit + ) + else: + leader_timeunits = int(fees["leaderTimeunitsAllocation"]) + validator_timeunits = int(fees["validatorTimeunitsAllocation"]) + base_total = 0 + by_round: list[dict[str, Any]] = [] + leader_only = str(execution_mode).upper() == "LEADER_ONLY" + + for round_index in range(capped_final_round + 1): + outcome = outcomes.get(round_index, "") + is_leader_appeal_replay = outcome in LEADER_APPEAL_CONSENSUS_ROUNDS + is_leader_appeal_placeholder = ( + round_index + 1 in outcomes + and outcomes[round_index + 1] in LEADER_APPEAL_CONSENSUS_ROUNDS + ) + validators = ( + int(num_of_validators) + if round_index == 0 + else _validators_per_round_safe(round_index) + ) + rotations = max(0, int(rotations_by_round.get(round_index, 0))) + attempts = attempts_by_round.get(round_index, []) + prior_attempts = attempts[:rotations] + current_attempt = attempts[-1] if attempts else None + prior_rotation_fee = 0 + if prior_attempts and all( + _entry_validator_receipts(attempt) for attempt in prior_attempts + ): + for attempt in prior_attempts: + aligned = _aligned_validator_count(attempt, exclude_leader=True) + rotated_leader_fee = ( + 0 + if _fee_alignment_result(attempt) == "deterministic_violation" + else leader_timeunits // 2 + ) + prior_rotation_fee += aligned * validator_timeunits + rotated_leader_fee + else: + prior_rotation_fee = rotations * _calculate_fee_for_round( + max(0, validators - 1), + 1, + leader_timeunits, + validator_timeunits, + leader_multiplier=(1, 2), + ) + + if round_index not in entries and not is_leader_appeal_placeholder: + round_fee = 0 + rule = "empty_round" + elif is_leader_appeal_placeholder: + round_fee = 0 + rule = "leader_appeal" + elif round_index in skipped_rounds: + round_fee = 0 + rule = "successful_appeal_skip" + elif is_leader_appeal_replay: + replay_result = ( + _fee_alignment_result(current_attempt) + if current_attempt is not None + else "unknown" + ) + aligned = ( + _aligned_validator_count(current_attempt) + if current_attempt is not None + and _entry_validator_receipts(current_attempt) + else validators + ) + if outcome == ConsensusRound.LEADER_TIMEOUT_APPEAL_FAILED.value: + # The replay timed out again. Its leader is paid from the + # forfeited appeal bond, not from the sender's time-unit pool. + round_fee = prior_rotation_fee + rule = "post_timeout_appeal_repeated_timeout" + else: + leader_fee = ( + 0 + if replay_result == "deterministic_violation" + else leader_timeunits + ) + if ( + outcome == ConsensusRound.LEADER_TIMEOUT_APPEAL_SUCCESSFUL.value + and replay_result != "deterministic_violation" + ): + leader_fee += leader_timeunits // 2 + rule = "post_timeout_appeal_150_percent" + elif replay_result == "deterministic_violation": + rule = "deterministic_violation_leader_withheld" + else: + rule = "leader_appeal_replay" + round_fee = ( + prior_rotation_fee + aligned * validator_timeunits + leader_fee + ) + elif outcome == ConsensusRound.LEADER_TIMEOUT.value: + previous = outcomes.get(round_index - 1, "") + # After a successful leader-timeout appeal, a repeated timeout's + # leader is paid from the prior appeal bond rather than fee money. + current_fee = ( + 0 + if previous == ConsensusRound.LEADER_TIMEOUT_APPEAL_SUCCESSFUL.value + else leader_timeunits // 2 + ) + round_fee = prior_rotation_fee + current_fee + rule = "leader_timeout" + elif outcome in VALIDATOR_APPEAL_CONSENSUS_ROUNDS: + if ( + outcome in APPEAL_SUCCESS_ROUNDS + and current_attempt is not None + and _entry_validator_receipts(current_attempt) + ): + appeal_result = _fee_alignment_result(current_attempt) + aligned = _aligned_validator_count( + current_attempt, + forced_result=appeal_result, + ) + vindicated = 0 + previous_attempts = _previous_non_appeal_attempts( + round_index, + attempts_by_round, + outcomes, + ) + # Consensus pays vindication only when the successful appeal + # establishes one of the four concrete result buckets. A + # NoMajority appeal committee is itself paid equally, but it + # does not prove any side of the overturned round correct. + if ( + appeal_result + in { + "agree", + "disagree", + "timeout", + "deterministic_violation", + } + and previous_attempts + and _entry_validator_receipts(previous_attempts[-1]) + ): + vindicated = _aligned_validator_count( + previous_attempts[-1], + forced_result=appeal_result, + ) + round_fee = (aligned + vindicated) * validator_timeunits + rule = "validator_appeal_success_with_vindication" + else: + # An unsuccessful validator appeal redistributes the complete + # fee pool for the seats that actually revealed (plus the + # forfeited bond) among aligned voters. Consensus records + # validators on reveal, so unrecorded committee seats remain + # refundable rather than being charged from the sender pool. + receipts = ( + _entry_validator_receipts(current_attempt) + if current_attempt is not None + else [] + ) + revealed = len(receipts) if receipts else validators + aligned = ( + _aligned_validator_count(current_attempt) + if receipts + else validators + ) + round_fee = revealed * validator_timeunits if aligned > 0 else 0 + rule = "validator_appeal_failed_redistribution" + elif outcome in leader_appeal_outcomes: + round_fee = 0 + rule = "leader_appeal" + else: + current_result = ( + _fee_alignment_result(current_attempt) + if current_attempt is not None + else "unknown" + ) + leader_fee = ( + 0 if current_result == "deterministic_violation" else leader_timeunits + ) + if ( + outcomes.get(round_index - 1, "") + == ConsensusRound.LEADER_TIMEOUT_APPEAL_SUCCESSFUL.value + ): + if current_result != "deterministic_violation": + leader_fee += leader_timeunits // 2 + rule = "post_timeout_appeal_150_percent" + elif current_result == "deterministic_violation": + rule = "deterministic_violation_leader_withheld" + else: + rule = "normal" + aligned = ( + _aligned_validator_count(current_attempt) + if current_attempt is not None + and _entry_validator_receipts(current_attempt) + else (0 if leader_only else validators) + ) + round_fee = prior_rotation_fee + aligned * validator_timeunits + leader_fee + + base_total += round_fee + by_round.append( + { + "round": round_index, + "outcome": outcome, + "rule": rule, + "rotations": rotations, + "timeUnitAmount": round_fee, + } + ) + + return base_total, by_round + + +def _consensus_attempt_entries_by_round( + consensus_history: dict[str, Any] | None, +) -> dict[int, list[dict[str, Any]]]: + if not isinstance(consensus_history, dict): + return {} + results = consensus_history.get("consensus_results") + if not isinstance(results, list): + return {} + + grouped: dict[int, list[dict[str, Any]]] = {} + pending: list[dict[str, Any]] = [] + previous_round: int | None = None + for entry in results: + if not isinstance(entry, dict): + continue + outcome = str(entry.get("consensus_round") or "") + if outcome in NON_ROUND_CONSENSUS_EVENTS: + pending.append(entry) + continue + if previous_round is None: + round_index = 0 + elif outcome in LEADER_APPEAL_CONSENSUS_ROUNDS: + round_index = previous_round + 2 + elif outcome in VALIDATOR_APPEAL_CONSENSUS_ROUNDS: + round_index = previous_round + (1 if previous_round % 2 == 0 else 2) + else: + round_index = previous_round + 1 + grouped[round_index] = [*pending, entry] + pending = [] + previous_round = round_index + return grouped + + +def _previous_non_appeal_attempts( + round_index: int, + attempts_by_round: dict[int, list[dict[str, Any]]], + outcomes: dict[int, str], +) -> list[dict[str, Any]]: + """Mirror FeesProcessor._findPreviousOriginalRound for vindication.""" + appeal_outcomes = VALIDATOR_APPEAL_CONSENSUS_ROUNDS | LEADER_APPEAL_CONSENSUS_ROUNDS + for candidate in sorted( + (index for index in attempts_by_round if index < int(round_index)), + reverse=True, + ): + if outcomes.get(candidate) in appeal_outcomes: + continue + attempts = attempts_by_round.get(candidate) or [] + if attempts: + return attempts + return [] + + +def _entry_validator_receipts(entry: dict[str, Any] | None) -> list[dict[str, Any]]: + if not isinstance(entry, dict): + return [] + receipts: list[dict[str, Any]] = [] + for key in ("leader_result", "validator_results"): + value = entry.get(key) + candidates = value if isinstance(value, list) else [value] + for receipt in candidates: + if not isinstance(receipt, dict): + continue + mode = receipt.get("mode") + if hasattr(mode, "value"): + mode = mode.value + if str(mode or "").lower() == "validator": + receipts.append(receipt) + return receipts + + +def _receipt_validator_address(receipt: dict[str, Any]) -> str: + node_config = receipt.get("node_config") or receipt.get("nodeConfig") or {} + if not isinstance(node_config, dict): + return "" + return str(node_config.get("address") or "").lower() + + +def _entry_leader_address(entry: dict[str, Any] | None) -> str: + if not isinstance(entry, dict): + return "" + value = entry.get("leader_result") + candidates = value if isinstance(value, list) else [value] + for receipt in candidates: + if not isinstance(receipt, dict): + continue + mode = receipt.get("mode") + if hasattr(mode, "value"): + mode = mode.value + if str(mode or "").lower() == "leader": + return _receipt_validator_address(receipt) + return "" + + +def _fee_alignment_result(entry: dict[str, Any] | None) -> str: + votes = [ + str(receipt.get("vote") or "").lower() + for receipt in _entry_validator_receipts(entry) + ] + total = len(votes) + if total == 0: + return "unknown" + majority = total / 2 + if votes.count("agree") > majority: + return "agree" + if votes.count("disagree") > majority: + return "disagree" + # RevealingState classifies Studio's local IDLE sentinel as the on-chain + # Timeout ballot before emitting/recording it. Fee alignment must derive + # the result from that classified ballot too, not from Studio's separate + # state-transition convention where IDLE contributes to disagreement. + if votes.count("timeout") + votes.count("idle") > majority: + return "timeout" + if votes.count("deterministic_violation") > majority: + return "deterministic_violation" + return "no_majority" + + +def _aligned_validator_count( + entry: dict[str, Any] | None, + *, + exclude_leader: bool = False, + forced_result: str | None = None, +) -> int: + receipts = _entry_validator_receipts(entry) + if exclude_leader: + leader = _entry_leader_address(entry) + removed = False + filtered: list[dict[str, Any]] = [] + for receipt in receipts: + if not removed and leader and _receipt_validator_address(receipt) == leader: + removed = True + continue + filtered.append(receipt) + receipts = filtered + + result = forced_result or _fee_alignment_result(entry) + if result == "no_majority": + return len(receipts) + if result == "unknown": + return len(receipts) + + aligned = 0 + for receipt in receipts: + vote = str(receipt.get("vote") or "").lower() + # Studio emits an IDLE reveal to Consensus as Timeout. Its local state + # transition tally treats IDLE as disagreement, but strict fee + # alignment uses the classified on-chain ballot. + if vote == result or (result == "timeout" and vote == "idle"): + aligned += 1 + return aligned + + +def _settlement_storage_recipient_count( + consensus_history: dict[str, Any] | None, + settlement_rounds: list[dict[str, Any]], + fees_distribution: dict[str, Any], + policy: StudioFeePolicy, + execution_mode: str, +) -> int: + """Count the unique fee-ledger recipients that share persistent storage. + + Consensus distributes storage over FeesProcessor's tracked validator set, + not the configured committee size. Rebuild the same tracking side effects: + paid recipients and nonzero penalties are tracked, as is a withheld DV + leader, while skipped/placeholder rounds and zero-value entries add nobody. + """ + attempts_by_round = _consensus_attempt_entries_by_round(consensus_history) + outcomes = _round_outcomes(consensus_history) + settlement_by_round = { + int(item.get("round", 0)): item for item in settlement_rounds + } + non_material_rules = { + "empty_round", + "successful_appeal_skip", + "leader_appeal", + } + normal_rules = { + "normal", + "deterministic_violation_leader_withheld", + "leader_appeal_replay", + "post_timeout_appeal_150_percent", + } + leader_timeout_rules = { + "leader_timeout", + "post_timeout_appeal_repeated_timeout", + } + fees = normalize_fees_distribution(fees_distribution) + price = int(policy.gen_per_time_unit) + validator_value = int(fees["validatorTimeunitsAllocation"]) * ( + price if price > 0 else 1 + ) + leader_value = int(fees["leaderTimeunitsAllocation"]) * (price if price > 0 else 1) + leader_only = str(execution_mode).upper() == "LEADER_ONLY" + recipients: set[str] = set() + + def add_leader(attempt: dict[str, Any], *, payable_value: int) -> None: + leader = _entry_leader_address(attempt) + if leader and ( + payable_value > 0 + or _fee_alignment_result(attempt) == "deterministic_violation" + ): + recipients.add(leader) + + def validator_addresses( + attempt: dict[str, Any], + *, + aligned_only: bool = False, + exclude_leader: bool = False, + forced_result: str | None = None, + ) -> list[str]: + receipts = _entry_validator_receipts(attempt) + if exclude_leader: + leader = _entry_leader_address(attempt) + removed = False + filtered: list[dict[str, Any]] = [] + for receipt in receipts: + if ( + not removed + and leader + and _receipt_validator_address(receipt) == leader + ): + removed = True + continue + filtered.append(receipt) + receipts = filtered + result = forced_result or _fee_alignment_result(attempt) + addresses: list[str] = [] + for receipt in receipts: + vote = str(receipt.get("vote") or "").lower() + aligned = ( + result in {"no_majority", "unknown"} + or vote == result + or (result == "timeout" and vote == "idle") + ) + if aligned_only and not aligned: + continue + address = _receipt_validator_address(receipt) + if address: + addresses.append(address) + return addresses + + for round_index, attempts in attempts_by_round.items(): + settlement = settlement_by_round.get(round_index) or {} + rule = str(settlement.get("rule") or "") + if rule in non_material_rules: + continue + + current_attempt = attempts[-1] if attempts else None + rotation_attempts = attempts[:-1] if attempts else [] + if int(settlement.get("rotations", 0) or 0) > 0: + for attempt in rotation_attempts: + add_leader(attempt, payable_value=leader_value // 2) + if validator_value > 0: + recipients.update(validator_addresses(attempt, exclude_leader=True)) + + if current_attempt is None: + continue + if rule in normal_rules: + add_leader(current_attempt, payable_value=leader_value) + if validator_value > 0 and not leader_only: + recipients.update(validator_addresses(current_attempt)) + elif rule in leader_timeout_rules: + payable_value = ( + 1 + if rule == "post_timeout_appeal_repeated_timeout" + else leader_value // 2 + ) + add_leader(current_attempt, payable_value=payable_value) + elif rule == "validator_appeal_success_with_vindication": + if validator_value > 0: + recipients.update(validator_addresses(current_attempt)) + elif rule == "validator_appeal_failed_redistribution": + # With a nonzero validator allocation every revealer is either + # paid or penalized. If it is zero, only aligned recipients of the + # forfeited bond enter the distribution index. + recipients.update( + validator_addresses( + current_attempt, + aligned_only=validator_value == 0, + ) + ) + + # A successful validator appeal also tracks each vindicated voter from the + # skipped original round, even when that address is not on the appeal jury. + for item in settlement_rounds: + if item.get("rule") != "validator_appeal_success_with_vindication": + continue + round_index = int(item.get("round", 0)) + appeal_attempts = attempts_by_round.get(round_index) or [] + original_attempts = _previous_non_appeal_attempts( + round_index, + attempts_by_round, + outcomes, + ) + if not appeal_attempts or not original_attempts: + continue + result = _fee_alignment_result(appeal_attempts[-1]) + if validator_value <= 0 or result not in { + "agree", + "disagree", + "timeout", + "deterministic_violation", + }: + continue + recipients.update( + validator_addresses( + original_attempts[-1], + aligned_only=True, + forced_result=result, + ) + ) + + return len(recipients) + + def calculate_round_fees( fees_distribution: dict[str, Any], num_of_validators: int, round: int = 0, policy: StudioFeePolicy | None = None, *, - enforce_gen_price_cap: bool = True, + enforce_gen_price_cap: bool = False, ) -> int: fees = normalize_fees_distribution(fees_distribution) policy = policy or StudioFeePolicy() if round == 0: time_unit_work = _calculate_initial_round_total(fees, num_of_validators) - appeal_profit_reserve = _calculate_appeal_profit_reserve(fees) + appeal_profit_reserve = _calculate_appeal_profit_reserve( + fees, + gen_per_time_unit=int(fees["maxPriceGenPerTimeUnit"]), + ) else: time_unit_work = _calculate_appeal_round_total(fees, round) appeal_profit_reserve = 0 @@ -524,16 +1308,10 @@ def calculate_round_fees( policy, enforce_cap=enforce_gen_price_cap, ) - priced_profit_reserve = _apply_time_unit_price( - appeal_profit_reserve, - max_price, - policy, - enforce_cap=enforce_gen_price_cap, - ) total = ( priced_work + _time_unit_overlay(priced_work, policy.time_unit_overlay_bps) - + priced_profit_reserve + + appeal_profit_reserve ) _enforce_gas_price_cap( policy.storage_unit_price, int(fees["storageFeeMaxGasPrice"]) @@ -561,34 +1339,37 @@ def default_transaction_fees_for_policy( policy: StudioFeePolicy | None = None, ) -> tuple[dict[str, int | list[int]], int]: policy = policy or StudioFeePolicy() + enabled = policy.fee_accounting_enabled() execution_budget_per_round = ( max( DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND, policy.message_fee_params_budget_floor(), ) - if policy.storage_unit_price > 0 or policy.receipt_gas_price > 0 + if enabled else 0 ) distribution = _serializable_fees_distribution( { "leaderTimeunitsAllocation": ( - DEFAULT_LEADER_TIMEUNITS_ALLOCATION - if policy.gen_per_time_unit > 0 - else 0 + DEFAULT_LEADER_TIMEUNITS_ALLOCATION if enabled else 0 ), "validatorTimeunitsAllocation": ( - DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION - if policy.gen_per_time_unit > 0 - else 0 + DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION if enabled else 0 ), "appealRounds": 0, "executionBudgetPerRound": execution_budget_per_round, "executionConsumed": 0, "totalMessageFees": 0, "rotations": [0], - "maxPriceGenPerTimeUnit": _with_cap_headroom(policy.gen_per_time_unit), - "storageFeeMaxGasPrice": _with_cap_headroom(policy.storage_unit_price), - "receiptFeeMaxGasPrice": _with_cap_headroom(policy.receipt_gas_price), + "maxPriceGenPerTimeUnit": ( + max(1, _with_cap_headroom(policy.gen_per_time_unit)) if enabled else 0 + ), + "storageFeeMaxGasPrice": ( + max(1, _with_cap_headroom(policy.storage_unit_price)) if enabled else 0 + ), + "receiptFeeMaxGasPrice": ( + max(1, _with_cap_headroom(policy.receipt_gas_price)) if enabled else 0 + ), } ) fee_value = ( @@ -619,9 +1400,18 @@ def studio_fee_config(policy: StudioFeePolicy | None = None) -> dict[str, Any]: "messageFeeParamsBudgetFloor": str( policy.message_fee_params_budget_floor() ), + "genvmStartBudgetFloor": str(policy.genvm_start_budget_floor()), "maxAllocationTreeDepth": str(policy.max_allocation_tree_depth), + "maxAllocatedMessages": str(policy.max_allocated_messages), "maxMessagesPerTx": str(policy.max_messages_per_tx), + "minExternalGasLimit": str(policy.min_external_gas_limit), + "maxEqOutputsBytes": str(policy.max_eq_outputs_bytes), + "maxSubmittedMessagesBytes": str(policy.max_submitted_messages_bytes), "timeUnitOverlayBps": str(policy.time_unit_overlay_bps), + "minProposeTimeunits": str(policy.min_propose_timeunits), + "maxProposeTimeunits": str(policy.max_propose_timeunits), + "minCommitTimeunits": str(policy.min_commit_timeunits), + "maxCommitTimeunits": str(policy.max_commit_timeunits), }, "capabilities": { "messageFees": { @@ -653,6 +1443,50 @@ def studio_fee_config(policy: StudioFeePolicy | None = None) -> dict[str, Any]: } +def _require_nonzero_fee_config(fees_distribution: dict[str, Any]) -> None: + # ConsensusHelpers.requireNonZeroFeeConfig assigns these stable field + # indices to the six mandatory fields on every fee-aware user submission. + required_fields = ( + (1, "leaderTimeunitsAllocation"), + (2, "validatorTimeunitsAllocation"), + (3, "executionBudgetPerRound"), + (4, "maxPriceGenPerTimeUnit"), + (5, "storageFeeMaxGasPrice"), + (6, "receiptFeeMaxGasPrice"), + ) + for field_index, field_name in required_fields: + if int(fees_distribution[field_name]) <= 0: + raise FeeValueMustBeNonZero(f"FeeValueMustBeNonZero({field_index})") + + +def _validate_phase_timeout_bounds( + leader_timeunits: int, + validator_timeunits: int, + policy: StudioFeePolicy, + *, + allow_zero: bool = False, +) -> None: + checks = ( + ( + int(leader_timeunits), + int(policy.min_propose_timeunits), + int(policy.max_propose_timeunits), + ), + ( + int(validator_timeunits), + int(policy.min_commit_timeunits), + int(policy.max_commit_timeunits), + ), + ) + for value, minimum, maximum in checks: + if allow_zero and value == 0: + continue + if value < minimum or value > maximum: + raise PhaseTimeoutOutOfBounds( + f"PhaseTimeoutOutOfBounds({value},{minimum},{maximum})" + ) + + def validate_transaction_fee_deposit( *, fees_distribution: dict[str, Any], @@ -665,6 +1499,13 @@ def validate_transaction_fee_deposit( ) -> int: policy = policy or StudioFeePolicy() fees = normalize_fees_distribution(fees_distribution) + if policy.fee_accounting_enabled() and policy.enforce_v06_submission_config: + _require_nonzero_fee_config(fees) + _validate_phase_timeout_bounds( + int(fees["leaderTimeunitsAllocation"]), + int(fees["validatorTimeunitsAllocation"]), + policy, + ) execution_budget_per_round = int(fees["executionBudgetPerRound"]) if ( not allow_low_execution_budget @@ -725,6 +1566,56 @@ def create_fee_accounting( ) +def activate_fee_accounting( + accounting: dict[str, Any], + policy: StudioFeePolicy | None = None, + *, + selection_pool_count: int | None = None, +) -> tuple[dict[str, Any], bool]: + """Lock v0.6 execution prices on first activation. + + Submission reserves time-unit work at the user's GEN ceiling. Consensus + checks the live GEN price only once the pending transaction activates; an + over-cap transaction is canceled with its escrow untouched. Storage and + receipt prices are also locked at activation, but do not cause a second + cap check after the submission-time quote. + """ + updated = copy.deepcopy(accounting) + if updated.get("activation_prices_locked"): + return updated, False + + if selection_pool_count is not None: + # Consensus appeal reservations use the activation epoch's frozen + # selection-pool authority, never the live staking population. + updated["selection_pool_count"] = max(0, int(selection_pool_count)) + + live_policy = policy or StudioFeePolicy.from_env() + fees = normalize_fees_distribution(updated.get("fees_distribution") or {}) + max_gen_price = int(fees["maxPriceGenPerTimeUnit"]) + if max_gen_price > 0 and live_policy.gen_per_time_unit > max_gen_price: + updated["activation_price_cap_exceeded"] = { + "actual": int(live_policy.gen_per_time_unit), + "maximum": max_gen_price, + } + return updated, True + + submission_policy = _accounting_policy(updated) + locked_policy = replace( + submission_policy, + gen_per_time_unit=int(live_policy.gen_per_time_unit), + storage_unit_price=int(live_policy.storage_unit_price), + receipt_gas_price=int(live_policy.receipt_gas_price), + ) + updated["policy_snapshot"] = locked_policy.to_snapshot() + updated["activation_prices_locked"] = True + updated["locked_prices"] = { + "genPerTimeUnit": int(live_policy.gen_per_time_unit), + "storageUnitPrice": int(live_policy.storage_unit_price), + "receiptGasPrice": int(live_policy.receipt_gas_price), + } + return updated, False + + def create_child_fee_accounting( *, message: dict[str, Any], @@ -739,24 +1630,37 @@ def create_child_fee_accounting( raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") fee_params = decode_internal_message_fee_params(message.get("feeParams", b"")) - capless_child_fees = _fees_distribution_from_internal_params( + _validate_internal_message_price_caps(fee_params) + _validate_phase_timeout_bounds( + int(fee_params["leaderTimeunitsAllocation"]), + int(fee_params["validatorTimeunitsAllocation"]), + policy, + allow_zero=True, + ) + funding_child_fees = _fees_distribution_from_internal_params( fee_params, total_message_fees=0, parent_fees_distribution=normalize_fees_distribution({}), ) - try: - child_primary = validate_transaction_fee_deposit( - fees_distribution=capless_child_fees, - message_allocations=[], - num_of_validators=VALIDATORS_PER_ROUND[0], - submitted_value=declared_budget, - user_value=0, - policy=policy, - ) - except InsufficientFees as exc: - raise MessageDeclaredBudgetInsufficient( - "MessageDeclaredBudgetInsufficient" - ) from exc + # Consensus prices child funding at the child's declared GEN ceiling. The + # live GEN price is checked only when the child activates, while storage + # and receipt ceilings guard the child's later work and therefore do not + # participate in this floor calculation. + funding_child_fees["storageFeeMaxGasPrice"] = 0 + funding_child_fees["receiptFeeMaxGasPrice"] = 0 + execution_budget_per_round = int(funding_child_fees["executionBudgetPerRound"]) + if ( + execution_budget_per_round > 0 + and execution_budget_per_round < policy.message_fee_params_budget_floor() + ): + raise BudgetTooLow("BudgetTooLow") + child_primary = calculate_round_fees( + funding_child_fees, + VALIDATORS_PER_ROUND[0], + 0, + policy, + enforce_gen_price_cap=False, + ) if declared_budget < child_primary: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") @@ -804,7 +1708,7 @@ def genvm_fee_context( if not accounting: return None, None - policy = _accounting_policy(accounting, policy) + policy = execution_policy_for_accounting(accounting, policy) fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) bucket_total = int(fees["executionBudgetPerRound"]) @@ -899,9 +1803,24 @@ def apply_fee_top_up( perform_fee_checks: bool = True, policy: StudioFeePolicy | None = None, ) -> dict[str, Any]: - policy = _accounting_policy(accounting, policy) + # Top-up validation uses the FeeManager configuration at the time of the + # top-up. Activation locks execution prices, not governance parameters or + # the gas-based minimum-budget floor. + policy = _live_policy_for_accounting(accounting, policy) amount = int(amount) + if amount <= 0: + raise InsufficientFees("InsufficientFees") incoming = normalize_fees_distribution(fees_distribution) + cap_owner = accounting.get("sender") + if sender and cap_owner and str(sender).lower() != str(cap_owner).lower(): + # Any account may fund an in-flight transaction, but Consensus pins + # price-cap authority to the first depositor. + for cap in ( + "maxPriceGenPerTimeUnit", + "storageFeeMaxGasPrice", + "receiptFeeMaxGasPrice", + ): + incoming[cap] = 0 incoming_message_fees = int(incoming["totalMessageFees"]) if incoming_message_fees > amount: raise InsufficientFees("InsufficientFees") @@ -950,6 +1869,22 @@ def apply_fee_top_up( updated["execution_budget_total"] = int(merged["executionBudgetPerRound"]) * ( get_leader_rounds(merged) ) + current_overlay_reserve = int(updated.get("time_unit_overlay_budget", 0) or 0) + updated["time_unit_overlay_budget"] = _carve_time_unit_overlay_reserve( + current_reserve=current_overlay_reserve, + cumulative_primary=int(updated["primary_fee_budget"]), + execution_budget=int(updated["execution_budget_total"]), + incoming_primary=primary_amount, + split_bps=policy.time_unit_overlay_bps, + ) + overlay_reserve_delta = ( + int(updated["time_unit_overlay_budget"]) - current_overlay_reserve + ) + _record_fee_contribution( + updated, + sender, + primary_amount - overlay_reserve_delta + incoming_message_fees, + ) updated.setdefault("top_ups", []).append( { "sender": sender, @@ -975,6 +1910,7 @@ def record_appeal_bond( top_up_and_submit: bool = False, terminal_committee_upper_bound: int | None = None, replacement_rotations: int | None = None, + leader_timeout_live_seats: int | None = None, time_unit_overlay_bps: int | None = None, policy: StudioFeePolicy | None = None, ) -> dict[str, Any]: @@ -998,6 +1934,7 @@ def record_appeal_bond( status=status, terminal_committee_upper_bound=terminal_committee_upper_bound, replacement_rotations=replacement_rotations, + leader_timeout_live_seats=leader_timeout_live_seats, policy=policy, ) bond = int(charge["bond"]) @@ -1026,6 +1963,14 @@ def record_appeal_bond( updated["appeal_funding_total"] = ( int(updated.get("appeal_funding_total", 0)) + funding ) + updated["time_unit_overlay_budget"] = int( + updated.get("time_unit_overlay_budget", 0) or 0 + ) + int(charge["fundingBreakdown"]["overlay"]) + _record_fee_contribution( + updated, + appealer, + funding - int(charge["fundingBreakdown"]["overlay"]), + ) updated["appeal_bonds_total"] = int(updated.get("appeal_bonds_total", 0)) + bond surplus = amount - total_required updated["appeal_charge_surplus_refunded"] = ( @@ -1059,6 +2004,7 @@ def calculate_appeal_charge( status: str, terminal_committee_upper_bound: int | None = None, replacement_rotations: int | None = None, + leader_timeout_live_seats: int | None = None, policy: StudioFeePolicy | None = None, ) -> dict[str, Any]: """Quote the same bond + typed funding split used by Consensus admission.""" @@ -1073,6 +2019,7 @@ def calculate_appeal_charge( leader_timeout_rotations_left=( replacement_rotations if status_value == "LEADER_TIMEOUT" else None ), + leader_timeout_live_seats=leader_timeout_live_seats, policy=policy, ) @@ -1159,6 +2106,7 @@ def calculate_min_appeal_bond( current_round: int, status: str, leader_timeout_rotations_left: int | None = None, + leader_timeout_live_seats: int | None = None, policy: StudioFeePolicy | None = None, ) -> int: policy = policy or StudioFeePolicy() @@ -1166,6 +2114,14 @@ def calculate_min_appeal_bond( current_round = max(0, int(current_round)) status_value = str(status).upper() if status_value == "LEADER_TIMEOUT": + if ( + leader_timeout_live_seats is not None + and int(leader_timeout_live_seats) <= 1 + ): + # The live committee only gates whether a replacement leader can + # be induced. When it can, pricing still uses the configured round + # size below, even though the timed-out leader was removed. + return 0 rotations_left = ( _appeal_rotation_allowance(fees, current_round) if leader_timeout_rotations_left is None @@ -1183,9 +2139,10 @@ def calculate_min_appeal_bond( if status_value == "UNDETERMINED": target_round = current_round + 2 + target_normal_index = target_round // 2 rotations = ( - int(fees["rotations"][target_round - 1]) - if target_round - 1 < len(fees["rotations"]) + int(fees["rotations"][target_normal_index]) + if target_normal_index < len(fees["rotations"]) else 0 ) total = _calculate_fee_for_round( @@ -1227,6 +2184,10 @@ def fill_message_fee_payload_from_allocation( accounting: dict[str, Any], message: dict[str, Any], ) -> dict[str, Any]: + if bool(message.get("useBalance", False)): + # Contract-funded messages carry their own canonical fee payload and + # bypass the sender's allocation tree in Consensus. + return copy.deepcopy(message) allocations = accounting.get("message_allocations") or [] if not allocations: return copy.deepcopy(message) @@ -1278,23 +2239,32 @@ def consume_message_fees( reported_total: int | None = None, policy: StudioFeePolicy | None = None, reimburse_external: bool = True, + external_executor: str | None = None, ) -> dict[str, Any]: - policy = _accounting_policy(accounting, policy) - if policy.max_messages_per_tx > 0 and len(messages) > policy.max_messages_per_tx: + live_policy = _live_policy_for_accounting(accounting, policy) + execution_policy = execution_policy_for_accounting(accounting, live_policy) + if len(messages) > _message_count_cap(live_policy): raise TooManyMessages("TooManyMessages") updated = copy.deepcopy(accounting) recalculated_total = 0 - external_reimbursement_total = 0 + external_consumption_total = 0 + initial_external_reimbursed = int( + updated.get("external_message_fee_reimbursed", 0) or 0 + ) + initial_external_remainder = int( + updated.get("external_message_fee_remainder", 0) or 0 + ) for message in messages: message_type = _message_type_value(message) if message_type == MESSAGE_TYPE_EXTERNAL: - external_reimbursement_total += _consume_external_message_fee( + external_consumption_total += _consume_external_message_fee( updated, message, - policy, + execution_policy, reimburse_external, + external_executor, ) continue @@ -1302,7 +2272,7 @@ def consume_message_fees( recalculated_total += _consume_internal_message_fee( updated, message, - policy, + live_policy, ) if reported_total is not None and int(reported_total) < recalculated_total: @@ -1311,25 +2281,41 @@ def consume_message_fees( attempted = ( int(updated.get("message_fee_consumed", 0)) + recalculated_total - + external_reimbursement_total + + external_consumption_total ) message_budget = int(updated.get("message_fee_budget", 0)) if attempted > message_budget: raise MessageBudgetExceeded("MessageBudgetExceeded") updated["message_fee_consumed"] = attempted - updated.setdefault("message_consumption_events", []).append( - { - "consumed": recalculated_total + external_reimbursement_total, - "internalConsumed": recalculated_total, - "externalReimbursed": external_reimbursement_total, - "remaining": message_budget - attempted, - } + consumption_event = { + "consumed": recalculated_total + external_consumption_total, + "internalConsumed": recalculated_total, + "externalReimbursed": int( + updated.get("external_message_fee_reimbursed", 0) or 0 + ) + - initial_external_reimbursed, + "remaining": message_budget - attempted, + } + external_remainder_settled = ( + int(updated.get("external_message_fee_remainder", 0) or 0) + - initial_external_remainder ) - _refresh_message_fee_accounting_report_if_present(updated, policy) + if external_remainder_settled > 0: + consumption_event["externalRemainderSettled"] = external_remainder_settled + updated.setdefault("message_consumption_events", []).append(consumption_event) + _refresh_message_fee_accounting_report_if_present(updated, execution_policy) return updated +def _message_count_cap(policy: StudioFeePolicy) -> int: + cap = max(0, int(policy.max_allocated_messages)) + fee_cap = int(policy.max_messages_per_tx) + if fee_cap > 0: + cap = min(cap, fee_cap) + return cap + + def _message_type_value(message: dict[str, Any]) -> int: return int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) @@ -1339,6 +2325,7 @@ def _consume_external_message_fee( message: dict[str, Any], policy: StudioFeePolicy, reimburse_external: bool, + external_executor: str | None, ) -> int: if int(message.get("declaredBudget", 0) or 0) != 0: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") @@ -1347,6 +2334,7 @@ def _consume_external_message_fee( message, policy, reimburse=reimburse_external, + executor=external_executor, ) @@ -1357,12 +2345,18 @@ def _consume_internal_message_fee( ) -> int: declared_budget = int(message.get("declaredBudget", 0) or 0) fee_params = decode_internal_message_fee_params(message.get("feeParams", b"")) + _validate_internal_message_price_caps(fee_params) _validate_internal_execution_budget_floor(fee_params, policy) min_required = min_message_primary_fees(fee_params, policy) if declared_budget < min_required: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + if bool(message.get("useBalance", False)): + # Consensus reserves this budget from the emitting contract (ghost), + # outside the sender-funded message bucket and allocation tree. + return 0 + _consume_against_allocation(accounting, message, declared_budget) return declared_budget @@ -1386,11 +2380,13 @@ def record_reveal_message_fees( reported_total: int | None = None, policy: StudioFeePolicy | None = None, ) -> dict[str, Any]: + live_policy = _live_policy_for_accounting(accounting, policy) + _enforce_submitted_messages_cap(messages, live_policy) updated = consume_message_fees( accounting, messages, reported_total=reported_total, - policy=policy, + policy=live_policy, reimburse_external=False, ) updated["message_fees_recorded_at_reveal"] = True @@ -1402,9 +2398,11 @@ def record_external_message_execution_fees( messages: list[dict[str, Any]], *, policy: StudioFeePolicy | None = None, + executor: str | None = None, ) -> dict[str, Any]: updated = copy.deepcopy(accounting) policy = _accounting_policy(updated, policy) + consumed_total = 0 reimbursement_total = 0 remainder_total = 0 updated_any = False @@ -1430,9 +2428,7 @@ def record_external_message_execution_fees( remainder = reservation - reimbursement attempted = ( - int(updated.get("message_fee_consumed", 0)) - + reimbursement_total - + reimbursement + int(updated.get("message_fee_consumed", 0)) + consumed_total + reservation ) message_budget = int(updated.get("message_fee_budget", 0)) if attempted > message_budget: @@ -1442,13 +2438,18 @@ def record_external_message_execution_fees( event["reimbursement"] = reimbursement event["remainder"] = remainder event["executionRecorded"] = True + _record_external_message_fee_payouts(updated, event, executor) + consumed_total += reservation reimbursement_total += reimbursement remainder_total += remainder updated_any = True if updated_any: updated["message_fee_consumed"] = ( - int(updated.get("message_fee_consumed", 0)) + reimbursement_total + int(updated.get("message_fee_consumed", 0)) + consumed_total + ) + updated["external_message_fee_settled"] = ( + int(updated.get("external_message_fee_settled", 0)) + consumed_total ) updated["external_message_fee_reimbursed"] = ( int(updated.get("external_message_fee_reimbursed", 0)) + reimbursement_total @@ -1458,9 +2459,10 @@ def record_external_message_execution_fees( ) updated.setdefault("message_consumption_events", []).append( { - "consumed": reimbursement_total, + "consumed": consumed_total, "internalConsumed": 0, "externalReimbursed": reimbursement_total, + "externalRemainderSettled": remainder_total, "remaining": max( 0, int(updated.get("message_fee_budget", 0)) @@ -1541,6 +2543,8 @@ def unwind_reveal_message_fees( != MESSAGE_TYPE_INTERNAL ): continue + if bool(message.get("useBalance", False)): + continue if acceptance_dispatched and bool(message.get("onAcceptance", False)): continue @@ -1591,7 +2595,8 @@ def record_execution_fee_consumption( policy: StudioFeePolicy | None = None, ) -> dict[str, Any]: updated = copy.deepcopy(accounting) - policy = _accounting_policy(updated, policy) + fallback_policy = execution_policy_for_accounting(updated, policy) + policy = _receipt_execution_policy(receipt, fallback_policy) message_payloads = _receipt_message_fee_payloads(updated, receipt) reported_message_fees_total = _receipt_reported_message_fees_total(receipt) if ( @@ -1661,6 +2666,27 @@ def record_execution_fee_consumption( return updated +def validate_receipt_admission_caps( + receipt: Any, + policy: StudioFeePolicy | None = None, +) -> None: + policy = policy or StudioFeePolicy.from_env() + eq_outputs_length = _receipt_eq_blocks_outputs_length(receipt) + if ( + policy.max_eq_outputs_bytes > 0 + and eq_outputs_length > policy.max_eq_outputs_bytes + ): + raise EqOutputsTooLarge( + f"EqOutputsTooLarge({eq_outputs_length},{policy.max_eq_outputs_bytes})" + ) + + message_payloads = _receipt_message_fee_payloads({}, receipt) + if message_payloads and _receipt_execution_allows_messages(receipt): + if len(message_payloads) > _message_count_cap(policy): + raise TooManyMessages("TooManyMessages") + _enforce_submitted_messages_cap(message_payloads, policy) + + def settle_fee_accounting( accounting: dict[str, Any], *, @@ -1669,61 +2695,130 @@ def settle_fee_accounting( actual_final_round: int | None = None, num_of_validators: int | None = None, consensus_history: dict[str, Any] | None = None, + execution_mode: str = "NORMAL", policy: StudioFeePolicy | None = None, ) -> tuple[dict[str, Any], int]: - policy = _accounting_policy(accounting, policy) - updated = record_execution_fee_consumption(accounting, receipt, policy) + policy_override = policy + locked_policy = _accounting_policy( + accounting, + policy_override if not accounting.get("activation_prices_locked") else None, + ) + live_policy = _live_policy_for_accounting(accounting, policy_override) + execution_policy = execution_policy_for_accounting(accounting, live_policy) + updated = record_execution_fee_consumption(accounting, receipt, live_policy) if updated.get("status") in {"settled", "canceled"}: return updated, 0 + updated = _record_historical_execution_fee_consumption( + updated, + receipt=receipt, + consensus_history=consensus_history, + policy=execution_policy, + ) primary_budget = int(updated.get("primary_fee_budget", 0)) execution_budget = int(updated.get("execution_budget_total", 0)) primary_required = int(updated.get("primary_fee_required", 0)) fees_distribution = updated.get("fees_distribution") or {} + settlement_rounds: list[dict[str, Any]] = [] if actual_final_round is not None: validators = int( num_of_validators or updated.get("num_of_initial_validators") or 0 ) - time_unit_budget = calculate_time_unit_fees_through_round( + time_unit_budget, settlement_rounds = _calculate_settled_time_unit_fees( fees_distribution, validators, actual_final_round, - policy, - consensus_history=consensus_history, - ) - execution_budget = int( - normalize_fees_distribution(fees_distribution)["executionBudgetPerRound"] - ) * get_leader_rounds_through_round( - fees_distribution, - actual_final_round, - consensus_history=consensus_history, + locked_policy, + consensus_history, + execution_mode, ) + # Consensus settles storage + receipt/write consumption against the + # complete unified reserve quoted at submission: + # + # executionBudgetPerRound * all configured leader slots + # + # Unused future slots are a refund breakdown only; they do not shrink + # the cap available to real cumulative execution. In particular, an + # early round-0 finish may consume more than one per-round slice (for + # example through persistent storage) while remaining fully backed by + # the transaction's multi-round reserve. updated["actual_final_round"] = int(actual_final_round) + if settlement_rounds: + updated["settlement_rounds"] = settlement_rounds else: time_unit_budget = max(0, primary_required - execution_budget) - execution_spent = min( - int(updated.get("execution_fee_consumed", 0)), execution_budget + execution_consumed = int(updated.get("execution_fee_consumed", 0)) + execution_spent = min(execution_consumed, execution_budget) + storage_fee = _bucket_value( + list(updated.get("execution_fee_consumed_buckets") or []), + 1, + ) + storage_recipients = _settlement_storage_recipient_count( + consensus_history, + settlement_rounds if actual_final_round is not None else [], + fees_distribution, + locked_policy, + execution_mode, + ) + if storage_recipients == 0 and time_unit_budget > 0 and not settlement_rounds: + # Legacy/synthetic histories without receipt identities retain the + # configured initial committee as the best available denominator. + storage_recipients = int( + num_of_validators or updated.get("num_of_initial_validators") or 0 + ) + capped_storage_fee = min(storage_fee, execution_budget) + storage_dust = ( + capped_storage_fee % storage_recipients + if storage_recipients > 0 + else capped_storage_fee ) + execution_spent = max(0, execution_spent - storage_dust) bond_settlements, bond_payout = _settle_appeal_bonds( updated, consensus_history=consensus_history, cancel=False, ) - appeal_profit_spent = sum( + appeal_profit_requested = sum( max(0, int(item.get("payout", 0)) - int(item.get("amount", 0))) for item in bond_settlements if item.get("status") == "successful" ) - time_unit_overlay_spent = _time_unit_overlay( + appeal_bond_sender_refund = sum( + int(item.get("senderRefund", 0) or 0) for item in bond_settlements + ) + current_overlay_bps = ( + int(policy_override.time_unit_overlay_bps) + if policy_override is not None + else int(StudioFeePolicy.from_env().time_unit_overlay_bps) + ) + time_unit_overlay_requested = _time_unit_overlay( time_unit_budget, - policy.time_unit_overlay_bps, + current_overlay_bps, + ) + overlay_budget = int(updated.get("time_unit_overlay_budget", primary_budget) or 0) + time_unit_overlay_spent = min(time_unit_overlay_requested, overlay_budget) + non_profit_spend = time_unit_budget + time_unit_overlay_spent + execution_spent + appeal_profit_spent = ( + appeal_profit_requested + if non_profit_spend + appeal_profit_requested <= primary_budget + else 0 ) + if appeal_profit_requested > 0 and appeal_profit_spent == 0: + # Consensus never partially promises a successful-appeal profit. If + # legacy/malformed accounting cannot fund the complete profit leg, all + # successful payouts are clamped to principal and that fee money stays + # in the sender residual. + for item in bond_settlements: + if item.get("status") != "successful": + continue + amount = int(item.get("amount", 0) or 0) + payout = int(item.get("payout", 0) or 0) + bond_payout -= max(0, payout - amount) + item["payout"] = amount + item["profitFunded"] = False primary_spent = min( primary_budget, - time_unit_budget - + time_unit_overlay_spent - + appeal_profit_spent - + execution_spent, + non_profit_spend + appeal_profit_spent, ) primary_refund = max( 0, primary_budget - primary_spent - int(updated.get("primary_fee_refunded", 0)) @@ -1735,35 +2830,156 @@ def settle_fee_accounting( - int(updated.get("message_fee_consumed", 0)) - int(updated.get("message_fee_refunded", 0)), ) - refund = primary_refund + message_refund + refund = primary_refund + message_refund + appeal_bond_sender_refund updated["status"] = "settled" updated["settlement_reason"] = reason updated["primary_fee_spent"] = primary_spent updated["time_unit_overlay_spent"] = time_unit_overlay_spent + updated["time_unit_overlay_requested"] = time_unit_overlay_requested + updated["time_unit_overlay_settlement_bps"] = current_overlay_bps + updated["appeal_profit_requested"] = appeal_profit_requested updated["appeal_profit_spent"] = appeal_profit_spent + updated["storage_fee_recipient_count"] = storage_recipients + updated["storage_fee_dust_refunded"] = storage_dust updated["primary_fee_refunded"] = ( int(updated.get("primary_fee_refunded", 0)) + primary_refund ) updated["message_fee_refunded"] = ( int(updated.get("message_fee_refunded", 0)) + message_refund ) + updated["appeal_bond_sender_refunded"] = ( + int(updated.get("appeal_bond_sender_refunded", 0)) + appeal_bond_sender_refund + ) updated["total_refunded"] = int(updated.get("total_refunded", 0)) + refund updated["appeal_bonds_payout_total"] = ( int(updated.get("appeal_bonds_payout_total", 0)) + bond_payout ) updated["appeal_bond_settlements"] = bond_settlements + updated["fee_refund_settlements"] = _allocate_fee_refund(updated, refund) updated.setdefault("refunds", []).append( { "reason": reason, "primary": primary_refund, "message": message_refund, + "appealBond": appeal_bond_sender_refund, "amount": refund, } ) - _refresh_message_fee_accounting_report_if_present(updated, policy) + _refresh_message_fee_accounting_report_if_present(updated, execution_policy) return updated, refund +def _record_historical_execution_fee_consumption( + accounting: dict[str, Any], + *, + receipt: Any | None, + consensus_history: dict[str, Any] | None, + policy: StudioFeePolicy, +) -> dict[str, Any]: + attempts = _historical_leader_proposal_receipts(consensus_history) + if not attempts: + return accounting + + updated = copy.deepcopy(accounting) + receipt_total = 0 + attempt_report: list[dict[str, Any]] = [] + for ( + round_index, + attempt_index, + attempt_receipt, + deterministic_violation, + ) in attempts: + timed_out = _receipt_is_leader_timeout(attempt_receipt) + attempt_policy = _receipt_execution_policy(attempt_receipt, policy) + report = ( + None + if timed_out or deterministic_violation + else _receipt_fee_report(attempt_receipt, attempt_policy) + ) + charged = _receipt_report_chargeable_fee(report) if report is not None else 0 + receipt_total += charged + attempt_report.append( + { + "round": round_index, + "attempt": attempt_index, + "leaderTimeout": timed_out, + "deterministicViolation": deterministic_violation, + "receiptFee": charged, + "receiptGasPrice": int(attempt_policy.receipt_gas_price), + } + ) + + final_consumed = _receipt_data_fees_consumed(receipt) + storage_fee = ( + _chargeable_storage_fee(receipt, final_consumed) + if final_consumed is not None + else _bucket_value( + list(updated.get("execution_fee_consumed_buckets") or []), + 1, + ) + ) + updated["execution_fee_consumed"] = receipt_total + storage_fee + updated["execution_fee_consumed_buckets"] = [receipt_total, storage_fee] + updated["historical_execution_attempts"] = attempt_report + updated["execution_fee_report"] = { + **(updated.get("execution_fee_report") or {}), + "historicalReceiptFees": { + "attempts": attempt_report, + "receiptFeeTotal": receipt_total, + "finalStorageFee": storage_fee, + "totalExecution": receipt_total + storage_fee, + }, + } + return updated + + +def _historical_leader_proposal_receipts( + consensus_history: dict[str, Any] | None, +) -> list[tuple[int, int, dict[str, Any], bool]]: + grouped = _consensus_attempt_entries_by_round(consensus_history) + proposals: list[tuple[int, int, dict[str, Any], bool]] = [] + for round_index in sorted(grouped): + for attempt_index, entry in enumerate(grouped[round_index]): + deterministic_violation = ( + _fee_alignment_result(entry) == "deterministic_violation" + ) + value = entry.get("leader_result") + candidates = value if isinstance(value, list) else [value] + for candidate in candidates: + if not isinstance(candidate, dict): + continue + mode = candidate.get("mode") + if hasattr(mode, "value"): + mode = mode.value + if str(mode or "").lower() == "leader": + proposals.append( + ( + round_index, + attempt_index, + candidate, + deterministic_violation, + ) + ) + break + return proposals + + +def _receipt_is_leader_timeout(receipt: Any) -> bool: + genvm_result = _receipt_genvm_result(receipt) + if isinstance(genvm_result, dict) and genvm_result.get("error_code") == ( + "CONSENSUS_LEADER_EXEC_TIMEOUT" + ): + return True + + result = _receipt_value(receipt, "result") + if isinstance(result, str): + try: + result = base64.b64decode(result) + except (ValueError, TypeError): + result = b"" + return isinstance(result, (bytes, bytearray)) and bytes(result).endswith(b"timeout") + + def cancel_fee_accounting( accounting: dict[str, Any], *, @@ -1804,11 +3020,13 @@ def cancel_fee_accounting( int(updated.get("appeal_bonds_payout_total", 0)) + bond_payout ) updated["appeal_bond_settlements"] = bond_settlements + updated["fee_refund_settlements"] = _allocate_fee_refund(updated, refund) updated.setdefault("refunds", []).append( { "reason": reason, "primary": primary_refund, "message": message_refund, + "appealBond": 0, "amount": refund, } ) @@ -1902,7 +3120,7 @@ def _validate_message_allocation_node( _validate_allocation_parent(index, node, message_allocations) if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL: - _validate_external_allocation(node, external_keys) + _validate_external_allocation(node, external_keys, policy) return int(node["budget"]) if int(node["messageType"]) != MESSAGE_TYPE_INTERNAL: @@ -1934,6 +3152,12 @@ def _validate_internal_allocation_budget( policy: StudioFeePolicy, ) -> int: internal_fee_params = decode_internal_message_fee_params(node["feeParams"]) + _validate_phase_timeout_bounds( + int(internal_fee_params["leaderTimeunitsAllocation"]), + int(internal_fee_params["validatorTimeunitsAllocation"]), + policy, + allow_zero=True, + ) min_required = _internal_allocation_min_required(node, internal_fee_params, policy) if int(node["budget"]) < min_required: raise AllocationLifecycleBudgetInsufficient( @@ -1944,6 +3168,21 @@ def _validate_internal_allocation_budget( return min_required +def _validate_internal_message_price_caps( + internal_fee_params: dict[str, Any], +) -> None: + # Consensus rejects a leader reveal whose child declares any zero price + # cap. Validate the identical canonical bytes at submission/allocation + # time too, so Studio cannot accept a message shape that v0.6 rejects. + for field_index, field in ( + (4, "maxPriceGenPerTimeUnit"), + (5, "storageFeeMaxGasPrice"), + (6, "receiptFeeMaxGasPrice"), + ): + if int(internal_fee_params[field]) <= 0: + raise FeeValueMustBeNonZero(f"FeeValueMustBeNonZero({field_index})") + + def _internal_allocation_min_required( node: dict[str, Any], internal_fee_params: dict[str, Any], @@ -2009,6 +3248,9 @@ def decode_internal_message_fee_params(fee_params: bytes | str) -> dict[str, Any "appealRounds": int(decoded[2]), "executionBudgetPerRound": int(decoded[3]), "rotations": [int(rotation) for rotation in decoded[4]], + "maxPriceGenPerTimeUnit": int(decoded[5]), + "storageFeeMaxGasPrice": int(decoded[6]), + "receiptFeeMaxGasPrice": int(decoded[7]), } @@ -2043,13 +3285,16 @@ def min_message_primary_fees( "executionConsumed": 0, "totalMessageFees": 0, "rotations": internal_fee_params["rotations"], - "maxPriceGenPerTimeUnit": 0, + "maxPriceGenPerTimeUnit": int( + internal_fee_params["maxPriceGenPerTimeUnit"] + ), "storageFeeMaxGasPrice": 0, "receiptFeeMaxGasPrice": 0, }, VALIDATORS_PER_ROUND[0], 0, policy, + enforce_gen_price_cap=False, ) @@ -2067,8 +3312,13 @@ def _calculate_appeal_round_total( fees: dict[str, int | list[int]], round: int, ) -> int: + # Fee schedules index rotations by normal-round ordinal, whereas callers + # use raw consensus rounds (0, 2, 4, ... for normal rounds). + rotations_index = round // 2 rotations = ( - int(fees["rotations"][round - 1]) if round - 1 < len(fees["rotations"]) else 0 + int(fees["rotations"][rotations_index]) + if rotations_index < len(fees["rotations"]) + else 0 ) return _calculate_fee_for_round( _validators_per_round_safe(round), @@ -2080,6 +3330,8 @@ def _calculate_appeal_round_total( def _calculate_appeal_profit_reserve( fees: dict[str, int | list[int]], + *, + gen_per_time_unit: int = 0, ) -> int: rotations = fees["rotations"] if not isinstance(rotations, list): @@ -2097,6 +3349,8 @@ def _calculate_appeal_profit_reserve( int(fees["leaderTimeunitsAllocation"]), int(fees["validatorTimeunitsAllocation"]), ) + if gen_per_time_unit > 0: + next_normal_bond *= gen_per_time_unit reserve += successful_appeal_profit(next_normal_bond) return reserve @@ -2124,6 +3378,32 @@ def _time_unit_overlay(time_unit_work: int, split_bps: int) -> int: return time_unit_work * split_bps // (10_000 - split_bps) +def _carve_time_unit_overlay_reserve( + *, + current_reserve: int, + cumulative_primary: int, + execution_budget: int, + incoming_primary: int, + split_bps: int, +) -> int: + """Mirror FeeManagerHelpers.carveOverlayReserve for one funding call.""" + current_reserve = max(0, int(current_reserve)) + incoming_primary = max(0, int(incoming_primary)) + split_bps = int(split_bps) + if split_bps <= 0: + return current_reserve + if split_bps >= 10_000: + raise FeeValidationError("InvalidTimeUnitOverlayBps") + + taxable_time_unit = max( + 0, + int(cumulative_primary) - int(execution_budget), + ) + target_reserve = taxable_time_unit * split_bps // 10_000 + reserve_delta = max(0, target_reserve - current_reserve) + return current_reserve + min(reserve_delta, incoming_primary) + + def successful_appeal_reward(appeal_bond: int) -> int: appeal_bond = int(appeal_bond) return ( @@ -2229,8 +3509,8 @@ def _leader_slots_for_round( def _round_outcomes(consensus_history: dict[str, Any] | None) -> dict[int, str]: return { - index: str(entry.get("consensus_round") or "") - for index, entry in enumerate(completed_consensus_rounds(consensus_history)) + round_index: str(entry.get("consensus_round") or "") + for round_index, entry in logical_fee_round_entries(consensus_history) } @@ -2249,6 +3529,12 @@ def _settle_appeal_bonds( return copy.deepcopy(existing), 0 outcomes = _round_outcomes(consensus_history) + entries = dict(logical_fee_round_entries(consensus_history)) + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + validator_timeunits = int(fees["validatorTimeunitsAllocation"]) + locked_policy = _accounting_policy(accounting) + if locked_policy.gen_per_time_unit > 0: + validator_timeunits *= int(locked_policy.gen_per_time_unit) settlements: list[dict[str, Any]] = [] payout_total = 0 for index, bond in enumerate(accounting.get("appeal_bonds") or []): @@ -2281,6 +3567,41 @@ def _settle_appeal_bonds( entry["outcome"] = outcome if status == "forfeited": entry["bond_forfeited"] = amount + if outcome == ConsensusRound.LEADER_TIMEOUT_APPEAL_FAILED.value: + leader_share = amount // 2 + entry["distribution"] = "leader-timeout-split" + entry["leaderPayout"] = leader_share + entry["senderRefund"] = amount - leader_share + elif outcome in { + ConsensusRound.VALIDATOR_APPEAL_FAILED.value, + ConsensusRound.VALIDATORS_TIMEOUT_APPEAL_FAILED.value, + }: + outcome_entry = entries.get(int(outcome_index or 0)) + receipts = _entry_validator_receipts(outcome_entry) + revealed = ( + len(receipts) + if receipts + else _validators_per_round_safe(int(outcome_index or 0)) + ) + aligned = ( + _aligned_validator_count(outcome_entry) if receipts else revealed + ) + pool = revealed * validator_timeunits + amount + dust = pool if aligned <= 0 else pool % aligned + sender_refund = min(amount, dust) + entry["distribution"] = "appeal-validators" + entry["bondDistributed"] = amount - sender_refund + if sender_refund: + entry["senderRefund"] = sender_refund + elif outcome == ConsensusRound.LEADER_APPEAL_FAILED.value: + outcome_entry = entries.get(int(outcome_index or 0)) + receipts = _entry_validator_receipts(outcome_entry) + aligned = _aligned_validator_count(outcome_entry) if receipts else 0 + sender_refund = amount if aligned <= 0 else amount % aligned + entry["distribution"] = "replay-validators" + entry["bondDistributed"] = amount - sender_refund + if sender_refund: + entry["senderRefund"] = sender_refund settlements.append(entry) return settlements, payout_total @@ -2312,15 +3633,20 @@ def _normalize_message_allocation(node: dict[str, Any]) -> dict[str, Any]: def _validate_external_allocation( node: dict[str, Any], external_keys: set[tuple[str, str]], + policy: StudioFeePolicy, ) -> None: if int(node["parentIndex"]) != NODE_ROOT_SENTINEL: raise AllocationTreeMalformed("AllocationTreeMalformed") + if bool(node["onAcceptance"]): + raise ExternalAllocationInvalid("ExternalOnAcceptanceNotSupported") external_fee_params = decode_external_message_fee_params(node["feeParams"]) gas_limit = int(external_fee_params["gasLimit"]) max_gas_price = int(external_fee_params["maxGasPrice"]) if gas_limit == 0 or max_gas_price == 0: raise ExternalAllocationInvalid("ExternalAllocationInvalid") + if gas_limit < int(policy.min_external_gas_limit): + raise ExternalAllocationInvalid("ExternalGasLimitBelowMinimum") per_call = gas_limit * max_gas_price budget = int(node["budget"]) @@ -2380,6 +3706,85 @@ def _fee_params_bytes(fee_params: bytes | str) -> bytes: return bytes(fee_params) +def _record_fee_contribution( + accounting: dict[str, Any], + depositor: str | None, + amount: int, +) -> None: + amount = max(0, int(amount)) + if amount == 0: + return + if not depositor: + accounting["untracked_contributions"] = ( + int(accounting.get("untracked_contributions", 0) or 0) + amount + ) + return + + contributions = accounting.setdefault("contributions", []) + if ( + contributions + and str(contributions[-1].get("depositor", "")).lower() + == str(depositor).lower() + ): + contributions[-1]["amount"] = int(contributions[-1].get("amount", 0)) + amount + return + if len(contributions) >= MAX_CONTRIBUTION_SEGMENTS: + accounting["untracked_contributions"] = ( + int(accounting.get("untracked_contributions", 0) or 0) + amount + ) + return + contributions.append({"depositor": depositor, "amount": amount}) + + +def _allocate_fee_refund( + accounting: dict[str, Any], + refundable: int, +) -> list[dict[str, Any]]: + refundable = max(0, int(refundable)) + if refundable == 0: + return [] + + contributions = [ + { + "depositor": item.get("depositor"), + "amount": max(0, int(item.get("amount", 0) or 0)), + } + for item in accounting.get("contributions") or [] + if isinstance(item, dict) and item.get("depositor") + ] + tracked_total = sum(item["amount"] for item in contributions) + tracked_refundable = min(refundable, tracked_total) + consumed = tracked_total - tracked_refundable + settlements: list[dict[str, Any]] = [] + cumulative = 0 + for item in contributions: + end = cumulative + item["amount"] + amount = 0 + if cumulative >= consumed: + amount = item["amount"] + elif end > consumed: + amount = end - consumed + if amount > 0: + settlements.append( + {"recipient": item["depositor"], "amount": amount, "source": "fifo"} + ) + cumulative = end + + remainder = refundable - sum(item["amount"] for item in settlements) + fallback = accounting.get("sender") + if remainder > 0 and fallback: + for settlement in settlements: + if str(settlement["recipient"]).lower() == str(fallback).lower(): + settlement["amount"] += remainder + settlement["source"] = "fifo+remainder" + break + else: + settlements.append( + {"recipient": fallback, "amount": remainder, "source": "remainder"} + ) + return settlements + + def _new_fee_accounting( *, fees_distribution: dict[str, Any], @@ -2393,11 +3798,24 @@ def _new_fee_accounting( policy: StudioFeePolicy, ) -> dict[str, Any]: fees = _serializable_fees_distribution(fees_distribution) + # FeeManager owns this counter. A caller may include the field in the ABI + # struct, but addFeesDistribution deliberately never copies it into + # storage; only receipt/storage charging advances it later. + fees["executionConsumed"] = 0 total_message_fees = int(fees["totalMessageFees"]) execution_budget_total = int(fees["executionBudgetPerRound"]) * get_leader_rounds( fees ) primary_required = max(0, int(required_fee_value) - total_message_fees) + primary_budget = max(0, int(fee_value) - total_message_fees) + overlay_budget = _carve_time_unit_overlay_reserve( + current_reserve=0, + cumulative_primary=primary_budget, + execution_budget=execution_budget_total, + incoming_primary=primary_budget, + split_bps=policy.time_unit_overlay_bps, + ) + tracked_contribution = primary_budget - overlay_budget + total_message_fees return { "version": 1, "source": source, @@ -2409,10 +3827,11 @@ def _new_fee_accounting( "paid_fee_value": int(fee_value), "required_fee_value": int(required_fee_value), "primary_fee_required": primary_required, - "primary_fee_budget": max(0, int(fee_value) - total_message_fees), + "primary_fee_budget": primary_budget, "primary_fee_spent": 0, "primary_fee_refunded": 0, "execution_budget_total": execution_budget_total, + "time_unit_overlay_budget": overlay_budget, "execution_fee_consumed": 0, "execution_fee_consumed_buckets": [], "genvm_fee_consumed_buckets": [], @@ -2424,9 +3843,13 @@ def _new_fee_accounting( "external_message_fee_reserved": 0, "external_message_fee_reimbursed": 0, "external_message_fee_remainder": 0, + "external_message_fee_settled": 0, "external_message_events": [], + "external_message_fee_payouts": [], "appeal_bonds": [], "appeal_bonds_total": 0, + "appeal_bonds_payout_total": 0, + "appeal_bond_sender_refunded": 0, "appeal_funding_total": 0, "appeal_charge_surplus_refunded": 0, "total_refunded": 0, @@ -2440,6 +3863,12 @@ def _new_fee_accounting( "feesDistribution": fees, } ], + "contributions": ( + [{"depositor": sender, "amount": tracked_contribution}] + if sender and tracked_contribution > 0 + else [] + ), + "untracked_contributions": 0, "fees_distribution": fees, "message_allocations": [ _serializable_message_allocation(allocation) @@ -2483,15 +3912,9 @@ def _fees_distribution_from_internal_params( "executionConsumed": 0, "totalMessageFees": int(total_message_fees), "rotations": [int(rotation) for rotation in fee_params["rotations"]], - "maxPriceGenPerTimeUnit": int( - parent_fees_distribution.get("maxPriceGenPerTimeUnit", 0) - ), - "storageFeeMaxGasPrice": int( - parent_fees_distribution.get("storageFeeMaxGasPrice", 0) - ), - "receiptFeeMaxGasPrice": int( - parent_fees_distribution.get("receiptFeeMaxGasPrice", 0) - ), + "maxPriceGenPerTimeUnit": int(fee_params["maxPriceGenPerTimeUnit"]), + "storageFeeMaxGasPrice": int(fee_params["storageFeeMaxGasPrice"]), + "receiptFeeMaxGasPrice": int(fee_params["receiptFeeMaxGasPrice"]), } @@ -2517,18 +3940,9 @@ def _genvm_message_fee_params( ), "execution_budget_per_round": int(decoded["executionBudgetPerRound"]), "rotations": [int(rotation) for rotation in decoded["rotations"]], - # Studio's chain-canonical per-node ABI carries only the first five - # internal fields. The three price caps live on fees_distribution, - # so add them only at the GenVM manager boundary. - "max_price_gen_per_time_unit": int( - fees_distribution.get("maxPriceGenPerTimeUnit", 0) - ), - "storage_fee_max_gas_price": int( - fees_distribution.get("storageFeeMaxGasPrice", 0) - ), - "receipt_fee_max_gas_price": int( - fees_distribution.get("receiptFeeMaxGasPrice", 0) - ), + "max_price_gen_per_time_unit": int(decoded["maxPriceGenPerTimeUnit"]), + "storage_fee_max_gas_price": int(decoded["storageFeeMaxGasPrice"]), + "receipt_fee_max_gas_price": int(decoded["receiptFeeMaxGasPrice"]), }, } @@ -2729,7 +4143,7 @@ def _is_matched_root_allocation( ): return False if _normalize_call_key(allocation["callKey"]) != _normalize_call_key( - message.get("callKey", CALL_KEY_WILDCARD) + message.get("callKey", EMPTY_CALL_KEY) ): return False if _fee_params_hex(allocation["feeParams"]) != _fee_params_hex( @@ -2775,36 +4189,48 @@ def _reserve_external_execution( policy: StudioFeePolicy, *, reimburse: bool = True, + executor: str | None = None, ) -> int: if bool(message.get("onAcceptance", False)): - return 0 + raise ExternalAllocationInvalid("ExternalOnAcceptanceNotSupported") allocations = accounting.get("message_allocations") or [] if not allocations: return 0 - resolved = _resolve_allocation(allocations, message) - if resolved is None: + candidates = list(_matching_root_allocations(allocations, message)) + if not candidates: return 0 - index, allocation = resolved - if int(allocation["messageType"]) != MESSAGE_TYPE_EXTERNAL: - return 0 + selected: tuple[int, dict[str, Any], int, int, int] | None = None + for index, allocation in candidates: + external_fee_params = decode_external_message_fee_params( + allocation["feeParams"] + ) + gas_limit = int(external_fee_params["gasLimit"]) + max_gas_price = int(external_fee_params["maxGasPrice"]) + locked_price = ( + min(policy.receipt_gas_price, max_gas_price) + if policy.receipt_gas_price > 0 + else 0 + ) + reservation = gas_limit * locked_price + key = str(index) + consumed = int(accounting.setdefault("allocation_consumed", {}).get(key, 0)) + attempted = consumed + reservation + if attempted <= int(allocation["budget"]): + selected = (index, allocation, gas_limit, locked_price, reservation) + break + + # Consensus falls through exact -> wildcard when the exact allocation is + # exhausted. If records existed but neither has room, the reveal fails. + if selected is None: + raise MessageBudgetExceeded("MessageBudgetExceeded") - external_fee_params = decode_external_message_fee_params(allocation["feeParams"]) - gas_limit = int(external_fee_params["gasLimit"]) - max_gas_price = int(external_fee_params["maxGasPrice"]) - locked_price = ( - min(policy.receipt_gas_price, max_gas_price) - if policy.receipt_gas_price > 0 - else 0 - ) - reservation = gas_limit * locked_price + index, _allocation, gas_limit, locked_price, reservation = selected key = str(index) consumed = int(accounting.setdefault("allocation_consumed", {}).get(key, 0)) attempted = consumed + reservation - if attempted > int(allocation["budget"]): - raise MessageBudgetExceeded("MessageBudgetExceeded") accounting["allocation_consumed"][key] = attempted gas_used = int(message.get("gasUsed", 0) or 0) @@ -2820,21 +4246,53 @@ def _reserve_external_execution( accounting["external_message_fee_remainder"] = ( int(accounting.get("external_message_fee_remainder", 0)) + remainder ) - accounting.setdefault("external_message_events", []).append( - { - "recipient": str(message.get("recipient", "")).lower(), - "callKey": _normalize_call_key(message.get("callKey", CALL_KEY_WILDCARD)), - "allocationIndex": index, - "gasLimit": gas_limit, - "lockedGasPrice": locked_price, - "reservation": reservation, - "gasUsed": gas_used if reimburse else 0, - "reimbursement": reimbursement if reimburse else 0, - "remainder": remainder if reimburse else 0, - "executionRecorded": bool(reimburse), - } - ) - return reimbursement if reimburse else 0 + event = { + "recipient": str(message.get("recipient", "")).lower(), + "callKey": _normalize_call_key(message.get("callKey", EMPTY_CALL_KEY)), + "allocationIndex": index, + "gasLimit": gas_limit, + "lockedGasPrice": locked_price, + "reservation": reservation, + "gasUsed": gas_used if reimburse else 0, + "reimbursement": reimbursement if reimburse else 0, + "remainder": remainder if reimburse else 0, + "executionRecorded": bool(reimburse), + } + accounting.setdefault("external_message_events", []).append(event) + if reimburse: + accounting["external_message_fee_settled"] = ( + int(accounting.get("external_message_fee_settled", 0)) + reservation + ) + _record_external_message_fee_payouts(accounting, event, executor) + return reservation if reimburse else 0 + + +def _record_external_message_fee_payouts( + accounting: dict[str, Any], + event: dict[str, Any], + executor: str | None, +) -> None: + reimbursement = int(event.get("reimbursement", 0) or 0) + remainder = int(event.get("remainder", 0) or 0) + depositor = accounting.get("sender") + executor_recipient = executor or depositor + payouts = accounting.setdefault("external_message_fee_payouts", []) + if reimbursement > 0 and executor_recipient: + payouts.append( + { + "recipient": executor_recipient, + "amount": reimbursement, + "source": "external-executor-reimbursement", + } + ) + if remainder > 0 and depositor: + payouts.append( + { + "recipient": depositor, + "amount": remainder, + "source": "external-execution-remainder", + } + ) def _find_unrefunded_external_message_event( @@ -2842,7 +4300,7 @@ def _find_unrefunded_external_message_event( message: dict[str, Any], ) -> int | None: recipient = str(message.get("recipient", "")).lower() - call_key = _normalize_call_key(message.get("callKey", CALL_KEY_WILDCARD)) + call_key = _normalize_call_key(message.get("callKey", EMPTY_CALL_KEY)) for index, event in enumerate(accounting.get("external_message_events") or []): if ( event.get("failureRefunded") @@ -2852,7 +4310,7 @@ def _find_unrefunded_external_message_event( continue if str(event.get("recipient", "")).lower() != recipient: continue - if _normalize_call_key(event.get("callKey", CALL_KEY_WILDCARD)) != call_key: + if _normalize_call_key(event.get("callKey", EMPTY_CALL_KEY)) != call_key: continue return index return None @@ -2863,13 +4321,13 @@ def _find_unexecuted_external_message_event( message: dict[str, Any], ) -> int | None: recipient = str(message.get("recipient", "")).lower() - call_key = _normalize_call_key(message.get("callKey", CALL_KEY_WILDCARD)) + call_key = _normalize_call_key(message.get("callKey", EMPTY_CALL_KEY)) for index, event in enumerate(accounting.get("external_message_events") or []): if event.get("executionRecorded") or event.get("unreserved"): continue if str(event.get("recipient", "")).lower() != recipient: continue - if _normalize_call_key(event.get("callKey", CALL_KEY_WILDCARD)) != call_key: + if _normalize_call_key(event.get("callKey", EMPTY_CALL_KEY)) != call_key: continue return index return None @@ -2879,7 +4337,11 @@ def _unreserve_external_message_fee( accounting: dict[str, Any], message: dict[str, Any], ) -> tuple[int, int, int]: - event_index = _find_unrefunded_external_message_event(accounting, message) + # Reveal unwind applies only to a reservation that has not executed yet. + # Once execution is recorded Consensus consumes the whole reservation and + # routes its reimbursement/remainder payouts; rolling that event back here + # would leave those terminal payouts inconsistent with the fee ledger. + event_index = _find_unexecuted_external_message_event(accounting, message) if event_index is None: return 0, 0, 0 @@ -2891,10 +4353,14 @@ def _unreserve_external_message_fee( allocation_consumed = accounting.setdefault("allocation_consumed", {}) consumed = int(allocation_consumed.get(allocation_index, 0) or 0) - allocation_consumed[allocation_index] = max(0, consumed - reservation) + remaining = max(0, consumed - reservation) + if remaining > 0: + allocation_consumed[allocation_index] = remaining + else: + allocation_consumed.pop(allocation_index, None) accounting["message_fee_consumed"] = max( 0, - int(accounting.get("message_fee_consumed", 0)) - reimbursement, + int(accounting.get("message_fee_consumed", 0)) - reservation, ) accounting["external_message_fee_reserved"] = max( 0, @@ -2908,6 +4374,10 @@ def _unreserve_external_message_fee( 0, int(accounting.get("external_message_fee_remainder", 0)) - remainder, ) + accounting["external_message_fee_settled"] = max( + 0, + int(accounting.get("external_message_fee_settled", 0)) - reservation, + ) event["unreserved"] = True return reservation, reimbursement, remainder @@ -2924,18 +4394,32 @@ def _decrement_allocation_consumed( allocation_consumed = accounting.setdefault("allocation_consumed", {}) key = str(index) consumed = int(allocation_consumed.get(key, 0) or 0) - allocation_consumed[key] = max(0, consumed - int(amount)) + remaining = max(0, consumed - int(amount)) + if remaining > 0: + allocation_consumed[key] = remaining + else: + allocation_consumed.pop(key, None) def _resolve_allocation( allocations: list[dict[str, Any]], message: dict[str, Any], ) -> tuple[int, dict[str, Any]] | None: + return next(_matching_root_allocations(allocations, message), None) + + +def _matching_root_allocations( + allocations: list[dict[str, Any]], + message: dict[str, Any], +): message_type = int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) recipient = str(message.get("recipient", "")).lower() - call_key = _normalize_call_key(message.get("callKey", CALL_KEY_WILDCARD)) + call_key = _normalize_call_key(message.get("callKey", EMPTY_CALL_KEY)) - for wanted_call_key in (call_key, CALL_KEY_WILDCARD): + wanted_call_keys = [call_key] + if call_key != CALL_KEY_WILDCARD: + wanted_call_keys.append(CALL_KEY_WILDCARD) + for wanted_call_key in wanted_call_keys: for index, raw_allocation in enumerate(allocations): allocation = _serializable_message_allocation(raw_allocation) if int(allocation["parentIndex"]) != NODE_ROOT_SENTINEL: @@ -2945,8 +4429,7 @@ def _resolve_allocation( if str(allocation["recipient"]).lower() != recipient: continue if _normalize_call_key(allocation["callKey"]) == wanted_call_key: - return index, allocation - return None + yield index, allocation def _receipt_message_fee_payloads( @@ -2961,7 +4444,9 @@ def _receipt_message_fee_payloads( payloads: list[dict[str, Any]] = [] for raw in _receipt_pending_transactions(receipt): message = _receipt_pending_transaction_fee_payload(raw) - if accounting.get("message_allocations"): + if accounting.get("message_allocations") and not bool( + message.get("useBalance", False) + ): message = fill_message_fee_payload_from_allocation(accounting, message) payloads.append(message) return payloads @@ -3022,7 +4507,7 @@ def _receipt_pending_transaction_fee_payload(raw: Any) -> dict[str, Any]: message, "call_key", "callKey", - CALL_KEY_WILDCARD, + EMPTY_CALL_KEY, ) if message_type == MESSAGE_TYPE_EXTERNAL: call_key = derive_external_message_call_key(call_key, data) @@ -3058,6 +4543,7 @@ def _receipt_pending_transaction_fee_payload(raw: Any) -> dict[str, Any]: [], ), "callKey": call_key, + "useBalance": bool(_message_field(message, "use_balance", "useBalance", False)), "gasUsed": int(_message_field(message, "gas_used", "gasUsed", 0) or 0), } @@ -3199,7 +4685,8 @@ def _message_fee_accounting_report(accounting: dict[str, Any]) -> dict[str, int] external_reserved = int(accounting.get("external_message_fee_reserved", 0) or 0) external_reimbursed = int(accounting.get("external_message_fee_reimbursed", 0) or 0) external_remainder = int(accounting.get("external_message_fee_remainder", 0) or 0) - declared_consumed = max(0, total_consumed - external_reimbursed) + external_settled = int(accounting.get("external_message_fee_settled", 0) or 0) + declared_consumed = max(0, total_consumed - external_settled) declared_refunded = int(accounting.get("message_fee_refunded", 0) or 0) genvm_metered_consumed = int(accounting.get("genvm_message_fee_consumed", 0) or 0) report = { @@ -3214,6 +4701,7 @@ def _message_fee_accounting_report(accounting: dict[str, Any]) -> dict[str, int] report["externalReserved"] = external_reserved report["externalReimbursed"] = external_reimbursed report["externalRemainder"] = external_remainder + report["externalSettled"] = external_settled report["totalConsumed"] = total_consumed if accounting.get("reported_message_fees_total") is not None: report["reportedTotal"] = int(accounting["reported_message_fees_total"]) @@ -3255,6 +4743,7 @@ def recommended_fee_preset( execution_floor += ( policy.receipt_gas_price * DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM ) + execution_floor = max(execution_floor, policy.genvm_start_budget_floor()) observed_execution = _observed_chargeable_execution_fee(accounting, report) recommended_execution = int(fees["executionBudgetPerRound"]) @@ -3371,6 +4860,24 @@ def _receipt_data_fees_consumed(receipt: Any | None) -> list[int] | None: return [max(0, int(total) - int(rest)) for total, rest in zip(totals, remaining)] +def _receipt_execution_policy( + receipt: Any | None, + fallback: StudioFeePolicy, +) -> StudioFeePolicy: + genvm_result = _receipt_genvm_result(receipt) + snapshot = ( + genvm_result.get(FEE_POLICY_SNAPSHOT_KEY) + if isinstance(genvm_result, dict) + else None + ) + if isinstance(snapshot, dict): + try: + return StudioFeePolicy.from_snapshot(snapshot) + except (KeyError, TypeError, ValueError): + pass + return fallback + + def _receipt_reported_message_fees_total(receipt: Any | None) -> int | None: if receipt is None: return None @@ -3396,6 +4903,7 @@ def _receipt_fee_report( return None eq_outputs_length = _receipt_eq_blocks_outputs_length(receipt) + validate_receipt_admission_caps(receipt, policy) receipt_bytes = policy.estimate_propose_receipt_bytes(eq_outputs_length) proposal_gas = policy.estimate_propose_receipt_gas(receipt_bytes) proposal_fee = proposal_gas * policy.receipt_gas_price @@ -3411,12 +4919,21 @@ def _receipt_fee_report( "totalStudioMeteredFee": proposal_fee, } - submitted_messages, message_reports = _receipt_submitted_messages_and_reports( - receipt, - message_payloads, + submitted_messages, message_reports = ( + _receipt_submitted_messages_and_reports(receipt, message_payloads) + if _receipt_execution_allows_messages(receipt) + else ([], []) ) if submitted_messages: message_bytes = len(encode([SUBMITTED_MESSAGE_ABI_TYPE], [submitted_messages])) + if ( + policy.max_submitted_messages_bytes > 0 + and message_bytes > policy.max_submitted_messages_bytes + ): + raise SubmittedMessagesTooLarge( + "SubmittedMessagesTooLarge" + f"({message_bytes},{policy.max_submitted_messages_bytes})" + ) message_gas = policy.estimate_message_reveal_gas( message_bytes, len(submitted_messages), @@ -3447,9 +4964,9 @@ def _receipt_fee_report( def _receipt_eq_blocks_outputs_length(receipt: Any) -> int: genvm_result = _receipt_genvm_result(receipt) if isinstance(genvm_result, dict): - explicit = genvm_result.get("eq_blocks_outputs_length") or genvm_result.get( - "eqBlocksOutputsLength" - ) + explicit = genvm_result.get("eq_blocks_outputs_length") + if explicit is None: + explicit = genvm_result.get("eqBlocksOutputsLength") if explicit is not None: return max(0, int(explicit)) @@ -3465,6 +4982,25 @@ def _receipt_submitted_messages(receipt: Any) -> list[tuple[Any, ...]]: return submitted +def _enforce_submitted_messages_cap( + messages: list[dict[str, Any]], + policy: StudioFeePolicy, +) -> int: + if not messages: + return 0 + submitted, _ = _receipt_submitted_messages_and_reports({}, messages) + message_bytes = len(encode([SUBMITTED_MESSAGE_ABI_TYPE], [submitted])) + if ( + policy.max_submitted_messages_bytes > 0 + and message_bytes > policy.max_submitted_messages_bytes + ): + raise SubmittedMessagesTooLarge( + "SubmittedMessagesTooLarge" + f"({message_bytes},{policy.max_submitted_messages_bytes})" + ) + return message_bytes + + def _receipt_submitted_messages_and_reports( receipt: Any, message_payloads: list[dict[str, Any]] | None = None, @@ -3518,7 +5054,7 @@ def _receipt_submitted_messages_and_reports( message, "call_key", "callKey", - CALL_KEY_WILDCARD, + EMPTY_CALL_KEY, ) if message_type == MESSAGE_TYPE_EXTERNAL: call_key_value = derive_external_message_call_key(call_key_value, data) @@ -3535,6 +5071,7 @@ def _receipt_submitted_messages_and_reports( declared_budget, allocation_subtree, call_key, + bool(_message_field(message, "use_balance", "useBalance", False)), ) ) reports.append( @@ -3562,11 +5099,182 @@ def _receipt_submitted_messages_and_reports( "allocationSubtree": "0x" + allocation_subtree.hex(), "allocationSubtreeBytes": len(allocation_subtree), "callKey": "0x" + call_key.hex(), + "useBalance": bool( + _message_field(message, "use_balance", "useBalance", False) + ), } ) return submitted, reports +def _message_effect_identities( + tx_id: str, + messages: list[dict[str, Any]], +) -> list[tuple[str, str]]: + """Mirror Messages._logicalOccurrence and _effectDescriptor. + + The application key deliberately excludes fee/value/lifecycle fields. Any + drift in those fields is caught by the full SubmittedMessage descriptor for + the same stable occurrence, exactly as in Consensus. + """ + + submitted, _ = _receipt_submitted_messages_and_reports({}, messages) + tx_id_bytes = _bytes32_field(tx_id) + tuple_type = SUBMITTED_MESSAGE_ABI_TYPE.removesuffix("[]") + occurrence_counts: dict[bytes, int] = {} + identities: list[tuple[str, str]] = [] + zero_address = "0x" + ("0" * 40) + + for submitted_message in submitted: + message_type = int(submitted_message[0]) + recipient = str(submitted_message[1]).lower() + data_hash = keccak(bytes(submitted_message[3])) + deploy_salt = ( + int(submitted_message[5]) + if message_type == MESSAGE_TYPE_INTERNAL and recipient == zero_address + else 0 + ) + application_key = keccak( + encode( + ["uint8", "address", "bytes32", "uint256"], + [message_type, recipient, data_hash, deploy_salt], + ) + ) + ordinal = occurrence_counts.get(application_key, 0) + occurrence_counts[application_key] = ordinal + 1 + occurrence = keccak( + encode( + ["bytes32", "bytes32", "uint256"], + [tx_id_bytes, application_key, ordinal], + ) + ) + descriptor = keccak(encode([tuple_type], [submitted_message])) + identities.append(("0x" + occurrence.hex(), "0x" + descriptor.hex())) + + return identities + + +def message_novelty_mask( + accounting: dict[str, Any], + tx_id: str, + messages: list[dict[str, Any]], +) -> list[bool]: + identities = _message_effect_identities(tx_id, messages) + descriptors = accounting.get("message_effect_descriptors") or {} + delivered = accounting.get("message_effect_delivered") or {} + novelty = [] + for occurrence, descriptor in identities: + expected = descriptors.get(occurrence) + if expected is not None and expected != descriptor: + raise MessageEffectDescriptorMismatch( + "MessageEffectDescriptorMismatch" + f"({tx_id},{occurrence},{expected},{descriptor})" + ) + novelty.append(not bool(delivered.get(occurrence, False))) + return novelty + + +def prepare_reveal_message_generation( + accounting: dict[str, Any], + tx_id: str, + messages: list[dict[str, Any]], + *, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + """Retire the prior reveal and charge only fresh logical occurrences.""" + + updated = copy.deepcopy(accounting) + prior_generation = updated.get("active_message_generation") + if isinstance(prior_generation, dict): + updated = unwind_reveal_message_fees( + updated, + list(prior_generation.get("messages") or []), + acceptance_dispatched=bool( + prior_generation.get("acceptanceDispatched", False) + ), + ) + + identities = _message_effect_identities(tx_id, messages) + novelty = message_novelty_mask(updated, tx_id, messages) + descriptors = updated.setdefault("message_effect_descriptors", {}) + for occurrence, descriptor in identities: + descriptors.setdefault(occurrence, descriptor) + + novel_messages = [ + message for message, is_novel in zip(messages, novelty) if is_novel + ] + updated = record_reveal_message_fees( + updated, + novel_messages, + policy=policy, + ) + updated["active_message_generation"] = { + "messages": _message_accounting_json_safe(messages), + "occurrences": [occurrence for occurrence, _ in identities], + "novelty": novelty, + "acceptanceDispatched": False, + } + return updated + + +def _message_accounting_json_safe(value: Any) -> Any: + if isinstance(value, bytes): + return "0x" + value.hex() + if isinstance(value, bytearray): + return "0x" + bytes(value).hex() + if isinstance(value, list): + return [_message_accounting_json_safe(item) for item in value] + if isinstance(value, tuple): + return [_message_accounting_json_safe(item) for item in value] + if isinstance(value, dict): + return {key: _message_accounting_json_safe(item) for key, item in value.items()} + return value + + +def discard_active_message_generation( + accounting: dict[str, Any], +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + prior_generation = updated.pop("active_message_generation", None) + if not isinstance(prior_generation, dict): + return updated + updated = unwind_reveal_message_fees( + updated, + list(prior_generation.get("messages") or []), + acceptance_dispatched=bool(prior_generation.get("acceptanceDispatched", False)), + ) + updated.pop("active_message_generation", None) + updated["message_fees_recorded_at_reveal"] = True + return updated + + +def mark_message_effects_delivered( + accounting: dict[str, Any], + tx_id: str, + messages: list[dict[str, Any]], + on: str, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + identities = _message_effect_identities(tx_id, messages) + # Reuse descriptor validation before installing an irreversible tombstone. + message_novelty_mask(updated, tx_id, messages) + delivered = updated.setdefault("message_effect_delivered", {}) + delivered_any = False + for message, (occurrence, descriptor) in zip(messages, identities): + if bool(message.get("onAcceptance", False)) != (on == "accepted"): + continue + updated.setdefault("message_effect_descriptors", {}).setdefault( + occurrence, descriptor + ) + delivered[occurrence] = True + delivered_any = True + + generation = updated.get("active_message_generation") + if delivered_any and on == "accepted" and isinstance(generation, dict): + generation["acceptanceDispatched"] = True + return updated + + def _message_fee_mode( message_type: int, allocation_subtree: bytes, @@ -3621,8 +5329,9 @@ def _pending_transaction_dict(pending_transaction: Any) -> dict[str, Any]: ), "fee_params": getattr(pending_transaction, "fee_params", b""), "declared_budget": getattr(pending_transaction, "declared_budget", 0), - "call_key": getattr(pending_transaction, "call_key", CALL_KEY_WILDCARD), + "call_key": getattr(pending_transaction, "call_key", EMPTY_CALL_KEY), "allocation_subtree": getattr(pending_transaction, "allocation_subtree", []), + "use_balance": getattr(pending_transaction, "use_balance", False), } @@ -3809,12 +5518,14 @@ def _normalize_call_key(call_key: bytes | str) -> str: def derive_external_message_call_key( call_key: bytes | str | None, calldata: Any ) -> str: - normalized = _normalize_call_key(call_key or CALL_KEY_WILDCARD) - if normalized != CALL_KEY_WILDCARD: + normalized = _normalize_call_key(call_key or EMPTY_CALL_KEY) + if normalized == CALL_KEY_WILDCARD: + return normalized + if normalized != EMPTY_CALL_KEY: return normalized raw_calldata = _bytes_field(calldata) if len(raw_calldata) < 4: - return CALL_KEY_WILDCARD + return EMPTY_CALL_KEY return "0x" + raw_calldata[:4].hex().ljust(64, "0") diff --git a/backend/protocol_rpc/rpc_methods.py b/backend/protocol_rpc/rpc_methods.py index bd51e9d23..9f47eed6e 100644 --- a/backend/protocol_rpc/rpc_methods.py +++ b/backend/protocol_rpc/rpc_methods.py @@ -310,6 +310,24 @@ def sim_calculate_round_fees( ) +@rpc.method("sim_minMessagePrimaryFees", log_policy=LogPolicy.debug()) +def sim_min_message_primary_fees(fee_params: str) -> str: + return impl.sim_min_message_primary_fees(fee_params) + + +@rpc.method("sim_estimateProposeReceiptGas", log_policy=LogPolicy.debug()) +def sim_estimate_propose_receipt_gas(eq_outputs_length: int = 0) -> dict[str, str]: + return impl.sim_estimate_propose_receipt_gas(eq_outputs_length) + + +@rpc.method("sim_estimateMessageRevealGas", log_policy=LogPolicy.debug()) +def sim_estimate_message_reveal_gas( + message_bytes: int, + message_count: int, +) -> dict[str, str]: + return impl.sim_estimate_message_reveal_gas(message_bytes, message_count) + + @rpc.method("sim_getConsensusContract", log_policy=LogPolicy.debug()) def get_consensus_contract( contract_name: str, diff --git a/docker-compose.yml b/docker-compose.yml index 9f5667594..88a28382a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -133,10 +133,19 @@ services: - RATE_LIMIT_ANON_PER_HOUR=${RATE_LIMIT_ANON_PER_HOUR:-500} - RATE_LIMIT_ANON_PER_DAY=${RATE_LIMIT_ANON_PER_DAY:-5000} - RATE_LIMIT_READ_MULTIPLIER=${RATE_LIMIT_READ_MULTIPLIER:-10} - - GENLAYER_STUDIO_GEN_PER_TIME_UNIT=${GENLAYER_STUDIO_GEN_PER_TIME_UNIT:-1000000000000000} - - GENLAYER_STUDIO_STORAGE_UNIT_PRICE=${GENLAYER_STUDIO_STORAGE_UNIT_PRICE:-1} - - GENLAYER_STUDIO_RECEIPT_GAS_PRICE=${GENLAYER_STUDIO_RECEIPT_GAS_PRICE:-1} + - GENLAYER_STUDIO_GEN_PER_TIME_UNIT=${GENLAYER_STUDIO_GEN_PER_TIME_UNIT:-1} + - GENLAYER_STUDIO_STORAGE_UNIT_PRICE=${GENLAYER_STUDIO_STORAGE_UNIT_PRICE:-250000000} + - GENLAYER_STUDIO_RECEIPT_GAS_PRICE=${GENLAYER_STUDIO_RECEIPT_GAS_PRICE:-250000000} - GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS=${GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS:-1500} + - GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS=${GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS:-30} + - GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS=${GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS:-600} + - GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS:-30} + - GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS:-600} + - GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT=${GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT:-0} + - GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES=${GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES:-20} + - GENLAYER_STUDIO_MAX_MESSAGES_PER_TX=${GENLAYER_STUDIO_MAX_MESSAGES_PER_TX:-0} + - GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES=${GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES:-0} + - GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES=${GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES:-0} - GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS=${GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS:-210000} - GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS=${GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS:-100000} - GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES=${GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES:-1024} @@ -325,10 +334,19 @@ services: - WEBDRIVERHOST=${WEBDRIVERHOST} - WEBDRIVERPORT=${WEBDRIVERPORT} - REDIS_URL=${REDIS_URL:-redis://redis:6379/0} - - GENLAYER_STUDIO_GEN_PER_TIME_UNIT=${GENLAYER_STUDIO_GEN_PER_TIME_UNIT:-1000000000000000} - - GENLAYER_STUDIO_STORAGE_UNIT_PRICE=${GENLAYER_STUDIO_STORAGE_UNIT_PRICE:-1} - - GENLAYER_STUDIO_RECEIPT_GAS_PRICE=${GENLAYER_STUDIO_RECEIPT_GAS_PRICE:-1} + - GENLAYER_STUDIO_GEN_PER_TIME_UNIT=${GENLAYER_STUDIO_GEN_PER_TIME_UNIT:-1} + - GENLAYER_STUDIO_STORAGE_UNIT_PRICE=${GENLAYER_STUDIO_STORAGE_UNIT_PRICE:-250000000} + - GENLAYER_STUDIO_RECEIPT_GAS_PRICE=${GENLAYER_STUDIO_RECEIPT_GAS_PRICE:-250000000} - GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS=${GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS:-1500} + - GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS=${GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS:-30} + - GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS=${GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS:-600} + - GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS:-30} + - GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS:-600} + - GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT=${GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT:-0} + - GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES=${GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES:-20} + - GENLAYER_STUDIO_MAX_MESSAGES_PER_TX=${GENLAYER_STUDIO_MAX_MESSAGES_PER_TX:-0} + - GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES=${GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES:-0} + - GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES=${GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES:-0} - GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS=${GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS:-210000} - GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS=${GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS:-100000} - GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES=${GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES:-1024} diff --git a/hardhat/contracts/v2_contracts/FeeManager.sol b/hardhat/contracts/v2_contracts/FeeManager.sol index d6e16461e..7d4faea33 100644 --- a/hardhat/contracts/v2_contracts/FeeManager.sol +++ b/hardhat/contracts/v2_contracts/FeeManager.sol @@ -905,9 +905,12 @@ contract FeeManager is } else { IFeeManager.FeesDistribution memory feesDistribution = feesDistributionForTx[txId]; + // `round` is the raw consensus round (0, 2, 4, ... for normal + // rounds), while `rotations` is indexed by normal-round ordinal. + uint256 rotationsIndex = round / 2; totalFeesToPay = _calculateFeeForARound( VALIDATORS_PER_ROUND[round], - feesDistribution.rotations[round - 1], + feesDistribution.rotations[rotationsIndex] + 1, feesDistribution.leaderTimeout, feesDistribution.validatorsTimeout ); @@ -993,4 +996,4 @@ contract FeeManager is } return result; } -} \ No newline at end of file +} diff --git a/hardhat/test/fees/FeeManager.RoundFeesRotations.test.js b/hardhat/test/fees/FeeManager.RoundFeesRotations.test.js new file mode 100644 index 000000000..7cb958938 --- /dev/null +++ b/hardhat/test/fees/FeeManager.RoundFeesRotations.test.js @@ -0,0 +1,36 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +describe("FeeManager.calculateRoundFees — normal-round rotation indexing", function () { + it("uses the normal-round ordinal for later raw consensus rounds", async function () { + const [consensus] = await ethers.getSigners(); + const feeManager = await ethers.deployContract("FeeManager"); + await feeManager.waitForDeployment(); + await feeManager.initialize(consensus.address); + + const txId = ethers.id("later-normal-round-rotation-quote"); + const feesDistribution = { + leaderTimeout: 100, + validatorsTimeout: 200, + appealRounds: 2, + rollupStorageFee: 0, + rollupGenVMFee: 0, + totalMessageFees: 0, + rotations: [0, 1, 2], + }; + + await feeManager.topUpFees( + txId, + feesDistribution, + 0, + false, + consensus.address + ); + + // Raw round 4 is normal-round ordinal 2. With rotations[2] = 2 and + // 23 validators, the quote covers 3 attempts at 100 + 23 * 200 each. + expect( + await feeManager.calculateRoundFees(txId, feesDistribution, 23, 4) + ).to.equal(3n * (100n + 23n * 200n)); + }); +}); diff --git a/tests/consensus/test_payable_scenarios.py b/tests/consensus/test_payable_scenarios.py index 53102ef2b..a7914d457 100644 --- a/tests/consensus/test_payable_scenarios.py +++ b/tests/consensus/test_payable_scenarios.py @@ -184,11 +184,14 @@ def _encode_internal_fee_params( appeals=0, execution_budget_per_round=0, rotations=None, + max_price_gen_per_time_unit=1, + storage_fee_max_gas_price=2**200, + receipt_fee_max_gas_price=2**200, ): if rotations is None: rotations = [0] * (appeals + 1) return encode( - ["(uint256,uint256,uint256,uint256,uint256[])"], + ["(uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)"], [ ( leader_timeunits, @@ -196,6 +199,9 @@ def _encode_internal_fee_params( appeals, execution_budget_per_round, rotations, + max_price_gen_per_time_unit, + storage_fee_max_gas_price, + receipt_fee_max_gas_price, ) ], ) diff --git a/tests/db-sqlalchemy/accounts_manager_test.py b/tests/db-sqlalchemy/accounts_manager_test.py index c97692731..740166670 100644 --- a/tests/db-sqlalchemy/accounts_manager_test.py +++ b/tests/db-sqlalchemy/accounts_manager_test.py @@ -254,6 +254,88 @@ def test_settle_tx_fee_accounting_once_refunds_surplus_and_is_idempotent( assert fee_accounting["message_fee_refunded"] == 55 +def test_settle_tx_fee_accounting_credits_external_execution_payouts_once( + accounts_manager: AccountsManager, + session: Session, +): + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + executor = "0x9999999999999999999999999999999999999999" + tx_hash = "0x" + "cf" * 32 + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=1_000), + num_of_validators=5, + submitted_value=2_100, + user_value=0, + sender=sender, + ) + accounting["message_fee_consumed"] = 700 + accounting["external_message_fee_reserved"] = 700 + accounting["external_message_fee_reimbursed"] = 420 + accounting["external_message_fee_remainder"] = 280 + accounting["external_message_fee_settled"] = 700 + accounting["external_message_fee_payouts"] = [ + { + "recipient": executor, + "amount": 420, + "source": "external-executor-reimbursement", + }, + { + "recipient": sender, + "amount": 280, + "source": "external-execution-remainder", + }, + ] + _insert_fee_accounted_transaction( + session, + sender=sender, + accounting=accounting, + tx_hash=tx_hash, + ) + + refund = accounts_manager.settle_tx_fee_accounting_once(tx_hash, sender) + second_refund = accounts_manager.settle_tx_fee_accounting_once(tx_hash, sender) + session.flush() + session.expire_all() + + assert refund == 300 + assert second_refund == 0 + assert accounts_manager.get_account_balance(executor) == 420 + assert accounts_manager.get_account_balance(sender) == 580 + + +def test_settle_tx_fee_accounting_refunds_to_accounting_sender( + accounts_manager: AccountsManager, + session: Session, +): + transaction_origin = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + contract_fee_sender = "0x1111111111111111111111111111111111111111" + tx_hash = "0x" + "ce" * 32 + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=55), + num_of_validators=5, + submitted_value=1_155, + user_value=0, + sender=contract_fee_sender, + ) + _insert_fee_accounted_transaction( + session, + sender=transaction_origin, + accounting=accounting, + tx_hash=tx_hash, + ) + + refund = accounts_manager.settle_tx_fee_accounting_once( + tx_hash, + transaction_origin, + ) + session.flush() + session.expire_all() + + assert refund == 55 + assert accounts_manager.get_account_balance(contract_fee_sender) == 55 + assert accounts_manager.get_account_balance(transaction_origin) == 0 + + def test_settle_tx_fee_accounting_once_uses_actual_final_round_for_refund( accounts_manager: AccountsManager, session: Session, diff --git a/tests/unit/consensus/test_decisions_revealing.py b/tests/unit/consensus/test_decisions_revealing.py index 34c8c2527..0b9429c4f 100644 --- a/tests/unit/consensus/test_decisions_revealing.py +++ b/tests/unit/consensus/test_decisions_revealing.py @@ -3,7 +3,6 @@ Tests call pure functions directly — no mocks, no async, no context objects. """ -import pytest from backend.consensus.decisions import decide_revealing, merge_appeal_validators from backend.consensus.effects import ( AddTimestampEffect, @@ -20,7 +19,6 @@ ) from backend.consensus.types import ConsensusResult, ConsensusRound - # ── Helpers ──────────────────────────────────────────────────────── @@ -369,11 +367,24 @@ def test_appealed_disagree_is_appeal_successful(self): assert next_state == ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL -class TestRevealingInvalidResult: - def test_raises_for_invalid_consensus_result(self): - with pytest.raises(ValueError, match="Invalid consensus result"): - decide_revealing( - **_base_kwargs( - consensus_result=ConsensusResult.DETERMINISTIC_VIOLATION, - ) +class TestRevealingDeterministicViolation: + def test_dv_max_rotations_is_undetermined(self): + next_state, _ = decide_revealing( + **_base_kwargs( + consensus_result=ConsensusResult.DETERMINISTIC_VIOLATION, + rotation_count=3, + config_rotation_rounds=3, ) + ) + assert next_state == "undetermined" + + def test_dv_with_rotations_rotates_accused_leader(self): + next_state, effects = decide_revealing( + **_base_kwargs( + consensus_result=ConsensusResult.DETERMINISTIC_VIOLATION, + rotation_count=0, + config_rotation_rounds=3, + ) + ) + assert next_state == "rotate" + assert _find_effect(effects, IncreaseRotationCountEffect) is not None diff --git a/tests/unit/consensus/test_eth_send_emission.py b/tests/unit/consensus/test_eth_send_emission.py index a07953587..6a549d76f 100644 --- a/tests/unit/consensus/test_eth_send_emission.py +++ b/tests/unit/consensus/test_eth_send_emission.py @@ -102,3 +102,24 @@ def test_is_not_deploy(self): is_eth_send=True, ) assert pt.is_deploy() is False + + +def test_internal_message_use_balance_roundtrip(): + original = PendingTransaction( + address="0xcontract", + calldata=b"\x01\x02", + code=None, + salt_nonce=0, + on="accepted", + value=7, + fee_params=b"fee-params", + declared_budget=55, + use_balance=True, + ) + + serialized = original.to_dict() + restored = PendingTransaction.from_dict(serialized) + + assert serialized["use_balance"] is True + assert restored.use_balance is True + assert restored.declared_budget == 55 diff --git a/tests/unit/test_consensus_voting.py b/tests/unit/test_consensus_voting.py index 934c4bfa1..de04c3f24 100644 --- a/tests/unit/test_consensus_voting.py +++ b/tests/unit/test_consensus_voting.py @@ -4,7 +4,6 @@ from backend.consensus.types import ConsensusResult from backend.node.types import Vote - # --- Majority logic (>50% required) --- @@ -52,25 +51,25 @@ def test_single_validator_disagree(): assert determine_consensus_from_votes(votes) == ConsensusResult.MAJORITY_DISAGREE -# --- IDLE counted as DISAGREE --- +# --- IDLE is classified as TIMEOUT, matching the rollup ballot --- -def test_idle_counts_as_disagree(): - """3 IDLE out of 5 → effective_disagree=3 > 2.5 → MAJORITY_DISAGREE.""" +def test_idle_counts_as_timeout(): + """3 IDLE out of 5 are revealed as timeout ballots.""" votes = [Vote.AGREE.value] * 2 + [Vote.IDLE.value] * 3 - assert determine_consensus_from_votes(votes) == ConsensusResult.MAJORITY_DISAGREE + assert determine_consensus_from_votes(votes) == ConsensusResult.TIMEOUT -def test_idle_plus_disagree(): - """1 IDLE + 2 DISAGREE = 3 effective_disagree > 2.5 → MAJORITY_DISAGREE.""" +def test_idle_does_not_join_disagree_bucket(): + """1 IDLE, 2 DISAGREE, and 2 AGREE has no majority.""" votes = [Vote.AGREE.value] * 2 + [Vote.DISAGREE.value] * 2 + [Vote.IDLE.value] * 1 - assert determine_consensus_from_votes(votes) == ConsensusResult.MAJORITY_DISAGREE + assert determine_consensus_from_votes(votes) == ConsensusResult.NO_MAJORITY def test_all_idle(): - """All IDLE → all counted as disagree → MAJORITY_DISAGREE.""" + """All IDLE → all classified as timeout.""" votes = [Vote.IDLE.value] * 5 - assert determine_consensus_from_votes(votes) == ConsensusResult.MAJORITY_DISAGREE + assert determine_consensus_from_votes(votes) == ConsensusResult.TIMEOUT def test_idle_not_enough_for_majority(): @@ -79,7 +78,14 @@ def test_idle_not_enough_for_majority(): assert determine_consensus_from_votes(votes) == ConsensusResult.MAJORITY_AGREE -def test_idle_with_timeout_no_majority(): - """2 IDLE, 2 TIMEOUT, 1 AGREE → no majority.""" +def test_idle_joins_timeout_bucket(): + """2 IDLE + 2 TIMEOUT are four protocol timeout ballots.""" votes = [Vote.IDLE.value] * 2 + [Vote.TIMEOUT.value] * 2 + [Vote.AGREE.value] * 1 - assert determine_consensus_from_votes(votes) == ConsensusResult.NO_MAJORITY + assert determine_consensus_from_votes(votes) == ConsensusResult.TIMEOUT + + +def test_deterministic_violation_majority(): + votes = [Vote.DETERMINISTIC_VIOLATION.value] * 3 + [Vote.AGREE.value] * 2 + assert ( + determine_consensus_from_votes(votes) == ConsensusResult.DETERMINISTIC_VIOLATION + ) diff --git a/tests/unit/test_node_state_proxy_metrics.py b/tests/unit/test_node_state_proxy_metrics.py index 7dca358db..a17fff735 100644 --- a/tests/unit/test_node_state_proxy_metrics.py +++ b/tests/unit/test_node_state_proxy_metrics.py @@ -29,11 +29,14 @@ def _encode_internal_fee_params( appeals=0, execution_budget_per_round=0, rotations=None, + max_price_gen_per_time_unit=1, + storage_fee_max_gas_price=2**200, + receipt_fee_max_gas_price=2**200, ): if rotations is None: rotations = [0] * (appeals + 1) return encode( - ["(uint256,uint256,uint256,uint256,uint256[])"], + ["(uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)"], [ ( leader_timeunits, @@ -41,6 +44,9 @@ def _encode_internal_fee_params( appeals, execution_budget_per_round, rotations, + max_price_gen_per_time_unit, + storage_fee_max_gas_price, + receipt_fee_max_gas_price, ) ], ) @@ -95,7 +101,6 @@ def test_host_provide_result_preserves_fee_metadata_from_genvm_emissions(): state_proxy=state, leader_results=None, ) - post_fee_params = _encode_internal_fee_params(leader_timeunits=6) post_fee_params_with_caps = encode( ["(uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)"], [(6, 10, 0, 0, [0], 11, 12, 13)], @@ -110,7 +115,7 @@ def test_host_provide_result_preserves_fee_metadata_from_genvm_emissions(): "recipient": "0x" + "22" * 20, "callKey": "0x" + "12" * 32, "budget": 60, - "feeParams": "0x" + post_fee_params.hex(), + "feeParams": "0x" + post_fee_params_with_caps.hex(), } ] genvm_subtree = encode( @@ -158,6 +163,7 @@ def test_host_provide_result_preserves_fee_metadata_from_genvm_emissions(): "declaredBudget": 60, "callKey": bytes.fromhex("12" * 32), "subtree": genvm_subtree, + "useBalance": True, }, { "type": "InternalDeployMessage", @@ -198,10 +204,11 @@ def test_host_provide_result_preserves_fee_metadata_from_genvm_emissions(): assert post.calldata == gvm_calldata.encode(["post", 1]) assert post.value == 7 assert post.on == "accepted" - assert post.fee_params == post_fee_params + assert post.fee_params == post_fee_params_with_caps assert post.declared_budget == 60 assert post.call_key == "0x" + "12" * 32 assert post.allocation_subtree == allocation_subtree + assert post.use_balance is True assert deploy.address == "0x" assert deploy.calldata == gvm_calldata.encode({"init": True}) @@ -414,9 +421,9 @@ async def test_run_genvm_passes_mode2_message_fee_allocations_to_genvm(): "validator_timeunits_allocation": 10, "execution_budget_per_round": 0, "rotations": [0], - "max_price_gen_per_time_unit": 0, - "storage_fee_max_gas_price": 0, - "receipt_fee_max_gas_price": 0, + "max_price_gen_per_time_unit": 1, + "storage_fee_max_gas_price": 2**200, + "receipt_fee_max_gas_price": 2**200, }, } assert allocation["children"][0]["recipient"].as_hex.lower() == child_recipient diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 15092cb3b..d96a269f6 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -10,6 +10,8 @@ from backend.consensus.base import ( _apply_external_message_freeze_check, _apply_message_value_withdrawals_for_phase, + _child_config_rotation_rounds, + _emit_messages, _get_messages_data, ) from backend.domain.types import TransactionType @@ -19,8 +21,10 @@ from backend.protocol_rpc.exceptions import JSONRPCError, NotFoundError from backend.protocol_rpc.endpoints import ( _current_fee_round, + _funded_max_rotations, _handle_appeal_or_top_up_and_submit, _handle_top_up_fees, + _normal_leader_count, get_transaction_status_details, get_transaction_status, _stage_simulated_call_value, @@ -28,7 +32,10 @@ _validate_fee_envelope, _with_default_simulation_fees, sim_calculate_round_fees, + sim_estimate_message_reveal_gas, + sim_estimate_propose_receipt_gas, sim_estimate_transaction_fees, + sim_min_message_primary_fees, ) from backend.protocol_rpc.fees import ( AllocationDuplicateKey, @@ -38,7 +45,9 @@ AllocationTreeMalformed, AllocationTreeTooDeep, BudgetTooLow, + EqOutputsTooLarge, ExternalAllocationInvalid, + FeeValueMustBeNonZero, InsufficientFees, InvalidAppealBond, InvalidAppealRounds, @@ -48,18 +57,23 @@ MessageBudgetExceeded, MessageDeclaredBudgetInsufficient, MessageEmissionPhaseMismatch, + MessageEffectDescriptorMismatch, MessageFeeParamsMismatch, MessageAllocationsNotEqualBudget, MessageFeesReportMismatch, MessageNoMatchingAllocation, Mode1MessageFeesRequireGenVMPerEmissionSupport, + PhaseTimeoutOutOfBounds, + SubmittedMessagesTooLarge, CALL_KEY_WILDCARD, + EMPTY_CALL_KEY, MESSAGE_ALLOCATION_NODE_ABI_TYPE, MIN_RECEIPT_BYTES, MESSAGE_REVEAL_LENGTH_SLOTS, NODE_ROOT_SENTINEL, NONDET_OUTPUT_LENGTH_BYTES, FEE_ACCOUNTING_KEY, + FEE_POLICY_SNAPSHOT_KEY, PROPOSE_RECEIPT_SLOTS, SUBMITTED_MESSAGE_ABI_TYPE, DEFAULT_GEN_PER_TIME_UNIT, @@ -73,6 +87,7 @@ TopUpCannotExtendSchedule, TooManyMessages, apply_fee_top_up, + activate_fee_accounting, calculate_appeal_charge, calculate_min_appeal_bond, calculate_round_fees, @@ -84,6 +99,9 @@ default_transaction_fees_for_policy, derive_external_message_call_key, fill_message_fee_payload_from_allocation, + mark_message_effects_delivered, + message_novelty_mask, + prepare_reveal_message_generation, genvm_fee_context, genvm_message_fee_allocation, get_leader_rounds, @@ -95,6 +113,7 @@ refund_failed_external_message_fee, required_fee_deposit, settle_fee_accounting, + stamp_receipt_execution_policy, studio_fee_config, successful_appeal_profit, successful_appeal_reward, @@ -166,6 +185,23 @@ def _required_env_fee_deposit(fees_distribution, num_of_validators=5): ) +def _env_fees_distribution(**overrides): + policy = StudioFeePolicy.from_env() + defaults, _ = default_transaction_fees_for_policy(policy) + values = { + "leader_timeunits": int(defaults["leaderTimeunitsAllocation"]), + "validator_timeunits": int(defaults["validatorTimeunitsAllocation"]), + "appeals": int(defaults["appealRounds"]), + "rotations": [int(value) for value in defaults["rotations"]], + "execution_budget_per_round": int(defaults["executionBudgetPerRound"]), + "max_price_gen_per_time_unit": int(defaults["maxPriceGenPerTimeUnit"]), + "storage_fee_max_gas_price": int(defaults["storageFeeMaxGasPrice"]), + "receipt_fee_max_gas_price": int(defaults["receiptFeeMaxGasPrice"]), + } + values.update(overrides) + return _fees_distribution(**values) + + def _encode_internal_fee_params( *, leader_timeunits=5, @@ -173,11 +209,14 @@ def _encode_internal_fee_params( appeals=0, execution_budget_per_round=0, rotations=None, + max_price_gen_per_time_unit=1, + storage_fee_max_gas_price=2**200, + receipt_fee_max_gas_price=2**200, ): if rotations is None: rotations = [0] * (appeals + 1) return encode( - ["(uint256,uint256,uint256,uint256,uint256[])"], + ["(uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)"], [ ( leader_timeunits, @@ -185,6 +224,9 @@ def _encode_internal_fee_params( appeals, execution_budget_per_round, rotations, + max_price_gen_per_time_unit, + storage_fee_max_gas_price, + receipt_fee_max_gas_price, ) ], ) @@ -198,6 +240,31 @@ def _external_selector_call_key(selector: bytes) -> str: return "0x" + selector.hex().ljust(64, "0") +def _history_receipt( + *, + mode: str, + address: str, + vote: str | None = None, + eq_outputs_length: int = 0, + data_fees_consumed: list[int] | None = None, + timeout: bool = False, +) -> dict: + genvm_result = {"eq_blocks_outputs_length": eq_outputs_length} + if data_fees_consumed is not None: + genvm_result["data_fees_consumed"] = data_fees_consumed + if timeout: + genvm_result["error_code"] = "CONSENSUS_LEADER_EXEC_TIMEOUT" + return { + "mode": mode, + "vote": vote, + "node_config": {"address": address}, + "result": base64.b64encode(b"\x00timeout" if timeout else b"\x00ok").decode(), + "execution_result": "ERROR" if timeout else "SUCCESS", + "genvm_result": genvm_result, + "pending_transactions": [], + } + + def _root_parent_index() -> int: return NODE_ROOT_SENTINEL @@ -210,27 +277,36 @@ def test_derive_external_message_call_key_preserves_explicit_value(): def test_derive_external_message_call_key_from_calldata_selector_when_omitted(): selector = b"\x12\x34\x56\x78" assert derive_external_message_call_key( - CALL_KEY_WILDCARD, + EMPTY_CALL_KEY, selector + b"payload", ) == _external_selector_call_key(selector) -def test_derive_external_message_call_key_keeps_wildcard_without_selector(): - assert derive_external_message_call_key(CALL_KEY_WILDCARD, b"\x12\x34") == ( - CALL_KEY_WILDCARD +def test_derive_external_message_call_key_keeps_empty_key_without_selector(): + assert ( + derive_external_message_call_key(EMPTY_CALL_KEY, b"\x12\x34") == EMPTY_CALL_KEY + ) + + +def test_derive_external_message_call_key_preserves_consensus_wildcard(): + assert ( + derive_external_message_call_key(CALL_KEY_WILDCARD, b"\x12\x34\x56\x78") + == CALL_KEY_WILDCARD ) def _allocation( *, message_type=1, - on_acceptance=True, + on_acceptance=None, parent_index=NODE_ROOT_SENTINEL, recipient="0x2222222222222222222222222222222222222222", - call_key="0x0000000000000000000000000000000000000000000000000000000000000000", + call_key=CALL_KEY_WILDCARD, budget=55, fee_params=None, ): + if on_acceptance is None: + on_acceptance = message_type == 1 if fee_params is None: fee_params = ( _encode_external_fee_params() @@ -368,6 +444,30 @@ def test_calculate_round_fees_applies_gen_per_time_unit_multiplier(): ) +def test_calculate_later_round_fees_uses_normal_round_rotation_ordinal(): + fees_distribution = _fees_distribution( + appeals=2, + rotations=[0, 1, 2], + max_price_gen_per_time_unit=1, + ) + + # Raw consensus round 4 is normal-round ordinal 2. Its configured two + # rotations price three attempts at the round-4 committee size (23). + assert calculate_round_fees(fees_distribution, 23, round=4) == 3 * (100 + 23 * 200) + + +def test_sim_min_message_primary_fees_decodes_canonical_v06_abi(monkeypatch): + monkeypatch.setenv("GENLAYER_STUDIO_GEN_PER_TIME_UNIT", "1") + monkeypatch.setenv("GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS", "1500") + fee_params = _encode_internal_fee_params( + max_price_gen_per_time_unit=1, + storage_fee_max_gas_price=2, + receipt_fee_max_gas_price=3, + ) + + assert sim_min_message_primary_fees("0x" + fee_params.hex()) == "64" + + def test_calculate_round_fees_reserves_profit_and_grosses_up_only_work(): fees_distribution = _fees_distribution( appeals=1, @@ -387,13 +487,73 @@ def test_calculate_round_fees_reserves_profit_and_grosses_up_only_work(): ) -def test_calculate_round_fees_rejects_price_caps(): - with pytest.raises(MaxPriceExceeded): +def test_calculate_round_fees_prices_odd_bond_before_profit_rounding(): + fees_distribution = _fees_distribution( + leader_timeunits=101, + validator_timeunits=200, + appeals=1, + rotations=[0, 0], + max_price_gen_per_time_unit=2, + ) + policy = StudioFeePolicy(gen_per_time_unit=2) + work = (1_101 + 1_501 + 2_301) * 2 + priced_bond = 2_301 * 2 + exact_profit = successful_appeal_profit(priced_bond) + + assert exact_profit == 6_903 + assert calculate_round_fees(fees_distribution, 5, policy=policy) == ( + work + exact_profit + ) + + +def test_settlement_uses_current_overlay_split_capped_by_funded_reserve(): + submission_policy = StudioFeePolicy( + gen_per_time_unit=1, + time_unit_overlay_bps=1_000, + ) + fees_distribution = _fees_distribution(max_price_gen_per_time_unit=1) + deposit = required_fee_deposit(fees_distribution, 5, submission_policy) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=deposit, + user_value=0, + policy=submission_policy, + ) + + assert deposit == 1_222 + assert accounting["time_unit_overlay_budget"] == 122 + + lower_split, lower_refund = settle_fee_accounting( + accounting, + actual_final_round=0, + num_of_validators=5, + policy=StudioFeePolicy(gen_per_time_unit=1, time_unit_overlay_bps=500), + ) + higher_split, higher_refund = settle_fee_accounting( + accounting, + actual_final_round=0, + num_of_validators=5, + policy=StudioFeePolicy(gen_per_time_unit=1, time_unit_overlay_bps=2_000), + ) + + assert lower_split["time_unit_overlay_requested"] == 57 + assert lower_split["time_unit_overlay_spent"] == 57 + assert lower_refund == 65 + assert higher_split["time_unit_overlay_requested"] == 275 + assert higher_split["time_unit_overlay_spent"] == 122 + assert higher_refund == 0 + + +def test_calculate_round_fees_reserves_at_gen_cap_without_submission_rejection(): + assert ( calculate_round_fees( _fees_distribution(max_price_gen_per_time_unit=5), 5, policy=StudioFeePolicy(gen_per_time_unit=10), ) + == 5_500 + ) with pytest.raises(MaxPriceExceeded): calculate_round_fees( @@ -435,6 +595,23 @@ def test_sim_calculate_round_fees_exposes_decimal_canonical_quote(monkeypatch): assert sim_calculate_round_fees(fees_distribution, 5, 0) == "9214" +def test_sim_receipt_metering_views_match_consensus_formulas(monkeypatch): + monkeypatch.setenv("GENLAYER_STUDIO_RECEIPT_GAS_PRICE", "2") + + proposal = sim_estimate_propose_receipt_gas(0) + reveal = sim_estimate_message_reveal_gas(352, 1) + + assert proposal == { + "receiptBytes": "1024", + "gas": "314384", + "fee": "628768", + } + assert reveal == { + "gas": "187632", + "fee": "375264", + } + + def test_time_unit_fees_through_round_refunds_unused_appeal_budget(): fees_distribution = _fees_distribution(appeals=2, rotations=[0, 0, 0]) @@ -536,12 +713,16 @@ def test_studio_fee_policy_matches_consensus_deterministic_receipt_estimators(): + (PROPOSE_RECEIPT_SLOTS * policy.gas_per_changed_slot) ) expected_receipt_floor_gas = ( - policy.intrinsic_gas + policy.fixed_propose_receipt_gas + + policy.intrinsic_gas + policy.bootloader_overhead + (MIN_RECEIPT_BYTES * policy.calldata_gas_per_byte) + (PROPOSE_RECEIPT_SLOTS * policy.gas_per_changed_slot) ) - expected_genvm_bucket_floor_gas = ( + expected_legacy_receipt_gas = expected_receipt_floor_gas - ( + policy.fixed_propose_receipt_gas + ) + expected_genvm_start_gas = ( policy.fixed_propose_receipt_gas + policy.intrinsic_gas + policy.bootloader_overhead @@ -571,7 +752,8 @@ def test_studio_fee_policy_matches_consensus_deterministic_receipt_estimators(): + ((MESSAGE_REVEAL_LENGTH_SLOTS + 2) * policy.gas_per_changed_slot) ) expected_consensus_message_reveal_gas = ( - policy.intrinsic_gas + policy.fixed_message_reveal_gas + + policy.intrinsic_gas + policy.bootloader_overhead + (320 * policy.calldata_gas_per_byte) + (2 * policy.gas_per_changed_slot) @@ -593,7 +775,7 @@ def test_studio_fee_policy_matches_consensus_deterministic_receipt_estimators(): calldata_length=MIN_RECEIPT_BYTES, slots_changed=PROPOSE_RECEIPT_SLOTS, ) - == expected_receipt_floor_gas + == expected_legacy_receipt_gas ) assert policy.estimate_message_reveal_gas(320, 2) == expected_message_reveal_gas assert ( @@ -602,8 +784,11 @@ def test_studio_fee_policy_matches_consensus_deterministic_receipt_estimators(): ) assert policy.estimate_nondet_output_start_gas() == expected_nondet_output_start_gas assert ( - policy.message_fee_params_budget_floor() == expected_genvm_bucket_floor_gas * 3 + policy.estimate_propose_receipt_gas(MIN_RECEIPT_BYTES) + == expected_receipt_floor_gas ) + assert policy.message_fee_params_budget_floor() == expected_receipt_floor_gas * 3 + assert policy.genvm_start_budget_floor() == expected_genvm_start_gas * 3 def test_studio_fee_config_exposes_default_nonzero_fee_policy(): @@ -620,18 +805,20 @@ def test_studio_fee_config_exposes_default_nonzero_fee_policy(): DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND, policy.message_fee_params_budget_floor(), ) - assert distribution["maxPriceGenPerTimeUnit"] == ( - DEFAULT_GEN_PER_TIME_UNIT * DEFAULT_PRICE_CAP_HEADROOM_BPS // 10_000 - ) - assert distribution["storageFeeMaxGasPrice"] == 2 - assert distribution["receiptFeeMaxGasPrice"] == 2 + expected_gen_cap = ( + DEFAULT_GEN_PER_TIME_UNIT * DEFAULT_PRICE_CAP_HEADROOM_BPS + 9_999 + ) // 10_000 + expected_storage_cap = ( + DEFAULT_STORAGE_UNIT_PRICE * DEFAULT_PRICE_CAP_HEADROOM_BPS + 9_999 + ) // 10_000 + expected_receipt_cap = ( + DEFAULT_RECEIPT_GAS_PRICE * DEFAULT_PRICE_CAP_HEADROOM_BPS + 9_999 + ) // 10_000 + assert distribution["maxPriceGenPerTimeUnit"] == expected_gen_cap + assert distribution["storageFeeMaxGasPrice"] == expected_storage_cap + assert distribution["receiptFeeMaxGasPrice"] == expected_receipt_cap assert ( - fee_value - == ( - 1100 - * (DEFAULT_GEN_PER_TIME_UNIT * DEFAULT_PRICE_CAP_HEADROOM_BPS // 10_000) - ) - + distribution["executionBudgetPerRound"] + fee_value == (1100 * expected_gen_cap) + distribution["executionBudgetPerRound"] ) config = studio_fee_config(policy) @@ -649,7 +836,7 @@ def test_studio_fee_config_exposes_default_nonzero_fee_policy(): } assert config["capabilities"]["messageFees"]["mode2"]["genvmExecution"] is True assert config["defaultFees"]["distribution"]["maxPriceGenPerTimeUnit"] == str( - DEFAULT_GEN_PER_TIME_UNIT * DEFAULT_PRICE_CAP_HEADROOM_BPS // 10_000 + expected_gen_cap ) assert config["defaultFees"]["feeValue"] == str(fee_value) @@ -706,8 +893,216 @@ def test_validate_transaction_fee_deposit_rejects_execution_budget_below_floor() ) +@pytest.mark.parametrize( + ("field", "field_index"), + [ + ("leaderTimeunitsAllocation", 1), + ("validatorTimeunitsAllocation", 2), + ("executionBudgetPerRound", 3), + ("maxPriceGenPerTimeUnit", 4), + ("storageFeeMaxGasPrice", 5), + ("receiptFeeMaxGasPrice", 6), + ], +) +def test_v06_submission_requires_all_six_nonzero_fee_fields(field, field_index): + fees_distribution = _fees_distribution( + execution_budget_per_round=1, + max_price_gen_per_time_unit=1, + storage_fee_max_gas_price=1, + receipt_fee_max_gas_price=1, + ) + fees_distribution[field] = 0 + policy = StudioFeePolicy( + gen_per_time_unit=1, + enforce_v06_submission_config=True, + ) + + with pytest.raises( + FeeValueMustBeNonZero, + match=rf"FeeValueMustBeNonZero\({field_index}\)", + ): + validate_transaction_fee_deposit( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=10_000_000, + user_value=0, + policy=policy, + ) + + +def test_v06_submission_enforces_deployed_phase_timeout_bounds(): + fees_distribution = _fees_distribution( + leader_timeunits=29, + validator_timeunits=30, + execution_budget_per_round=1, + max_price_gen_per_time_unit=1, + storage_fee_max_gas_price=1, + receipt_fee_max_gas_price=1, + ) + policy = StudioFeePolicy( + gen_per_time_unit=1, + enforce_v06_submission_config=True, + min_propose_timeunits=30, + max_propose_timeunits=600, + min_commit_timeunits=30, + max_commit_timeunits=600, + ) + + with pytest.raises(PhaseTimeoutOutOfBounds, match="29,30,600"): + validate_transaction_fee_deposit( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=10_000_000, + user_value=0, + policy=policy, + ) + + +def test_activation_checks_live_gen_cap_and_locks_all_live_prices(): + submission_policy = StudioFeePolicy( + gen_per_time_unit=2, + storage_unit_price=3, + receipt_gas_price=4, + ) + fees_distribution = _fees_distribution( + execution_budget_per_round=1, + max_price_gen_per_time_unit=5, + storage_fee_max_gas_price=10, + receipt_fee_max_gas_price=10, + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit( + fees_distribution, + 5, + submission_policy, + ), + user_value=0, + policy=submission_policy, + allow_low_execution_budget=True, + ) + + canceled, should_cancel = activate_fee_accounting( + accounting, + StudioFeePolicy(gen_per_time_unit=6), + ) + assert should_cancel is True + assert canceled["activation_price_cap_exceeded"] == { + "actual": 6, + "maximum": 5, + } + + activated, should_cancel = activate_fee_accounting( + accounting, + StudioFeePolicy( + gen_per_time_unit=4, + storage_unit_price=7, + receipt_gas_price=8, + ), + selection_pool_count=11, + ) + assert should_cancel is False + assert activated["activation_prices_locked"] is True + assert activated["locked_prices"] == { + "genPerTimeUnit": 4, + "storageUnitPrice": 7, + "receiptGasPrice": 8, + } + assert activated["policy_snapshot"]["gen_per_time_unit"] == 4 + assert activated["policy_snapshot"]["storage_unit_price"] == 7 + assert activated["policy_snapshot"]["receipt_gas_price"] == 8 + assert activated["selection_pool_count"] == 11 + + relocked, should_cancel = activate_fee_accounting( + activated, + StudioFeePolicy(gen_per_time_unit=100), + ) + assert should_cancel is False + assert relocked["locked_prices"] == activated["locked_prices"] + + +def test_runtime_execution_policy_locks_only_activation_prices(): + submission_policy = StudioFeePolicy( + gen_per_time_unit=2, + storage_unit_price=3, + receipt_gas_price=4, + calldata_gas_per_byte=5, + fixed_propose_receipt_gas=6, + ) + fees_distribution = _fees_distribution( + execution_budget_per_round=1, + max_price_gen_per_time_unit=10, + storage_fee_max_gas_price=10, + receipt_fee_max_gas_price=10, + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit( + fees_distribution, + 5, + submission_policy, + ), + user_value=0, + policy=submission_policy, + allow_low_execution_budget=True, + ) + activated, should_cancel = activate_fee_accounting( + accounting, + StudioFeePolicy( + gen_per_time_unit=7, + storage_unit_price=8, + receipt_gas_price=9, + ), + ) + assert should_cancel is False + + _, gas_data = genvm_fee_context( + activated, + StudioFeePolicy( + gen_per_time_unit=70, + storage_unit_price=80, + receipt_gas_price=90, + calldata_gas_per_byte=11, + fixed_propose_receipt_gas=13, + ), + ) + + assert gas_data["genPerTimeUnit"] == "7" + assert gas_data["storageUnitPrice"] == "8" + assert gas_data["receiptGasPerByte"] == str(9 * 11) + assert gas_data["fixedProposeReceiptGas"] == str(9 * 13) + + +def test_activated_reveal_reads_live_message_count_cap(monkeypatch): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=120), + num_of_validators=5, + submitted_value=1220, + user_value=0, + ) + activated, should_cancel = activate_fee_accounting( + accounting, + StudioFeePolicy(), + ) + assert should_cancel is False + monkeypatch.setenv("GENLAYER_STUDIO_MAX_MESSAGES_PER_TX", "1") + message = { + "messageType": 1, + "recipient": "0x2222222222222222222222222222222222222222", + "onAcceptance": True, + "feeParams": _encode_internal_fee_params(), + "declaredBudget": 55, + "callKey": "0x" + "12" * 32, + } + + with pytest.raises(TooManyMessages): + record_reveal_message_fees(activated, [message, message]) + + def test_endpoint_fee_envelope_rejects_insufficient_fee_deposit(): - fees_distribution = _fees_distribution(total_message_fees=55) + fees_distribution = _env_fees_distribution(total_message_fees=55) fee_value = _required_env_fee_deposit(fees_distribution) - 1 decoded = DecodedRollupTransaction( from_address="0x1111111111111111111111111111111111111111", @@ -736,7 +1131,7 @@ def test_endpoint_fee_envelope_rejects_insufficient_fee_deposit(): def test_endpoint_fee_envelope_accepts_exact_fee_deposit(): - fees_distribution = _fees_distribution(total_message_fees=55) + fees_distribution = _env_fees_distribution(total_message_fees=55) fee_value = _required_env_fee_deposit(fees_distribution) decoded = DecodedRollupTransaction( from_address="0x1111111111111111111111111111111111111111", @@ -763,9 +1158,90 @@ def test_endpoint_fee_envelope_accepts_exact_fee_deposit(): _validate_fee_envelope(decoded) +def test_submission_clamps_runtime_rotations_to_smallest_funded_round(): + fees_distribution = _env_fees_distribution( + appeals=2, + rotations=[3, 1, 2], + ) + decoded = DecodedRollupTransaction( + from_address="0x1111111111111111111111111111111111111111", + to_address="0x0000000000000000000000000000000000000000", + data=DecodedRollupTransactionData( + function_name="addTransaction", + args=DecodedRollupTransactionDataArgs( + sender="0x1111111111111111111111111111111111111111", + recipient="0x2222222222222222222222222222222222222222", + num_of_initial_validators=5, + max_rotations=4, + data="0x", + fees_distribution=fees_distribution, + ), + ), + type="2", + nonce=0, + value=0, + ) + + assert _funded_max_rotations(decoded, 4) == 1 + + +def test_internal_child_clamps_parent_rotations_to_its_own_fee_schedule(): + data = { + "fees_distribution": _fees_distribution( + appeals=2, + rotations=[2, 0, 1], + ) + } + + assert _child_config_rotation_rounds(3, data) == 0 + assert _child_config_rotation_rounds(1, {"calldata": b"legacy"}) == 1 + + +def test_internal_child_uses_protocol_round_zero_committee_not_parent_size(): + calls = [] + + class _Processor: + def insert_transaction(self, *args, **kwargs): + calls.append((args, kwargs)) + + context = SimpleNamespace( + transaction=SimpleNamespace( + to_address="0x1111111111111111111111111111111111111111", + execution_mode="NORMAL", + num_of_initial_validators=7, + hash="0x" + "12" * 32, + config_rotation_rounds=3, + sim_config=None, + origin_address=None, + ), + transactions_processor=_Processor(), + ) + child_data = { + "fees_distribution": _fees_distribution(rotations=[0]), + } + + _emit_messages( + context, + [ + ( + "0x2222222222222222222222222222222222222222", + child_data, + TransactionType.RUN_CONTRACT.value, + 0, + 0, + ) + ], + {"tx_ids_hex": ["0x" + "34" * 32]}, + "accepted", + ) + + assert calls[0][1]["num_of_initial_validators"] == 5 + assert calls[0][1]["config_rotation_rounds"] == 0 + + def test_simulation_fee_accounting_accepts_sdk_style_fee_options(): policy = StudioFeePolicy.from_env() - fees_distribution = _fees_distribution( + fees_distribution = _env_fees_distribution( execution_budget_per_round=policy.message_fee_params_budget_floor() ) fee_value = _required_env_fee_deposit(fees_distribution) @@ -789,15 +1265,23 @@ def test_simulation_fee_accounting_accepts_sdk_style_fee_options(): def test_simulation_fee_accounting_accepts_sdk_style_message_allocations(): - fee_params = _encode_internal_fee_params() policy = StudioFeePolicy.from_env() + fee_params = _encode_internal_fee_params( + leader_timeunits=30, + validator_timeunits=30, + max_price_gen_per_time_unit=policy.gen_per_time_unit, + ) budget = calculate_round_fees( - _fees_distribution(leader_timeunits=5, validator_timeunits=10), + _fees_distribution( + leader_timeunits=30, + validator_timeunits=30, + max_price_gen_per_time_unit=policy.gen_per_time_unit, + ), 5, policy=policy, ) allocation = _allocation(budget=budget, fee_params=fee_params) - fees_distribution = _fees_distribution(total_message_fees=budget) + fees_distribution = _env_fees_distribution(total_message_fees=budget) accounting = _simulation_fee_accounting( { @@ -819,7 +1303,7 @@ def test_simulation_fee_accounting_accepts_sdk_style_message_allocations(): "onAcceptance": True, "parentIndex": NODE_ROOT_SENTINEL, "recipient": "0x2222222222222222222222222222222222222222", - "callKey": "0x0000000000000000000000000000000000000000000000000000000000000000", + "callKey": CALL_KEY_WILDCARD, "budget": budget, "feeParams": "0x" + fee_params.hex(), } @@ -827,7 +1311,7 @@ def test_simulation_fee_accounting_accepts_sdk_style_message_allocations(): def test_simulation_fee_accounting_defaults_to_required_deposit(): - fees_distribution = _fees_distribution(total_message_fees=55) + fees_distribution = _env_fees_distribution(total_message_fees=55) required_fee_value = _required_env_fee_deposit(fees_distribution) accounting = _simulation_fee_accounting( { @@ -848,12 +1332,13 @@ def test_simulation_fee_accounting_defaults_to_required_deposit(): def test_simulation_fee_accounting_rejects_insufficient_sdk_fee_value(): + fees_distribution = _env_fees_distribution(total_message_fees=55) with pytest.raises(JSONRPCError, match="InsufficientFees"): _simulation_fee_accounting( { "fees": { - "distribution": _fees_distribution(total_message_fees=55), - "feeValue": 1154, + "distribution": fees_distribution, + "feeValue": _required_env_fee_deposit(fees_distribution) - 1, }, "numOfInitialValidators": 5, }, @@ -974,15 +1459,17 @@ async def test_sim_estimate_transaction_fees_returns_mode2_recommended_preset( policy = StudioFeePolicy.from_env() execution_budget = policy.message_fee_params_budget_floor() fee_params = _encode_internal_fee_params( - leader_timeunits=5, - validator_timeunits=10, + leader_timeunits=30, + validator_timeunits=30, execution_budget_per_round=execution_budget, + max_price_gen_per_time_unit=policy.gen_per_time_unit, ) message_budget = calculate_round_fees( _fees_distribution( - leader_timeunits=5, - validator_timeunits=10, + leader_timeunits=30, + validator_timeunits=30, execution_budget_per_round=execution_budget, + max_price_gen_per_time_unit=policy.gen_per_time_unit, ), 5, policy=policy, @@ -993,7 +1480,7 @@ async def test_sim_estimate_transaction_fees_returns_mode2_recommended_preset( budget=message_budget, fee_params="0x" + fee_params.hex(), ) - fees_distribution = _fees_distribution( + fees_distribution = _env_fees_distribution( execution_budget_per_round=execution_budget, total_message_fees=message_budget, ) @@ -1090,13 +1577,15 @@ async def test_sim_estimate_transaction_fees_returns_mode1_observed_message_pres ): policy = StudioFeePolicy.from_env() fee_params = _encode_internal_fee_params( - leader_timeunits=6, - validator_timeunits=10, + leader_timeunits=30, + validator_timeunits=30, + max_price_gen_per_time_unit=policy.gen_per_time_unit, ) message_budget = calculate_round_fees( _fees_distribution( - leader_timeunits=6, - validator_timeunits=10, + leader_timeunits=30, + validator_timeunits=30, + max_price_gen_per_time_unit=policy.gen_per_time_unit, ), 5, policy=policy, @@ -1105,7 +1594,7 @@ async def test_sim_estimate_transaction_fees_returns_mode1_observed_message_pres message_budget * DEFAULT_PRICE_CAP_HEADROOM_BPS + 9_999 ) // 10_000 recipient = "0x2222222222222222222222222222222222222222" - fees_distribution = _fees_distribution( + fees_distribution = _env_fees_distribution( execution_budget_per_round=policy.message_fee_params_budget_floor(), total_message_fees=message_budget, ) @@ -1176,11 +1665,14 @@ async def fake_sim_call(**kwargs): assert message["messageType"] == "Internal" assert message["feeParams"] == "0x" + fee_params.hex() assert message["feeParamsDecoded"] == { - "leaderTimeunitsAllocation": 6, - "validatorTimeunitsAllocation": 10, + "leaderTimeunitsAllocation": 30, + "validatorTimeunitsAllocation": 30, "appealRounds": 0, "executionBudgetPerRound": 0, "rotations": [0], + "maxPriceGenPerTimeUnit": policy.gen_per_time_unit, + "storageFeeMaxGasPrice": str(2**200), + "receiptFeeMaxGasPrice": str(2**200), } assert int(message["declaredBudget"]) == message_budget assert message["allocationSubtree"] == "0x" @@ -1216,7 +1708,7 @@ async def test_sim_estimate_transaction_fees_returns_external_message_fee_report budget=message_budget, fee_params="0x" + fee_params.hex(), ) - fees_distribution = _fees_distribution(total_message_fees=message_budget) + fees_distribution = _env_fees_distribution(total_message_fees=message_budget) params = { "scenarioName": "external-transfer", "type": "write", @@ -1275,8 +1767,12 @@ async def fake_sim_call(**kwargs): report = result["feeReport"] preset = result["recommendedPreset"] message = report["messageReveal"]["messages"][0] - expected_reservation = gas_limit * policy.receipt_gas_price - expected_reimbursement = 11 * policy.receipt_gas_price + # External fee params cap the reimbursable gas price independently of the + # live policy; the v0.6 default is intentionally far above this fixture's + # explicit maxGasPrice. + effective_gas_price = min(policy.receipt_gas_price, max_gas_price) + expected_reservation = gas_limit * effective_gas_price + expected_reimbursement = 11 * effective_gas_price assert result["scenario"] == "external-transfer" assert ( @@ -1421,7 +1917,7 @@ def test_message_allocations_reject_duplicate_root_internal_keys(): def test_message_allocations_reject_duplicate_normalized_root_internal_keys(): allocations = [ _allocation(budget=55, call_key="0x0"), - _allocation(budget=55, call_key=CALL_KEY_WILDCARD), + _allocation(budget=55, call_key=EMPTY_CALL_KEY), ] with pytest.raises(AllocationDuplicateKey): @@ -1453,9 +1949,43 @@ def test_message_allocations_reject_depth_above_default_cap(): validate_message_allocations(allocations, total_message_fees=330) +def test_internal_allocation_defers_zero_price_cap_rejection_until_reveal(): + fee_params = _encode_internal_fee_params( + max_price_gen_per_time_unit=0, + storage_fee_max_gas_price=0, + receipt_fee_max_gas_price=0, + ) + allocation = _allocation(budget=55, fee_params=fee_params) + + validate_message_allocations([allocation], total_message_fees=55) + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=55), + message_allocations=[allocation], + num_of_validators=5, + submitted_value=1_155, + user_value=0, + ) + + with pytest.raises(FeeValueMustBeNonZero, match=r"FeeValueMustBeNonZero\(4\)"): + consume_message_fees( + accounting, + [ + { + "messageType": 1, + "recipient": allocation["recipient"], + "onAcceptance": True, + "feeParams": fee_params, + "declaredBudget": 55, + "callKey": allocation["callKey"], + } + ], + ) + + def test_message_allocations_accept_valid_external_allocation(): allocation = _allocation( message_type=0, + on_acceptance=False, budget=210_000, fee_params=_encode_external_fee_params(gas_limit=21_000, max_gas_price=10), ) @@ -1466,6 +1996,7 @@ def test_message_allocations_accept_valid_external_allocation(): def test_message_allocations_reject_invalid_external_allocation(): allocation = _allocation( message_type=0, + on_acceptance=False, budget=210_001, fee_params=_encode_external_fee_params(gas_limit=21_000, max_gas_price=10), ) @@ -1551,7 +2082,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ), _allocation( message_type=0, - call_key=CALL_KEY_WILDCARD, + call_key=EMPTY_CALL_KEY, budget=210_000, fee_params=_encode_external_fee_params(), ), @@ -1591,6 +2122,36 @@ def test_message_allocations_reject_external_allocation_invariants(): ) +def test_message_allocations_reject_external_on_acceptance(): + allocation = _allocation( + message_type=0, + on_acceptance=True, + budget=210_000, + fee_params=_encode_external_fee_params(gas_limit=21_000, max_gas_price=10), + ) + + with pytest.raises( + ExternalAllocationInvalid, match="ExternalOnAcceptanceNotSupported" + ): + validate_message_allocations([allocation], total_message_fees=210_000) + + +def test_message_allocations_enforce_external_gas_limit_floor(): + allocation = _allocation( + message_type=0, + on_acceptance=False, + budget=209_990, + fee_params=_encode_external_fee_params(gas_limit=20_999, max_gas_price=10), + ) + + with pytest.raises(ExternalAllocationInvalid, match="ExternalGasLimitBelowMinimum"): + validate_message_allocations( + [allocation], + total_message_fees=209_990, + policy=StudioFeePolicy(min_external_gas_limit=21_000), + ) + + def test_transaction_fee_validation_runs_message_allocation_checks(): fees_distribution = _fees_distribution(total_message_fees=56) @@ -1728,9 +2289,9 @@ def test_genvm_message_fee_allocation_maps_studio_nodes(): "validator_timeunits_allocation": 10, "execution_budget_per_round": 0, "rotations": [0], - "max_price_gen_per_time_unit": 11, - "storage_fee_max_gas_price": 12, - "receipt_fee_max_gas_price": 13, + "max_price_gen_per_time_unit": 1, + "storage_fee_max_gas_price": 2**200, + "receipt_fee_max_gas_price": 2**200, }, }, "children": [], @@ -1792,9 +2353,9 @@ def test_genvm_message_fee_allocation_nests_descendants_under_roots(): "validator_timeunits_allocation": 10, "execution_budget_per_round": 0, "rotations": [0], - "max_price_gen_per_time_unit": 0, - "storage_fee_max_gas_price": 0, - "receipt_fee_max_gas_price": 0, + "max_price_gen_per_time_unit": 1, + "storage_fee_max_gas_price": 2**200, + "receipt_fee_max_gas_price": 2**200, }, }, "children": [], @@ -2073,6 +2634,23 @@ def test_execution_fee_report_uses_locked_receipt_price_by_default(): ) +def test_execution_fee_report_enforces_consensus_eq_outputs_byte_cap(): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(execution_budget_per_round=1_000_000), + num_of_validators=5, + submitted_value=1_001_100, + user_value=0, + ) + receipt = {"genvm_result": {"eq_blocks_outputs_length": 9}} + + with pytest.raises(EqOutputsTooLarge, match=r"EqOutputsTooLarge\(9,8\)"): + record_execution_fee_consumption( + accounting, + receipt, + StudioFeePolicy(max_eq_outputs_bytes=8), + ) + + def test_execution_fee_consumption_reports_execution_budget_overrun(): accounting = create_fee_accounting( fees_distribution=_fees_distribution(execution_budget_per_round=100), @@ -2310,7 +2888,7 @@ def test_simulation_fee_consumption_fills_mode2_payload_from_allocation(): True, NODE_ROOT_SENTINEL, recipient, - bytes(32), + bytes.fromhex(CALL_KEY_WILDCARD[2:]), 75, fee_params, ) @@ -2660,7 +3238,7 @@ def test_simulation_fee_consumption_records_external_allocation_reservation(): ) message = recorded["execution_fee_report"]["messageReveal"]["messages"][0] - assert recorded["message_fee_consumed"] == 77 + assert recorded["message_fee_consumed"] == 700 assert recorded["allocation_consumed"] == {"0": 700} assert recorded["external_message_fee_reserved"] == 700 assert recorded["external_message_fee_reimbursed"] == 77 @@ -2684,13 +3262,26 @@ def test_simulation_fee_consumption_records_external_allocation_reservation(): "declaredConsumed": 0, "genvmMeteredConsumed": 0, "declaredRefunded": 0, - "remaining": 923, + "remaining": 300, "meteringDelta": 0, "externalReserved": 700, "externalReimbursed": 77, "externalRemainder": 623, - "totalConsumed": 77, + "externalSettled": 700, + "totalConsumed": 700, } + assert recorded["external_message_fee_payouts"] == [ + { + "recipient": "0x1111111111111111111111111111111111111111", + "amount": 77, + "source": "external-executor-reimbursement", + }, + { + "recipient": "0x1111111111111111111111111111111111111111", + "amount": 623, + "source": "external-execution-remainder", + }, + ] preset = recorded["recommended_fee_preset"] assert preset["messageBudgetMode"] == "allocation-preserved" assert preset["distribution"]["totalMessageFees"] == 1_000 @@ -2713,7 +3304,7 @@ def test_simulation_fee_consumption_records_external_allocation_reservation(): }, ) - assert refunded["message_fee_consumed"] == 77 + assert refunded["message_fee_consumed"] == 700 assert refunded["allocation_consumed"] == {"0": 700} assert refunded["external_message_fee_reimbursed"] == 77 assert refunded["external_message_events"][0]["failureRefunded"] is True @@ -2810,6 +3401,7 @@ def test_execution_fee_consumption_reports_deterministic_receipt_fee_components( 55, expected_subtree, bytes.fromhex("34" * 32), + False, ) ] ], @@ -2861,12 +3453,16 @@ def test_execution_fee_consumption_reports_deterministic_receipt_fee_components( "appealRounds": 0, "executionBudgetPerRound": 0, "rotations": [0], + "maxPriceGenPerTimeUnit": 1, + "storageFeeMaxGasPrice": 2**200, + "receiptFeeMaxGasPrice": 2**200, }, "feeParamsBytes": len(fee_params), "declaredBudget": 55, "allocationSubtree": "0x" + expected_subtree.hex(), "allocationSubtreeBytes": len(expected_subtree), "callKey": "0x" + "34" * 32, + "useBalance": False, } ] assert message_reveal["fee"] == ( @@ -3281,7 +3877,16 @@ def test_recommended_fee_preset_adds_message_execution_headroom_over_floor(): ) preset = recorded["recommended_fee_preset"] - assert preset["distribution"]["executionBudgetPerRound"] == floor + 10_000 + observed_with_padding = ( + recorded["execution_fee_report"]["totalEstimatedFee"] + * DEFAULT_PRICE_CAP_HEADROOM_BPS + + 9_999 + ) // 10_000 + assert preset["distribution"]["executionBudgetPerRound"] == max( + floor + 10_000, + observed_with_padding, + policy.genvm_start_budget_floor(), + ) assert preset["distribution"]["totalMessageFees"] == 55 @@ -3342,6 +3947,7 @@ def test_execution_fee_report_handles_mode1_internal_messages_without_allocation 60, b"", bytes.fromhex("12" * 32), + False, ), ( 1, @@ -3354,6 +3960,7 @@ def test_execution_fee_report_handles_mode1_internal_messages_without_allocation 70, b"", bytes.fromhex("34" * 32), + False, ), ] @@ -3381,12 +3988,16 @@ def test_execution_fee_report_handles_mode1_internal_messages_without_allocation "appealRounds": 0, "executionBudgetPerRound": 0, "rotations": [0], + "maxPriceGenPerTimeUnit": 1, + "storageFeeMaxGasPrice": 2**200, + "receiptFeeMaxGasPrice": 2**200, }, "feeParamsBytes": len(fee_params_a), "declaredBudget": 60, "allocationSubtree": "0x", "allocationSubtreeBytes": 0, "callKey": "0x" + "12" * 32, + "useBalance": False, }, { "messageFeeMode": "mode1", @@ -3403,12 +4014,16 @@ def test_execution_fee_report_handles_mode1_internal_messages_without_allocation "appealRounds": 0, "executionBudgetPerRound": 0, "rotations": [0], + "maxPriceGenPerTimeUnit": 1, + "storageFeeMaxGasPrice": 2**200, + "receiptFeeMaxGasPrice": 2**200, }, "feeParamsBytes": len(fee_params_b), "declaredBudget": 70, "allocationSubtree": "0x", "allocationSubtreeBytes": 0, "callKey": "0x" + "34" * 32, + "useBalance": False, }, ] assert recorded["message_fee_consumed"] == 130 @@ -3620,6 +4235,7 @@ def test_execution_fee_report_handles_external_message_reveal_encoding(): 0, b"", bytes.fromhex("78" * 32), + False, ) ] @@ -3642,6 +4258,7 @@ def test_execution_fee_report_handles_external_message_reveal_encoding(): "allocationSubtree": "0x", "allocationSubtreeBytes": 0, "callKey": "0x" + "78" * 32, + "useBalance": False, } ] @@ -3674,78 +4291,1161 @@ def test_execution_fee_report_derives_external_call_key_from_calldata_selector() assert message["callKey"] == _external_selector_call_key(bytes.fromhex("aabbccdd")) -def test_settle_fee_accounting_caps_execution_spend_to_actual_round_budget(): +def test_settle_fee_accounting_uses_full_unified_budget_after_early_finish(): fees_distribution = _fees_distribution( appeals=2, rotations=[0, 0, 0], execution_budget_per_round=100, ) - accounting = create_fee_accounting( - fees_distribution=fees_distribution, - num_of_validators=5, - submitted_value=required_fee_deposit(fees_distribution, 5), - user_value=0, - sender="0x1111111111111111111111111111111111111111", + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + sender="0x1111111111111111111111111111111111111111", + ) + receipt = {"genvm_result": {"data_fees_consumed": [300]}} + + settled, refund = settle_fee_accounting( + accounting, + receipt=receipt, + actual_final_round=0, + num_of_validators=5, + ) + + assert settled["execution_fee_consumed"] == 300 + assert settled["actual_final_round"] == 0 + # Consensus caps cumulative execution against the complete configured + # unified reserve (5 leader slots * 100), even though the transaction + # finalized in round 0. Future-slot capacity remains spendable backing; + # only its unused residual is reported/refunded. + assert accounting["execution_budget_total"] == 500 + assert settled["primary_fee_spent"] == 1400 + assert refund == 21900 + + +def test_settle_fee_accounting_uses_actual_round_for_primary_refund(): + fees_distribution = _fees_distribution(appeals=2, rotations=[0, 0, 0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + sender="0x1111111111111111111111111111111111111111", + ) + + settled, refund = settle_fee_accounting( + accounting, + actual_final_round=0, + num_of_validators=5, + ) + + assert settled["primary_fee_spent"] == 1100 + assert settled["actual_final_round"] == 0 + assert refund == 21700 + + +def test_leader_timeout_settlement_matches_fee_simulator_50_paid_1050_refunded(): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(), + num_of_validators=5, + submitted_value=1100, + user_value=0, + sender="0x1111111111111111111111111111111111111111", + ) + consensus_history = { + "consensus_results": [ + {"consensus_round": "Leader Timeout"}, + ] + } + + settled, refund = settle_fee_accounting( + accounting, + actual_final_round=0, + num_of_validators=5, + consensus_history=consensus_history, + ) + + assert settled["primary_fee_spent"] == 50 + assert refund == 1050 + assert settled["settlement_rounds"] == [ + { + "round": 0, + "outcome": "Leader Timeout", + "rule": "leader_timeout", + "rotations": 0, + "timeUnitAmount": 50, + } + ] + + +def test_leader_timeout_half_is_rounded_after_locked_gen_price_scaling(): + policy = StudioFeePolicy(gen_per_time_unit=2) + fees_distribution = _fees_distribution( + leader_timeunits=101, + validator_timeunits=200, + max_price_gen_per_time_unit=2, + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5, policy), + user_value=0, + policy=policy, + ) + + settled, refund = settle_fee_accounting( + accounting, + actual_final_round=0, + num_of_validators=5, + consensus_history={ + "consensus_results": [{"consensus_round": "Leader Timeout"}] + }, + policy=policy, + ) + + # Consensus first prices the 101-TU allocation to 202 wei, then pays + # floor(202 / 2) = 101. Rounding 101 / 2 before pricing would pay 100. + assert settled["primary_fee_spent"] == 101 + assert settled["settlement_rounds"][0]["timeUnitAmount"] == 101 + assert refund == required_fee_deposit(fees_distribution, 5, policy) - 101 + + +def test_normal_settlement_refunds_non_aligned_validator_allocations(): + leader = "0x1111111111111111111111111111111111111111" + validator_addresses = [ + leader, + "0x2222222222222222222222222222222222222222", + "0x3333333333333333333333333333333333333333", + "0x4444444444444444444444444444444444444444", + "0x5555555555555555555555555555555555555555", + ] + votes = ["agree", "agree", "agree", "disagree", "disagree"] + proposal = _history_receipt(mode="leader", address=leader) + validation = [ + _history_receipt(mode="validator", address=address, vote=vote) + for address, vote in zip(validator_addresses, votes) + ] + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(), + num_of_validators=5, + submitted_value=1100, + user_value=0, + sender=leader, + ) + + settled, refund = settle_fee_accounting( + accounting, + actual_final_round=0, + num_of_validators=5, + consensus_history={ + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [proposal, validation[0]], + "validator_results": validation[1:], + } + ] + }, + ) + + assert settled["primary_fee_spent"] == 700 + assert settled["settlement_rounds"][0]["timeUnitAmount"] == 700 + assert refund == 400 + + +def test_leader_only_settlement_does_not_charge_unexecuted_validator_seats(): + leader = "0x1111111111111111111111111111111111111111" + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(), + num_of_validators=5, + submitted_value=1100, + user_value=0, + sender=leader, + ) + + settled, refund = settle_fee_accounting( + accounting, + actual_final_round=0, + num_of_validators=5, + execution_mode="LEADER_ONLY", + consensus_history={ + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [_history_receipt(mode="leader", address=leader)], + "validator_results": [], + } + ] + }, + ) + + assert settled["primary_fee_spent"] == 100 + assert settled["settlement_rounds"][0]["timeUnitAmount"] == 100 + assert refund == 1000 + + +def test_settlement_refunds_storage_division_dust_like_consensus(): + leader = "0x1111111111111111111111111111111111111111" + validator_addresses = [ + leader, + "0x2222222222222222222222222222222222222222", + "0x3333333333333333333333333333333333333333", + ] + fees_distribution = _fees_distribution(execution_budget_per_round=100) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + sender=leader, + ) + history = { + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [ + _history_receipt(mode="leader", address=leader), + _history_receipt(mode="validator", address=leader, vote="agree"), + ], + "validator_results": [ + _history_receipt(mode="validator", address=address, vote="agree") + for address in validator_addresses[1:] + ], + } + ] + } + + settled, refund = settle_fee_accounting( + accounting, + receipt={"genvm_result": {"data_fees_consumed": [0, 5]}}, + actual_final_round=0, + num_of_validators=5, + consensus_history=history, + ) + + # Consensus pays floor(5 / 3) to each tracked validator and returns the + # two-wei remainder. Time-unit spend is 100 + 3 * 200 = 700. + assert settled["storage_fee_recipient_count"] == 3 + assert settled["storage_fee_dust_refunded"] == 2 + assert settled["primary_fee_spent"] == 703 + assert refund == required_fee_deposit(fees_distribution, 5) - 703 + + +def test_settlement_refunds_all_storage_when_zero_value_round_tracks_nobody(): + leader = "0x1111111111111111111111111111111111111111" + fees_distribution = _fees_distribution( + leader_timeunits=0, + validator_timeunits=0, + execution_budget_per_round=100, + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + sender=leader, + ) + history = { + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [ + _history_receipt(mode="leader", address=leader), + _history_receipt(mode="validator", address=leader, vote="agree"), + ], + "validator_results": [ + _history_receipt( + mode="validator", + address="0x2222222222222222222222222222222222222222", + vote="agree", + ) + ], + } + ] + } + + settled, refund = settle_fee_accounting( + accounting, + receipt={"genvm_result": {"data_fees_consumed": [0, 5]}}, + actual_final_round=0, + num_of_validators=5, + consensus_history=history, + ) + + # FeesProcessor does not index zero-value rewards or zero penalties, so + # storage has no recipient denominator and the complete bucket is dust. + assert settled["storage_fee_recipient_count"] == 0 + assert settled["storage_fee_dust_refunded"] == 5 + assert settled["primary_fee_spent"] == 0 + assert refund == 100 + + +def test_leader_appeal_history_expands_placeholder_and_replay_round_fees(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + accounting = record_appeal_bond( + accounting, + amount=2_300, + appealer="0x9999999999999999999999999999999999999999", + current_round=0, + status="UNDETERMINED", + ) + original_leader = "0x1111111111111111111111111111111111111111" + replay_leader = "0x2222222222222222222222222222222222222222" + replay_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote="agree", + ) + for index in range(20, 31) + ] + history = { + "consensus_results": [ + { + "consensus_round": "Undetermined", + "leader_result": [ + _history_receipt(mode="leader", address=original_leader) + ], + "validator_results": [], + }, + { + "consensus_round": "Leader Appeal Successful", + "leader_result": [ + _history_receipt(mode="leader", address=replay_leader) + ], + "validator_results": replay_validators, + }, + ] + } + + settled, refund = settle_fee_accounting( + accounting, + actual_final_round=_infer_final_round(history), + num_of_validators=5, + consensus_history=history, + ) + + assert _infer_final_round(history) == 2 + assert settled["primary_fee_spent"] == 5_750 + assert refund == 2_600 + assert [item["rule"] for item in settled["settlement_rounds"]] == [ + "successful_appeal_skip", + "leader_appeal", + "leader_appeal_replay", + ] + assert settled["settlement_rounds"][2]["timeUnitAmount"] == 2_300 + + +def test_chained_validator_appeal_preserves_empty_even_fee_round(): + history = { + "consensus_results": [ + {"consensus_round": "Accepted"}, + {"consensus_round": "Validator Appeal Failed"}, + {"consensus_round": "Validator Appeal Successful"}, + ] + } + fees_distribution = _fees_distribution(appeals=2, rotations=[0, 0, 0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + + settled, _ = settle_fee_accounting( + accounting, + actual_final_round=_infer_final_round(history), + num_of_validators=5, + consensus_history=history, + ) + + assert _infer_final_round(history) == 3 + assert settled["settlement_rounds"][2]["rule"] == "empty_round" + assert settled["settlement_rounds"][2]["timeUnitAmount"] == 0 + + +def test_chained_successful_validator_appeal_vindicates_last_non_appeal_round(): + fees_distribution = _fees_distribution(appeals=2, rotations=[0, 0, 0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + original_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote=vote, + ) + for index, vote in enumerate( + ["agree", "agree", "agree", "disagree", "disagree"], + start=1, + ) + ] + failed_appeal_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote=vote, + ) + for index, vote in enumerate(["agree", "agree", "disagree"], start=11) + ] + successful_appeal_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote=vote, + ) + for index, vote in enumerate( + ["disagree", "disagree", "disagree", "disagree", "agree", "agree", "agree"], + start=21, + ) + ] + history = { + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x1111111111111111111111111111111111111111", + ), + original_validators[0], + ], + "validator_results": original_validators[1:], + }, + { + "consensus_round": "Validator Appeal Failed", + "validator_results": failed_appeal_validators, + }, + { + "consensus_round": "Validator Appeal Successful", + "validator_results": successful_appeal_validators, + }, + ] + } + + settled, _ = settle_fee_accounting( + accounting, + actual_final_round=3, + num_of_validators=5, + consensus_history=history, + ) + + # Solidity leaves the first failed-appeal jury payable (3 * 200), keeps + # the empty logical gap, and scans past both to vindicate the two Disagree + # voters from the last non-appeal round (4 jury + 2 vindicated) * 200. + assert settled["settlement_rounds"][1]["rule"] == ( + "validator_appeal_failed_redistribution" + ) + assert settled["settlement_rounds"][1]["timeUnitAmount"] == 600 + assert settled["settlement_rounds"][2]["rule"] == "empty_round" + assert settled["settlement_rounds"][3]["timeUnitAmount"] == 1_200 + + +def test_successful_validator_appeal_with_no_majority_does_not_vindicate_original_round(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + original_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote=vote, + ) + for index, vote in enumerate( + ["agree", "agree", "agree", "disagree", "disagree"], + start=1, + ) + ] + appeal_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote=vote, + ) + for index, vote in enumerate( + ["agree", "agree", "agree", "disagree", "disagree", "timeout", "timeout"], + start=11, + ) + ] + history = { + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x1111111111111111111111111111111111111111", + ), + original_validators[0], + ], + "validator_results": original_validators[1:], + }, + { + "consensus_round": "Validator Appeal Successful", + "validator_results": appeal_validators, + }, + ] + } + + settled, _ = settle_fee_accounting( + accounting, + actual_final_round=1, + num_of_validators=5, + consensus_history=history, + ) + + # Consensus pays every NoMajority appeal revealer, but vindicates nobody + # from the overturned round because the appeal established no clear side. + assert settled["settlement_rounds"][0]["timeUnitAmount"] == 0 + assert settled["settlement_rounds"][1]["timeUnitAmount"] == 1_400 + + +def test_failed_validator_appeal_charges_only_revealers_and_refunds_distribution_dust(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + accounting = record_appeal_bond( + accounting, + amount=1_400, + appealer="0x9999999999999999999999999999999999999999", + current_round=0, + status="ACCEPTED", + ) + original_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote="agree", + ) + for index in range(1, 6) + ] + appeal_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote=vote, + ) + for index, vote in enumerate( + ["agree", "agree", "agree", "disagree"], + start=11, + ) + ] + history = { + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x1111111111111111111111111111111111111111", + ), + original_validators[0], + ], + "validator_results": original_validators[1:], + }, + { + "consensus_round": "Validator Appeal Failed", + "validator_results": appeal_validators, + }, + ] + } + + settled, refund = settle_fee_accounting( + accounting, + actual_final_round=1, + num_of_validators=5, + consensus_history=history, + ) + + # Four of seven scheduled appeal seats revealed, so only 4 * 200 of the + # sender pool is consumed. (800 + 1400) / 3 leaves one wei of bond dust. + assert settled["settlement_rounds"][1]["timeUnitAmount"] == 800 + assert settled["appeal_bond_sender_refunded"] == 1 + assert settled["appeal_bond_settlements"][0]["bondDistributed"] == 1_399 + assert settled["appeal_bond_settlements"][0]["senderRefund"] == 1 + assert settled["refunds"][0]["appealBond"] == 1 + assert refund >= 1 + + +def test_failed_validator_appeal_dust_uses_priced_validator_allocation(): + policy = StudioFeePolicy(gen_per_time_unit=2) + fees_distribution = _fees_distribution( + appeals=1, + rotations=[0, 0], + max_price_gen_per_time_unit=2, + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5, policy), + user_value=0, + policy=policy, + ) + accounting = record_appeal_bond( + accounting, + amount=2_800, + appealer="0x9999999999999999999999999999999999999999", + current_round=0, + status="ACCEPTED", + policy=policy, + ) + appeal_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote=vote, + ) + for index, vote in enumerate( + ["agree", "agree", "agree", "disagree"], + start=11, + ) + ] + history = { + "consensus_results": [ + {"consensus_round": "Accepted"}, + { + "consensus_round": "Validator Appeal Failed", + "validator_results": appeal_validators, + }, + ] + } + + settled, _ = settle_fee_accounting( + accounting, + actual_final_round=1, + num_of_validators=5, + consensus_history=history, + policy=policy, + ) + + # (4 revealers * (200 TU * 2 wei/TU) + 2,800 bond) % 3 aligned = 2. + assert settled["settlement_rounds"][1]["timeUnitAmount"] == 1_600 + assert settled["appeal_bond_sender_refunded"] == 2 + assert settled["appeal_bond_settlements"][0]["bondDistributed"] == 2_798 + + +def test_failed_leader_appeal_dv_replay_withholds_leader_and_refunds_bond_dust(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + accounting = record_appeal_bond( + accounting, + amount=2_300, + appealer="0x9999999999999999999999999999999999999999", + current_round=0, + status="UNDETERMINED", + ) + original_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote=vote, + ) + for index, vote in enumerate( + ["agree", "agree", "disagree", "disagree", "timeout"], + start=1, + ) + ] + replay_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote=("deterministic_violation" if index < 26 else "agree"), + ) + for index in range(20, 31) + ] + history = { + "consensus_results": [ + { + "consensus_round": "Undetermined", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x1111111111111111111111111111111111111111", + ), + original_validators[0], + ], + "validator_results": original_validators[1:], + }, + { + "consensus_round": "Leader Appeal Failed", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x2222222222222222222222222222222222222222", + ), + replay_validators[0], + ], + "validator_results": replay_validators[1:], + }, + ] + } + + settled, _ = settle_fee_accounting( + accounting, + actual_final_round=_infer_final_round(history), + num_of_validators=5, + consensus_history=history, + ) + + assert settled["settlement_rounds"][2]["rule"] == ( + "deterministic_violation_leader_withheld" + ) + assert settled["settlement_rounds"][2]["timeUnitAmount"] == 1_200 + assert settled["appeal_bond_sender_refunded"] == 2 + assert settled["appeal_bond_settlements"][0]["bondDistributed"] == 2_298 + + +def test_failed_leader_timeout_appeal_refunds_sender_half_of_bond(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + accounting = record_appeal_bond( + accounting, + amount=1_100, + appealer="0x9999999999999999999999999999999999999999", + current_round=0, + status="LEADER_TIMEOUT", + leader_timeout_live_seats=5, + ) + history = { + "consensus_results": [ + {"consensus_round": "Leader Timeout"}, + { + "consensus_round": "Leader Timeout Appeal Failed", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x2222222222222222222222222222222222222222", + timeout=True, + ) + ], + "validator_results": [], + }, + ] + } + + settled, refund = settle_fee_accounting( + accounting, + actual_final_round=_infer_final_round(history), + num_of_validators=5, + consensus_history=history, + ) + + assert settled["primary_fee_spent"] == 50 + assert settled["appeal_bond_sender_refunded"] == 550 + assert refund == 8_850 + assert settled["refunds"][0]["appealBond"] == 550 + assert settled["appeal_bond_settlements"][0] == { + "bondIndex": 0, + "appealer": "0x9999999999999999999999999999999999999999", + "amount": 1_100, + "round": 0, + "status": "forfeited", + "payout": 0, + "outcomeRound": 2, + "outcome": "Leader Timeout Appeal Failed", + "bond_forfeited": 1_100, + "distribution": "leader-timeout-split", + "leaderPayout": 550, + "senderRefund": 550, + } + + +def test_fee_alignment_classifies_idle_ballots_as_onchain_timeouts(): + leader = "0x1111111111111111111111111111111111111111" + addresses = [ + leader, + "0x2222222222222222222222222222222222222222", + "0x3333333333333333333333333333333333333333", + "0x4444444444444444444444444444444444444444", + "0x5555555555555555555555555555555555555555", + ] + votes = ["idle", "idle", "idle", "disagree", "disagree"] + proposal = _history_receipt(mode="leader", address=leader) + validation = [ + _history_receipt(mode="validator", address=address, vote=vote) + for address, vote in zip(addresses, votes) + ] + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(), + num_of_validators=5, + submitted_value=1_100, + user_value=0, + sender=leader, + ) + + settled, refund = settle_fee_accounting( + accounting, + actual_final_round=0, + num_of_validators=5, + consensus_history={ + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [proposal, validation[0]], + "validator_results": validation[1:], + } + ] + }, + ) + + assert settled["primary_fee_spent"] == 700 + assert refund == 400 + + +def test_deterministic_violation_withholds_leader_time_and_receipt_fees(): + policy = StudioFeePolicy(receipt_gas_price=1) + fees_distribution = _fees_distribution(execution_budget_per_round=1_000_000) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5, policy), + user_value=0, + policy=policy, + ) + leader = _history_receipt( + mode="leader", + address="0x1111111111111111111111111111111111111111", + eq_outputs_length=64, + ) + validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote=vote, + ) + for index, vote in enumerate( + [ + "deterministic_violation", + "deterministic_violation", + "deterministic_violation", + "agree", + "agree", + ], + start=2, + ) + ] + history = { + "consensus_results": [ + { + "consensus_round": "Undetermined", + "leader_result": [leader, validators[0]], + "validator_results": validators[1:], + } + ] + } + + settled, _ = settle_fee_accounting( + accounting, + receipt=leader, + actual_final_round=0, + num_of_validators=5, + consensus_history=history, + policy=policy, + ) + + assert settled["settlement_rounds"][0]["rule"] == ( + "deterministic_violation_leader_withheld" + ) + assert settled["settlement_rounds"][0]["timeUnitAmount"] == 600 + assert settled["execution_fee_consumed"] == 0 + assert settled["historical_execution_attempts"] == [ + { + "round": 0, + "attempt": 0, + "leaderTimeout": False, + "deterministicViolation": True, + "receiptFee": 0, + "receiptGasPrice": 1, + } + ] + + +def test_settlement_accumulates_receipt_fees_for_every_leader_attempt(): + policy = StudioFeePolicy(receipt_gas_price=1) + fees_distribution = _fees_distribution( + rotations=[1], + execution_budget_per_round=1_000_000, + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5, policy), + user_value=0, + policy=policy, + ) + addresses = [f"0x{index:040x}" for index in range(1, 7)] + + def attempt(leader_index: int, eq_outputs_length: int) -> tuple[dict, list[dict]]: + proposal = _history_receipt( + mode="leader", + address=addresses[leader_index], + eq_outputs_length=eq_outputs_length, + ) + validators = [ + _history_receipt( + mode="validator", + address=address, + vote="agree", + ) + for address in addresses[leader_index : leader_index + 5] + ] + return proposal, validators + + first_proposal, first_validators = attempt(0, 0) + final_proposal, final_validators = attempt(1, 64) + final_proposal["genvm_result"]["data_fees_consumed"] = [0, 37] + history = { + "consensus_results": [ + { + "consensus_round": "Leader Rotation", + "leader_result": [first_proposal, first_validators[0]], + "validator_results": first_validators[1:], + }, + { + "consensus_round": "Accepted", + "leader_result": [final_proposal, final_validators[0]], + "validator_results": final_validators[1:], + }, + ] + } + + settled, _ = settle_fee_accounting( + accounting, + receipt=final_proposal, + actual_final_round=0, + num_of_validators=5, + consensus_history=history, + policy=policy, + ) + + expected_receipts = sum( + policy.estimate_propose_receipt_gas( + policy.estimate_propose_receipt_bytes(eq_outputs_length) + ) + for eq_outputs_length in (0, 64) + ) + assert settled["execution_fee_consumed_buckets"] == [expected_receipts, 37] + assert settled["execution_fee_consumed"] == expected_receipts + 37 + assert len(settled["historical_execution_attempts"]) == 2 + + +def test_settlement_preserves_each_attempts_live_metering_snapshot(): + submission_policy = StudioFeePolicy(receipt_gas_price=2) + fees_distribution = _fees_distribution( + rotations=[1], + execution_budget_per_round=1_000, + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit( + fees_distribution, + 5, + submission_policy, + ), + user_value=0, + policy=submission_policy, + allow_low_execution_budget=True, + ) + activated, should_cancel = activate_fee_accounting( + accounting, + StudioFeePolicy(receipt_gas_price=2), + ) + assert should_cancel is False + + first = _history_receipt( + mode="leader", + address="0x1111111111111111111111111111111111111111", + ) + final = _history_receipt( + mode="leader", + address="0x2222222222222222222222222222222222222222", + ) + first_policy = stamp_receipt_execution_policy( + first, + activated, + StudioFeePolicy( + receipt_gas_price=90, + intrinsic_gas=0, + bootloader_overhead=0, + gas_per_changed_slot=0, + calldata_gas_per_byte=0, + fixed_propose_receipt_gas=11, + receipt_wrapper_bytes=0, + ), + ) + final_policy = stamp_receipt_execution_policy( + final, + activated, + StudioFeePolicy( + receipt_gas_price=99, + intrinsic_gas=0, + bootloader_overhead=0, + gas_per_changed_slot=0, + calldata_gas_per_byte=0, + fixed_propose_receipt_gas=17, + receipt_wrapper_bytes=0, + ), + ) + assert first_policy.receipt_gas_price == 2 + assert final_policy.receipt_gas_price == 2 + assert ( + first["genvm_result"][FEE_POLICY_SNAPSHOT_KEY]["fixed_propose_receipt_gas"] + == 11 ) - receipt = {"genvm_result": {"data_fees_consumed": [300]}} - settled, refund = settle_fee_accounting( - accounting, - receipt=receipt, + settled, _ = settle_fee_accounting( + activated, + receipt=final, actual_final_round=0, num_of_validators=5, + consensus_history={ + "consensus_results": [ + { + "consensus_round": "Leader Rotation", + "leader_result": [first], + "validator_results": [], + }, + { + "consensus_round": "Accepted", + "leader_result": [final], + "validator_results": [], + }, + ] + }, + policy=StudioFeePolicy( + receipt_gas_price=500, + fixed_propose_receipt_gas=999, + ), ) - assert settled["execution_fee_consumed"] == 300 - assert settled["actual_final_round"] == 0 - assert settled["primary_fee_spent"] == 1200 - assert refund == 22100 + assert [ + attempt["receiptFee"] for attempt in settled["historical_execution_attempts"] + ] == [22, 34] + assert settled["execution_fee_consumed"] == 56 -def test_settle_fee_accounting_uses_actual_round_for_primary_refund(): - fees_distribution = _fees_distribution(appeals=2, rotations=[0, 0, 0]) +def test_historical_error_attempt_does_not_charge_message_reveal(): + policy = StudioFeePolicy( + receipt_gas_price=1, + intrinsic_gas=0, + bootloader_overhead=0, + gas_per_changed_slot=0, + calldata_gas_per_byte=1, + fixed_propose_receipt_gas=10, + fixed_message_reveal_gas=1_000, + receipt_wrapper_bytes=0, + ) + fees_distribution = _fees_distribution(execution_budget_per_round=10_000) accounting = create_fee_accounting( fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=required_fee_deposit(fees_distribution, 5), + submitted_value=required_fee_deposit(fees_distribution, 5, policy), user_value=0, - sender="0x1111111111111111111111111111111111111111", + policy=policy, ) + failed = _history_receipt( + mode="leader", + address="0x1111111111111111111111111111111111111111", + ) + failed["execution_result"] = "FinishedWithError" + failed["pending_transactions"] = [ + { + "messageType": "Internal", + "recipient": "0x2222222222222222222222222222222222222222", + "data": "0x1234", + "onAcceptance": True, + "value": 0, + "declaredBudget": 0, + "callKey": CALL_KEY_WILDCARD, + } + ] - settled, refund = settle_fee_accounting( + settled, _ = settle_fee_accounting( accounting, + receipt=failed, actual_final_round=0, num_of_validators=5, + consensus_history={ + "consensus_results": [ + { + "consensus_round": "Undetermined", + "leader_result": [failed], + "validator_results": [], + } + ] + }, + policy=policy, ) - assert settled["primary_fee_spent"] == 1100 - assert settled["actual_final_round"] == 0 - assert refund == 21700 + assert settled["historical_execution_attempts"][0]["receiptFee"] == 10 + assert settled["execution_fee_consumed"] == 10 -def test_settle_fee_accounting_charges_half_leader_allocation_for_timeout_round(): +def test_leader_timeout_attempt_consumes_no_receipt_fee(): + policy = StudioFeePolicy(receipt_gas_price=1) + fees_distribution = _fees_distribution(execution_budget_per_round=1_000_000) accounting = create_fee_accounting( - fees_distribution=_fees_distribution(), + fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=1100, + submitted_value=required_fee_deposit(fees_distribution, 5, policy), user_value=0, - sender="0x1111111111111111111111111111111111111111", + policy=policy, + ) + timeout_receipt = _history_receipt( + mode="leader", + address="0x1111111111111111111111111111111111111111", + timeout=True, ) - consensus_history = { - "consensus_results": [ - {"consensus_round": "Leader Timeout"}, - ] - } - settled, refund = settle_fee_accounting( + settled, _ = settle_fee_accounting( accounting, + receipt=timeout_receipt, actual_final_round=0, num_of_validators=5, - consensus_history=consensus_history, + consensus_history={ + "consensus_results": [ + { + "consensus_round": "Leader Timeout", + "leader_result": [timeout_receipt], + "validator_results": [], + } + ] + }, + policy=policy, ) - assert settled["primary_fee_spent"] == 1050 - assert refund == 50 + assert settled["execution_fee_consumed"] == 0 + assert settled["historical_execution_attempts"][0]["leaderTimeout"] is True + assert settled["historical_execution_attempts"][0]["receiptFee"] == 0 def test_cancel_fee_accounting_refunds_unspent_buckets_and_is_idempotent(): @@ -3769,6 +5469,7 @@ def test_cancel_fee_accounting_refunds_unspent_buckets_and_is_idempotent(): "reason": "canceled", "primary": 1100, "message": 55, + "appealBond": 0, "amount": 1155, } assert second_refund == 0 @@ -3928,9 +5629,38 @@ def test_mode1_message_fees_reject_declared_budget_below_child_minimum(): ) +def test_use_balance_message_validates_floor_without_consuming_sender_bucket(): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=0), + num_of_validators=5, + submitted_value=1_100, + user_value=0, + ) + + updated = consume_message_fees( + accounting, + [ + { + "messageType": 1, + "recipient": "0x2222222222222222222222222222222222222222", + "onAcceptance": True, + "feeParams": _encode_internal_fee_params(), + "declaredBudget": 55, + "callKey": EMPTY_CALL_KEY, + "useBalance": True, + } + ], + ) + + assert updated["message_fee_budget"] == 0 + assert updated["message_fee_consumed"] == 0 + assert updated["allocation_consumed"] == {} + assert updated["message_consumption_events"][-1]["internalConsumed"] == 0 + + def test_mode2_message_fees_use_exact_then_wildcard_allocation_match(): exact_call_key = "0x" + "12" * 32 - wildcard_call_key = "0x" + "0" * 32 + wildcard_call_key = CALL_KEY_WILDCARD exact_fee_params = _encode_internal_fee_params(leader_timeunits=6) wildcard_fee_params = _encode_internal_fee_params(leader_timeunits=7) recipient = "0x2222222222222222222222222222222222222222" @@ -4172,7 +5902,7 @@ def test_mode2_external_message_fees_use_legacy_fallback_without_matching_alloca } -def test_mode2_external_message_fees_ignore_on_acceptance_execution_reservation(): +def test_mode2_external_message_fees_reject_on_acceptance_execution_reservation(): allocation = _allocation( message_type=0, on_acceptance=False, @@ -4187,62 +5917,23 @@ def test_mode2_external_message_fees_ignore_on_acceptance_execution_reservation( user_value=0, ) - updated = consume_message_fees( - accounting, - [ - { - "messageType": 0, - "recipient": allocation["recipient"], - "onAcceptance": True, - "declaredBudget": 0, - "callKey": allocation["callKey"], - "gasUsed": 1_000, - } - ], - policy=StudioFeePolicy(receipt_gas_price=7), - ) - - assert updated["message_fee_consumed"] == 0 - assert updated["allocation_consumed"] == {} - assert updated["external_message_fee_reserved"] == 0 - assert updated["external_message_events"] == [] - - -def test_external_message_execution_reservation_ignores_allocation_phase_pin(): - allocation = _allocation( - message_type=0, - on_acceptance=True, - budget=1_000, - fee_params=_encode_external_fee_params(gas_limit=100, max_gas_price=10), - ) - accounting = create_fee_accounting( - fees_distribution=_fees_distribution(total_message_fees=1_000), - message_allocations=[allocation], - num_of_validators=5, - submitted_value=2_100, - user_value=0, - ) - - updated = consume_message_fees( - accounting, - [ - { - "messageType": 0, - "recipient": allocation["recipient"], - "onAcceptance": False, - "declaredBudget": 0, - "callKey": allocation["callKey"], - "gasUsed": 60, - } - ], - policy=StudioFeePolicy(receipt_gas_price=7), - ) - - assert updated["allocation_consumed"] == {"0": 700} - assert updated["external_message_fee_reserved"] == 700 - assert updated["external_message_fee_reimbursed"] == 420 - assert updated["external_message_fee_remainder"] == 280 - assert updated["message_fee_consumed"] == 420 + with pytest.raises( + ExternalAllocationInvalid, match="ExternalOnAcceptanceNotSupported" + ): + consume_message_fees( + accounting, + [ + { + "messageType": 0, + "recipient": allocation["recipient"], + "onAcceptance": True, + "declaredBudget": 0, + "callKey": allocation["callKey"], + "gasUsed": 1_000, + } + ], + policy=StudioFeePolicy(receipt_gas_price=7), + ) def test_consume_external_message_fees_use_exact_then_wildcard_allocation_match(): @@ -4261,7 +5952,7 @@ def test_consume_external_message_fees_use_exact_then_wildcard_allocation_match( message_type=0, on_acceptance=False, recipient=recipient, - call_key="0x" + "0" * 32, + call_key=CALL_KEY_WILDCARD, budget=2_000, fee_params=_encode_external_fee_params(gas_limit=200, max_gas_price=10), ), @@ -4300,7 +5991,74 @@ def test_consume_external_message_fees_use_exact_then_wildcard_allocation_match( assert updated["allocation_consumed"] == {"0": 700, "1": 1_400} assert updated["external_message_fee_reserved"] == 2_100 assert updated["external_message_fee_reimbursed"] == 210 - assert updated["message_fee_consumed"] == 210 + assert updated["message_fee_consumed"] == 2_100 + + +def test_external_message_reservation_spills_from_exhausted_exact_to_wildcard(): + recipient = "0x2222222222222222222222222222222222222222" + exact_call_key = "0x" + "12" * 32 + message = { + "messageType": 0, + "recipient": recipient, + "onAcceptance": False, + "declaredBudget": 0, + "callKey": exact_call_key, + "gasUsed": 10, + } + allocations = [ + _allocation( + message_type=0, + on_acceptance=False, + recipient=recipient, + call_key=exact_call_key, + budget=1_000, + fee_params=_encode_external_fee_params(gas_limit=100, max_gas_price=10), + ), + _allocation( + message_type=0, + on_acceptance=False, + recipient=recipient, + call_key=CALL_KEY_WILDCARD, + budget=1_000, + fee_params=_encode_external_fee_params(gas_limit=100, max_gas_price=10), + ), + ] + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=2_000), + message_allocations=allocations, + num_of_validators=5, + submitted_value=3_100, + user_value=0, + ) + + revealed = record_reveal_message_fees( + accounting, + [message, message], + policy=StudioFeePolicy(receipt_gas_price=10), + ) + + assert revealed["allocation_consumed"] == {"0": 1_000, "1": 1_000} + assert [ + event["allocationIndex"] for event in revealed["external_message_events"] + ] == [ + 0, + 1, + ] + + unwound = unwind_reveal_message_fees( + revealed, + [message, message], + ) + + assert unwound["allocation_consumed"] == {} + assert unwound["external_message_fee_reserved"] == 0 + assert [ + event["allocationIndex"] for event in unwound["external_message_events"] + ] == [ + 0, + 1, + ] + assert all(event["unreserved"] for event in unwound["external_message_events"]) def test_consume_message_fees_rejects_internal_message_without_internal_allocation(): @@ -4312,6 +6070,7 @@ def test_consume_message_fees_rejects_internal_message_without_internal_allocati message_allocations=[ _allocation( message_type=0, + on_acceptance=False, recipient=recipient, call_key=call_key, budget=210_000, @@ -4373,11 +6132,11 @@ def test_consume_external_message_fees_reserves_and_reimburses_executor_gas(): assert updated["external_message_fee_reserved"] == 147_000 assert updated["external_message_fee_reimbursed"] == 7_000 assert updated["external_message_fee_remainder"] == 140_000 - assert updated["message_fee_consumed"] == 7_000 + assert updated["message_fee_consumed"] == 147_000 settled, refund = settle_fee_accounting(updated) - assert refund == 203_000 - assert settled["message_fee_refunded"] == 203_000 + assert refund == 63_000 + assert settled["message_fee_refunded"] == 63_000 def test_consume_external_message_fees_caps_reimbursement_at_reserved_gas_limit(): @@ -4456,7 +6215,66 @@ def test_consume_external_message_fees_locks_allocation_max_gas_price_cap(): assert updated["external_message_events"][0]["lockedGasPrice"] == 10 -def test_reveal_external_message_reserves_then_execution_reimburses_once(): +def test_reveal_external_message_reserves_then_execution_reimburses_once(): + allocation = _allocation( + message_type=0, + on_acceptance=False, + budget=1_000, + fee_params=_encode_external_fee_params(gas_limit=100, max_gas_price=10), + ) + message = { + "messageType": 0, + "recipient": allocation["recipient"], + "onAcceptance": False, + "declaredBudget": 0, + "callKey": allocation["callKey"], + "gasUsed": 60, + } + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=1_000), + message_allocations=[allocation], + num_of_validators=5, + submitted_value=2_100, + user_value=0, + ) + + revealed = record_reveal_message_fees( + accounting, + [message], + policy=StudioFeePolicy(receipt_gas_price=7), + ) + + assert revealed["message_fees_recorded_at_reveal"] is True + assert revealed["allocation_consumed"] == {"0": 700} + assert revealed["message_fee_consumed"] == 0 + assert revealed["external_message_fee_reserved"] == 700 + assert revealed["external_message_fee_reimbursed"] == 0 + assert revealed["external_message_fee_remainder"] == 0 + assert revealed["external_message_events"][0]["executionRecorded"] is False + + executed = record_external_message_execution_fees( + revealed, + [message], + policy=StudioFeePolicy(receipt_gas_price=7), + ) + executed_again = record_external_message_execution_fees( + executed, + [message], + policy=StudioFeePolicy(receipt_gas_price=7), + ) + + assert executed["message_fee_consumed"] == 700 + assert executed["external_message_fee_reserved"] == 700 + assert executed["external_message_fee_reimbursed"] == 420 + assert executed["external_message_fee_remainder"] == 280 + assert executed["external_message_events"][0]["executionRecorded"] is True + assert executed["external_message_events"][0]["gasUsed"] == 60 + assert executed_again == executed + + +def test_external_execution_routes_reimbursement_and_remainder_like_consensus(): + depositor = "0x1111111111111111111111111111111111111111" + executor = "0x9999999999999999999999999999999999999999" allocation = _allocation( message_type=0, on_acceptance=False, @@ -4477,40 +6295,38 @@ def test_reveal_external_message_reserves_then_execution_reimburses_once(): num_of_validators=5, submitted_value=2_100, user_value=0, + sender=depositor, ) - revealed = record_reveal_message_fees( accounting, [message], policy=StudioFeePolicy(receipt_gas_price=7), ) - assert revealed["message_fees_recorded_at_reveal"] is True - assert revealed["allocation_consumed"] == {"0": 700} - assert revealed["message_fee_consumed"] == 0 - assert revealed["external_message_fee_reserved"] == 700 - assert revealed["external_message_fee_reimbursed"] == 0 - assert revealed["external_message_fee_remainder"] == 0 - assert revealed["external_message_events"][0]["executionRecorded"] is False - executed = record_external_message_execution_fees( revealed, [message], policy=StudioFeePolicy(receipt_gas_price=7), + executor=executor, ) - executed_again = record_external_message_execution_fees( - executed, - [message], - policy=StudioFeePolicy(receipt_gas_price=7), - ) + settled, refund = settle_fee_accounting(executed) - assert executed["message_fee_consumed"] == 420 - assert executed["external_message_fee_reserved"] == 700 - assert executed["external_message_fee_reimbursed"] == 420 - assert executed["external_message_fee_remainder"] == 280 - assert executed["external_message_events"][0]["executionRecorded"] is True - assert executed["external_message_events"][0]["gasUsed"] == 60 - assert executed_again == executed + assert executed["message_fee_consumed"] == 700 + assert executed["external_message_fee_settled"] == 700 + assert executed["external_message_fee_payouts"] == [ + { + "recipient": executor, + "amount": 420, + "source": "external-executor-reimbursement", + }, + { + "recipient": depositor, + "amount": 280, + "source": "external-execution-remainder", + }, + ] + assert refund == 300 + assert settled["message_fee_refunded"] == 300 def test_refund_failed_external_message_fee_preserves_spent_executor_gas(): @@ -4545,7 +6361,7 @@ def test_refund_failed_external_message_fee_preserves_spent_executor_gas(): refunded_again = refund_failed_external_message_fee(refunded, message) assert refunded["allocation_consumed"] == {"0": 147_000} - assert refunded["message_fee_consumed"] == 7_000 + assert refunded["message_fee_consumed"] == 147_000 assert refunded["external_message_fee_reserved"] == 147_000 assert refunded["external_message_fee_reimbursed"] == 7_000 assert refunded["external_message_fee_remainder"] == 140_000 @@ -4564,8 +6380,8 @@ def test_refund_failed_external_message_fee_preserves_spent_executor_gas(): assert refunded_again == refunded settled, refund = settle_fee_accounting(refunded) - assert refund == 203_000 - assert settled["message_fee_refunded"] == 203_000 + assert refund == 63_000 + assert settled["message_fee_refunded"] == 63_000 def test_refund_failed_external_message_fee_marks_exact_or_wildcard_match_only(): @@ -4592,7 +6408,7 @@ def test_refund_failed_external_message_fee_marks_exact_or_wildcard_match_only() message_type=0, on_acceptance=False, recipient=recipient, - call_key="0x" + "0" * 32, + call_key=CALL_KEY_WILDCARD, budget=2_000, fee_params=_encode_external_fee_params(gas_limit=200, max_gas_price=10), ), @@ -4623,7 +6439,7 @@ def test_refund_failed_external_message_fee_marks_exact_or_wildcard_match_only() refunded = refund_failed_external_message_fee(consumed, wildcard_message) assert refunded["allocation_consumed"] == {"0": 700, "1": 1_400} - assert refunded["message_fee_consumed"] == 210 + assert refunded["message_fee_consumed"] == 2_100 assert refunded["external_message_fee_reserved"] == 2_100 assert refunded["external_message_fee_reimbursed"] == 210 assert refunded["external_message_fee_remainder"] == 1_890 @@ -4703,7 +6519,7 @@ def test_unwind_reveal_message_fees_rolls_back_empty_rereveal_before_acceptance( submitted_value=2_155, user_value=0, ) - consumed = consume_message_fees( + consumed = record_reveal_message_fees( accounting, [internal_message, external_message], reported_total=55, @@ -4718,7 +6534,7 @@ def test_unwind_reveal_message_fees_rolls_back_empty_rereveal_before_acceptance( assert consumed["message_fee_consumed"] == 55 assert consumed["allocation_consumed"] == {"0": 55, "1": 700} assert unwound["message_fee_consumed"] == 0 - assert unwound["allocation_consumed"] == {"0": 0, "1": 0} + assert unwound["allocation_consumed"] == {} assert unwound["external_message_fee_reserved"] == 0 assert unwound["external_message_fee_reimbursed"] == 0 assert unwound["external_message_fee_remainder"] == 0 @@ -4730,7 +6546,7 @@ def test_unwind_reveal_message_fees_rolls_back_empty_rereveal_before_acceptance( "internalRefunded": 55, "externalUnreserved": 700, "externalReimbursementRolledBack": 0, - "externalRemainderRolledBack": 700, + "externalRemainderRolledBack": 0, "remaining": 1_055, } ] @@ -4877,6 +6693,195 @@ def test_consume_message_fees_respects_max_messages_per_tx_policy(): ) +def test_consume_message_fees_enforces_protocol_hard_cap_by_default(): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=0), + num_of_validators=5, + submitted_value=1_100, + user_value=0, + ) + messages = [ + { + "messageType": 0, + "recipient": f"0x{index:040x}", + "onAcceptance": False, + "declaredBudget": 0, + } + for index in range(1, 22) + ] + + assert len(messages[:20]) == 20 + consume_message_fees(accounting, messages[:20]) + with pytest.raises(TooManyMessages): + consume_message_fees(accounting, messages) + + +def test_identical_accepted_message_rereveal_keeps_single_lifetime_charge(): + tx_id = "0x" + ("ab" * 32) + message = { + "messageType": 1, + "recipient": "0x2222222222222222222222222222222222222222", + "value": 0, + "data": b"same-call", + "onAcceptance": True, + "saltNonce": 0, + "feeParams": _encode_internal_fee_params(), + "declaredBudget": 55, + "callKey": "0x" + ("12" * 32), + "useBalance": False, + } + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=55), + num_of_validators=5, + submitted_value=1_155, + user_value=0, + ) + + first = prepare_reveal_message_generation(accounting, tx_id, [message]) + delivered = mark_message_effects_delivered(first, tx_id, [message], "accepted") + second = prepare_reveal_message_generation(delivered, tx_id, [message]) + + assert first["message_fee_consumed"] == 55 + assert delivered["active_message_generation"]["acceptanceDispatched"] is True + assert second["message_fee_consumed"] == 55 + assert second["active_message_generation"]["novelty"] == [False] + assert second["message_consumption_events"][-1]["consumed"] == 0 + # The generation ledger is persisted in transaction JSON, so bytes must be + # encoded rather than leaking a non-serializable Python value into jsonb. + json.dumps(second["active_message_generation"]) + + +def test_message_occurrence_descriptor_rejects_value_or_fee_drift(): + tx_id = "0x" + ("bc" * 32) + message = { + "messageType": 1, + "recipient": "0x2222222222222222222222222222222222222222", + "value": 0, + "data": b"same-call", + "onAcceptance": True, + "saltNonce": 0, + "feeParams": _encode_internal_fee_params(), + "declaredBudget": 55, + "callKey": "0x" + ("12" * 32), + "useBalance": False, + } + accounting = mark_message_effects_delivered({}, tx_id, [message], "accepted") + drifted = {**message, "value": 1} + + with pytest.raises(MessageEffectDescriptorMismatch): + message_novelty_mask(accounting, tx_id, [drifted]) + + +def test_message_occurrence_ordinals_are_permutation_stable_for_duplicates(): + tx_id = "0x" + ("cd" * 32) + + def message(recipient: str): + return { + "messageType": 0, + "recipient": recipient, + "value": 0, + "data": b"selector", + "onAcceptance": True, + "saltNonce": 0, + "feeParams": b"", + "declaredBudget": 0, + "callKey": "0x" + ("12" * 32), + "useBalance": False, + } + + a = message("0x2222222222222222222222222222222222222222") + b = message("0x3333333333333333333333333333333333333333") + accounting = mark_message_effects_delivered({}, tx_id, [a, b, a], "accepted") + + assert message_novelty_mask(accounting, tx_id, [b, a, a, a]) == [ + False, + False, + False, + True, + ] + + +def test_receipt_admission_applies_message_count_cap_to_fee_free_messages(): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(execution_budget_per_round=1_000_000), + num_of_validators=5, + submitted_value=1_001_100, + user_value=0, + ) + messages = [ + { + "is_eth_send": True, + "address": f"0x{index:040x}", + "calldata": b"", + "on": "finalized", + "value": 0, + } + for index in (1, 2) + ] + + with pytest.raises(TooManyMessages): + record_execution_fee_consumption( + accounting, + {"pending_transactions": messages}, + StudioFeePolicy(max_messages_per_tx=1), + ) + + +def test_receipt_admission_enforces_protocol_hard_cap_for_fee_free_messages(): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(execution_budget_per_round=1_000_000), + num_of_validators=5, + submitted_value=1_001_100, + user_value=0, + ) + messages = [ + { + "is_eth_send": True, + "address": f"0x{index:040x}", + "calldata": b"", + "on": "finalized", + "value": 0, + } + for index in range(1, 22) + ] + + record_execution_fee_consumption( + accounting, + {"pending_transactions": messages[:20]}, + StudioFeePolicy(), + ) + with pytest.raises(TooManyMessages): + record_execution_fee_consumption( + accounting, + {"pending_transactions": messages}, + StudioFeePolicy(), + ) + + +def test_record_reveal_message_fees_enforces_canonical_message_byte_cap(): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=55), + num_of_validators=5, + submitted_value=1_155, + user_value=0, + ) + message = { + "messageType": 1, + "recipient": "0x2222222222222222222222222222222222222222", + "onAcceptance": True, + "feeParams": _encode_internal_fee_params(), + "declaredBudget": 55, + "useBalance": False, + } + + with pytest.raises(SubmittedMessagesTooLarge, match="SubmittedMessagesTooLarge"): + record_reveal_message_fees( + accounting, + [message], + policy=StudioFeePolicy(max_submitted_messages_bytes=1), + ) + + def test_consume_message_fees_rejects_allocation_overrun_and_fee_param_mismatch(): fee_params = _encode_internal_fee_params() accounting = create_fee_accounting( @@ -4990,7 +6995,7 @@ def test_settle_fee_accounting_pays_successful_appeal_bond_plus_profit(): consensus_history = { "consensus_results": [ {"consensus_round": "Accepted"}, - {"consensus_round": "Leader Appeal Successful"}, + {"consensus_round": "Validator Appeal Successful"}, ] } @@ -5001,7 +7006,8 @@ def test_settle_fee_accounting_pays_successful_appeal_bond_plus_profit(): consensus_history=consensus_history, ) - assert refund == 3650 + assert refund == 4850 + assert settled["primary_fee_spent"] == 3500 assert settled["appeal_bonds_payout_total"] == 3500 assert settled["appeal_profit_spent"] == 2100 assert settled["appeal_bond_settlements"] == [ @@ -5013,11 +7019,60 @@ def test_settle_fee_accounting_pays_successful_appeal_bond_plus_profit(): "status": "successful", "payout": 3500, "outcomeRound": 1, - "outcome": "Leader Appeal Successful", + "outcome": "Validator Appeal Successful", } ] +def test_settle_fee_accounting_clamps_unfunded_appeal_profit_to_principal(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + recorded = record_appeal_bond( + accounting, + amount=1_400, + appealer="0x1111111111111111111111111111111111111111", + current_round=0, + status="ACCEPTED", + ) + # Adversarial legacy state: less fee money remains than the complete + # 2,100-wei profit leg. Consensus promises no partial profit in this case. + recorded["primary_fee_budget"] = 2_000 + history = { + "consensus_results": [ + {"consensus_round": "Accepted"}, + { + "consensus_round": "Validator Appeal Successful", + "validator_results": [ + _history_receipt( + mode="validator", + address="0x2222222222222222222222222222222222222222", + vote="agree", + ) + ], + }, + ] + } + + settled, refund = settle_fee_accounting( + recorded, + actual_final_round=1, + num_of_validators=5, + consensus_history=history, + ) + + assert settled["appeal_profit_requested"] == 2_100 + assert settled["appeal_profit_spent"] == 0 + assert settled["appeal_bonds_payout_total"] == 1_400 + assert settled["appeal_bond_settlements"][0]["payout"] == 1_400 + assert settled["appeal_bond_settlements"][0]["profitFunded"] is False + assert refund == 1_800 + + def test_settle_fee_accounting_explicitly_forfeits_failed_appeal_bond(): fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) accounting = create_fee_accounting( @@ -5036,7 +7091,7 @@ def test_settle_fee_accounting_explicitly_forfeits_failed_appeal_bond(): consensus_history = { "consensus_results": [ {"consensus_round": "Accepted"}, - {"consensus_round": "Leader Appeal Failed"}, + {"consensus_round": "Validator Appeal Failed"}, ] } @@ -5047,7 +7102,7 @@ def test_settle_fee_accounting_explicitly_forfeits_failed_appeal_bond(): consensus_history=consensus_history, ) - assert refund == 5750 + assert refund == 5850 assert settled["appeal_bonds_payout_total"] == 0 assert settled["appeal_bond_settlements"][0]["status"] == "forfeited" assert settled["appeal_bond_settlements"][0]["bond_forfeited"] == 1400 @@ -5154,6 +7209,29 @@ def test_leader_timeout_bond_matches_fee_simulator_configured_round_vector(): assert bond_with_two_rotations_left == 3 * (100 + 5 * 200) +def test_leader_timeout_live_seats_gate_eligibility_not_configured_pricing(): + fees_distribution = _fees_distribution() + + assert ( + calculate_min_appeal_bond( + fees_distribution, + current_round=0, + status="LEADER_TIMEOUT", + leader_timeout_live_seats=1, + ) + == 0 + ) + assert ( + calculate_min_appeal_bond( + fees_distribution, + current_round=0, + status="LEADER_TIMEOUT", + leader_timeout_live_seats=4, + ) + == 100 + 5 * 200 + ) + + def test_leader_appeal_bonds_count_the_mandatory_attempt(): fees_distribution = _fees_distribution( appeals=1, @@ -5178,6 +7256,21 @@ def test_leader_appeal_bonds_count_the_mandatory_attempt(): ) +def test_later_undetermined_appeal_bond_uses_next_normal_rotation_ordinal(): + fees_distribution = _fees_distribution( + appeals=3, + rotations=[0, 1, 2, 3], + ) + + # A leader appeal from raw round 2 induces raw round 4, which is normal + # ordinal 2 and therefore uses rotations[2] (three attempts). + assert calculate_min_appeal_bond( + fees_distribution, + current_round=2, + status="UNDETERMINED", + ) == 3 * (100 + 23 * 200) + + def test_successful_appeal_return_matches_fee_simulator_exact_integer_math(): bond = 10**30 + 1 @@ -5193,8 +7286,9 @@ def test_timeout_appeal_bond_bypasses_stale_max_price_cap(): max_price_gen_per_time_unit=10, ) - with pytest.raises(MaxPriceExceeded): - calculate_round_fees(fees_distribution, 5, policy=policy) + # Submission quoting stays at the user's ceiling even when the live price + # has already risen; the transaction is canceled only at activation. + assert calculate_round_fees(fees_distribution, 5, policy=policy) > 0 bond = calculate_min_appeal_bond( fees_distribution, @@ -5309,6 +7403,9 @@ def test_appeal_admission_uses_current_overlay_with_locked_gen_price(): breakdown = updated["appeal_bonds"][0]["fundingBreakdown"] assert breakdown["taxableWork"] == 3_700 * 3 assert breakdown["overlay"] == breakdown["taxableWork"] * 2_000 // 8_000 + assert updated["time_unit_overlay_budget"] == ( + accounting["time_unit_overlay_budget"] + breakdown["overlay"] + ) def test_top_up_and_submit_appeal_refreshes_recommended_fee_preset(): @@ -5376,9 +7473,9 @@ def test_create_child_fee_accounting_seeds_mode1_bucket_without_child_allocation ) assert child_fees["totalMessageFees"] == 15 - assert child_fees["maxPriceGenPerTimeUnit"] == 999 - assert child_fees["storageFeeMaxGasPrice"] == 888 - assert child_fees["receiptFeeMaxGasPrice"] == 777 + assert child_fees["maxPriceGenPerTimeUnit"] == 1 + assert child_fees["storageFeeMaxGasPrice"] == 2**200 + assert child_fees["receiptFeeMaxGasPrice"] == 2**200 assert child_accounting["paid_fee_value"] == 70 assert child_accounting["primary_fee_budget"] == 55 assert child_accounting["message_fee_budget"] == 15 @@ -5386,7 +7483,7 @@ def test_create_child_fee_accounting_seeds_mode1_bucket_without_child_allocation assert child_accounting["user_value"] == 7 -def test_create_child_fee_accounting_validates_primary_before_inherited_caps(): +def test_create_child_fee_accounting_prices_primary_at_child_owned_caps(): policy = StudioFeePolicy( gen_per_time_unit=2, storage_unit_price=3, @@ -5395,11 +7492,15 @@ def test_create_child_fee_accounting_validates_primary_before_inherited_caps(): fee_params = _encode_internal_fee_params( leader_timeunits=5, validator_timeunits=10, + max_price_gen_per_time_unit=2, + storage_fee_max_gas_price=3, + receipt_fee_max_gas_price=4, ) child_primary = calculate_round_fees( _fees_distribution( leader_timeunits=5, validator_timeunits=10, + max_price_gen_per_time_unit=2, ), 5, policy=policy, @@ -5424,9 +7525,9 @@ def test_create_child_fee_accounting_validates_primary_before_inherited_caps(): policy=policy, ) - assert child_fees["maxPriceGenPerTimeUnit"] == 1 - assert child_fees["storageFeeMaxGasPrice"] == 2 - assert child_fees["receiptFeeMaxGasPrice"] == 3 + assert child_fees["maxPriceGenPerTimeUnit"] == 2 + assert child_fees["storageFeeMaxGasPrice"] == 3 + assert child_fees["receiptFeeMaxGasPrice"] == 4 assert child_fees["totalMessageFees"] == 0 assert child_accounting["primary_fee_required"] == child_primary assert child_accounting["primary_fee_budget"] == child_primary @@ -5470,6 +7571,7 @@ def test_create_child_fee_accounting_installs_child_allocation_subtree(): message_allocations=[ _allocation( recipient="0x3333333333333333333333333333333333333333", + call_key=EMPTY_CALL_KEY, budget=110, fee_params=_encode_internal_fee_params(), ), @@ -5522,6 +7624,7 @@ def test_create_child_fee_accounting_strips_leaf_matched_root_subtree(): message_allocations=[ _allocation( recipient="0x3333333333333333333333333333333333333333", + call_key=EMPTY_CALL_KEY, budget=55, fee_params=fee_params, ) @@ -5583,6 +7686,7 @@ def update_transaction_fee_accounting(self, tx_hash, accounting): def _message_dispatch_context(accounting): processor = _MessageDispatchTxProcessor() + executor = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" tx = SimpleNamespace( hash="0x" + "ab" * 32, to_address="0x9999999999999999999999999999999999999999", @@ -5591,7 +7695,17 @@ def _message_dispatch_context(accounting): data={FEE_ACCOUNTING_KEY: accounting}, status=None, ) - return SimpleNamespace(transaction=tx, transactions_processor=processor), processor + consensus_data = SimpleNamespace( + leader_receipt=[SimpleNamespace(node_config={"address": executor})] + ) + return ( + SimpleNamespace( + transaction=tx, + transactions_processor=processor, + consensus_data=consensus_data, + ), + processor, + ) class _MessageValueAccountsManager: @@ -5665,7 +7779,14 @@ def _pending_external_value(value, *, on="accepted", recipient=None): ) -def _pending_internal_value(value, *, on="accepted", recipient=None): +def _pending_internal_value( + value, + *, + on="accepted", + recipient=None, + use_balance=False, + declared_budget=0, +): return PendingTransaction( address=recipient or "0x5555555555555555555555555555555555555555", calldata=b"\x12\x34", @@ -5673,6 +7794,8 @@ def _pending_internal_value(value, *, on="accepted", recipient=None): salt_nonce=0, on=on, value=value, + use_balance=use_balance, + declared_budget=declared_budget, ) @@ -5735,10 +7858,77 @@ def test_external_message_freeze_counts_prior_finalization_reservations(): "value": 4, }, { - "messageType": "1", + "messageType": "1", + "onAcceptance": False, + "value": 100, + }, + ], + } + ] + } + ) + context = _message_value_context( + balance=10, + session=_ExternalFreezeSession(accepted_rows=[prior_row]), + ) + receipt = _leader_receipt_with_messages([_pending_external_value(5, on="accepted")]) + + _apply_external_message_freeze_check(context, receipt) + + assert receipt.execution_result == ExecutionResultStatus.ERROR + assert receipt.genvm_result["external_message_freeze"] == { + "declaredValue": 5, + "availableLimit": 4, + "balance": 10, + "reservedExternal": prior_finalization_freeze, + } + + +def test_message_freeze_counts_external_and_use_balance_obligations_together(): + context = _message_value_context(balance=10) + receipt = _leader_receipt_with_messages( + [ + _pending_external_value(2, on="accepted"), + _pending_internal_value( + 2, + on="finalized", + use_balance=True, + declared_budget=7, + ), + ] + ) + + _apply_external_message_freeze_check(context, receipt) + + assert receipt.execution_result == ExecutionResultStatus.ERROR + assert receipt.pending_transactions == [] + assert b"ContractMessageFreezeExceeded" in receipt.result + assert receipt.genvm_result["error_code"] == "CONTRACT_MESSAGE_FREEZE_EXCEEDED" + assert receipt.genvm_result["external_message_freeze"] == { + "declaredValue": 11, + "availableLimit": 10, + "balance": 10, + "reservedExternal": 0, + "externalValue": 2, + "useBalanceFee": 7, + "useBalanceValue": 2, + } + + +def test_message_freeze_counts_prior_use_balance_finalization_reservations(): + prior_row = SimpleNamespace( + consensus_data={ + "leader_receipt": [ + { + "execution_result": ExecutionResultStatus.SUCCESS.value, + "pending_transactions": [ + { + "messageType": 1, "onAcceptance": False, - "value": 100, - }, + "value": 2, + "declaredBudget": 5, + "useBalance": True, + } ], } ] @@ -5748,17 +7938,21 @@ def test_external_message_freeze_counts_prior_finalization_reservations(): balance=10, session=_ExternalFreezeSession(accepted_rows=[prior_row]), ) - receipt = _leader_receipt_with_messages([_pending_external_value(5, on="accepted")]) + receipt = _leader_receipt_with_messages( + [ + _pending_internal_value( + 0, + use_balance=True, + declared_budget=4, + ) + ] + ) _apply_external_message_freeze_check(context, receipt) assert receipt.execution_result == ExecutionResultStatus.ERROR - assert receipt.genvm_result["external_message_freeze"] == { - "declaredValue": 5, - "availableLimit": 4, - "balance": 10, - "reservedExternal": prior_finalization_freeze, - } + assert receipt.genvm_result["external_message_freeze"]["reservedExternal"] == 7 + assert receipt.genvm_result["external_message_freeze"]["availableLimit"] == 3 def test_message_value_withdrawal_reserves_finalized_external_value_before_internal(): @@ -5803,16 +7997,63 @@ def test_message_value_withdrawal_drops_unbacked_external_value(): ] +def test_message_value_withdrawal_debits_use_balance_fee_and_value_from_ghost(): + context = _message_value_context(balance=20) + use_balance_child = _pending_internal_value( + 3, + use_balance=True, + declared_budget=7, + ) + ordinary_child = _pending_internal_value(4) + + adjusted = _apply_message_value_withdrawals_for_phase( + context, + [use_balance_child, ordinary_child], + "accepted", + ) + + assert adjusted == [use_balance_child, ordinary_child] + assert context.accounts_manager.balance == 6 + assert context.accounts_manager.debits == [ + (context.transaction.to_address, 10), + (context.transaction.to_address, 4), + ] + + +def test_message_value_withdrawal_keeps_short_use_balance_child_unfunded(): + context = _message_value_context(balance=9) + use_balance_child = _pending_internal_value( + 3, + use_balance=True, + declared_budget=7, + ) + + adjusted = _apply_message_value_withdrawals_for_phase( + context, + [use_balance_child], + "accepted", + ) + + assert len(adjusted) == 1 + assert adjusted[0].value == 0 + assert adjusted[0].declared_budget == 0 + assert adjusted[0].use_balance is True + assert context.accounts_manager.balance == 9 + assert context.accounts_manager.debits == [] + + def test_message_dispatch_creates_mode1_child_fee_accounting_from_pending_metadata( monkeypatch, ): monkeypatch.setenv("GENLAYER_STUDIO_GEN_PER_TIME_UNIT", "1") monkeypatch.setenv("GENLAYER_STUDIO_STORAGE_UNIT_PRICE", "0") monkeypatch.setenv("GENLAYER_STUDIO_RECEIPT_GAS_PRICE", "0") + monkeypatch.setenv("GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS", "1") + monkeypatch.setenv("GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS", "1") policy = StudioFeePolicy.from_env() fee_params = _encode_internal_fee_params() child_budget = 64 - fees_distribution = _fees_distribution(total_message_fees=child_budget) + fees_distribution = _env_fees_distribution(total_message_fees=child_budget) accounting = create_fee_accounting( fees_distribution=fees_distribution, num_of_validators=5, @@ -5873,6 +8114,8 @@ def test_message_dispatch_fills_mode2_child_fee_accounting_from_allocation_subtr monkeypatch.setenv("GENLAYER_STUDIO_GEN_PER_TIME_UNIT", "1") monkeypatch.setenv("GENLAYER_STUDIO_STORAGE_UNIT_PRICE", "0") monkeypatch.setenv("GENLAYER_STUDIO_RECEIPT_GAS_PRICE", "0") + monkeypatch.setenv("GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS", "1") + monkeypatch.setenv("GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS", "1") policy = StudioFeePolicy.from_env() root_fee_params = _encode_internal_fee_params(leader_timeunits=6) child_fee_params = _encode_internal_fee_params(leader_timeunits=7) @@ -5882,7 +8125,7 @@ def test_message_dispatch_fills_mode2_child_fee_accounting_from_allocation_subtr child_call_key = "0x" + "34" * 32 child_budget = 67 root_budget = 132 - fees_distribution = _fees_distribution(total_message_fees=root_budget) + fees_distribution = _env_fees_distribution(total_message_fees=root_budget) accounting = create_fee_accounting( fees_distribution=fees_distribution, message_allocations=[ @@ -5962,7 +8205,7 @@ def test_message_dispatch_records_revealed_external_message_execution_fees( budget=1_000, fee_params=fee_params, ) - fees_distribution = _fees_distribution(total_message_fees=1_000) + fees_distribution = _env_fees_distribution(total_message_fees=1_000) accounting = create_fee_accounting( fees_distribution=fees_distribution, message_allocations=[allocation], @@ -6010,9 +8253,21 @@ def test_message_dispatch_records_revealed_external_message_execution_fees( assert updated["external_message_fee_reserved"] == 700 assert updated["external_message_fee_reimbursed"] == 420 assert updated["external_message_fee_remainder"] == 280 - assert updated["message_fee_consumed"] == 420 + assert updated["message_fee_consumed"] == 700 assert updated["external_message_events"][0]["executionRecorded"] is True assert updated["external_message_events"][0]["gasUsed"] == 60 + assert updated["external_message_fee_payouts"] == [ + { + "recipient": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "amount": 420, + "source": "external-executor-reimbursement", + }, + { + "recipient": "0x1111111111111111111111111111111111111111", + "amount": 280, + "source": "external-execution-remainder", + }, + ] def test_apply_fee_top_up_extends_budgets(): @@ -6036,6 +8291,137 @@ def test_apply_fee_top_up_extends_budgets(): assert updated["message_fee_budget"] == 55 +def test_apply_fee_top_up_rejects_zero_value_like_consensus(): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(), + num_of_validators=5, + submitted_value=1_100, + user_value=0, + ) + + with pytest.raises(InsufficientFees): + apply_fee_top_up( + accounting, + fees_distribution=_top_up_distribution(), + amount=0, + ) + + +def test_apply_fee_top_up_carves_cumulative_overlay_reserve(): + policy = StudioFeePolicy(gen_per_time_unit=1, time_unit_overlay_bps=1_000) + fees_distribution = _fees_distribution(max_price_gen_per_time_unit=1) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5, policy), + user_value=0, + policy=policy, + ) + + updated = apply_fee_top_up( + accounting, + fees_distribution=_top_up_distribution(), + amount=1_000, + perform_fee_checks=False, + policy=policy, + ) + + assert accounting["time_unit_overlay_budget"] == 122 + assert updated["time_unit_overlay_budget"] == 222 + + +def test_settlement_refunds_top_up_contributors_fifo(): + original = "0x1111111111111111111111111111111111111111" + rescuer = "0x2222222222222222222222222222222222222222" + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(), + num_of_validators=5, + submitted_value=1_100, + user_value=0, + sender=original, + ) + topped_up = apply_fee_top_up( + accounting, + fees_distribution=_top_up_distribution(), + amount=500, + sender=rescuer, + perform_fee_checks=False, + ) + + settled, refund = settle_fee_accounting( + topped_up, + actual_final_round=0, + num_of_validators=5, + policy=StudioFeePolicy(), + ) + + assert refund == 500 + assert settled["contributions"] == [ + {"depositor": original, "amount": 1_100}, + {"depositor": rescuer, "amount": 500}, + ] + assert settled["fee_refund_settlements"] == [ + {"recipient": rescuer, "amount": 500, "source": "fifo"} + ] + + +def test_third_party_top_up_cannot_raise_original_depositor_price_caps(): + owner = "0x1111111111111111111111111111111111111111" + third_party = "0x2222222222222222222222222222222222222222" + fees_distribution = _fees_distribution( + max_price_gen_per_time_unit=10, + storage_fee_max_gas_price=10, + receipt_fee_max_gas_price=10, + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + sender=owner, + ) + raised_caps = _top_up_distribution( + max_price_gen_per_time_unit=20, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, + ) + + third_party_update = apply_fee_top_up( + accounting, + fees_distribution=raised_caps, + amount=1, + sender=third_party, + ) + owner_update = apply_fee_top_up( + accounting, + fees_distribution=raised_caps, + amount=11_000, + sender=owner, + ) + + assert third_party_update["fees_distribution"]["maxPriceGenPerTimeUnit"] == 10 + assert third_party_update["fees_distribution"]["storageFeeMaxGasPrice"] == 10 + assert third_party_update["fees_distribution"]["receiptFeeMaxGasPrice"] == 10 + assert owner_update["fees_distribution"]["maxPriceGenPerTimeUnit"] == 20 + assert owner_update["fees_distribution"]["storageFeeMaxGasPrice"] == 20 + assert owner_update["fees_distribution"]["receiptFeeMaxGasPrice"] == 20 + + +def test_submission_ignores_caller_supplied_execution_consumed(): + fees_distribution = _fees_distribution(execution_consumed=999) + + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + + assert accounting["fees_distribution"]["executionConsumed"] == 0 + assert accounting["execution_fee_consumed"] == 0 + assert accounting["top_ups"][0]["feesDistribution"]["executionConsumed"] == 0 + + def test_apply_fee_top_up_rejects_schedule_extension_after_activation(): initial_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) incoming_distribution = _fees_distribution( @@ -6183,7 +8569,7 @@ def test_apply_fee_top_up_only_raises_existing_price_caps(): storage_fee_max_gas_price=0, receipt_fee_max_gas_price=60, ), - amount=0, + amount=1, ) assert unchanged["fees_distribution"]["maxPriceGenPerTimeUnit"] == 100 @@ -6218,7 +8604,7 @@ def test_apply_fee_top_up_only_raises_existing_price_caps(): storage_fee_max_gas_price=85, receipt_fee_max_gas_price=70, ), - amount=0, + amount=1, ) assert still_uncapped["fees_distribution"]["maxPriceGenPerTimeUnit"] == 0 @@ -6352,7 +8738,7 @@ def _fee_accounted_tx(*, status="PENDING", accounting=None): def _env_fee_accounting(fees_distribution=None): policy = StudioFeePolicy.from_env() - fees_distribution = fees_distribution or _fees_distribution() + fees_distribution = fees_distribution or _env_fees_distribution() return create_fee_accounting( fees_distribution=fees_distribution, num_of_validators=5, @@ -6393,8 +8779,28 @@ def test_top_up_fees_endpoint_updates_accounting_and_debits_sender(): assert accounts.debits == [("0x1111111111111111111111111111111111111111", amount)] +def test_terminal_committee_excludes_compacted_leader_appeal_replay_leader(): + history = { + "consensus_results": [ + {"consensus_round": "Undetermined"}, + {"consensus_round": "Leader Appeal Successful"}, + ] + } + + assert _current_fee_round(history) == 2 + assert _normal_leader_count(history) == 2 + + @pytest.mark.parametrize( - "status", ["ACCEPTED", "UNDETERMINED", "FINALIZED", "CANCELED"] + "status", + [ + "ACCEPTED", + "UNDETERMINED", + "LEADER_TIMEOUT", + "VALIDATORS_TIMEOUT", + "FINALIZED", + "CANCELED", + ], ) def test_top_up_fees_endpoint_rejects_final_decided_transaction_status(status): tx = _fee_accounted_tx(status=status) @@ -6453,6 +8859,90 @@ def send_message(self, log_event, log_to_terminal=True): ] +def test_leader_timeout_appeal_uses_live_transaction_rotations_not_fee_schedule(): + fees = _env_fees_distribution(rotations=[3]) + accounting = _env_fee_accounting(fees) + tx = _fee_accounted_tx(status="LEADER_TIMEOUT", accounting=accounting) + tx["config_rotation_rounds"] = 1 + tx["leader_timeout_validators"] = [ + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222", + "0x3333333333333333333333333333333333333333", + "0x4444444444444444444444444444444444444444", + "0x5555555555555555555555555555555555555555", + ] + charge = calculate_appeal_charge( + fees, + current_round=0, + status="LEADER_TIMEOUT", + replacement_rotations=1, + leader_timeout_live_seats=5, + policy=StudioFeePolicy.from_env(), + ) + transactions = _FakeTransactionsProcessor(tx) + + class _MsgHandler: + def send_message(self, log_event, log_to_terminal=True): + pass + + _handle_appeal_or_top_up_and_submit( + accounts_manager=_FakeAccountsManager(balance=0), + transactions_processor=transactions, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal( + tx["hash"], + amount=charge["bond"] + charge["funding"], + ), + ) + + recorded = transactions.updated_fee_accounting["appeal_bonds"][0] + assert recorded["minimumRequired"] == charge["bond"] + assert recorded["fundingBreakdown"] == charge["fundingBreakdown"] + assert recorded["fundingBreakdown"]["executionBacking"] == ( + 3 * int(fees["executionBudgetPerRound"]) + ) + + +def test_submit_appeal_prices_against_frozen_activation_pool(): + accounting = _env_fee_accounting() + accounting, should_cancel = activate_fee_accounting( + accounting, + StudioFeePolicy.from_env(), + selection_pool_count=9, + ) + assert should_cancel is False + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) + tx["consensus_history"] = {"consensus_results": [{"consensus_round": "Accepted"}]} + transactions = _FakeTransactionsProcessor(tx) + charge = calculate_appeal_charge( + accounting["fees_distribution"], + current_round=0, + status="ACCEPTED", + # One normal leader is durably excluded from the frozen pool. + terminal_committee_upper_bound=8, + policy=StudioFeePolicy.from_env(), + ) + + class _MsgHandler: + def send_message(self, log_event, log_to_terminal=True): + pass + + _handle_appeal_or_top_up_and_submit( + accounts_manager=_FakeAccountsManager(balance=0), + transactions_processor=transactions, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal( + tx["hash"], + amount=charge["bond"] + charge["funding"], + ), + ) + + assert ( + transactions.updated_fee_accounting["appeal_bonds"][0]["fundingBreakdown"] + == charge["fundingBreakdown"] + ) + + def test_top_up_and_submit_appeal_endpoint_expands_capacity_only(): accounting = _env_fee_accounting() tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) @@ -6543,6 +9033,43 @@ def send_message(self, log_event, log_to_terminal=True): assert transactions.appeal_updates == [] +def test_submit_appeal_endpoint_rejects_elapsed_appeal_window_before_charging( + monkeypatch, +): + accounting = _env_fee_accounting() + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) + tx["timestamp_awaiting_finalization"] = 1_000 + tx["appeal_processing_time"] = 5 + tx["appeal_failed"] = 1 + transactions = _FakeTransactionsProcessor(tx) + charge = _env_appeal_charge(accounting) + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setenv("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0.2") + # Deadline = 1000 + 5 paused seconds + 30 * 0.8 = 1029. Consensus + # rejects at the boundary, before accepting or escrowing any appeal value. + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_029) + + class _MsgHandler: + def send_message(self, log_event, log_to_terminal=True): + pass + + accounts = _FakeAccountsManager(balance=5000) + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + _handle_appeal_or_top_up_and_submit( + accounts_manager=accounts, + transactions_processor=transactions, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal( + tx["hash"], + amount=charge["bond"] + charge["funding"], + ), + ) + + assert transactions.updated_fee_accounting is None + assert transactions.appeal_updates == [] + assert accounts.debits == [] + + def test_submit_appeal_endpoint_rejects_zero_bond_when_fee_accounting_enabled(): tx = _fee_accounted_tx(status="ACCEPTED", accounting=_env_fee_accounting()) transactions = _FakeTransactionsProcessor(tx) @@ -6563,7 +9090,67 @@ def send_message(self, log_event, log_to_terminal=True): assert transactions.appeal_updates == [] -def test_current_fee_round_ignores_leader_rotation_events(): +@pytest.mark.parametrize("status", ["PENDING", "FINALIZED", "CANCELED"]) +def test_gasless_submit_appeal_still_enforces_appealable_status(status): + tx = _fee_accounted_tx(status=status) + tx["data"] = {} + transactions = _FakeTransactionsProcessor(tx) + + class _MsgHandler: + def send_message(self, log_event, log_to_terminal=True): + pass + + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + _handle_appeal_or_top_up_and_submit( + accounts_manager=_FakeAccountsManager(balance=0), + transactions_processor=transactions, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal(tx["hash"], amount=0), + ) + + assert transactions.updated_fee_accounting is None + assert transactions.appeal_updates == [] + + +def test_gasless_submit_appeal_still_rejects_stale_decision_and_duplicate(): + tx = _fee_accounted_tx(status="ACCEPTED") + tx["data"] = {} + + class _MsgHandler: + def send_message(self, log_event, log_to_terminal=True): + pass + + stale_transactions = _FakeTransactionsProcessor(tx) + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + _handle_appeal_or_top_up_and_submit( + accounts_manager=_FakeAccountsManager(balance=0), + transactions_processor=stale_transactions, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal( + tx["hash"], + amount=0, + expected_decision_id=2, + ), + ) + assert stale_transactions.appeal_updates == [] + + tx["appealed"] = True + duplicate_transactions = _FakeTransactionsProcessor(tx) + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + _handle_appeal_or_top_up_and_submit( + accounts_manager=_FakeAccountsManager(balance=0), + transactions_processor=duplicate_transactions, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal( + tx["hash"], + amount=0, + expected_decision_id=1, + ), + ) + assert duplicate_transactions.appeal_updates == [] + + +def test_current_fee_round_ignores_rotation_events_and_expands_leader_appeal(): consensus_history = { "consensus_results": [ {"consensus_round": "Accepted"}, @@ -6573,8 +9160,8 @@ def test_current_fee_round_ignores_leader_rotation_events(): ] } - assert _current_fee_round(consensus_history) == 1 - assert _infer_final_round(consensus_history) == 1 + assert _current_fee_round(consensus_history) == 2 + assert _infer_final_round(consensus_history) == 2 def _processor_transaction(*, accounting=None, execution_result=None): diff --git a/tests/unit/test_timeunit_consumption.py b/tests/unit/test_timeunit_consumption.py index 5733e7324..1e35e0ef8 100644 --- a/tests/unit/test_timeunit_consumption.py +++ b/tests/unit/test_timeunit_consumption.py @@ -156,6 +156,26 @@ def test_time_unit_consumption_multi_round_totals(): ] +def test_time_unit_consumption_reports_compacted_leader_replay_at_logical_round_two(): + history = _history( + _entry( + ConsensusRound.UNDETERMINED.value, + leader_result=[_receipt("leader", 1000)], + ), + _entry( + ConsensusRound.LEADER_APPEAL_SUCCESSFUL.value, + leader_result=[_receipt("leader", 2000)], + validator_results=[_receipt("validator", 1000)], + ), + ) + + consumption = time_unit_consumption(history, None) + + assert [entry["round"] for entry in consumption["per_round"]] == [0, 2] + assert consumption["per_round"][1]["leader_timeunits"] == 2 + assert consumption["per_round"][1]["validator_timeunits"] == 1 + + def test_time_unit_consumption_trailing_rotation_emits_final_entry(): history = _history( _entry( From 9dfb13f3075496b054426c4a350cde84bb17bd22 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 21:19:59 +0100 Subject: [PATCH 06/55] fix(hardhat): restore reproducible deployment profile --- .gitignore | 1 + hardhat/hardhat.config.js | 2 +- hardhat/package-lock.json | 5487 +++++++++++++++++ hardhat/package.json | 23 +- .../test/deploy/001_deploy_contracts.test.js | 50 +- 5 files changed, 5510 insertions(+), 53 deletions(-) create mode 100644 hardhat/package-lock.json diff --git a/.gitignore b/.gitignore index 5c04b1e77..0a404af24 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,7 @@ consensus/nodes/nodes.json # npm node_modules/ package-lock.json +!hardhat/package-lock.json /package.json # Nginx TLS certificates and keys diff --git a/hardhat/hardhat.config.js b/hardhat/hardhat.config.js index 3cf619cb8..61cfce7e4 100644 --- a/hardhat/hardhat.config.js +++ b/hardhat/hardhat.config.js @@ -1,4 +1,4 @@ -require("@nomicfoundation/hardhat-toolbox"); +require("@nomicfoundation/hardhat-ethers"); require("hardhat-deploy"); const fs = require("node:fs"); diff --git a/hardhat/package-lock.json b/hardhat/package-lock.json new file mode 100644 index 000000000..d002836ec --- /dev/null +++ b/hardhat/package-lock.json @@ -0,0 +1,5487 @@ +{ + "name": "hardhat-node", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hardhat-node", + "version": "1.0.0", + "dependencies": { + "@nomicfoundation/hardhat-ethers": "3.1.3", + "@openzeppelin/contracts": "5.1.0", + "@openzeppelin/contracts-upgradeable": "5.1.0", + "chai": "4.5.0", + "ethers": "6.15.0", + "fs-extra": "11.3.2", + "hardhat": "2.29.1", + "hardhat-deploy": "0.12.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@ethersproject/abi": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.8.0.tgz", + "integrity": "sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-provider": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", + "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/networks": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/web": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-signer": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.8.0.tgz", + "integrity": "sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0" + } + }, + "node_modules/@ethersproject/address": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.8.0.tgz", + "integrity": "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/rlp": "^5.8.0" + } + }, + "node_modules/@ethersproject/base64": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.8.0.tgz", + "integrity": "sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0" + } + }, + "node_modules/@ethersproject/basex": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.8.0.tgz", + "integrity": "sha512-PIgTszMlDRmNwW9nhS6iqtVfdTAKosA7llYXNmGPw4YAI1PUyMv28988wAb41/gHF/WqGdoLv0erHaRcHRKW2Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/properties": "^5.8.0" + } + }, + "node_modules/@ethersproject/bignumber": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.8.0.tgz", + "integrity": "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "bn.js": "^5.2.1" + } + }, + "node_modules/@ethersproject/bytes": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.8.0.tgz", + "integrity": "sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/constants": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.8.0.tgz", + "integrity": "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0" + } + }, + "node_modules/@ethersproject/contracts": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/contracts/-/contracts-5.8.0.tgz", + "integrity": "sha512-0eFjGz9GtuAi6MZwhb4uvUM216F38xiuR0yYCjKJpNfSEy4HUM8hvqqBj9Jmm0IUz8l0xKEhWwLIhPgxNY0yvQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abi": "^5.8.0", + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/transactions": "^5.8.0" + } + }, + "node_modules/@ethersproject/hash": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.8.0.tgz", + "integrity": "sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/hdnode": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.8.0.tgz", + "integrity": "sha512-4bK1VF6E83/3/Im0ERnnUeWOY3P1BZml4ZD3wcH8Ys0/d1h1xaFt6Zc+Dh9zXf9TapGro0T4wvO71UTCp3/uoA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/basex": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/pbkdf2": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/sha2": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0", + "@ethersproject/strings": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/wordlists": "^5.8.0" + } + }, + "node_modules/@ethersproject/json-wallets": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.8.0.tgz", + "integrity": "sha512-HxblNck8FVUtNxS3VTEYJAcwiKYsBIF77W15HufqlBF9gGfhmYOJtYZp8fSDZtn9y5EaXTE87zDwzxRoTFk11w==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/hdnode": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/pbkdf2": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/random": "^5.8.0", + "@ethersproject/strings": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "aes-js": "3.0.0", + "scrypt-js": "3.0.1" + } + }, + "node_modules/@ethersproject/json-wallets/node_modules/aes-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", + "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", + "license": "MIT" + }, + "node_modules/@ethersproject/keccak256": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.8.0.tgz", + "integrity": "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "js-sha3": "0.8.0" + } + }, + "node_modules/@ethersproject/logger": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.8.0.tgz", + "integrity": "sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT" + }, + "node_modules/@ethersproject/networks": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.8.0.tgz", + "integrity": "sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/pbkdf2": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.8.0.tgz", + "integrity": "sha512-wuHiv97BrzCmfEaPbUFpMjlVg/IDkZThp9Ri88BpjRleg4iePJaj2SW8AIyE8cXn5V1tuAaMj6lzvsGJkGWskg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/sha2": "^5.8.0" + } + }, + "node_modules/@ethersproject/properties": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.8.0.tgz", + "integrity": "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/providers": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.8.0.tgz", + "integrity": "sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/base64": "^5.8.0", + "@ethersproject/basex": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/networks": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/random": "^5.8.0", + "@ethersproject/rlp": "^5.8.0", + "@ethersproject/sha2": "^5.8.0", + "@ethersproject/strings": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/web": "^5.8.0", + "bech32": "1.1.4", + "ws": "8.18.0" + } + }, + "node_modules/@ethersproject/providers/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@ethersproject/random": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.8.0.tgz", + "integrity": "sha512-E4I5TDl7SVqyg4/kkA/qTfuLWAQGXmSOgYyO01So8hLfwgKvYK5snIlzxJMk72IFdG/7oh8yuSqY2KX7MMwg+A==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/rlp": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.8.0.tgz", + "integrity": "sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/sha2": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.8.0.tgz", + "integrity": "sha512-dDOUrXr9wF/YFltgTBYS0tKslPEKr6AekjqDW2dbn1L1xmjGR+9GiKu4ajxovnrDbwxAKdHjW8jNcwfz8PAz4A==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "hash.js": "1.1.7" + } + }, + "node_modules/@ethersproject/signing-key": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.8.0.tgz", + "integrity": "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "bn.js": "^5.2.1", + "elliptic": "6.6.1", + "hash.js": "1.1.7" + } + }, + "node_modules/@ethersproject/solidity": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.8.0.tgz", + "integrity": "sha512-4CxFeCgmIWamOHwYN9d+QWGxye9qQLilpgTU0XhYs1OahkclF+ewO+3V1U0mvpiuQxm5EHHmv8f7ClVII8EHsA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/sha2": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/strings": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.8.0.tgz", + "integrity": "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/transactions": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", + "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/rlp": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0" + } + }, + "node_modules/@ethersproject/units": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.8.0.tgz", + "integrity": "sha512-lxq0CAnc5kMGIiWW4Mr041VT8IhNM+Pn5T3haO74XZWFulk7wH1Gv64HqE96hT4a7iiNMdOCFEBgaxWuk8ETKQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/wallet": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.8.0.tgz", + "integrity": "sha512-G+jnzmgg6UxurVKRKvw27h0kvG75YKXZKdlLYmAHeF32TGUzHkOFd7Zn6QHOTYRFWnfjtSSFjBowKo7vfrXzPA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/hdnode": "^5.8.0", + "@ethersproject/json-wallets": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/random": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/wordlists": "^5.8.0" + } + }, + "node_modules/@ethersproject/web": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.8.0.tgz", + "integrity": "sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/wordlists": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.8.0.tgz", + "integrity": "sha512-2df9bbXicZws2Sb5S6ET493uJ0Z84Fjr3pC4tu/qlnZERibZCeUVuqdtt+7Tv9xxhUxHoIekIA7avrKUWHrezg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT", + "optional": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@noble/curves": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz", + "integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/secp256k1": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.1.tgz", + "integrity": "sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/@nomicfoundation/edr": { + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr/-/edr-0.12.0-next.23.tgz", + "integrity": "sha512-F2/6HZh8Q9RsgkOIkRrckldbhPjIZY7d4mT9LYuW68miwGQ5l7CkAgcz9fRRiurA0+YJhtsbx/EyrD9DmX9BOw==", + "license": "MIT", + "dependencies": { + "@nomicfoundation/edr-darwin-arm64": "0.12.0-next.23", + "@nomicfoundation/edr-darwin-x64": "0.12.0-next.23", + "@nomicfoundation/edr-linux-arm64-gnu": "0.12.0-next.23", + "@nomicfoundation/edr-linux-arm64-musl": "0.12.0-next.23", + "@nomicfoundation/edr-linux-x64-gnu": "0.12.0-next.23", + "@nomicfoundation/edr-linux-x64-musl": "0.12.0-next.23", + "@nomicfoundation/edr-win32-x64-msvc": "0.12.0-next.23" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-darwin-arm64": { + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-arm64/-/edr-darwin-arm64-0.12.0-next.23.tgz", + "integrity": "sha512-Amh7mRoDzZyJJ4efqoePqdoZOzharmSOttZuJDlVE5yy07BoE8hL6ZRpa5fNYn0LCqn/KoWs8OHANWxhKDGhvQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-darwin-x64": { + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-x64/-/edr-darwin-x64-0.12.0-next.23.tgz", + "integrity": "sha512-9wn489FIQm7m0UCD+HhktjWx6vskZzeZD9oDc2k9ZvbBzdXwPp5tiDqUBJ+eQpByAzCDfteAJwRn2lQCE0U+Iw==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-linux-arm64-gnu": { + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-gnu/-/edr-linux-arm64-gnu-0.12.0-next.23.tgz", + "integrity": "sha512-nlk5EejSzEUfEngv0Jkhqq3/wINIfF2ED9wAofc22w/V1DV99ASh9l3/e/MIHOQFecIZ9MDqt0Em9/oDyB1Uew==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-linux-arm64-musl": { + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-musl/-/edr-linux-arm64-musl-0.12.0-next.23.tgz", + "integrity": "sha512-SJuPBp3Rc6vM92UtVTUxZQ/QlLhLfwTftt2XUiYohmGKB3RjGzpgduEFMCA0LEnucUckU6UHrJNFHiDm77C4PQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-linux-x64-gnu": { + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-gnu/-/edr-linux-x64-gnu-0.12.0-next.23.tgz", + "integrity": "sha512-NU+Qs3u7Qt6t3bJFdmmjd5CsvgI2bPPzO31KifM2Ez96/jsXYho5debtTQnimlb5NAqiHTSlxjh/F8ROcptmeQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-linux-x64-musl": { + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-musl/-/edr-linux-x64-musl-0.12.0-next.23.tgz", + "integrity": "sha512-F78fZA2h6/ssiCSZOovlgIu0dUeI7ItKPsDDF3UUlIibef052GCXmliMinC90jVPbrjUADMd1BUwjfI0Z8OllQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-win32-x64-msvc": { + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-win32-x64-msvc/-/edr-win32-x64-msvc-0.12.0-next.23.tgz", + "integrity": "sha512-IfJZQJn7d/YyqhmguBIGoCKjE9dKjbu6V6iNEPApfwf5JyyjHYyyfkLU4rf7hygj57bfH4sl1jtQ6r8HnT62lw==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/hardhat-ethers": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-ethers/-/hardhat-ethers-3.1.3.tgz", + "integrity": "sha512-208JcDeVIl+7Wu3MhFUUtiA8TJ7r2Rn3Wr+lSx9PfsDTKkbsAsWPY6N6wQ4mtzDv0/pB9nIbJhkjoHe1EsgNsA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "lodash.isequal": "^4.5.0" + }, + "peerDependencies": { + "ethers": "^6.14.0", + "hardhat": "^2.28.0" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer/-/solidity-analyzer-0.1.2.tgz", + "integrity": "sha512-q4n32/FNKIhQ3zQGGw5CvPF6GTvDCpYwIf7bEY/dZTZbgfDsHyjJwURxUJf3VQuuJj+fDIFl4+KkBVbw4Ef6jA==", + "license": "MIT", + "engines": { + "node": ">= 12" + }, + "optionalDependencies": { + "@nomicfoundation/solidity-analyzer-darwin-arm64": "0.1.2", + "@nomicfoundation/solidity-analyzer-darwin-x64": "0.1.2", + "@nomicfoundation/solidity-analyzer-linux-arm64-gnu": "0.1.2", + "@nomicfoundation/solidity-analyzer-linux-arm64-musl": "0.1.2", + "@nomicfoundation/solidity-analyzer-linux-x64-gnu": "0.1.2", + "@nomicfoundation/solidity-analyzer-linux-x64-musl": "0.1.2", + "@nomicfoundation/solidity-analyzer-win32-x64-msvc": "0.1.2" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-darwin-arm64": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-darwin-arm64/-/solidity-analyzer-darwin-arm64-0.1.2.tgz", + "integrity": "sha512-JaqcWPDZENCvm++lFFGjrDd8mxtf+CtLd2MiXvMNTBD33dContTZ9TWETwNFwg7JTJT5Q9HEecH7FA+HTSsIUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-darwin-x64": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-darwin-x64/-/solidity-analyzer-darwin-x64-0.1.2.tgz", + "integrity": "sha512-fZNmVztrSXC03e9RONBT+CiksSeYcxI1wlzqyr0L7hsQlK1fzV+f04g2JtQ1c/Fe74ZwdV6aQBdd6Uwl1052sw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-linux-arm64-gnu": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-arm64-gnu/-/solidity-analyzer-linux-arm64-gnu-0.1.2.tgz", + "integrity": "sha512-3d54oc+9ZVBuB6nbp8wHylk4xh0N0Gc+bk+/uJae+rUgbOBwQSfuGIbAZt1wBXs5REkSmynEGcqx6DutoK0tPA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-linux-arm64-musl": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-arm64-musl/-/solidity-analyzer-linux-arm64-musl-0.1.2.tgz", + "integrity": "sha512-iDJfR2qf55vgsg7BtJa7iPiFAsYf2d0Tv/0B+vhtnI16+wfQeTbP7teookbGvAo0eJo7aLLm0xfS/GTkvHIucA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-linux-x64-gnu": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-x64-gnu/-/solidity-analyzer-linux-x64-gnu-0.1.2.tgz", + "integrity": "sha512-9dlHMAt5/2cpWyuJ9fQNOUXFB/vgSFORg1jpjX1Mh9hJ/MfZXlDdHQ+DpFCs32Zk5pxRBb07yGvSHk9/fezL+g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-linux-x64-musl": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-x64-musl/-/solidity-analyzer-linux-x64-musl-0.1.2.tgz", + "integrity": "sha512-GzzVeeJob3lfrSlDKQw2bRJ8rBf6mEYaWY+gW0JnTDHINA0s2gPR4km5RLIj1xeZZOYz4zRw+AEeYgLRqB2NXg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-win32-x64-msvc": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-win32-x64-msvc/-/solidity-analyzer-win32-x64-msvc-0.1.2.tgz", + "integrity": "sha512-Fdjli4DCcFHb4Zgsz0uEJXZ2K7VEO+w5KVv7HmT7WO10iODdU9csC2az4jrhEsRtiR9Gfd74FlG0NYlw1BMdyA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@openzeppelin/contracts": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.1.0.tgz", + "integrity": "sha512-p1ULhl7BXzjjbha5aqst+QMLY+4/LCWADXOCsmLHRM77AqiPjnd9vvUN9sosUfhL9JGKpZ0TjEGxgvnizmWGSA==", + "license": "MIT", + "peer": true + }, + "node_modules/@openzeppelin/contracts-upgradeable": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-5.1.0.tgz", + "integrity": "sha512-AIElwP5Ck+cslNE+Hkemf5SxjJoF4wBvvjxc27Rp+9jaPs/CLIaUBMYe1FNzhdiN0cYuwGRmYaRHmmntuiju4Q==", + "license": "MIT", + "peerDependencies": { + "@openzeppelin/contracts": "5.1.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz", + "integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.4.0", + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.3.0.tgz", + "integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@sentry/core": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-5.30.0.tgz", + "integrity": "sha512-TmfrII8w1PQZSZgPpUESqjB+jC6MvZJZdLtE/0hZ+SrnKhW3x5WlYLvTXZpcWePYBku7rl2wn1RZu6uT0qCTeg==", + "license": "BSD-3-Clause", + "dependencies": { + "@sentry/hub": "5.30.0", + "@sentry/minimal": "5.30.0", + "@sentry/types": "5.30.0", + "@sentry/utils": "5.30.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/core/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@sentry/hub": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-5.30.0.tgz", + "integrity": "sha512-2tYrGnzb1gKz2EkMDQcfLrDTvmGcQPuWxLnJKXJvYTQDGLlEvi2tWz1VIHjunmOvJrB5aIQLhm+dcMRwFZDCqQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@sentry/types": "5.30.0", + "@sentry/utils": "5.30.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/hub/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@sentry/minimal": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-5.30.0.tgz", + "integrity": "sha512-BwWb/owZKtkDX+Sc4zCSTNcvZUq7YcH3uAVlmh/gtR9rmUvbzAA3ewLuB3myi4wWRAMEtny6+J/FN/x+2wn9Xw==", + "license": "BSD-3-Clause", + "dependencies": { + "@sentry/hub": "5.30.0", + "@sentry/types": "5.30.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/minimal/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@sentry/node": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-5.30.0.tgz", + "integrity": "sha512-Br5oyVBF0fZo6ZS9bxbJZG4ApAjRqAnqFFurMVJJdunNb80brh7a5Qva2kjhm+U6r9NJAB5OmDyPkA1Qnt+QVg==", + "license": "BSD-3-Clause", + "dependencies": { + "@sentry/core": "5.30.0", + "@sentry/hub": "5.30.0", + "@sentry/tracing": "5.30.0", + "@sentry/types": "5.30.0", + "@sentry/utils": "5.30.0", + "cookie": "^0.4.1", + "https-proxy-agent": "^5.0.0", + "lru_map": "^0.3.3", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/node/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@sentry/tracing": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-5.30.0.tgz", + "integrity": "sha512-dUFowCr0AIMwiLD7Fs314Mdzcug+gBVo/+NCMyDw8tFxJkwWAKl7Qa2OZxLQ0ZHjakcj1hNKfCQJ9rhyfOl4Aw==", + "license": "MIT", + "dependencies": { + "@sentry/hub": "5.30.0", + "@sentry/minimal": "5.30.0", + "@sentry/types": "5.30.0", + "@sentry/utils": "5.30.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/tracing/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@sentry/types": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.30.0.tgz", + "integrity": "sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/utils": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.30.0.tgz", + "integrity": "sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww==", + "license": "BSD-3-Clause", + "dependencies": { + "@sentry/types": "5.30.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/utils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz", + "integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==", + "license": "MIT", + "optional": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "license": "MIT", + "optional": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "license": "MIT", + "optional": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "optional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/adm-zip": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.16.tgz", + "integrity": "sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==", + "license": "MIT", + "engines": { + "node": ">=0.3.0" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT", + "optional": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bech32": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", + "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bn.js": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", + "license": "MIT" + }, + "node_modules/boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "license": "ISC" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/command-exists": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "license": "MIT", + "optional": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encode-utf8": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz", + "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==", + "license": "MIT" + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ethers": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.15.0.tgz", + "integrity": "sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, + "node_modules/ethers/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/fmix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/fmix/-/fmix-0.1.0.tgz", + "integrity": "sha512-Y6hyofImk9JdzU8k5INtTXX1cu8LDlePWDFU5sftm9H+zKCr5SGrVjdhkvsim646cw5zD0nADj8oHyXMZmCZ9w==", + "license": "MIT", + "dependencies": { + "imul": "^1.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fp-ts": { + "version": "1.19.3", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-1.19.3.tgz", + "integrity": "sha512-H5KQDspykdHuztLTg+ajGN0Z2qUjcEf3Ybxc6hLt0k7/zPkn29XnKnxlBPyW2XIddWrGaJBzBl4VLYOtk39yZg==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hardhat": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-2.29.1.tgz", + "integrity": "sha512-BVPE15F9+gWH5Eof7aAhprXTi05mNHwn32fwIKMeMFqrkU3Jun1f7NubXsJ0SOeMqu0mchF6KkW7rx1CHinlWw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@ethereumjs/util": "^9.1.0", + "@ethersproject/abi": "^5.1.2", + "@nomicfoundation/edr": "0.12.0-next.23", + "@nomicfoundation/solidity-analyzer": "^0.1.0", + "@sentry/node": "^5.18.1", + "adm-zip": "^0.4.16", + "aggregate-error": "^3.0.0", + "ansi-escapes": "^4.3.0", + "boxen": "^5.1.2", + "chokidar": "^4.0.0", + "ci-info": "^2.0.0", + "debug": "^4.1.1", + "enquirer": "^2.3.0", + "env-paths": "^2.2.0", + "ethereum-cryptography": "^1.0.3", + "find-up": "^5.0.0", + "fp-ts": "1.19.3", + "fs-extra": "^7.0.1", + "immutable": "^4.0.0-rc.12", + "io-ts": "1.10.4", + "json-stream-stringify": "^3.1.4", + "keccak": "^3.0.2", + "lodash": "^4.17.11", + "micro-eth-signer": "^0.14.0", + "mnemonist": "^0.38.0", + "mocha": "^11.1.0", + "p-map": "^4.0.0", + "picocolors": "^1.1.0", + "raw-body": "^2.4.1", + "resolve": "1.17.0", + "semver": "^6.3.0", + "solc": "0.8.26", + "source-map-support": "^0.5.13", + "stacktrace-parser": "^0.1.10", + "tinyglobby": "^0.2.6", + "tsort": "0.0.1", + "undici": "^5.14.0", + "uuid": "^8.3.2", + "ws": "^7.4.6" + }, + "bin": { + "hardhat": "internal/cli/bootstrap.js" + }, + "peerDependencies": { + "ts-node": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/hardhat-deploy": { + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/hardhat-deploy/-/hardhat-deploy-0.12.4.tgz", + "integrity": "sha512-bYO8DIyeGxZWlhnMoCBon9HNZb6ji0jQn7ngP1t5UmGhC8rQYhji7B73qETMOFhzt5ECZPr+U52duj3nubsqdQ==", + "license": "MIT", + "dependencies": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/abstract-signer": "^5.7.0", + "@ethersproject/address": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/constants": "^5.7.0", + "@ethersproject/contracts": "^5.7.0", + "@ethersproject/providers": "^5.7.2", + "@ethersproject/solidity": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "@ethersproject/wallet": "^5.7.0", + "@types/qs": "^6.9.7", + "axios": "^0.21.1", + "chalk": "^4.1.2", + "chokidar": "^3.5.2", + "debug": "^4.3.2", + "enquirer": "^2.3.6", + "ethers": "^5.7.0", + "form-data": "^4.0.0", + "fs-extra": "^10.0.0", + "match-all": "^1.2.6", + "murmur-128": "^0.2.1", + "qs": "^6.9.4", + "zksync-ethers": "^5.0.0" + } + }, + "node_modules/hardhat-deploy/node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, + "node_modules/hardhat-deploy/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/hardhat-deploy/node_modules/ethers": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz", + "integrity": "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abi": "5.8.0", + "@ethersproject/abstract-provider": "5.8.0", + "@ethersproject/abstract-signer": "5.8.0", + "@ethersproject/address": "5.8.0", + "@ethersproject/base64": "5.8.0", + "@ethersproject/basex": "5.8.0", + "@ethersproject/bignumber": "5.8.0", + "@ethersproject/bytes": "5.8.0", + "@ethersproject/constants": "5.8.0", + "@ethersproject/contracts": "5.8.0", + "@ethersproject/hash": "5.8.0", + "@ethersproject/hdnode": "5.8.0", + "@ethersproject/json-wallets": "5.8.0", + "@ethersproject/keccak256": "5.8.0", + "@ethersproject/logger": "5.8.0", + "@ethersproject/networks": "5.8.0", + "@ethersproject/pbkdf2": "5.8.0", + "@ethersproject/properties": "5.8.0", + "@ethersproject/providers": "5.8.0", + "@ethersproject/random": "5.8.0", + "@ethersproject/rlp": "5.8.0", + "@ethersproject/sha2": "5.8.0", + "@ethersproject/signing-key": "5.8.0", + "@ethersproject/solidity": "5.8.0", + "@ethersproject/strings": "5.8.0", + "@ethersproject/transactions": "5.8.0", + "@ethersproject/units": "5.8.0", + "@ethersproject/wallet": "5.8.0", + "@ethersproject/web": "5.8.0", + "@ethersproject/wordlists": "5.8.0" + } + }, + "node_modules/hardhat-deploy/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/hardhat-deploy/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/hardhat/node_modules/@ethereumjs/rlp": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/rlp/-/rlp-5.0.2.tgz", + "integrity": "sha512-DziebCdg4JpGlEqEdGgXmjqcFoJi+JGulUXwEjsZGAscAQ7MyD/7LE/GVCP29vEQxKc7AAwjT3A2ywHp2xfoCA==", + "license": "MPL-2.0", + "bin": { + "rlp": "bin/rlp.cjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/hardhat/node_modules/@ethereumjs/util": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@ethereumjs/util/-/util-9.1.0.tgz", + "integrity": "sha512-XBEKsYqLGXLah9PNJbgdkigthkG7TAGvlD/sH12beMXEyHDyigfcbdvHhmLyDWgDyOJn4QwiQUaF7yeuhnjdog==", + "license": "MPL-2.0", + "dependencies": { + "@ethereumjs/rlp": "^5.0.2", + "ethereum-cryptography": "^2.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/hardhat/node_modules/@ethereumjs/util/node_modules/ethereum-cryptography": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz", + "integrity": "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "1.4.2", + "@noble/hashes": "1.4.0", + "@scure/bip32": "1.4.0", + "@scure/bip39": "1.3.0" + } + }, + "node_modules/hardhat/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/hardhat/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/hardhat/node_modules/ethereum-cryptography": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-1.2.0.tgz", + "integrity": "sha512-6yFQC9b5ug6/17CQpCyE3k9eKBMdhyVjzUy1WkiuY/E4vj/SXDBbCw8QEIaXqf0Mf2SnY6RmpDcwlUmBSS0EJw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.2.0", + "@noble/secp256k1": "1.7.1", + "@scure/bip32": "1.1.5", + "@scure/bip39": "1.1.1" + } + }, + "node_modules/hardhat/node_modules/ethereum-cryptography/node_modules/@noble/hashes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.2.0.tgz", + "integrity": "sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/hardhat/node_modules/ethereum-cryptography/node_modules/@scure/bip32": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.1.5.tgz", + "integrity": "sha512-XyNh1rB0SkEqd3tXcXMi+Xe1fvg+kUIcoRIEujP1Jgv7DqW2r9lg3Ah0NkFaCs9sTkQAQA8kw7xiRXzENi9Rtw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.2.0", + "@noble/secp256k1": "~1.7.0", + "@scure/base": "~1.1.0" + } + }, + "node_modules/hardhat/node_modules/ethereum-cryptography/node_modules/@scure/bip39": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.1.1.tgz", + "integrity": "sha512-t+wDck2rVkh65Hmv280fYdVdY25J9YeEUIgn2LG1WM6gxFkGzcksoDiUkWVpVp3Oex9xGC68JU2dSbUfwZ2jPg==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.2.0", + "@scure/base": "~1.1.0" + } + }, + "node_modules/hardhat/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/hardhat/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/hardhat/node_modules/mocha": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", + "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==", + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/hardhat/node_modules/resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "license": "MIT", + "dependencies": { + "path-parse": "^1.0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hardhat/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/hardhat/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/hardhat/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/hardhat/node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "license": "Apache-2.0" + }, + "node_modules/hardhat/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/hardhat/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/hardhat/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/hardhat/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immutable": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", + "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", + "license": "MIT" + }, + "node_modules/imul": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/imul/-/imul-1.0.1.tgz", + "integrity": "sha512-WFAgfwPLAjU66EKt6vRdTlKj4nAgIDQzh29JonLa4Bqtl6D8JrIMvWjCnx7xEjVNmP3U0fM5o8ZObk7d0f62bA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/io-ts": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-1.10.4.tgz", + "integrity": "sha512-b23PteSnYXSONJ6JQXRAlvJhuw8KOtkqa87W4wDtvMrud/DTJd5X+NpOOI+O/zZwVq6v0VLAaJ+1EDViKEuN9g==", + "license": "MIT", + "dependencies": { + "fp-ts": "^1.0.0" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-stream-stringify": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/json-stream-stringify/-/json-stream-stringify-3.1.7.tgz", + "integrity": "sha512-F4MWetLtY42YMaAKw5cV4e47zMD5aOT+tjjQWjX18ACtdkQ5Y/vrcfbcQ107Rh+MXjOCIx4KhW0wPmOvG8iQ5w==", + "license": "MIT", + "engines": { + "node": ">=7.10.1" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keccak": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.4.tgz", + "integrity": "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru_map": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.3.3.tgz", + "integrity": "sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "license": "ISC", + "optional": true + }, + "node_modules/match-all": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/match-all/-/match-all-1.2.7.tgz", + "integrity": "sha512-qSpsBKarh55r9KyXzFC3xBLRf2GlGasba2em9kbpRsSlGvdTAqjx3QD0r3FKSARiW+OE4iMHYsolM3aX9n5djw==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/micro-eth-signer": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/micro-eth-signer/-/micro-eth-signer-0.14.0.tgz", + "integrity": "sha512-5PLLzHiVYPWClEvZIXXFu5yutzpadb73rnQCpUqIHu3No3coFuWQNfE5tkBQJ7djuLYl6aRLaS0MgWJYGoqiBw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.8.1", + "@noble/hashes": "~1.7.1", + "micro-packed": "~0.7.2" + } + }, + "node_modules/micro-eth-signer/node_modules/@noble/curves": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz", + "integrity": "sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.2" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/micro-eth-signer/node_modules/@noble/hashes": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz", + "integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/micro-packed": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.7.3.tgz", + "integrity": "sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg==", + "license": "MIT", + "dependencies": { + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/micro-packed/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mnemonist": { + "version": "0.38.5", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.5.tgz", + "integrity": "sha512-bZTFT5rrPKtPJxj8KSV0WkPyNxl72vQepqqVUAW2ARUpUSF2qXMB6jZj7hW5/k7C1rtpzqbD/IIbJwLXUjCHeg==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/murmur-128": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/murmur-128/-/murmur-128-0.2.1.tgz", + "integrity": "sha512-WseEgiRkI6aMFBbj8Cg9yBj/y+OdipwVC7zUo3W2W1JAJITwouUOtpqsmGSg67EQmwwSyod7hsVsWY5LsrfQVg==", + "license": "MIT", + "dependencies": { + "encode-utf8": "^1.0.2", + "fmix": "^0.1.0", + "imul": "^1.0.0" + } + }, + "node_modules/node-addon-api": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", + "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==", + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scrypt-js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-3.0.1.tgz", + "integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==", + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/solc": { + "version": "0.8.26", + "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.26.tgz", + "integrity": "sha512-yiPQNVf5rBFHwN6SIf3TUUvVAFKcQqmSUFeq+fb6pNRCo0ZCgpYOZDi3BVoezCPIAcKrVYd/qXlBLUP9wVrZ9g==", + "license": "MIT", + "dependencies": { + "command-exists": "^1.2.8", + "commander": "^8.1.0", + "follow-redirects": "^1.12.1", + "js-sha3": "0.8.0", + "memorystream": "^0.3.1", + "semver": "^5.5.0", + "tmp": "0.0.33" + }, + "bin": { + "solcjs": "solc.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/solc/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/stacktrace-parser/node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/tsort": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/tsort/-/tsort-0.0.1.tgz", + "integrity": "sha512-Tyrf5mxF8Ofs1tNoxA13lFeZ2Zrbd6cKbuH3V+MQ5sb6DtBj5FjrXVsRWT8YvNAQTqNoz66dz1WsbigI22aEnw==", + "license": "MIT" + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "license": "MIT", + "optional": true + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zksync-ethers": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/zksync-ethers/-/zksync-ethers-5.11.2.tgz", + "integrity": "sha512-R3mOhKJQ79SZe5PzBHCje3X++XKyyOZBVlP00eYlGEgqjW6eFzn4sPnruCA+2+QofwwAjooVc1TdoF+C4eXkaA==", + "license": "MIT", + "dependencies": { + "ethers": "~5.7.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "ethers": "~5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/abi": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.7.0.tgz", + "integrity": "sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/constants": "^5.7.0", + "@ethersproject/hash": "^5.7.0", + "@ethersproject/keccak256": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/strings": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/abstract-provider": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz", + "integrity": "sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/networks": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "@ethersproject/web": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/abstract-signer": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz", + "integrity": "sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/properties": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/address": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.7.0.tgz", + "integrity": "sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/keccak256": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/rlp": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/base64": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.7.0.tgz", + "integrity": "sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/basex": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.7.0.tgz", + "integrity": "sha512-ywlh43GwZLv2Voc2gQVTKBoVQ1mti3d8HK5aMxsfu/nRDnMmNqaSJ3r3n85HBByT8OpoY96SXM1FogC533T4zw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/properties": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/bignumber": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz", + "integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "bn.js": "^5.2.1" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/bytes": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz", + "integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/constants": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.7.0.tgz", + "integrity": "sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/contracts": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/contracts/-/contracts-5.7.0.tgz", + "integrity": "sha512-5GJbzEU3X+d33CdfPhcyS+z8MzsTrBGk/sc+G+59+tPa9yFkl6HQ9D6L0QMgNTA9q8dT0XKxxkyp883XsQvbbg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/abstract-provider": "^5.7.0", + "@ethersproject/abstract-signer": "^5.7.0", + "@ethersproject/address": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/constants": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/transactions": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/hash": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.7.0.tgz", + "integrity": "sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.7.0", + "@ethersproject/address": "^5.7.0", + "@ethersproject/base64": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/keccak256": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/strings": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/hdnode": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.7.0.tgz", + "integrity": "sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.7.0", + "@ethersproject/basex": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/pbkdf2": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/sha2": "^5.7.0", + "@ethersproject/signing-key": "^5.7.0", + "@ethersproject/strings": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "@ethersproject/wordlists": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/json-wallets": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz", + "integrity": "sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.7.0", + "@ethersproject/address": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/hdnode": "^5.7.0", + "@ethersproject/keccak256": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/pbkdf2": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/random": "^5.7.0", + "@ethersproject/strings": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "aes-js": "3.0.0", + "scrypt-js": "3.0.1" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/keccak256": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.7.0.tgz", + "integrity": "sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "js-sha3": "0.8.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/logger": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz", + "integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT" + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/networks": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.7.1.tgz", + "integrity": "sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/pbkdf2": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz", + "integrity": "sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/sha2": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/properties": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.7.0.tgz", + "integrity": "sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/providers": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.7.2.tgz", + "integrity": "sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.7.0", + "@ethersproject/abstract-signer": "^5.7.0", + "@ethersproject/address": "^5.7.0", + "@ethersproject/base64": "^5.7.0", + "@ethersproject/basex": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/constants": "^5.7.0", + "@ethersproject/hash": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/networks": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/random": "^5.7.0", + "@ethersproject/rlp": "^5.7.0", + "@ethersproject/sha2": "^5.7.0", + "@ethersproject/strings": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "@ethersproject/web": "^5.7.0", + "bech32": "1.1.4", + "ws": "7.4.6" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/random": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.7.0.tgz", + "integrity": "sha512-19WjScqRA8IIeWclFme75VMXSBvi4e6InrUNuaR4s5pTF2qNhcGdCUwdxUVGtDDqC00sDLCO93jPQoDUH4HVmQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/logger": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/rlp": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.7.0.tgz", + "integrity": "sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/logger": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/sha2": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.7.0.tgz", + "integrity": "sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "hash.js": "1.1.7" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/signing-key": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.7.0.tgz", + "integrity": "sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "bn.js": "^5.2.1", + "elliptic": "6.5.4", + "hash.js": "1.1.7" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/solidity": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", + "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/keccak256": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/sha2": "^5.7.0", + "@ethersproject/strings": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/strings": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", + "integrity": "sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/constants": "^5.7.0", + "@ethersproject/logger": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/transactions": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.7.0.tgz", + "integrity": "sha512-kmcNicCp1lp8qanMTC3RIikGgoJ80ztTyvtsFvCYpSCfkjhD0jZ2LOrnbcuxuToLIUYYf+4XwD1rP+B/erDIhQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/constants": "^5.7.0", + "@ethersproject/keccak256": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/rlp": "^5.7.0", + "@ethersproject/signing-key": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/units": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", + "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/constants": "^5.7.0", + "@ethersproject/logger": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/wallet": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", + "integrity": "sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.7.0", + "@ethersproject/abstract-signer": "^5.7.0", + "@ethersproject/address": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/hash": "^5.7.0", + "@ethersproject/hdnode": "^5.7.0", + "@ethersproject/json-wallets": "^5.7.0", + "@ethersproject/keccak256": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/random": "^5.7.0", + "@ethersproject/signing-key": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "@ethersproject/wordlists": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/web": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.7.1.tgz", + "integrity": "sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/base64": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/strings": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/@ethersproject/wordlists": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.7.0.tgz", + "integrity": "sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/hash": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/strings": "^5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/aes-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", + "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", + "license": "MIT" + }, + "node_modules/zksync-ethers/node_modules/ethers": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", + "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abi": "5.7.0", + "@ethersproject/abstract-provider": "5.7.0", + "@ethersproject/abstract-signer": "5.7.0", + "@ethersproject/address": "5.7.0", + "@ethersproject/base64": "5.7.0", + "@ethersproject/basex": "5.7.0", + "@ethersproject/bignumber": "5.7.0", + "@ethersproject/bytes": "5.7.0", + "@ethersproject/constants": "5.7.0", + "@ethersproject/contracts": "5.7.0", + "@ethersproject/hash": "5.7.0", + "@ethersproject/hdnode": "5.7.0", + "@ethersproject/json-wallets": "5.7.0", + "@ethersproject/keccak256": "5.7.0", + "@ethersproject/logger": "5.7.0", + "@ethersproject/networks": "5.7.1", + "@ethersproject/pbkdf2": "5.7.0", + "@ethersproject/properties": "5.7.0", + "@ethersproject/providers": "5.7.2", + "@ethersproject/random": "5.7.0", + "@ethersproject/rlp": "5.7.0", + "@ethersproject/sha2": "5.7.0", + "@ethersproject/signing-key": "5.7.0", + "@ethersproject/solidity": "5.7.0", + "@ethersproject/strings": "5.7.0", + "@ethersproject/transactions": "5.7.0", + "@ethersproject/units": "5.7.0", + "@ethersproject/wallet": "5.7.0", + "@ethersproject/web": "5.7.1", + "@ethersproject/wordlists": "5.7.0" + } + }, + "node_modules/zksync-ethers/node_modules/ws": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", + "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/hardhat/package.json b/hardhat/package.json index 0c3fc385b..446ff74d7 100644 --- a/hardhat/package.json +++ b/hardhat/package.json @@ -6,22 +6,19 @@ "node": "hardhat node" }, "dependencies": { - "@nomicfoundation/hardhat-ethers": "^4.0.0", - "@nomicfoundation/hardhat-network-helpers": "^3.0.0", - "@nomicfoundation/hardhat-toolbox": "^7.0.0", - "@nomicfoundation/hardhat-verify": "^3.0.0", - "@openzeppelin/contracts": "^5.1.0", - "@openzeppelin/contracts-upgradeable": "^5.1.0", - "@typechain/hardhat": "^9.0.0", - "ethers": "^6.4.0", - "hardhat": "^3.0.0", - "hardhat-gas-reporter": "^2.3.0", - "solidity-coverage": "^0.8.0" + "@nomicfoundation/hardhat-ethers": "3.1.3", + "@openzeppelin/contracts": "5.1.0", + "@openzeppelin/contracts-upgradeable": "5.1.0", + "chai": "4.5.0", + "ethers": "6.15.0", + "fs-extra": "11.3.2", + "hardhat": "2.29.1", + "hardhat-deploy": "0.12.4" }, "engines": { "node": ">=18.0.0" }, - "devDependencies": { - "hardhat-deploy": "^2.0.0" + "overrides": { + "elliptic": "6.6.1" } } diff --git a/hardhat/test/deploy/001_deploy_contracts.test.js b/hardhat/test/deploy/001_deploy_contracts.test.js index c8cb59209..90a9a94ef 100644 --- a/hardhat/test/deploy/001_deploy_contracts.test.js +++ b/hardhat/test/deploy/001_deploy_contracts.test.js @@ -1,8 +1,6 @@ const { expect } = require("chai"); -const { ethers } = require("hardhat"); +const { deployments, ethers } = require("hardhat"); const { ZeroAddress } = ethers; -const fs = require("fs-extra"); -const path = require("path"); describe("Deploy Script", function () { let contracts = {}; @@ -29,26 +27,12 @@ describe("Deploy Script", function () { ]; before(async function () { + await deployments.fixture(); [owner, validator1, validator2, validator3, validator4, validator5] = await ethers.getSigners(); - // Load contracts from deployment files - console.log("Loading contracts from deployment files..."); - const deployPath = path.join('./deployments/localhost'); - - // Verify that directory exists - if (!await fs.pathExists(deployPath)) { - throw new Error(`Deployment directory not found: ${deployPath}. Run deploy.js script first.`); - } - - // Load each contract from its deployment file + console.log("Loading contracts from the deployment fixture..."); for (const contractName of expectedContracts) { - const deployFilePath = path.join(deployPath, `${contractName}.json`); - - if (!await fs.pathExists(deployFilePath)) { - throw new Error(`Deployment file not found for ${contractName}: ${deployFilePath}`); - } - - const deployData = await fs.readJson(deployFilePath); + const deployData = await deployments.get(contractName); // Create contract instance using address and ABI contracts[contractName] = await ethers.getContractAt( @@ -56,29 +40,17 @@ describe("Deploy Script", function () { deployData.address ); - console.log(`Loaded ${contractName} from ${deployFilePath}`); + console.log(`Loaded ${contractName} from the deployment registry`); } }); - describe("Deployment Files Verification", function() { - it("should have all contracts in directories", async function() { - console.log("\n[Test] Verifying contract files in directories..."); - - const deployPath = path.join('./deployments/localhost'); - + describe("Deployment Registry Verification", function() { + it("should register every deployed contract", async function() { + console.log("\n[Test] Verifying deployment registry entries..."); for (const contractName of expectedContracts) { - // Verify in deployments - const deployContractPath = path.join(deployPath, `${contractName}.json`); - expect( - await fs.pathExists(deployContractPath), - `${contractName} should exist in deployments directory` - ).to.be.true; - - // Verify that the files are valid and match - const deployData = JSON.parse(await fs.readFile(deployContractPath, 'utf8')); - + const deployData = await deployments.get(contractName); const contractAddress = await contracts[contractName].getAddress(); - expect(deployData.address, `${contractName} should have valid address in deployments`) + expect(deployData.address, `${contractName} should have a registered deployment`) .to.equal(contractAddress); console.log(`[Test] ✓ ${contractName} verified`); @@ -237,4 +209,4 @@ describe("Deploy Script", function () { ).to.be.true; }); }); -}); \ No newline at end of file +}); From 179897c38a2470b1885e7e0294909d0ddf698d48 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 22:20:41 +0100 Subject: [PATCH 07/55] fix(consensus): match v0.6 round lifecycle --- backend/consensus/base.py | 78 ++- backend/consensus/decisions.py | 8 +- backend/consensus/history.py | 15 + backend/consensus/types.py | 42 ++ .../transactions_processor.py | 300 +++++++++--- backend/protocol_rpc/endpoints.py | 31 +- backend/protocol_rpc/fees.py | 40 +- .../consensus/test_appeal_state_restore.py | 1 + .../consensus/test_decisions_revealing.py | 4 +- tests/unit/test_studio_fees.py | 446 +++++++++++++++++- 10 files changed, 845 insertions(+), 120 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index f32a0b15d..a41779a9a 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -68,6 +68,7 @@ message_novelty_mask, prepare_reveal_message_generation, record_external_message_execution_fees, + runtime_rotations_for_round, stamp_receipt_execution_policy, unwind_reveal_message_fees, validate_receipt_admission_caps, @@ -76,7 +77,12 @@ import backend.validators as validators from backend.node.genvm.origin.host_fns import ResultCode -from backend.consensus.types import ConsensusResult, ConsensusRound +from backend.consensus.types import ( + ConsensusResult, + ConsensusRound, + consensus_vote_type_code, +) +from backend.consensus.history import logical_fee_round_entries from backend.consensus.utils import determine_consensus_from_votes from backend.consensus.decisions import ( decide_undetermined, @@ -499,6 +505,7 @@ def __init__( self.validator_nodes: list = [] self.validation_results: list = [] self.rotation_count: int = 0 + self.active_fee_round: int | None = None self.consensus_service = consensus_service self.leader: dict = {} # Shared for the lifetime of this transaction context (leader + validators). @@ -1290,6 +1297,17 @@ async def process_validator_appeal( context.transaction.hash, None ) + # The successful review is followed by one terminal normal + # recomputation, not another validator-appeal pass. + context.transactions_processor.set_transaction_appeal( + context.transaction.hash, False + ) + context.transaction.appealed = False + context.transactions_processor.set_transaction_appeal_validators_timeout( + context.transaction.hash, False + ) + context.transaction.appeal_validators_timeout = False + await ConsensusAlgorithm.dispatch_transaction_status_update( context.transactions_processor, context.transaction.hash, @@ -2118,6 +2136,7 @@ async def handle(self, context): context.transaction.hash ) ) + context.active_fee_round = _active_execution_fee_round(context.transaction) # Pre-effects: timestamp + reset rotation count pre_effects = decide_pending_pre( @@ -2966,15 +2985,18 @@ async def handle(self, context): # Determine consensus result consensus_result = determine_consensus_from_votes(list(context.votes.values())) - # Build vote reveal entries (IDLE→TIMEOUT for on-chain events) + # Build vote reveal entries with canonical v0.6 VoteType ordinals. vote_reveal_entries = [] for validation_result in context.validation_results: - chain_vote = ( - Vote.TIMEOUT - if validation_result.vote == Vote.IDLE - else validation_result.vote + vote_reveal_entries.append( + ( + validation_result.node_config, + consensus_vote_type_code( + validation_result.vote, + validation_result.execution_result, + ), + ) ) - vote_reveal_entries.append((validation_result.node_config, int(chain_vote))) # Leader receipt split if ( @@ -3010,7 +3032,10 @@ async def handle(self, context): appeal_validators_timeout=context.transaction.appeal_validators_timeout, appeal_undetermined=context.transaction.appeal_undetermined, rotation_count=context.rotation_count, - config_rotation_rounds=context.transaction.config_rotation_rounds, + config_rotation_rounds=_runtime_rotation_limit( + context.transaction, + context.active_fee_round, + ), vote_reveal_entries=vote_reveal_entries, consensus_data_dict=context.consensus_data.to_dict( strip_contract_state=True @@ -3937,7 +3962,42 @@ def _child_config_rotation_rounds(parent_rotations: int, data: dict[str, Any]) - return max(0, int(parent_rotations)) if not rotations: return 0 - return min(max(0, int(parent_rotations)), *(max(0, int(v)) for v in rotations)) + return min(max(0, int(parent_rotations)), max(max(0, int(v)) for v in rotations)) + + +def _active_execution_fee_round(transaction: Transaction) -> int: + """Resolve the raw round once, before appeal flags mutate during retries.""" + + entries = logical_fee_round_entries(transaction.consensus_history) + last_round = entries[-1][0] if entries else 0 + if transaction.appeal_undetermined or transaction.appeal_leader_timeout: + return last_round + 2 + if entries and last_round % 2 == 1: + # Successful validator review is followed by one terminal normal round. + return last_round + 1 + return last_round + + +def _runtime_rotation_limit( + transaction: Transaction, + raw_round: int | None = None, +) -> int: + """Mirror Consensus' per-normal-round funded rotation allowance.""" + + if transaction.appealed or transaction.appeal_validators_timeout: + return 0 + if raw_round is None: + raw_round = _active_execution_fee_round(transaction) + + accounting = (transaction.data or {}).get(FEE_ACCOUNTING_KEY) or {} + fees_distribution = accounting.get("fees_distribution") + if not isinstance(fees_distribution, dict): + return max(0, int(transaction.config_rotation_rounds or 0)) + return runtime_rotations_for_round( + fees_distribution, + transaction.config_rotation_rounds, + raw_round, + ) def _emit_messages( diff --git a/backend/consensus/decisions.py b/backend/consensus/decisions.py index 31a33c8b8..a963db097 100644 --- a/backend/consensus/decisions.py +++ b/backend/consensus/decisions.py @@ -32,7 +32,11 @@ SetTimestampLastVoteEffect, Effect, ) -from backend.consensus.types import ConsensusResult, ConsensusRound +from backend.consensus.types import ( + ConsensusResult, + ConsensusRound, + consensus_result_type_code, +) # ── UndeterminedState ────────────────────────────────────────────── @@ -604,7 +608,7 @@ def decide_revealing( node_config["address"], chain_vote_int, is_last, - int(consensus_result) if is_last else int(ConsensusResult.IDLE), + consensus_result_type_code(consensus_result) if is_last else 0, ), ) ) diff --git a/backend/consensus/history.py b/backend/consensus/history.py index 53404e68e..86650789f 100644 --- a/backend/consensus/history.py +++ b/backend/consensus/history.py @@ -34,6 +34,10 @@ ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, ConsensusRound.VALIDATORS_TIMEOUT_APPEAL_FAILED.value, } +TERMINAL_VALIDATOR_APPEAL_ROUNDS = { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, +} def is_completed_consensus_round(entry: dict[str, Any]) -> bool: @@ -60,6 +64,17 @@ def completed_consensus_round_index(consensus_history: dict[str, Any] | None) -> return entries[-1][0] if entries else 0 +def has_terminal_validator_appeal( + consensus_history: dict[str, Any] | None, +) -> bool: + """Whether a successful validator review made the next normal round final.""" + + return any( + str(entry.get("consensus_round") or "") in TERMINAL_VALIDATOR_APPEAL_ROUNDS + for entry in completed_consensus_rounds(consensus_history) + ) + + def _next_logical_fee_round(previous_round: int | None, outcome: str) -> int: if previous_round is None: return 0 diff --git a/backend/consensus/types.py b/backend/consensus/types.py index b96eb965d..1e10f0f54 100644 --- a/backend/consensus/types.py +++ b/backend/consensus/types.py @@ -1,5 +1,7 @@ from enum import Enum +from backend.node.types import ExecutionResultStatus, Vote + class ConsensusResult(Enum): IDLE = "IDLE" @@ -32,6 +34,46 @@ def __int__(self) -> int: return values[self] +def consensus_result_type_code(result: ConsensusResult) -> int: + """Return the canonical v0.6 ITransactions.ResultType ordinal.""" + + return { + ConsensusResult.IDLE: 0, + ConsensusResult.AGREE: 1, + ConsensusResult.MAJORITY_AGREE: 1, + ConsensusResult.DISAGREE: 2, + ConsensusResult.MAJORITY_DISAGREE: 2, + ConsensusResult.TIMEOUT: 3, + ConsensusResult.DETERMINISTIC_VIOLATION: 4, + ConsensusResult.NO_MAJORITY: 5, + }[result] + + +def consensus_vote_type_code( + vote: Vote | str, + execution_result: ExecutionResultStatus | str | None = None, +) -> int: + """Translate a Studio ballot to the canonical v0.6 VoteType ordinal.""" + + local_vote = Vote.from_string(vote) if isinstance(vote, str) else vote + if local_vote == Vote.NOT_VOTED: + return 0 + if local_vote == Vote.AGREE: + status = ( + execution_result.value + if isinstance(execution_result, ExecutionResultStatus) + else str(execution_result or "").upper() + ) + return 2 if status == ExecutionResultStatus.ERROR.value else 1 + if local_vote in {Vote.TIMEOUT, Vote.IDLE}: + return 3 + if local_vote == Vote.DISAGREE: + return 4 + if local_vote == Vote.DETERMINISTIC_VIOLATION: + return 5 + raise ValueError(f"Unsupported vote: {local_vote}") + + class ConsensusRound(Enum): ACCEPTED = "Accepted" LEADER_ROTATION = "Leader Rotation" diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index e78d6dc1c..0690b9f5a 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session, defer, selectinload from sqlalchemy import or_, desc, and_, JSON, type_coerce, text -from backend.node.types import Vote, Receipt, ExecutionResultStatus +from backend.node.types import Receipt, ExecutionResultStatus from .models import Transactions, TransactionStatus from eth_utils import to_bytes, keccak, is_address, to_checksum_address import json @@ -15,10 +15,23 @@ import time from backend.domain.types import TransactionType from web3 import Web3 -from backend.consensus.types import ConsensusRound -from backend.consensus.history import time_unit_consumption +from backend.consensus.types import ( + ConsensusResult, + ConsensusRound, + consensus_result_type_code, + consensus_vote_type_code, +) +from backend.consensus.history import ( + completed_consensus_round_index, + completed_consensus_rounds, + time_unit_consumption, +) from backend.consensus.utils import determine_consensus_from_votes -from backend.protocol_rpc.fees import FEE_ACCOUNTING_KEY, normalize_fees_distribution +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + normalize_fees_distribution, + runtime_rotations_for_round, +) from backend.database_handler.terminal_snapshot_pruner import ( SnapshotArchiveReader, snapshot_archive_read_through_enabled, @@ -27,7 +40,7 @@ MAX_JSON_SAFE_INTEGER = (2**53) - 1 -# Canonical v0.6 ITransactions.TransactionStatus ordinals (0-14). Studio-only +# Canonical v0.6 ITransactions.TransactionStatus ordinals (0-13). Studio-only # states map to their on-chain equivalents (ACTIVATED -> Proposing: activation # transitions the on-chain tx into Proposing). TRANSACTION_STATUS_CODES = { @@ -43,10 +56,9 @@ "CANCELED": 8, "APPEAL_REVEALING": 9, "APPEAL_COMMITTING": 10, - "READY_TO_FINALIZE": 11, - "VALIDATORS_TIMEOUT": 12, - "LEADER_TIMEOUT": 13, - "LEADER_REVEALING": 14, + "VALIDATORS_TIMEOUT": 11, + "LEADER_TIMEOUT": 12, + "LEADER_REVEALING": 13, } @@ -56,38 +68,54 @@ class TransactionAddressFilter(Enum): FROM = "from" -def get_validator_vote_hash(validator_address: str, vote_type: int, nonce: int) -> str: +def get_validator_vote_hash( + validator_address: str, + vote_type: int, + nonce: int, + other_execution_fields_hash: bytes = bytes(32), +) -> str: """ Generate a hash for validator vote data using Solidity keccak. Args: validator_address: Address of the validator - vote_type: Numeric vote type (1=AGREE, 2=DISAGREE, etc.) + vote_type: Canonical v0.6 ``ITransactions.VoteType`` ordinal. nonce: Transaction nonce Returns: str: Hex-encoded hash with 0x prefix """ vote_hash_bytes = Web3.solidity_keccak( - ["address", "uint8", "uint256"], [validator_address, vote_type, nonce] + ["address", "uint8", "bytes32", "uint256"], + [validator_address, vote_type, other_execution_fields_hash, nonce], ) return Web3.to_hex(vote_hash_bytes) -def get_tx_execution_hash(leader_address: str, vote_type: int) -> str: +def get_tx_execution_hash( + leader_address: str, + vote_type: int, + nonce: int, + messages_and_other_fields_hash: bytes = bytes(32), +) -> str: """ Generate a hash for transaction execution data using Solidity keccak. Args: leader_address: Address of the consensus leader - vote_type: Numeric vote type + vote_type: Canonical v0.6 ``ITransactions.VoteType`` ordinal. + nonce: Studio's deterministic compatibility salt for this transaction. + messages_and_other_fields_hash: Canonical bytes32 preimage field when + available. Studio currently has no Solidity reveal payload, so the + compatibility view uses the zero sentinel consistently for leader + and validator hashes. Returns: str: Hex-encoded hash with 0x prefix """ tx_execution_hash_bytes = Web3.solidity_keccak( ["address", "uint8", "bytes32", "uint256"], - [leader_address, vote_type, b"", 4444], + [leader_address, vote_type, messages_and_other_fields_hash, nonce], ) return Web3.to_hex(tx_execution_hash_bytes) @@ -250,6 +278,14 @@ def _execution_result_fields(consensus_data: dict | None) -> tuple[int, str]: receipt = TransactionsProcessor._leader_receipt(consensus_data) if not isinstance(receipt, dict): return 0, "NOT_VOTED" + genvm_result = receipt.get("genvm_result") + if isinstance(genvm_result, dict) and str( + genvm_result.get("error_code") or "" + ) in { + "CONSENSUS_LEADER_EXEC_TIMEOUT", + "CONSENSUS_VALIDATOR_EXEC_TIMEOUT", + }: + return 3, "TIMEOUT" value = str(receipt.get("execution_result") or "").upper() if value == "SUCCESS": return 1, "FINISHED_WITH_RETURN" @@ -257,6 +293,100 @@ def _execution_result_fields(consensus_data: dict | None) -> tuple[int, str]: return 2, "FINISHED_WITH_ERROR" return 0, "NOT_VOTED" + @staticmethod + def _result_type_code(result: ConsensusResult) -> int: + """Map Studio's decision enum to v0.6 ITransactions.ResultType.""" + + return consensus_result_type_code(result) + + @staticmethod + def _rpc_raw_round( + transaction_data: dict, + completed_rounds: list[dict], + ) -> int: + """Return the raw round currently materialized by v0.6 Consensus.""" + + completed_raw = ( + completed_consensus_round_index(transaction_data.get("consensus_history")) + if completed_rounds + else 0 + ) + status = str(transaction_data.get("status") or "").upper() + executing = status in { + "PENDING", + "ACTIVATED", + "PROPOSING", + "COMMITTING", + "REVEALING", + "LEADER_REVEALING", + } + if not executing: + return completed_raw + + if transaction_data.get("appeal_undetermined") or transaction_data.get( + "appeal_leader_timeout" + ): + return completed_raw + 2 + if transaction_data.get("appealed") or transaction_data.get( + "appeal_validators_timeout" + ): + return completed_raw + (1 if completed_raw % 2 == 0 else 2) + + if completed_rounds: + last_outcome = str(completed_rounds[-1].get("consensus_round") or "") + if last_outcome in { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + }: + return completed_raw + 1 + return completed_raw + + @staticmethod + def _appeal_bond_for_round(accounting: dict | None, raw_round: int) -> int: + """Project Studio appeal custody onto Consensus RoundData.appealBond.""" + + if not isinstance(accounting, dict): + return 0 + entries = accounting.get("appeal_bonds") + if not isinstance(entries, list): + return 0 + for entry in reversed(entries): + if not isinstance(entry, dict): + continue + source_round = int(entry.get("sourceRound", entry.get("round", 0)) or 0) + status = str(entry.get("status") or "").upper() + default_appeal_round = ( + source_round + (1 if source_round % 2 == 0 else 2) + if status in {"ACCEPTED", "VALIDATORS_TIMEOUT"} + else source_round + 2 + ) + appeal_round = int( + entry.get("appealRound", default_appeal_round) or default_appeal_round + ) + if status == "LEADER_TIMEOUT": + bond_rounds = {appeal_round - 1} + elif status == "UNDETERMINED": + bond_rounds = {appeal_round - 1, appeal_round} + else: + bond_rounds = {appeal_round} + if raw_round in bond_rounds: + return int(entry.get("amount", entry.get("minimumRequired", 0)) or 0) + return 0 + + @staticmethod + def _last_history_outcome(transaction_data: dict) -> str: + rounds = completed_consensus_rounds(transaction_data.get("consensus_history")) + return str(rounds[-1].get("consensus_round") or "") if rounds else "" + + @staticmethod + def _is_timeout_outcome(outcome: str) -> bool: + return outcome in { + ConsensusRound.LEADER_TIMEOUT.value, + ConsensusRound.VALIDATORS_TIMEOUT.value, + ConsensusRound.LEADER_TIMEOUT_APPEAL_FAILED.value, + ConsensusRound.VALIDATORS_TIMEOUT_APPEAL_FAILED.value, + } + @staticmethod def _leader_receipt(consensus_data: dict | None) -> dict | None: if not isinstance(consensus_data, dict): @@ -545,28 +675,30 @@ def insert_transaction( def _process_round_data(self, transaction_data: dict) -> dict: """Process round data and prepare transaction data.""" - if ( - transaction_data["consensus_history"] is not None - and "consensus_results" in transaction_data["consensus_history"] - ): - transaction_data["num_of_rounds"] = str( - len(transaction_data["consensus_history"]["consensus_results"]) - ) - else: - transaction_data["num_of_rounds"] = "0" + completed_rounds = completed_consensus_rounds( + transaction_data.get("consensus_history") + ) + # Despite its historical name, ConsensusData.numOfRounds exposes the + # current raw round index, not a cardinality (round 0 -> 0, round 3 -> 3). + raw_round = self._rpc_raw_round(transaction_data, completed_rounds) + transaction_data["num_of_rounds"] = str(raw_round) validator_votes_name = [] validator_votes = [] validator_votes_hash = [] round_validators = [] - if ( - transaction_data["consensus_history"] is not None - and "consensus_results" in transaction_data["consensus_history"] - ): - round_number = str( - len(transaction_data["consensus_history"]["consensus_results"]) - 1 - ) - last_round = transaction_data["consensus_history"]["consensus_results"][-1] + round_number = str(raw_round) + last_round_outcome = "" + completed_raw = ( + completed_consensus_round_index(transaction_data["consensus_history"]) + if completed_rounds + else 0 + ) + if completed_rounds and raw_round == completed_raw: + # Ignore trailing in-round UI events such as Leader Rotation. They + # do not create a Solidity round and cannot be the lastRound view. + last_round = completed_rounds[-1] + last_round_outcome = str(last_round.get("consensus_round") or "") leader = self._select_receipt(last_round.get("leader_result"), index=1) if ( leader is not None @@ -575,7 +707,9 @@ def _process_round_data(self, transaction_data: dict) -> dict: and leader["node_config"].get("address") is not None ): validator_votes_name.append(leader["vote"].upper()) - vote_number = int(Vote.from_string(leader["vote"])) + vote_number = consensus_vote_type_code( + leader["vote"], leader.get("execution_result") + ) validator_votes.append(vote_number) leader_address = leader["node_config"]["address"] validator_votes_hash.append( @@ -585,9 +719,11 @@ def _process_round_data(self, transaction_data: dict) -> dict: ) round_validators.append(leader_address) - for validator in last_round["validator_results"]: + for validator in last_round.get("validator_results") or []: validator_votes_name.append(validator["vote"].upper()) - vote_number = int(Vote.from_string(validator["vote"])) + vote_number = consensus_vote_type_code( + validator["vote"], validator.get("execution_result") + ) validator_votes.append(vote_number) validator_address = validator["node_config"]["address"] validator_votes_hash.append( @@ -596,22 +732,23 @@ def _process_round_data(self, transaction_data: dict) -> dict: ) ) round_validators.append(validator_address) - else: - round_number = "0" - # Handle upgrade transactions specially - they bypass consensus # and have upgrade_result instead of votes - if ( + if self._is_timeout_outcome(last_round_outcome): + last_round_result = self._result_type_code(ConsensusResult.TIMEOUT) + elif ( transaction_data.get("type") == TransactionType.UPGRADE_CONTRACT and transaction_data.get("consensus_data") is not None and "upgrade_result" in transaction_data["consensus_data"] ): - from backend.consensus.types import ConsensusResult - if transaction_data["consensus_data"]["upgrade_result"] == "success": - last_round_result = int(ConsensusResult.MAJORITY_AGREE) + last_round_result = self._result_type_code( + ConsensusResult.MAJORITY_AGREE + ) else: - last_round_result = int(ConsensusResult.MAJORITY_DISAGREE) + last_round_result = self._result_type_code( + ConsensusResult.MAJORITY_DISAGREE + ) elif ( # Handle LEADER_ONLY mode specially - no validators, so no votes to count # If the transaction is ACCEPTED or FINALIZED, the leader execution was successful @@ -619,25 +756,42 @@ def _process_round_data(self, transaction_data: dict) -> dict: and transaction_data.get("status") in [TransactionStatus.ACCEPTED.value, TransactionStatus.FINALIZED.value] ): - from backend.consensus.types import ConsensusResult - - last_round_result = int(ConsensusResult.MAJORITY_AGREE) + last_round_result = self._result_type_code(ConsensusResult.MAJORITY_AGREE) else: - last_round_result = int( + last_round_result = self._result_type_code( determine_consensus_from_votes( [vote.lower() for vote in validator_votes_name] ) ) + fee_accounting = (transaction_data.get("data") or {}).get(FEE_ACCOUNTING_KEY) + fees_distribution = ( + fee_accounting.get("fees_distribution") + if isinstance(fee_accounting, dict) + else None + ) + if isinstance(fees_distribution, dict): + rotation_limit = runtime_rotations_for_round( + fees_distribution, + transaction_data.get("config_rotation_rounds"), + int(round_number), + ) + else: + rotation_limit = max( + 0, int(transaction_data.get("config_rotation_rounds") or 0) + ) + transaction_data["last_round"] = { "round": round_number, "leader_index": "0", "votes_committed": str(len(validator_votes_name)), "votes_revealed": str(len(validator_votes_name)), - "appeal_bond": "0", + "appeal_bond": str(self._appeal_bond_for_round(fee_accounting, raw_round)), "rotations_left": str( - (transaction_data.get("config_rotation_rounds") or 0) - - (transaction_data.get("rotation_count") or 0) + max( + 0, + rotation_limit - int(transaction_data.get("rotation_count") or 0), + ) ), "result": last_round_result, "round_validators": round_validators, @@ -668,11 +822,11 @@ def _prepare_basic_transaction_data(self, transaction_data: dict) -> dict: "processing_block": "0", "proposal_block": "0", } - if ( - transaction_data["consensus_history"] is not None - and "consensus_results" in transaction_data["consensus_history"] - ): - first_round = transaction_data["consensus_history"]["consensus_results"][0] + history_rounds = completed_consensus_rounds( + transaction_data.get("consensus_history") + ) + if history_rounds: + first_round = history_rounds[0] leader = self._select_receipt(first_round.get("leader_result"), index=0) if ( leader is not None @@ -732,7 +886,7 @@ def _process_execution_hash(self, transaction_data: dict) -> dict: and "leader_receipt" in transaction_data["consensus_data"] ): leader_receipt = self._select_receipt( - transaction_data["consensus_data"]["leader_receipt"], index=1 + transaction_data["consensus_data"]["leader_receipt"], index=0 ) if ( @@ -741,9 +895,17 @@ def _process_execution_hash(self, transaction_data: dict) -> dict: and leader_receipt["node_config"].get("address") is not None and leader_receipt.get("vote") is not None ): + vote_type = self._execution_result_fields( + {"leader_receipt": leader_receipt} + )[0] + if vote_type == 0: + vote_type = consensus_vote_type_code( + leader_receipt["vote"], leader_receipt.get("execution_result") + ) transaction_data["tx_execution_hash"] = get_tx_execution_hash( leader_receipt["node_config"]["address"], - int(Vote.from_string(leader_receipt["vote"])), + vote_type, + int(transaction_data.get("nonce") or 0), ) else: transaction_data["tx_execution_hash"] = "" @@ -837,7 +999,15 @@ def _process_queue(self, transaction_data: dict) -> dict: status_to_queue_type = { TransactionStatus.PENDING.value: "1", TransactionStatus.ACTIVATED.value: "1", + TransactionStatus.PROPOSING.value: "1", + TransactionStatus.COMMITTING.value: "1", + TransactionStatus.REVEALING.value: "1", + "LEADER_REVEALING": "1", + "APPEAL_COMMITTING": "1", + "APPEAL_REVEALING": "1", TransactionStatus.ACCEPTED.value: "2", + TransactionStatus.LEADER_TIMEOUT.value: "2", + TransactionStatus.VALIDATORS_TIMEOUT.value: "2", TransactionStatus.UNDETERMINED.value: "3", } transaction_data["queue_type"] = status_to_queue_type.get( @@ -848,6 +1018,12 @@ def _process_queue(self, transaction_data: dict) -> dict: return transaction_data def _process_result(self, transaction_data: dict) -> dict: + if self._is_timeout_outcome(self._last_history_outcome(transaction_data)): + consensus_result = ConsensusResult.TIMEOUT + transaction_data["result"] = self._result_type_code(consensus_result) + transaction_data["result_name"] = consensus_result.value + return transaction_data + # Handle upgrade transactions specially - they bypass consensus # and have upgrade_result instead of votes if ( @@ -855,13 +1031,11 @@ def _process_result(self, transaction_data: dict) -> dict: and transaction_data.get("consensus_data") is not None and "upgrade_result" in transaction_data["consensus_data"] ): - from backend.consensus.types import ConsensusResult - if transaction_data["consensus_data"]["upgrade_result"] == "success": consensus_result = ConsensusResult.MAJORITY_AGREE else: consensus_result = ConsensusResult.MAJORITY_DISAGREE - transaction_data["result"] = int(consensus_result) + transaction_data["result"] = self._result_type_code(consensus_result) transaction_data["result_name"] = consensus_result.value return transaction_data @@ -873,10 +1047,8 @@ def _process_result(self, transaction_data: dict) -> dict: TransactionStatus.ACCEPTED.value, TransactionStatus.FINALIZED.value, ]: - from backend.consensus.types import ConsensusResult - consensus_result = ConsensusResult.MAJORITY_AGREE - transaction_data["result"] = int(consensus_result) + transaction_data["result"] = self._result_type_code(consensus_result) transaction_data["result_name"] = consensus_result.value return transaction_data @@ -887,7 +1059,7 @@ def _process_result(self, transaction_data: dict) -> dict: else: votes_temp = [] consensus_result = determine_consensus_from_votes(votes_temp) - transaction_data["result"] = int(consensus_result) + transaction_data["result"] = self._result_type_code(consensus_result) transaction_data["result_name"] = consensus_result.value return transaction_data diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index f12aa04e2..22bfb1477 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -65,8 +65,8 @@ validate_transaction_fee_deposit, ) from backend.consensus.history import ( - actual_leader_rotations_by_round, completed_consensus_round_index, + has_terminal_validator_appeal, logical_fee_round_entries, ) from backend.errors.errors import InvalidAddressError, InvalidTransactionError @@ -1930,11 +1930,10 @@ def _funded_max_rotations( ) -> int: """Mirror Consensus' submission-time rotation-capacity clamp. - Consensus seeds every normal round from one transaction-level - ``initialRotations`` value, then clamps that value to the smallest funded - entry in ``feesDistribution.rotations``. Studio must persist the same - effective value or it can execute attempts that the submitted fee - schedule never reserved. + ``initialRotations`` remains the transaction-wide ceiling. Consensus + clamps it to the largest funded entry so later normal rounds retain their + paid capacity; each round is separately bounded by its own schedule entry + at runtime. """ data = decoded_rollup_transaction.data @@ -1950,7 +1949,7 @@ def _funded_max_rotations( rotations = normalize_fees_distribution(data.args.fees_distribution)["rotations"] if not rotations: return int(requested_max_rotations) - return min(int(requested_max_rotations), *(int(value) for value in rotations)) + return min(int(requested_max_rotations), max(int(value) for value in rotations)) # `DebugMode` is ordered least- to most-permissive, and the manager gates @@ -2152,6 +2151,8 @@ def _handle_appeal_or_top_up_and_submit( TransactionStatus.VALIDATORS_TIMEOUT.value, } or bool(tx.get("appealed")): raise InvalidTransactionError("CanNotAppeal") + if has_terminal_validator_appeal(tx.get("consensus_history")): + raise InvalidTransactionError("CanNotAppeal") appeal_window_started = tx.get("timestamp_awaiting_finalization") if appeal_window_started is not None: finality_window = int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) @@ -2207,22 +2208,6 @@ def _handle_appeal_or_top_up_and_submit( live_validators = tx.get("leader_timeout_validators") if isinstance(live_validators, list): leader_timeout_live_seats = len(live_validators) - # Consensus prices the live `rotationsLeft` stored on the timed-out - # round. Studio's equivalent is the submission-clamped transaction - # capacity minus rotations already consumed in this logical round; - # the per-round fee schedule may be larger and is not the runtime - # counter. - configured = max(0, int(tx.get("config_rotation_rounds") or 0)) - used = actual_leader_rotations_by_round(consensus_history).get( - current_round, - 0, - ) - replacement_rotations = max(0, configured - used) - elif status == TransactionStatus.UNDETERMINED.value: - replacement_rotations = max( - 0, - int(tx.get("config_rotation_rounds") or 0), - ) try: updated = record_appeal_bond( fee_accounting, diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 90248c2cd..0dd840bd0 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -1985,7 +1985,12 @@ def record_appeal_bond( "fundingBreakdown": charge["fundingBreakdown"], "requiredCharge": total_required, "surplusRefund": surplus, + # Keep the legacy source-round field, and also persist the raw + # round that Consensus creates at admission. The RPC compatibility + # view needs the latter to expose RoundData.appealBond correctly. "round": current_round if round is None else round, + "sourceRound": current_round, + "appealRound": int(charge["appealRound"]), "status": status, "minimumRequired": bond, "topUpAndSubmit": bool(top_up_and_submit), @@ -2038,11 +2043,7 @@ def calculate_appeal_charge( appeal_round = current_round + 2 jury_count = 0 if replacement_rotations is None: - replacement_rotations = ( - _appeal_rotation_allowance(fees, current_round) - if status_value == "LEADER_TIMEOUT" - else int(fees["rotations"][0] if fees["rotations"] else 0) - ) + replacement_rotations = _appeal_rotation_allowance(fees, current_round) replacement_rotations = max(0, int(replacement_rotations)) pre_funded = appeal_round + (appeal_round & 1) <= int(fees["appealRounds"]) * 2 @@ -2174,10 +2175,31 @@ def _appeal_rotation_allowance( rotations = fees["rotations"] if not isinstance(rotations, list) or not rotations: return 0 - current_normal_index = max(0, int(current_round)) // 2 - if current_normal_index < len(rotations): - return int(rotations[current_normal_index]) - return int(rotations[-1]) + next_normal_index = max(0, int(current_round)) // 2 + 1 + if next_normal_index < len(rotations): + return int(rotations[next_normal_index]) + return 0 + + +def runtime_rotations_for_round( + fees_distribution: dict[str, Any], + transaction_rotation_cap: int | None, + raw_round: int, +) -> int: + """Return the exact funded runtime allowance for one raw normal round.""" + + raw_round = max(0, int(raw_round)) + if raw_round % 2 != 0: + return 0 + fees = normalize_fees_distribution(fees_distribution) + normal_ordinal = raw_round // 2 + rotations = fees["rotations"] + funded = ( + int(rotations[normal_ordinal]) + if isinstance(rotations, list) and normal_ordinal < len(rotations) + else 0 + ) + return min(max(0, int(transaction_rotation_cap or 0)), max(0, funded)) def fill_message_fee_payload_from_allocation( diff --git a/tests/unit/consensus/test_appeal_state_restore.py b/tests/unit/consensus/test_appeal_state_restore.py index 06d2ed7f5..e3e3d12ae 100644 --- a/tests/unit/consensus/test_appeal_state_restore.py +++ b/tests/unit/consensus/test_appeal_state_restore.py @@ -135,6 +135,7 @@ async def test_missing_snapshot_non_deploy_refetches_from_db(self, monkeypatch): contract_processor.update_contract_state.assert_called_once_with( CONTRACT_ADDRESS, accepted_state=stored_accepted ) + transactions_processor.set_transaction_appeal.assert_called_with(TX_HASH, False) @pytest.mark.asyncio async def test_missing_snapshot_non_deploy_unfetchable_skips_restore( diff --git a/tests/unit/consensus/test_decisions_revealing.py b/tests/unit/consensus/test_decisions_revealing.py index 0b9429c4f..b7b7d531b 100644 --- a/tests/unit/consensus/test_decisions_revealing.py +++ b/tests/unit/consensus/test_decisions_revealing.py @@ -145,11 +145,11 @@ def test_vote_revealed_events(self): # First two: is_last=False, result=IDLE assert vote_reveals[0].extra_args[2] is False - assert vote_reveals[0].extra_args[3] == int(ConsensusResult.IDLE) + assert vote_reveals[0].extra_args[3] == 0 # Last: is_last=True, result=consensus_result assert vote_reveals[2].extra_args[2] is True - assert vote_reveals[2].extra_args[3] == int(ConsensusResult.MAJORITY_AGREE) + assert vote_reveals[2].extra_args[3] == 1 def test_timestamp_last_vote(self): _, effects = decide_revealing(**_base_kwargs()) diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index d96a269f6..1aa77fd36 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -6,6 +6,7 @@ import pytest import rlp from eth_abi import encode +from web3 import Web3 from backend.consensus.base import ( _apply_external_message_freeze_check, @@ -13,11 +14,15 @@ _child_config_rotation_rounds, _emit_messages, _get_messages_data, + _runtime_rotation_limit, ) from backend.domain.types import TransactionType from backend.errors.errors import InvalidTransactionError from backend.database_handler.accounts_manager import _infer_final_round -from backend.database_handler.transactions_processor import TransactionsProcessor +from backend.database_handler.transactions_processor import ( + TransactionsProcessor, + get_tx_execution_hash, +) from backend.protocol_rpc.exceptions import JSONRPCError, NotFoundError from backend.protocol_rpc.endpoints import ( _current_fee_round, @@ -1158,7 +1163,7 @@ def test_endpoint_fee_envelope_accepts_exact_fee_deposit(): _validate_fee_envelope(decoded) -def test_submission_clamps_runtime_rotations_to_smallest_funded_round(): +def test_submission_preserves_largest_funded_round_as_global_rotation_cap(): fees_distribution = _env_fees_distribution( appeals=2, rotations=[3, 1, 2], @@ -1182,7 +1187,7 @@ def test_submission_clamps_runtime_rotations_to_smallest_funded_round(): value=0, ) - assert _funded_max_rotations(decoded, 4) == 1 + assert _funded_max_rotations(decoded, 4) == 3 def test_internal_child_clamps_parent_rotations_to_its_own_fee_schedule(): @@ -1193,10 +1198,43 @@ def test_internal_child_clamps_parent_rotations_to_its_own_fee_schedule(): ) } - assert _child_config_rotation_rounds(3, data) == 0 + assert _child_config_rotation_rounds(3, data) == 2 assert _child_config_rotation_rounds(1, {"calldata": b"legacy"}) == 1 +def test_runtime_rotations_follow_each_normal_round_schedule_entry(): + accounting = _env_fee_accounting( + _env_fees_distribution(appeals=2, rotations=[3, 1, 2]) + ) + transaction = SimpleNamespace( + appealed=False, + appeal_validators_timeout=False, + appeal_undetermined=True, + appeal_leader_timeout=False, + consensus_history={"consensus_results": [{"consensus_round": "Undetermined"}]}, + data={FEE_ACCOUNTING_KEY: accounting}, + config_rotation_rounds=3, + ) + + # The appeal replays raw round 2, which uses rotations[1], not the global + # cap and not the smallest entry across the transaction. + assert _runtime_rotation_limit(transaction) == 1 + + # The raw round remains pinned across retries even after the state machine + # clears the appeal flag before selecting a replacement leader. + transaction.appeal_undetermined = False + assert _runtime_rotation_limit(transaction, raw_round=2) == 1 + + transaction.consensus_history = { + "consensus_results": [ + {"consensus_round": "Accepted"}, + {"consensus_round": "Validator Appeal Successful"}, + ] + } + # The terminal recomputation is raw round 2 as well. + assert _runtime_rotation_limit(transaction) == 1 + + def test_internal_child_uses_protocol_round_zero_committee_not_parent_size(): calls = [] @@ -7209,6 +7247,16 @@ def test_leader_timeout_bond_matches_fee_simulator_configured_round_vector(): assert bond_with_two_rotations_left == 3 * (100 + 5 * 200) +def test_leader_timeout_bond_uses_the_next_normal_round_schedule(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 2]) + + assert calculate_min_appeal_bond( + fees_distribution, + current_round=0, + status="LEADER_TIMEOUT", + ) == 3 * (100 + 5 * 200) + + def test_leader_timeout_live_seats_gate_eligibility_not_configured_pricing(): fees_distribution = _fees_distribution() @@ -8859,11 +8907,11 @@ def send_message(self, log_event, log_to_terminal=True): ] -def test_leader_timeout_appeal_uses_live_transaction_rotations_not_fee_schedule(): - fees = _env_fees_distribution(rotations=[3]) +def test_leader_timeout_appeal_uses_next_normal_fee_schedule(): + fees = _env_fees_distribution(appeals=1, rotations=[3, 2]) accounting = _env_fee_accounting(fees) tx = _fee_accounted_tx(status="LEADER_TIMEOUT", accounting=accounting) - tx["config_rotation_rounds"] = 1 + tx["config_rotation_rounds"] = 3 tx["leader_timeout_validators"] = [ "0x1111111111111111111111111111111111111111", "0x2222222222222222222222222222222222222222", @@ -8875,7 +8923,7 @@ def test_leader_timeout_appeal_uses_live_transaction_rotations_not_fee_schedule( fees, current_round=0, status="LEADER_TIMEOUT", - replacement_rotations=1, + replacement_rotations=2, leader_timeout_live_seats=5, policy=StudioFeePolicy.from_env(), ) @@ -8897,10 +8945,35 @@ def send_message(self, log_event, log_to_terminal=True): recorded = transactions.updated_fee_accounting["appeal_bonds"][0] assert recorded["minimumRequired"] == charge["bond"] + assert recorded["minimumRequired"] == 3 * (100 + 5 * 200) + assert recorded["sourceRound"] == 0 + assert recorded["appealRound"] == 2 assert recorded["fundingBreakdown"] == charge["fundingBreakdown"] - assert recorded["fundingBreakdown"]["executionBacking"] == ( - 3 * int(fees["executionBudgetPerRound"]) - ) + assert recorded["fundingBreakdown"]["executionBacking"] == 0 + + +def test_submit_appeal_rejects_terminal_validator_recomputation(): + tx = _fee_accounted_tx(status="ACCEPTED") + tx["consensus_history"] = { + "consensus_results": [ + {"consensus_round": "Accepted"}, + {"consensus_round": "Validator Appeal Successful"}, + {"consensus_round": "Accepted"}, + ] + } + transactions = _FakeTransactionsProcessor(tx) + + class _MsgHandler: + def send_message(self, log_event, log_to_terminal=True): + pass + + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + _handle_appeal_or_top_up_and_submit( + accounts_manager=_FakeAccountsManager(balance=0), + transactions_processor=transactions, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal(tx["hash"], amount=10**30), + ) def test_submit_appeal_prices_against_frozen_activation_pool(): @@ -9164,6 +9237,18 @@ def test_current_fee_round_ignores_rotation_events_and_expands_leader_appeal(): assert _infer_final_round(consensus_history) == 2 +def test_current_fee_round_skips_even_gaps_between_failed_validator_appeals(): + consensus_history = { + "consensus_results": [ + {"consensus_round": "Accepted"}, + {"consensus_round": "Validator Appeal Failed"}, + {"consensus_round": "Validator Appeal Failed"}, + ] + } + + assert _current_fee_round(consensus_history) == 3 + + def _processor_transaction(*, accounting=None, execution_result=None): consensus_data = None if execution_result is not None: @@ -9234,6 +9319,53 @@ def get_transaction_status(self, transaction_hash): } +@pytest.mark.parametrize( + ("status", "code"), + [ + ("VALIDATORS_TIMEOUT", 11), + ("LEADER_TIMEOUT", 12), + ("LEADER_REVEALING", 13), + ], +) +def test_transaction_status_details_rpc_uses_consensus_v06_timeout_ordinals( + status, code +): + class _Processor: + def get_transaction_status(self, transaction_hash): + return TransactionsProcessor._status_payload(status) + + assert get_transaction_status_details(_Processor(), "0x1234") == { + "status": status, + "statusCode": code, + } + + +@pytest.mark.parametrize( + ("status", "queue_type"), + [ + ("PENDING", "1"), + ("PROPOSING", "1"), + ("COMMITTING", "1"), + ("REVEALING", "1"), + ("APPEAL_COMMITTING", "1"), + ("APPEAL_REVEALING", "1"), + ("LEADER_REVEALING", "1"), + ("ACCEPTED", "2"), + ("LEADER_TIMEOUT", "2"), + ("VALIDATORS_TIMEOUT", "2"), + ("UNDETERMINED", "3"), + ("FINALIZED", "0"), + ("CANCELED", "0"), + ], +) +def test_transaction_queue_type_matches_consensus_v06_status_family(status, queue_type): + processor = TransactionsProcessor.__new__(TransactionsProcessor) + + result = processor._process_queue({"status": status}) + + assert result["queue_type"] == queue_type + + def test_transaction_status_details_rpc_raises_not_found(): class _Processor: def get_transaction_status(self, transaction_hash): @@ -9259,6 +9391,298 @@ def test_transaction_payload_maps_execution_result_name(): assert failed["txExecutionResultName"] == "FINISHED_WITH_ERROR" +def test_transaction_payload_maps_execution_timeout_to_vote_type_timeout(): + assert TransactionsProcessor._execution_result_fields( + { + "leader_receipt": [ + { + "execution_result": "ERROR", + "genvm_result": {"error_code": "CONSENSUS_LEADER_EXEC_TIMEOUT"}, + } + ] + } + ) == (3, "TIMEOUT") + + +@pytest.mark.parametrize( + ("result", "code"), + [ + ("MAJORITY_AGREE", 1), + ("MAJORITY_DISAGREE", 2), + ("TIMEOUT", 3), + ("DETERMINISTIC_VIOLATION", 4), + ("NO_MAJORITY", 5), + ], +) +def test_transaction_round_result_uses_consensus_v06_result_type(result, code): + from backend.consensus.types import ConsensusResult + + assert TransactionsProcessor._result_type_code(ConsensusResult(result)) == code + + +def test_transaction_round_payload_uses_raw_round_index_and_ignores_rotation_events(): + processor = TransactionsProcessor.__new__(TransactionsProcessor) + leader = { + "vote": "agree", + "execution_result": "SUCCESS", + "node_config": {"address": "0x1111111111111111111111111111111111111111"}, + } + transaction = { + "consensus_history": { + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [leader, leader], + "validator_results": [], + }, + { + "consensus_round": "Leader Rotation", + "leader_result": [], + "validator_results": [], + }, + ] + }, + "data": {}, + "nonce": 7, + "config_rotation_rounds": 0, + "rotation_count": 0, + "type": TransactionType.RUN_CONTRACT, + "status": "ACCEPTED", + "execution_mode": "NORMAL", + } + + result = processor._process_round_data(transaction) + + # ConsensusData.numOfRounds is the current raw index despite its name. + assert result["num_of_rounds"] == "0" + assert result["last_round"]["round"] == "0" + assert result["last_round"]["validator_votes"] == [1] + + +def test_transaction_round_payload_handles_empty_history_and_raw_appeal_gaps(): + processor = TransactionsProcessor.__new__(TransactionsProcessor) + base = { + "data": {}, + "nonce": 7, + "config_rotation_rounds": 0, + "rotation_count": 0, + "type": TransactionType.RUN_CONTRACT, + "status": "PENDING", + "execution_mode": "NORMAL", + } + + empty = processor._process_round_data( + {**base, "consensus_history": {"consensus_results": []}} + ) + assert empty["num_of_rounds"] == "0" + assert empty["last_round"]["round"] == "0" + + rounds = [ + { + "consensus_round": outcome, + "leader_result": [], + "validator_results": [], + } + for outcome in [ + "Accepted", + "Validator Appeal Failed", + "Validator Appeal Failed", + ] + ] + appealed = processor._process_round_data( + {**base, "consensus_history": {"consensus_results": rounds}} + ) + assert appealed["num_of_rounds"] == "3" + assert appealed["last_round"]["round"] == "3" + + +@pytest.mark.parametrize( + ( + "status", + "active_flags", + "history_outcomes", + "bond_status", + "appeal_round", + "expected_round", + "expected_bond", + ), + [ + ("REVEALING", {"appealed": True}, ["Accepted"], "ACCEPTED", 1, 1, 1400), + ( + "PROPOSING", + {"appeal_undetermined": True}, + ["Undetermined"], + "UNDETERMINED", + 2, + 2, + 2300, + ), + ( + "PROPOSING", + {"appeal_leader_timeout": True}, + ["Leader Timeout"], + "LEADER_TIMEOUT", + 2, + 2, + 0, + ), + ( + "PROPOSING", + {}, + ["Accepted", "Validator Appeal Successful"], + "ACCEPTED", + 1, + 2, + 0, + ), + ], +) +def test_transaction_round_payload_projects_active_v06_round_and_bond( + status, + active_flags, + history_outcomes, + bond_status, + appeal_round, + expected_round, + expected_bond, +): + processor = TransactionsProcessor.__new__(TransactionsProcessor) + rounds = [ + { + "consensus_round": outcome, + "leader_result": [], + "validator_results": [], + } + for outcome in history_outcomes + ] + transaction = { + "consensus_history": {"consensus_results": rounds}, + "data": { + FEE_ACCOUNTING_KEY: { + "appeal_bonds": [ + { + "sourceRound": 0, + "appealRound": appeal_round, + "status": bond_status, + "amount": 1400 if bond_status == "ACCEPTED" else 2300, + } + ] + } + }, + "nonce": 7, + "config_rotation_rounds": 0, + "rotation_count": 0, + "type": TransactionType.RUN_CONTRACT, + "status": status, + "execution_mode": "NORMAL", + **active_flags, + } + + result = processor._process_round_data(transaction) + + assert result["num_of_rounds"] == str(expected_round) + assert result["last_round"]["round"] == str(expected_round) + assert result["last_round"]["appeal_bond"] == str(expected_bond) + assert result["last_round"]["validator_votes"] == [] + + +def test_execution_hash_uses_the_transaction_nonce_in_the_v06_preimage(): + leader = "0x1111111111111111111111111111111111111111" + + expected = Web3.to_hex( + Web3.solidity_keccak( + ["address", "uint8", "bytes32", "uint256"], + [leader, 1, bytes(32), 7], + ) + ) + + assert get_tx_execution_hash(leader, 1, 7) == expected + assert get_tx_execution_hash(leader, 1, 7) != get_tx_execution_hash(leader, 1, 8) + + +@pytest.mark.parametrize( + ("vote", "execution_result", "code"), + [ + ("agree", "SUCCESS", 1), + ("agree", "ERROR", 2), + ("timeout", None, 3), + ("idle", None, 3), + ("disagree", None, 4), + ("deterministic_violation", None, 5), + ], +) +def test_studio_votes_map_to_consensus_v06_vote_type(vote, execution_result, code): + from backend.consensus.types import consensus_vote_type_code + + assert consensus_vote_type_code(vote, execution_result) == code + + +@pytest.mark.parametrize( + ("votes", "code"), + [ + ({"a": "agree", "b": "agree", "c": "disagree"}, 1), + ({"a": "disagree", "b": "disagree", "c": "agree"}, 2), + ({"a": "timeout", "b": "idle", "c": "agree"}, 3), + ( + { + "a": "deterministic_violation", + "b": "deterministic_violation", + "c": "agree", + }, + 4, + ), + ({"a": "agree", "b": "disagree"}, 5), + ], +) +def test_transaction_top_level_result_uses_consensus_v06_result_type(votes, code): + processor = TransactionsProcessor.__new__(TransactionsProcessor) + transaction = { + "type": TransactionType.RUN_CONTRACT, + "status": "ACCEPTED", + "execution_mode": "NORMAL", + "consensus_data": {"votes": votes}, + } + + assert processor._process_result(transaction)["result"] == code + + +@pytest.mark.parametrize( + "outcome", + [ + "Leader Timeout", + "Validators Timeout", + "Leader Timeout Appeal Failed", + "Validators Timeout Appeal Failed", + ], +) +def test_timeout_history_sets_top_level_and_round_result_without_validator_votes( + outcome, +): + processor = TransactionsProcessor.__new__(TransactionsProcessor) + transaction = { + "consensus_history": { + "consensus_results": [ + { + "consensus_round": outcome, + "leader_result": [], + "validator_results": [], + } + ] + }, + "consensus_data": {"votes": {}}, + "data": {}, + "nonce": 7, + "config_rotation_rounds": 0, + "rotation_count": 0, + "type": TransactionType.RUN_CONTRACT, + "status": "FINALIZED", + "execution_mode": "NORMAL", + } + + assert processor._process_result(dict(transaction))["result"] == 3 + assert processor._process_round_data(dict(transaction))["last_round"]["result"] == 3 + + def test_transaction_payload_includes_canonical_fee_object_with_decimal_strings(): accounting = create_fee_accounting( fees_distribution=_fees_distribution( From 5060b163f9626b14e6826a7a4e92f948559b51bb Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 00:58:45 +0100 Subject: [PATCH 08/55] fix(consensus): bind appeals to exact decisions --- backend/protocol_rpc/endpoints.py | 9 ++++--- backend/protocol_rpc/transactions_parser.py | 5 ++-- tests/unit/test_studio_fees.py | 30 ++++++++++++++++++++- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 22bfb1477..42b25dfbf 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -2178,10 +2178,11 @@ def _handle_appeal_or_top_up_and_submit( # monotonic ordinal from its alternating normal/appeal round history. # Round 0 -> decision 1; rounds 1/2 -> decision 2; rounds 3/4 -> 3. current_decision_id = 1 + ((current_round + 1) // 2) - if ( - expected_decision_id is not None - and int(expected_decision_id) != current_decision_id - ): + # v0.6 binds every appeal to one exact materialized decision. Older SDKs + # emitted selectors without this argument; Studio may still decode those + # transactions to surface a canonical error, but must never admit the + # unbound appeal when deployed Consensus would reject it. + if expected_decision_id is None or int(expected_decision_id) != current_decision_id: raise InvalidTransactionError("CanNotAppeal") fee_accounting = (tx.get("data") or {}).get(FEE_ACCOUNTING_KEY) diff --git a/backend/protocol_rpc/transactions_parser.py b/backend/protocol_rpc/transactions_parser.py index 95c8b386a..19c41f3d6 100644 --- a/backend/protocol_rpc/transactions_parser.py +++ b/backend/protocol_rpc/transactions_parser.py @@ -196,8 +196,9 @@ "name": "topUpAndSubmitAppeal", } -# Keep accepting transactions produced by the currently released SDKs while -# the toolchain migrates to exact DecisionId authority. +# Decode transactions produced by older SDKs so the RPC boundary can return +# the canonical CanNotAppeal error. Admission still requires the exact +# DecisionId, matching v0.6 Consensus. FEE_AWARE_LEGACY_TOP_UP_AND_SUBMIT_APPEAL_ABI = { **FEE_AWARE_TOP_UP_FEES_ABI, "name": "topUpAndSubmitAppeal", diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 1aa77fd36..d245bfe58 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -8746,7 +8746,7 @@ def _decoded_appeal( tx_id, *, amount, - expected_decision_id=None, + expected_decision_id=1, fees_distribution=None, top_up_and_submit=False, ): @@ -9106,6 +9106,34 @@ def send_message(self, log_event, log_to_terminal=True): assert transactions.appeal_updates == [] +def test_submit_appeal_endpoint_rejects_missing_decision_id_before_charging(): + accounting = _env_fee_accounting() + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) + transactions = _FakeTransactionsProcessor(tx) + charge = _env_appeal_charge(accounting) + + class _MsgHandler: + def send_message(self, log_event, log_to_terminal=True): + pass + + accounts = _FakeAccountsManager(balance=5000) + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + _handle_appeal_or_top_up_and_submit( + accounts_manager=accounts, + transactions_processor=transactions, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal( + tx["hash"], + amount=charge["bond"] + charge["funding"], + expected_decision_id=None, + ), + ) + + assert accounts.debits == [] + assert transactions.updated_fee_accounting is None + assert transactions.appeal_updates == [] + + def test_submit_appeal_endpoint_rejects_elapsed_appeal_window_before_charging( monkeypatch, ): From 7ef2a2640fd8ee358a7bb46f8a7ba74129e4d05b Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 01:20:44 +0100 Subject: [PATCH 09/55] feat(rpc): expose v0.6 decision lifecycle --- .../transactions_processor.py | 25 +++ backend/protocol_rpc/endpoints.py | 201 ++++++++++++++++-- backend/protocol_rpc/rpc_methods.py | 22 ++ .../default_contracts/consensus_main.py | 5 + tests/unit/test_studio_fees.py | 129 +++++++++++ 5 files changed, 369 insertions(+), 13 deletions(-) diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index 0690b9f5a..842f99215 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -1899,6 +1899,31 @@ def get_transaction_status(self, transaction_hash: str) -> dict | None: transaction_status = transaction.status return self._status_payload(transaction_status.value) + def is_transaction_finalization_head(self, transaction_hash: str) -> bool: + """Return whether no older non-terminal transaction blocks this decision. + + This mirrors the per-recipient ordering gate used by + ``claim_next_finalization`` so lifecycle projection never advertises a + Finalize action that the worker is not allowed to execute. + """ + transaction = ( + self.session.query(Transactions).filter_by(hash=transaction_hash).first() + ) + if transaction is None: + return False + return ( + not self.session.query(Transactions.hash) + .filter( + Transactions.to_address == transaction.to_address, + Transactions.created_at < transaction.created_at, + Transactions.hash != transaction.hash, + Transactions.status.notin_( + [TransactionStatus.FINALIZED, TransactionStatus.CANCELED] + ), + ) + .first() + ) + def get_processing_transaction_for_contract( self, contract_address: str ) -> dict | None: diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 42b25dfbf..0eb6b101f 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -1,5 +1,6 @@ # rpc/endpoints.py import copy +import math import random import json import time @@ -52,6 +53,7 @@ FeeValidationError, StudioFeePolicy, apply_fee_top_up, + calculate_appeal_charge, calculate_round_fees, decode_internal_message_fee_params, create_fee_accounting, @@ -73,6 +75,7 @@ from backend.database_handler.errors import ContractNotFoundError from backend.database_handler.transactions_processor import ( + TRANSACTION_STATUS_CODES, TransactionAddressFilter, TransactionsProcessor, ) @@ -1804,6 +1807,189 @@ def get_transaction_status_details( return status +_DECISION_STATUSES = { + TransactionStatus.ACCEPTED.value, + TransactionStatus.UNDETERMINED.value, + TransactionStatus.LEADER_TIMEOUT.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, +} + + +def _transaction_appeal_deadline(transaction: dict) -> float | None: + started_at = transaction.get("timestamp_awaiting_finalization") + if started_at is None: + return None + if str(transaction.get("execution_mode") or "") in { + "LEADER_ONLY", + "LEADER_SELF_VALIDATOR", + }: + return float(started_at) + finality_window = int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) + failed_reduction = float( + os.environ.get("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0") + ) + failed_reduction = min(1.0, max(0.0, failed_reduction)) + return ( + float(started_at) + + float(transaction.get("appeal_processing_time") or 0) + + finality_window + * ((1.0 - failed_reduction) ** int(transaction.get("appeal_failed") or 0)) + ) + + +def get_transaction_lifecycle( + transactions_processor: TransactionsProcessor, + params: dict, +) -> dict: + """Project Studio's stored decision state through the v0.6 lifecycle ABI.""" + if not isinstance(params, dict): + raise JSONRPCError(code=-32602, message="params must be an object", data={}) + transaction_hash = params.get("txId") or params.get("tx_id") + if not isinstance(transaction_hash, str) or not transaction_hash: + raise JSONRPCError(code=-32602, message="txId is required", data={}) + transaction = transactions_processor.get_transaction_by_hash(transaction_hash) + if transaction is None: + raise NotFoundError( + message=f"Transaction {transaction_hash} not found", + data={"hash": transaction_hash}, + ) + + requested_timestamp = params.get("timestamp") + try: + evaluated_at = ( + int(time.time()) + if requested_timestamp is None + else int(requested_timestamp) + ) + except (TypeError, ValueError) as exc: + raise JSONRPCError( + code=-32602, + message="timestamp must be a non-negative integer", + data={}, + ) from exc + if evaluated_at < 0: + raise JSONRPCError( + code=-32602, + message="timestamp must be a non-negative integer", + data={}, + ) + + status = str(transaction.get("status") or "UNINITIALIZED").upper() + stored_status_code = TRANSACTION_STATUS_CODES.get(status, 0) + decision_active = status in _DECISION_STATUSES and not bool( + transaction.get("appealed") + ) + current_round = _current_fee_round(transaction.get("consensus_history")) + decision_id = 1 + ((current_round + 1) // 2) if decision_active else None + + if not decision_active: + resolution_source_code = 0 + elif status == TransactionStatus.LEADER_TIMEOUT.value: + resolution_source_code = 3 # LeaderReceiptTimeout + else: + appeal_attempt = current_round % 2 == 1 + reveal_timed_out = status == TransactionStatus.VALIDATORS_TIMEOUT.value + if appeal_attempt: + resolution_source_code = 10 if reveal_timed_out else 9 + else: + resolution_source_code = 7 if reveal_timed_out else 6 + + resolution_action_code = 0 + deadline = _transaction_appeal_deadline(transaction) + if ( + decision_active + and deadline is not None + and evaluated_at >= deadline + and transactions_processor.is_transaction_finalization_head(transaction_hash) + ): + resolution_action_code = 6 # Finalize + + return { + "storedStatusCode": stored_status_code, + # ResolutionKernel leaves a decision in its stored status until the + # separately reported Finalize action is actually committed. + "projectedStatusCode": stored_status_code, + "resolutionActionCode": resolution_action_code, + "resolutionSourceCode": resolution_source_code, + "decisionId": str(decision_id) if decision_id is not None else None, + "decisionActive": decision_active, + "evaluatedAt": evaluated_at, + } + + +def estimate_latest_appeal_charge( + transactions_processor: TransactionsProcessor, + params: dict, +) -> dict: + """Return the exact decision-bound appeal quote used by Studio admission.""" + if not isinstance(params, dict): + raise JSONRPCError(code=-32602, message="params must be an object", data={}) + transaction_hash = params.get("txId") or params.get("tx_id") + if not isinstance(transaction_hash, str) or not transaction_hash: + raise JSONRPCError(code=-32602, message="txId is required", data={}) + transaction = transactions_processor.get_transaction_by_hash(transaction_hash) + if transaction is None: + raise NotFoundError( + message=f"Transaction {transaction_hash} not found", + data={"hash": transaction_hash}, + ) + + status = str(transaction.get("status") or "") + if ( + status not in _DECISION_STATUSES + or bool(transaction.get("appealed")) + or has_terminal_validator_appeal(transaction.get("consensus_history")) + ): + raise InvalidTransactionError("CanNotAppeal") + deadline = _transaction_appeal_deadline(transaction) + if deadline is not None and time.time() >= deadline: + raise InvalidTransactionError("CanNotAppeal") + + fee_accounting = (transaction.get("data") or {}).get(FEE_ACCOUNTING_KEY) + if fee_accounting is None: + raise InvalidTransactionError("FeeAccountingMissing") + session = getattr(transactions_processor, "session", None) + frozen_pool_count = fee_accounting.get("selection_pool_count") + validator_count = ( + int(frozen_pool_count) + if frozen_pool_count is not None + else ( + ValidatorsRegistry(session).count_validators() + if session is not None + else int( + fee_accounting.get("num_of_initial_validators") + or transaction.get("num_of_initial_validators") + or 5 + ) + ) + ) + current_round = _current_fee_round(transaction.get("consensus_history")) + live_seats = None + if status == TransactionStatus.LEADER_TIMEOUT.value: + live_validators = transaction.get("leader_timeout_validators") + if isinstance(live_validators, list): + live_seats = len(live_validators) + charge = calculate_appeal_charge( + fee_accounting["fees_distribution"], + current_round=current_round, + status=status, + terminal_committee_upper_bound=max( + 0, + validator_count + - _normal_leader_count(transaction.get("consensus_history")), + ), + leader_timeout_live_seats=live_seats, + policy=StudioFeePolicy.from_env(), + ) + decision_id = 1 + ((current_round + 1) // 2) + return { + "decisionId": str(decision_id), + "bond": str(int(charge["bond"])), + "funding": str(int(charge["funding"])), + "appealDeadline": str(math.ceil(deadline)) if deadline is not None else "0", + } + + async def eth_call( session: Session, accounts_manager: AccountsManager, @@ -2153,19 +2339,8 @@ def _handle_appeal_or_top_up_and_submit( raise InvalidTransactionError("CanNotAppeal") if has_terminal_validator_appeal(tx.get("consensus_history")): raise InvalidTransactionError("CanNotAppeal") - appeal_window_started = tx.get("timestamp_awaiting_finalization") - if appeal_window_started is not None: - finality_window = int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) - failed_reduction = float( - os.environ.get("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0") - ) - failed_reduction = min(1.0, max(0.0, failed_reduction)) - appeal_deadline = ( - float(appeal_window_started) - + float(tx.get("appeal_processing_time") or 0) - + finality_window - * ((1.0 - failed_reduction) ** int(tx.get("appeal_failed") or 0)) - ) + appeal_deadline = _transaction_appeal_deadline(tx) + if appeal_deadline is not None: # Consensus rejects at `block.timestamp >= appealDeadline`; do the # same at the RPC admission boundary instead of relying on the # asynchronous finalization worker to win the race first. diff --git a/backend/protocol_rpc/rpc_methods.py b/backend/protocol_rpc/rpc_methods.py index 9f47eed6e..7b63672c6 100644 --- a/backend/protocol_rpc/rpc_methods.py +++ b/backend/protocol_rpc/rpc_methods.py @@ -548,6 +548,28 @@ def get_transaction_status_details( ) +@rpc.method("gen_getTransactionLifecycle", log_policy=LogPolicy.debug()) +def get_transaction_lifecycle( + params: dict, + transactions_processor: TransactionsProcessor = Depends(get_transactions_processor), +) -> dict: + return impl.get_transaction_lifecycle( + transactions_processor=transactions_processor, + params=params, + ) + + +@rpc.method("gen_estimateLatestAppealCharge", log_policy=LogPolicy.debug()) +def estimate_latest_appeal_charge( + params: dict, + transactions_processor: TransactionsProcessor = Depends(get_transactions_processor), +) -> dict: + return impl.estimate_latest_appeal_charge( + transactions_processor=transactions_processor, + params=params, + ) + + @rpc.method("eth_call", log_policy=LogPolicy.debug()) async def eth_call( params: dict, diff --git a/backend/rollup/default_contracts/consensus_main.py b/backend/rollup/default_contracts/consensus_main.py index 5ece85478..b1340801c 100644 --- a/backend/rollup/default_contracts/consensus_main.py +++ b/backend/rollup/default_contracts/consensus_main.py @@ -1277,6 +1277,11 @@ "internalType": "bytes32", "name": "_txId", "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "_expectedDecisionId", + "type": "uint256" } ], "name": "submitAppeal", diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index d245bfe58..14d09b06b 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -30,6 +30,8 @@ _handle_appeal_or_top_up_and_submit, _handle_top_up_fees, _normal_leader_count, + estimate_latest_appeal_charge, + get_transaction_lifecycle, get_transaction_status_details, get_transaction_status, _stage_simulated_call_value, @@ -8711,6 +8713,7 @@ def __init__(self, transaction): self.transaction = transaction self.updated_fee_accounting = None self.appeal_updates = [] + self.finalization_head = True def get_transaction_by_hash(self, tx_hash): if tx_hash != self.transaction["hash"]: @@ -8727,6 +8730,10 @@ def set_transaction_appeal(self, tx_hash, appeal): self.appeal_updates.append((tx_hash, appeal)) self.transaction["appealed"] = appeal + def is_transaction_finalization_head(self, tx_hash): + assert tx_hash == self.transaction["hash"] + return self.finalization_head + def _decoded_top_up(tx_id, *, amount, fees_distribution=None): return DecodedRollupTransaction( @@ -9347,6 +9354,128 @@ def get_transaction_status(self, transaction_hash): } +def test_transaction_lifecycle_exposes_active_v06_decision_before_deadline( + monkeypatch, +): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + tx = _fee_accounted_tx(status="ACCEPTED") + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=5, + appeal_failed=0, + appealed=False, + execution_mode="NORMAL", + ) + processor = _FakeTransactionsProcessor(tx) + + assert get_transaction_lifecycle( + processor, {"txId": tx["hash"], "timestamp": 1_034} + ) == { + "storedStatusCode": 5, + "projectedStatusCode": 5, + "resolutionActionCode": 0, + "resolutionSourceCode": 6, + "decisionId": "1", + "decisionActive": True, + "evaluatedAt": 1_034, + } + + +def test_transaction_lifecycle_projects_finalize_at_exact_deadline(monkeypatch): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + tx = _fee_accounted_tx(status="VALIDATORS_TIMEOUT") + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=5, + appeal_failed=0, + appealed=False, + execution_mode="NORMAL", + ) + processor = _FakeTransactionsProcessor(tx) + + lifecycle = get_transaction_lifecycle( + processor, {"txId": tx["hash"], "timestamp": 1_035} + ) + + assert lifecycle["storedStatusCode"] == 11 + assert lifecycle["projectedStatusCode"] == 11 + assert lifecycle["resolutionActionCode"] == 6 + assert lifecycle["resolutionSourceCode"] == 7 + assert lifecycle["decisionId"] == "1" + assert lifecycle["decisionActive"] is True + + +def test_transaction_lifecycle_does_not_finalize_behind_older_transaction( + monkeypatch, +): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + tx = _fee_accounted_tx(status="ACCEPTED") + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=0, + appeal_failed=0, + appealed=False, + execution_mode="NORMAL", + ) + processor = _FakeTransactionsProcessor(tx) + processor.finalization_head = False + + lifecycle = get_transaction_lifecycle( + processor, {"txId": tx["hash"], "timestamp": 1_030} + ) + + assert lifecycle["resolutionActionCode"] == 0 + + +def test_transaction_lifecycle_uses_appeal_decision_ordinal_and_source(): + tx = _fee_accounted_tx(status="ACCEPTED") + tx.update( + consensus_history={ + "consensus_results": [ + {"consensus_round": "Accepted"}, + {"consensus_round": "Validator Appeal Failed"}, + ] + }, + timestamp_awaiting_finalization=1_000, + appealed=False, + execution_mode="NORMAL", + ) + processor = _FakeTransactionsProcessor(tx) + + lifecycle = get_transaction_lifecycle( + processor, {"txId": tx["hash"], "timestamp": 1_000} + ) + + assert lifecycle["decisionId"] == "2" + assert lifecycle["resolutionSourceCode"] == 9 + + +def test_latest_appeal_charge_rpc_uses_admission_quote_and_decision_id( + monkeypatch, +): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_001) + accounting = _env_fee_accounting() + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=0, + appeal_failed=0, + appealed=False, + execution_mode="NORMAL", + ) + expected = _env_appeal_charge(accounting) + + assert estimate_latest_appeal_charge( + _FakeTransactionsProcessor(tx), {"txId": tx["hash"]} + ) == { + "decisionId": "1", + "bond": str(expected["bond"]), + "funding": str(expected["funding"]), + "appealDeadline": "1030", + } + + @pytest.mark.parametrize( ("status", "code"), [ From 4b42a1a732c36cc4f11932f92923ad135a5eedd7 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 04:09:29 +0100 Subject: [PATCH 10/55] fix(fees): complete v0.6 runtime parity --- backend/consensus/base.py | 427 +++++---- backend/consensus/decisions.py | 17 +- backend/consensus/history.py | 96 ++ backend/consensus/utils.py | 13 +- backend/consensus/worker.py | 24 +- backend/database_handler/accounts_manager.py | 12 +- .../transactions_processor.py | 194 ++++ backend/protocol_rpc/endpoints.py | 361 ++++++-- backend/protocol_rpc/fees.py | 193 +++- backend/protocol_rpc/health.py | 66 +- backend/protocol_rpc/transactions_parser.py | 33 + backend/protocol_rpc/types.py | 7 + .../default_contracts/consensus_main.py | 148 ++- .../components/Simulator/TransactionItem.vue | 94 +- .../TransactionItem.fees.behavioral.test.ts | 165 +++- tests/db-sqlalchemy/accounts_manager_test.py | 51 +- .../test_fee_top_up_concurrency.py | 156 ++++ .../test_health_orphan_detection.py | 122 ++- .../consensus/test_decisions_revealing.py | 12 +- tests/unit/test_studio_fees.py | 869 +++++++++++++++++- tests/unit/test_transactions_parser.py | 105 +++ 21 files changed, 2853 insertions(+), 312 deletions(-) create mode 100644 tests/db-sqlalchemy/test_fee_top_up_concurrency.py diff --git a/backend/consensus/base.py b/backend/consensus/base.py index a41779a9a..72743b625 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -82,7 +82,11 @@ ConsensusRound, consensus_vote_type_code, ) -from backend.consensus.history import logical_fee_round_entries +from backend.consensus.history import ( + TERMINAL_VALIDATOR_APPEAL_ROUNDS, + latest_decision_metadata, + logical_fee_round_entries, +) from backend.consensus.utils import determine_consensus_from_votes from backend.consensus.decisions import ( decide_undetermined, @@ -99,7 +103,6 @@ prepare_committing, decide_post_committing, should_rollback_after_accepted, - has_appeal_capacity, ) from backend.consensus.effect_executor import EffectExecutor from backend.node.genvm import get_code_slot @@ -839,13 +842,28 @@ def can_finalize_transaction( ] # Check if finalization criteria are met + decision = latest_decision_metadata( + getattr(transaction, "consensus_history", None) + ) + exact_deadline = None + if decision is not None: + try: + exact_deadline = int(decision.get("appealDeadline") or 0) + except (TypeError, ValueError): + exact_deadline = None time_based_finalization = ( - time.time() - - transaction.timestamp_awaiting_finalization - - transaction.appeal_processing_time - ) > self.finality_window_time * ( - (1 - self.finality_window_appeal_failed_reduction) - ** transaction.appeal_failed + time.time() >= exact_deadline + if exact_deadline + else ( + time.time() + - transaction.timestamp_awaiting_finalization + - transaction.appeal_processing_time + ) + >= self.finality_window_time + * ( + (1 - self.finality_window_appeal_failed_reduction) + ** transaction.appeal_failed + ) ) if immediate_finalization or time_based_finalization: @@ -953,11 +971,19 @@ async def process_leader_appeal( ) ) - if not has_appeal_capacity( - num_involved_validators=len(transaction.consensus_data.validators), - num_used_leader_addresses=len(used_leader_addresses), - num_total_validators=len(validators_snapshot.nodes), - ): + logical_entries = logical_fee_round_entries(transaction.consensus_history) + current_round = logical_entries[-1][0] if logical_entries else 0 + required_fresh = VALIDATORS_PER_ROUND[ + min(current_round + 1, len(VALIDATORS_PER_ROUND) - 1) + ] + available_fresh = max( + 0, + len(validators_snapshot.nodes) + - len(transaction.consensus_data.validators) + - len(used_leader_addresses), + ) + + if available_fresh < required_fresh: self.msg_handler.send_message( LogEvent( "consensus_event", @@ -1050,19 +1076,7 @@ async def process_leader_timeout_appeal( ) context.transaction.appeal_undetermined = False - used_leader_addresses = ( - ConsensusAlgorithm.get_used_leader_addresses_from_consensus_history( - context.transactions_processor.get_transaction_by_hash( - context.transaction.hash - )["consensus_history"] - ) - ) - - if not has_appeal_capacity( - num_involved_validators=len(transaction.leader_timeout_validators), - num_used_leader_addresses=len(used_leader_addresses), - num_total_validators=len(validators_snapshot.nodes), - ): + if not transaction.leader_timeout_validators: self.msg_handler.send_message( LogEvent( "consensus_event", @@ -1149,6 +1163,8 @@ async def process_validator_appeal( context.consensus_data.leader_receipt = ( transaction.consensus_data.leader_receipt ) + logical_entries = logical_fee_round_entries(transaction.consensus_history) + current_round = logical_entries[-1][0] if logical_entries else 0 try: # Attempt to get extra validators for the appeal process _, context.remaining_validators = ConsensusAlgorithm.get_extra_validators( @@ -1156,6 +1172,13 @@ async def process_validator_appeal( transaction.consensus_history, transaction.consensus_data, transaction.appeal_failed, + required_extra_validators=VALIDATORS_PER_ROUND[ + min( + current_round + 1, + len(VALIDATORS_PER_ROUND) - 1, + ) + ], + allow_short=True, ) except ValueError as e: # When no validators are found, then the appeal failed @@ -1346,39 +1369,25 @@ def get_extra_validators( consensus_history: dict, consensus_data: ConsensusData, appeal_failed: int, + required_extra_validators: int | None = None, + allow_short: bool = False, ): """ - Get extra validators for the appeal process according to the following formula: - - when appeal_failed = 0, add n + 2 validators - - when appeal_failed > 0, add (2 * appeal_failed * n + 1) + 2 validators - Note that for appeal_failed > 0, the returned set contains the old validators - from the previous appeal round and new validators. - - Selection of the extra validators: - appeal_failed | PendingState | Reused validators | Extra selected | Total - | validators | from the previous | validators for the | validators - | | appeal round | appeal | - ---------------------------------------------------------------------------------- - 0 | n | 0 | n+2 | 2n+2 - 1 | n | n+2 | n+1 | 3n+3 - 2 | n | 2n+3 | 2n | 5n+3 - 3 | n | 4n+3 | 2n | 7n+3 - └───────┬──────┘ └─────────┬────────┘ - │ | - Validators after the ◄────────┘ └──► Validators during the appeal - appeal. This equals for appeal_failed > 0 - the Total validators = (2*appeal_failed*n+1)+2 - of the row above, This is the formula from - and are in consensus_data. above and it is what is - For appeal_failed > 0 returned by this function - = (2*appeal_failed-1)*n+3 - This is used to calculate n + Select validators not already consumed by the transaction. + + Train callers pass ``required_extra_validators`` from the canonical + round schedule. Validator appeals also pass ``allow_short=True`` so a + pool with 1..K-1 fresh validators seats all of them; leader appeals + retain the exact-size rule. The historical ``appeal_failed`` formulas + remain only as a fallback for callers outside the train path. Args: all_validators (List[dict]): List of all validators. consensus_history (dict): Dictionary of consensus rounds results and status changes. consensus_data (ConsensusData): Data related to the consensus process. appeal_failed (int): Number of times the appeal has failed. + required_extra_validators: Exact scheduled fresh jury/committee size. + allow_short: Whether a non-empty capacity-limited selection is valid. Returns: list: List of current validators. @@ -1408,8 +1417,23 @@ def get_extra_validators( raise ValueError("No validators found") nb_current_validators = len(current_validators) + 1 # including the leader - if appeal_failed == 0: - # Calculate extra validators when no appeal has failed + if required_extra_validators is not None: + # v0.6 appeal juries are independent fresh selections. Every + # address retained in consensus_data was consumed by an earlier + # round; history leaders are excluded above. A validator appeal + # may seat every remaining 1..K-1 validator, while a leader appeal + # passes allow_short=False and therefore retains its exact K rule. + requested = max(0, int(required_extra_validators)) + extra_validators = get_validators_for_transaction( + not_used_validators, requested + ) + if not allow_short and len(extra_validators) != requested: + raise ValueError( + f"Not enough fresh validators: required {requested}, " + f"available {len(extra_validators)}" + ) + elif appeal_failed == 0: + # Legacy caller fallback; train callers pass the explicit schedule. extra_validators = get_validators_for_transaction( not_used_validators, nb_current_validators + 2 ) @@ -1511,6 +1535,22 @@ def add_new_validator( return new_validator + validators + @staticmethod + def get_terminal_replacement_validators( + all_validators: List[dict], consensus_history: dict + ) -> list[dict]: + """Return the full terminal electorate minus prior normal leaders.""" + used_leaders = ( + ConsensusAlgorithm.get_used_leader_addresses_from_consensus_history( + consensus_history + ) + ) + return [ + validator + for validator in all_validators + if validator["address"] not in used_leaders + ] + @staticmethod def get_used_leader_addresses_from_consensus_history( consensus_history: dict, current_leader_receipt: Receipt | None = None @@ -1528,11 +1568,16 @@ def get_used_leader_addresses_from_consensus_history( used_leader_addresses = set() if consensus_history is not None and "consensus_results" in consensus_history: for consensus_round in consensus_history["consensus_results"]: - leader_receipt = consensus_round["leader_result"] - if leader_receipt: - used_leader_addresses.update( - [leader_receipt[0]["node_config"]["address"]] - ) + leader_receipt = consensus_round.get("leader_result") or [] + if not leader_receipt: + continue + address = ( + (leader_receipt[0].get("node_config") or {}).get("address") + if isinstance(leader_receipt[0], dict) + else None + ) + if address: + used_leader_addresses.add(address) # consensus_history does not contain the latest consensus_data if current_leader_receipt: @@ -2152,19 +2197,28 @@ async def handle(self, context): # with a full value/fee refund; storage and receipt prices simply lock. fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) if fee_accounting: - activated_accounting, should_cancel = activate_fee_accounting( - fee_accounting, - StudioFeePolicy.from_env(), - selection_pool_count=( - len(context.validators_snapshot.nodes) - if context.validators_snapshot is not None - else None - ), - ) - context.transactions_processor.update_transaction_fee_accounting( - context.transaction.hash, - activated_accounting, + activation_result: dict[str, bool] = {} + + def activate_latest(current_fee_accounting): + updated, should_cancel = activate_fee_accounting( + current_fee_accounting, + StudioFeePolicy.from_env(), + selection_pool_count=( + len(context.validators_snapshot.nodes) + if context.validators_snapshot is not None + else None + ), + ) + activation_result["should_cancel"] = should_cancel + return updated + + activated_accounting = ( + context.transactions_processor.mutate_transaction_fee_accounting( + context.transaction.hash, + activate_latest, + ) ) + should_cancel = activation_result["should_cancel"] context.transaction.data = { **(context.transaction.data or {}), FEE_ACCOUNTING_KEY: activated_accounting, @@ -2286,6 +2340,12 @@ async def handle(self, context): context.transaction.consensus_history, context.transaction.consensus_data, 0, + required_extra_validators=VALIDATORS_PER_ROUND[ + min( + max(0, context.active_fee_round - 1), + len(VALIDATORS_PER_ROUND) - 1, + ) + ], ) ) context.involved_validators = current_validators + extra_validators @@ -2300,28 +2360,40 @@ async def handle(self, context): ) elif context.transaction.appeal_leader_timeout: - used_leader_addresses = ( - ConsensusAlgorithm.get_used_leader_addresses_from_consensus_history( - context.transaction.consensus_history - ) - ) - assert context.validators_snapshot is not None - old_validators = [ - x.validator.to_dict() for x in context.validators_snapshot.nodes - ] - context.involved_validators = ConsensusAlgorithm.add_new_validator( - old_validators, - context.transaction.leader_timeout_validators, - used_leader_addresses, + # Consensus removes the timed-out leader and replays with the + # surviving committee; it does not select a replacement seat. + context.involved_validators = list( + context.transaction.leader_timeout_validators ) else: if context.transaction.consensus_data: - context.involved_validators, _ = ( - ConsensusAlgorithm.get_validators_from_consensus_data( - all_validators, context.transaction.consensus_data, True - ) + history_entries = logical_fee_round_entries( + context.transaction.consensus_history + ) + last_outcome = ( + str(history_entries[-1][1].get("consensus_round") or "") + if history_entries + else "" ) + if last_outcome in TERMINAL_VALIDATOR_APPEAL_ROUNDS: + # A successful validator review is followed by one + # terminal normal recomputation over the full frozen + # electorate, excluding only prior normal-round leaders. + # Prior validators and jurors are deliberately eligible + # again; this is not another fresh-jury selection. + context.involved_validators = ( + ConsensusAlgorithm.get_terminal_replacement_validators( + all_validators, + context.transaction.consensus_history, + ) + ) + else: + context.involved_validators, _ = ( + ConsensusAlgorithm.get_validators_from_consensus_data( + all_validators, context.transaction.consensus_data, True + ) + ) if not context.involved_validators: context.msg_handler.send_message( LogEvent( @@ -2842,7 +2914,10 @@ def _is_quorum_reached(votes_so_far: list[str], total_votes: int) -> bool: Vote.IDLE.value, ) outcomes = { - determine_consensus_from_votes(votes_so_far + [pending_vote] * pending) + determine_consensus_from_votes( + votes_so_far + [pending_vote] * pending, + _terminal_decision_electorate_size(context), + ) for pending_vote in possible_pending_votes } if len(outcomes) != 1: @@ -2983,7 +3058,10 @@ async def handle(self, context): ) # Determine consensus result - consensus_result = determine_consensus_from_votes(list(context.votes.values())) + consensus_result = determine_consensus_from_votes( + list(context.votes.values()), + _terminal_decision_electorate_size(context), + ) # Build vote reveal entries with canonical v0.6 VoteType ordinals. vote_reveal_entries = [] @@ -3648,17 +3726,19 @@ def _record_parent_message_fee_consumption( if not parent_fee_accounting or not message_fee_payloads: return - updated_accounting = _consume_parent_message_fee_payloads( - parent_fee_accounting, - message_fee_payloads, - reveal_recorded, - _message_execution_fee_recipient(context), + updated_accounting = ( + context.transactions_processor.mutate_transaction_fee_accounting( + context.transaction.hash, + lambda current: _consume_parent_message_fee_payloads( + current, + message_fee_payloads, + reveal_recorded, + _message_execution_fee_recipient(context), + ), + ) ) context.transaction.data = dict(context.transaction.data or {}) context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting - context.transactions_processor.update_transaction_fee_accounting( - context.transaction.hash, updated_accounting - ) def _message_execution_fee_recipient(context: TransactionContext) -> str | None: @@ -3740,28 +3820,31 @@ def _sync_reveal_message_fee_accounting( if not parent_fee_accounting: return - message_fee_payloads = _reveal_message_fee_payloads( - parent_fee_accounting, - leader_receipt.pending_transactions, - ) - if not message_fee_payloads: - return + def prepare_latest(current_fee_accounting): + message_fee_payloads = _reveal_message_fee_payloads( + current_fee_accounting, + leader_receipt.pending_transactions, + ) + if not message_fee_payloads: + return current_fee_accounting + try: + return prepare_reveal_message_generation( + current_fee_accounting, + context.transaction.hash, + message_fee_payloads, + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc - try: - updated_accounting = prepare_reveal_message_generation( - parent_fee_accounting, + updated_accounting = ( + context.transactions_processor.mutate_transaction_fee_accounting( context.transaction.hash, - message_fee_payloads, + prepare_latest, ) - except FeeValidationError as exc: - raise RuntimeError(str(exc)) from exc + ) context.transaction.data = dict(context.transaction.data or {}) context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting - context.transactions_processor.update_transaction_fee_accounting( - context.transaction.hash, - updated_accounting, - ) def _reveal_message_fee_payloads( @@ -3822,27 +3905,33 @@ def _mark_message_phase_delivered( parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) if not parent_fee_accounting: return - payloads = _reveal_message_fee_payloads( - parent_fee_accounting, - pending_transactions, - ) - if not payloads: - return - try: - updated_accounting = mark_message_effects_delivered( - parent_fee_accounting, + pending_list = list(pending_transactions) + + def mark_latest(current_fee_accounting): + payloads = _reveal_message_fee_payloads( + current_fee_accounting, + pending_list, + ) + if not payloads: + return current_fee_accounting + try: + return mark_message_effects_delivered( + current_fee_accounting, + context.transaction.hash, + payloads, + on, + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + + updated_accounting = ( + context.transactions_processor.mutate_transaction_fee_accounting( context.transaction.hash, - payloads, - on, + mark_latest, ) - except FeeValidationError as exc: - raise RuntimeError(str(exc)) from exc + ) context.transaction.data = dict(context.transaction.data or {}) context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting - context.transactions_processor.update_transaction_fee_accounting( - context.transaction.hash, - updated_accounting, - ) def _unwind_discarded_reveal_message_fee_accounting( @@ -3852,18 +3941,6 @@ def _unwind_discarded_reveal_message_fee_accounting( if not parent_fee_accounting: return - if isinstance(parent_fee_accounting.get("active_message_generation"), dict): - updated_accounting = discard_active_message_generation( - parent_fee_accounting, - ) - context.transaction.data = dict(context.transaction.data or {}) - context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting - context.transactions_processor.update_transaction_fee_accounting( - context.transaction.hash, - updated_accounting, - ) - return - prior_receipts = _leader_receipts_from_consensus_data( context.transaction.consensus_data ) @@ -3871,28 +3948,41 @@ def _unwind_discarded_reveal_message_fee_accounting( prior_receipts = _leader_receipts_from_consensus_history( context.transaction.consensus_history ) - if not prior_receipts: + if ( + not isinstance(parent_fee_accounting.get("active_message_generation"), dict) + and not prior_receipts + ): return - message_fee_payloads = _reveal_message_fee_payloads( - parent_fee_accounting, - _receipt_pending_transactions(prior_receipts[0]), - ) - if not message_fee_payloads: - return + def unwind_latest(current_fee_accounting): + if isinstance(current_fee_accounting.get("active_message_generation"), dict): + return discard_active_message_generation(current_fee_accounting) + if not prior_receipts: + return current_fee_accounting + message_fee_payloads = _reveal_message_fee_payloads( + current_fee_accounting, + _receipt_pending_transactions(prior_receipts[0]), + ) + if not message_fee_payloads: + return current_fee_accounting + updated = unwind_reveal_message_fees( + current_fee_accounting, + message_fee_payloads, + acceptance_dispatched=( + context.transaction.status == TransactionStatus.ACCEPTED + ), + ) + updated["message_fees_recorded_at_reveal"] = True + return updated - updated_accounting = unwind_reveal_message_fees( - parent_fee_accounting, - message_fee_payloads, - acceptance_dispatched=context.transaction.status == TransactionStatus.ACCEPTED, + updated_accounting = ( + context.transactions_processor.mutate_transaction_fee_accounting( + context.transaction.hash, + unwind_latest, + ) ) - updated_accounting["message_fees_recorded_at_reveal"] = True context.transaction.data = dict(context.transaction.data or {}) context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting - context.transactions_processor.update_transaction_fee_accounting( - context.transaction.hash, - updated_accounting, - ) def _coerce_pending_transaction(raw: Any) -> PendingTransaction: @@ -3978,6 +4068,27 @@ def _active_execution_fee_round(transaction: Transaction) -> int: return last_round +def _terminal_decision_electorate_size(context: TransactionContext) -> int | None: + """Frozen threshold authority for a terminal normal recomputation.""" + + entries = logical_fee_round_entries( + getattr(context.transaction, "consensus_history", None) + ) + if not entries: + return None + last_outcome = str(entries[-1][1].get("consensus_round") or "") + if last_outcome not in TERMINAL_VALIDATOR_APPEAL_ROUNDS: + return None + + accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) or {} + frozen_count = accounting.get("selection_pool_count") + if frozen_count is not None: + return max(0, int(frozen_count)) + if context.validators_snapshot is not None: + return len(context.validators_snapshot.nodes) + return None + + def _runtime_rotation_limit( transaction: Transaction, raw_round: int | None = None, diff --git a/backend/consensus/decisions.py b/backend/consensus/decisions.py index a963db097..b592a20cc 100644 --- a/backend/consensus/decisions.py +++ b/backend/consensus/decisions.py @@ -533,23 +533,18 @@ def merge_appeal_validators( current_validation_results: list, appeal_failed: int, ) -> tuple[dict, list]: - """Pure merge of appeal vote/validator data. + """Retain the transaction-wide consumed set after a fresh appeal jury. Returns: (merged_votes, merged_validators) """ merged_votes = existing_votes | current_votes - if appeal_failed == 0: - merged_validators = existing_validators + current_validation_results - elif appeal_failed == 1: - n = (len(existing_validators) - 1) // 2 - merged_validators = existing_validators[: n - 1] + current_validation_results - else: - n = len(current_validation_results) - (len(existing_validators) + 1) - merged_validators = existing_validators[: n - 1] + current_validation_results - - return merged_votes, merged_validators + # Consensus's selection authority permanently excludes every registry + # index consumed by earlier rounds. Studio stores the equivalent address + # set in consensus_data; never slice old validators back out after a failed + # appeal or a later jury could select them again. + return merged_votes, existing_validators + current_validation_results def decide_revealing( diff --git a/backend/consensus/history.py b/backend/consensus/history.py index 86650789f..23c1ef60a 100644 --- a/backend/consensus/history.py +++ b/backend/consensus/history.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy from typing import Any from backend.consensus.types import ConsensusRound @@ -39,6 +40,101 @@ ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, } +# Studio has no deployed TransactionManager, so persist the small part of its +# immutable DecisionRecord that governs exact-ID commands and appeal timing in +# consensus_history. Keeping it beside the round history makes snapshots and +# existing transaction reads carry the authority without a schema migration. +LATEST_DECISION_KEY = "latestDecision" +ACTIVE_APPEAL_BASIS_KEY = "activeAppealBasis" + + +def latest_decision_metadata( + consensus_history: dict[str, Any] | None, +) -> dict[str, Any] | None: + if not isinstance(consensus_history, dict): + return None + value = consensus_history.get(LATEST_DECISION_KEY) + return value if isinstance(value, dict) else None + + +def current_decision_id(consensus_history: dict[str, Any] | None) -> int: + """Return Studio's equivalent of the latest materialized DecisionId.""" + + metadata = latest_decision_metadata(consensus_history) + if metadata is not None: + try: + decision_id = int(metadata.get("decisionId") or 0) + except (TypeError, ValueError): + decision_id = 0 + if decision_id > 0: + return decision_id + round_index = completed_consensus_round_index(consensus_history) + return 1 + ((round_index + 1) // 2) + + +def prepare_appeal_decision_basis( + consensus_history: dict[str, Any] | None, + *, + expected_decision_id: int, + submitted_at: int, + appeal_deadline: int, + retention_bps: int, +) -> dict[str, Any]: + """Freeze the exact reduced window that Consensus freezes at submission.""" + + updated = copy.deepcopy(consensus_history or {}) + decision_id = current_decision_id(updated) + if decision_id != int(expected_decision_id): + raise ValueError("DecisionBasisMismatch") + remaining = int(appeal_deadline) - int(submitted_at) + if remaining <= 0: + raise ValueError("CanNotAppeal") + retention_bps = min(10_000, max(0, int(retention_bps))) + next_window = (remaining * retention_bps) // 10_000 + if next_window == 0: + next_window = 1 + updated[ACTIVE_APPEAL_BASIS_KEY] = { + "decisionId": decision_id, + "submittedAt": int(submitted_at), + "nextAppealWindow": next_window, + } + return updated + + +def materialize_decision_metadata( + consensus_history: dict[str, Any] | None, + *, + status: str, + materialized_at: int, + default_appeal_window: int, +) -> dict[str, Any]: + """Advance DecisionId and bind one immutable Studio decision window.""" + + updated = copy.deepcopy(consensus_history or {}) + round_index = completed_consensus_round_index(updated) + decision_id = 1 + ((round_index + 1) // 2) + basis = updated.get(ACTIVE_APPEAL_BASIS_KEY) + if isinstance(basis, dict): + try: + basis_decision_id = int(basis.get("decisionId") or 0) + appeal_window = int(basis.get("nextAppealWindow") or 0) + except (TypeError, ValueError) as exc: + raise ValueError("DecisionBasisMismatch") from exc + if basis_decision_id != decision_id - 1 or appeal_window <= 0: + raise ValueError("DecisionBasisMismatch") + else: + appeal_window = max(1, int(default_appeal_window)) + + materialized_at = int(materialized_at) + updated[LATEST_DECISION_KEY] = { + "decisionId": decision_id, + "status": str(status).upper(), + "materializedAt": materialized_at, + "appealDeadline": materialized_at + appeal_window, + } + updated.pop(ACTIVE_APPEAL_BASIS_KEY, None) + return updated + def is_completed_consensus_round(entry: dict[str, Any]) -> bool: return str(entry.get("consensus_round") or "") not in NON_ROUND_CONSENSUS_EVENTS diff --git a/backend/consensus/utils.py b/backend/consensus/utils.py index 407bbfc9e..8c9df4907 100644 --- a/backend/consensus/utils.py +++ b/backend/consensus/utils.py @@ -2,7 +2,9 @@ from backend.node.types import Vote -def determine_consensus_from_votes(votes_list: list[str]) -> ConsensusResult: +def determine_consensus_from_votes( + votes_list: list[str], electorate_size: int | None = None +) -> ConsensusResult: """ Determine consensus from a list of votes using actual majority (>50%). @@ -14,7 +16,14 @@ def determine_consensus_from_votes(votes_list: list[str]) -> ConsensusResult: Returns: ConsensusResult: The consensus result """ - total = len(votes_list) + # Terminal normal rounds deliberately keep the strict-majority threshold + # of the full frozen electorate even though prior normal leaders are not + # seated. Ordinary rounds use their local committee size. + total = ( + len(votes_list) + if electorate_size is None + else max(len(votes_list), int(electorate_size)) + ) majority = total / 2 # need strictly more than half agree_count = votes_list.count(Vote.AGREE.value) diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index bcd29c2e2..3ba51bd99 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -311,8 +311,16 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: AND ( t.execution_mode IN ('LEADER_ONLY', 'LEADER_SELF_VALIDATOR') OR ( - EXTRACT(EPOCH FROM NOW()) - t.timestamp_awaiting_finalization - COALESCE(t.appeal_processing_time, 0) - ) > :finality_window_seconds * POWER(1 - :appeal_failed_reduction, COALESCE(t.appeal_failed, 0)) + EXTRACT(EPOCH FROM NOW()) >= COALESCE( + NULLIF(t.consensus_history->'latestDecision'->>'appealDeadline', '')::double precision, + t.timestamp_awaiting_finalization + + COALESCE(t.appeal_processing_time, 0) + + :finality_window_seconds * POWER( + 1 - :appeal_failed_reduction, + COALESCE(t.appeal_failed, 0) + ) + ) + ) )) OR -- Defensive: timestamp NULL + row past stranded threshold @@ -512,8 +520,16 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: AND ( t3.execution_mode IN ('LEADER_ONLY', 'LEADER_SELF_VALIDATOR') OR ( - EXTRACT(EPOCH FROM NOW()) - t3.timestamp_awaiting_finalization - COALESCE(t3.appeal_processing_time, 0) - ) > :finality_window_seconds * POWER(1 - :appeal_failed_reduction, COALESCE(t3.appeal_failed, 0)) + EXTRACT(EPOCH FROM NOW()) >= COALESCE( + NULLIF(t3.consensus_history->'latestDecision'->>'appealDeadline', '')::double precision, + t3.timestamp_awaiting_finalization + + COALESCE(t3.appeal_processing_time, 0) + + :finality_window_seconds * POWER( + 1 - :appeal_failed_reduction, + COALESCE(t3.appeal_failed, 0) + ) + ) + ) ) AND (t3.blocked_at IS NULL OR t3.blocked_at < NOW() - CAST(:timeout AS INTERVAL)) diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index dff206302..312356d21 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -188,7 +188,11 @@ def cancel_tx_fee_accounting_once( self, tx_hash: str, sender_address: str, reason: str = "canceled" ) -> int: transaction = ( - self.session.query(Transactions).filter_by(hash=tx_hash).one_or_none() + self.session.query(Transactions) + .filter_by(hash=tx_hash) + .populate_existing() + .with_for_update() + .one_or_none() ) if transaction is None: return 0 @@ -217,7 +221,11 @@ def settle_tx_fee_accounting_once( reason: str = "finalized", ) -> int: transaction = ( - self.session.query(Transactions).filter_by(hash=tx_hash).one_or_none() + self.session.query(Transactions) + .filter_by(hash=tx_hash) + .populate_existing() + .with_for_update() + .one_or_none() ) if transaction is None: return 0 diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index 842f99215..b34c1fabd 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -13,6 +13,8 @@ import json import base64 import time +import os +from typing import Callable from backend.domain.types import TransactionType from web3 import Web3 from backend.consensus.types import ( @@ -24,11 +26,14 @@ from backend.consensus.history import ( completed_consensus_round_index, completed_consensus_rounds, + materialize_decision_metadata, + prepare_appeal_decision_basis, time_unit_consumption, ) from backend.consensus.utils import determine_consensus_from_votes from backend.protocol_rpc.fees import ( FEE_ACCOUNTING_KEY, + apply_fee_top_up, normalize_fees_distribution, runtime_rotations_for_round, ) @@ -1376,6 +1381,107 @@ def update_transaction_fee_accounting( data["fee_accounting"] = fee_accounting self.update_transaction_data(transaction_hash, data) + def mutate_transaction_fee_accounting( + self, + transaction_hash: str, + mutator: Callable[[dict], dict], + ) -> dict: + """Mutate the latest fee accounting while holding the transaction row. + + Consensus workers run asynchronously, while RPC top-ups can arrive at + any in-flight status. Every worker-side accounting transition must + therefore derive from the post-lock JSON value; writing a snapshot + captured before a concurrent top-up would erase paid budget. + """ + + row = ( + self.session.execute( + text("SELECT data FROM transactions WHERE hash = :hash FOR UPDATE"), + {"hash": transaction_hash}, + ) + .mappings() + .first() + ) + if row is None: + raise ValueError("TransactionNotFound") + data = dict(row["data"] or {}) + current = data.get(FEE_ACCOUNTING_KEY) + if not isinstance(current, dict): + raise ValueError("FeeAccountingMissing") + updated = mutator(current) + data[FEE_ACCOUNTING_KEY] = updated + self.session.execute( + text( + "UPDATE transactions SET data = CAST(:data AS jsonb)" + " WHERE hash = :hash" + ), + {"hash": transaction_hash, "data": json.dumps(data)}, + ) + self.session.commit() + return updated + + def apply_transaction_fee_top_up( + self, + transaction_hash: str, + *, + fees_distribution: dict, + amount: int, + sender: str, + policy, + ) -> dict: + """Apply one top-up to the latest accounting snapshot under a row lock. + + The caller commits only after debiting the sender on this same session. + This gives Studio the serial execution semantics of FeeManager.topUpFees: + concurrent top-ups compose instead of both charging against one stale + read and allowing the last JSON write to erase the other contribution. + """ + + row = ( + self.session.execute( + text( + "SELECT status, data, num_of_initial_validators" + " FROM transactions WHERE hash = :hash FOR UPDATE" + ), + {"hash": transaction_hash}, + ) + .mappings() + .first() + ) + if row is None: + raise ValueError("TransactionNotFound") + if row["status"] in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.UNDETERMINED.value, + TransactionStatus.LEADER_TIMEOUT.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, + TransactionStatus.FINALIZED.value, + TransactionStatus.CANCELED.value, + }: + raise ValueError("InvalidTransactionStatus") + + data = dict(row["data"] or {}) + current = data.get(FEE_ACCOUNTING_KEY) + if current is None: + raise ValueError("FeeAccountingMissing") + updated = apply_fee_top_up( + current, + fees_distribution=fees_distribution, + amount=amount, + sender=sender, + num_of_validators=int(row["num_of_initial_validators"] or 5), + policy=policy, + ) + data[FEE_ACCOUNTING_KEY] = updated + self.session.execute( + text( + "UPDATE transactions SET data = CAST(:data AS jsonb)" + " WHERE hash = :hash" + ), + {"hash": transaction_hash, "data": json.dumps(data)}, + ) + return updated + def get_transaction_count(self, address: str) -> int: # Normalize address to checksum format try: @@ -1447,6 +1553,80 @@ def set_transaction_appeal(self, transaction_hash: str, appeal: bool): if result.rowcount > 0: self.session.commit() + def admit_transaction_appeal( + self, + transaction_hash: str, + *, + expected_decision_id: int, + submitted_at: int, + appeal_deadline: int, + retention_bps: int, + prepare_fee_accounting: Callable[[dict | None], tuple[dict | None, int]], + ) -> int: + """Atomically reserve one exact-decision appeal. + + The caller deliberately commits only after applying the matching + account debit/refund on this same session. This keeps Studio's appeal + admission equivalent to one payable Solidity transaction and makes a + concurrent second submission lose at the ``appealed = false`` gate. + """ + + row = ( + self.session.execute( + text( + "SELECT status, appealed, consensus_history, data FROM transactions" + " WHERE hash = :hash FOR UPDATE" + ), + {"hash": transaction_hash}, + ) + .mappings() + .first() + ) + if row is None: + raise ValueError("TransactionNotFound") + if row["status"] not in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.UNDETERMINED.value, + TransactionStatus.LEADER_TIMEOUT.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, + } or bool(row["appealed"]): + raise ValueError("CanNotAppeal") + # The RPC may have waited for this row lock. Consensus evaluates the + # deadline at transaction execution, not when the caller began + # submitting it, so re-sample after lock acquisition and reject an + # appeal whose window expired while queued. + admitted_at = max(int(submitted_at), int(time.time())) + updated = prepare_appeal_decision_basis( + row["consensus_history"], + expected_decision_id=expected_decision_id, + submitted_at=admitted_at, + appeal_deadline=appeal_deadline, + retention_bps=retention_bps, + ) + data = dict(row["data"] or {}) + fee_accounting, surplus_refund = prepare_fee_accounting( + data.get(FEE_ACCOUNTING_KEY) + ) + if fee_accounting is not None: + data[FEE_ACCOUNTING_KEY] = fee_accounting + self.session.execute( + text( + "UPDATE transactions" + " SET appealed = true," + " timestamp_appeal = :submitted_at," + " consensus_history = CAST(:history AS jsonb)," + " data = CAST(:data AS jsonb)" + " WHERE hash = :hash" + ), + { + "hash": transaction_hash, + "submitted_at": admitted_at, + "history": json.dumps(updated), + "data": json.dumps(data), + }, + ) + return int(surplus_refund) + def set_transaction_timestamp_awaiting_finalization( self, transaction_hash: str, timestamp_awaiting_finalization: int = None ): @@ -1636,6 +1816,20 @@ def update_consensus_history( "current_status_changes": [], "current_monitoring": {}, } + if extra_status_change in { + TransactionStatus.ACCEPTED, + TransactionStatus.UNDETERMINED, + TransactionStatus.LEADER_TIMEOUT, + TransactionStatus.VALIDATORS_TIMEOUT, + }: + new_history = materialize_decision_metadata( + new_history, + status=extra_status_change.value, + materialized_at=int(time.time()), + default_appeal_window=int( + os.environ.get("VITE_FINALITY_WINDOW", "1800") + ), + ) self.session.execute( text( diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 0eb6b101f..bb0953e9e 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -50,9 +50,9 @@ from backend.protocol_rpc.transactions_parser import TransactionParser from backend.protocol_rpc.fees import ( FEE_ACCOUNTING_KEY, + VALIDATORS_PER_ROUND, FeeValidationError, StudioFeePolicy, - apply_fee_top_up, calculate_appeal_charge, calculate_round_fees, decode_internal_message_fee_params, @@ -67,8 +67,11 @@ validate_transaction_fee_deposit, ) from backend.consensus.history import ( + actual_leader_rotations_by_round, completed_consensus_round_index, + current_decision_id as history_current_decision_id, has_terminal_validator_appeal, + latest_decision_metadata, logical_fee_round_entries, ) from backend.errors.errors import InvalidAddressError, InvalidTransactionError @@ -97,6 +100,7 @@ from backend.protocol_rpc.types import ( DecodedRollupTransaction, DecodedTopUpFeesDataArgs, + DecodedFinalizeTransactionDataArgs, DecodedsubmitAppealDataArgs, ) from backend.database_handler.snapshot_manager import SnapshotManager @@ -1814,16 +1818,64 @@ def get_transaction_status_details( TransactionStatus.VALIDATORS_TIMEOUT.value, } +_PROTOCOL_TRANSACTION_STATUS_NAMES = ( + "Uninitialized", + "Pending", + "Proposing", + "Committing", + "Revealing", + "Accepted", + "Undetermined", + "Finalized", + "Canceled", + "AppealRevealing", + "AppealCommitting", + "ValidatorsTimeout", + "LeaderTimeout", + "LeaderRevealing", +) +_RESOLUTION_ACTION_NAMES = ( + "NoOp", + "Cancel", + "ReplaceActor", + "RotateLeader", + "ResolveAppeal", + "MaterializeDecision", + "Finalize", +) +_RESOLUTION_SOURCE_NAMES = ( + "Unspecified", + "ActivationInsufficientValidators", + "ProposalHanging", + "LeaderReceiptTimeout", + "CommitHanging", + "LeaderRevealHanging", + "FullReveal", + "RevealDeadline", + "AppealCommitHanging", + "AppealFullReveal", + "AppealRevealDeadline", + "SelectionDepleted", +) + def _transaction_appeal_deadline(transaction: dict) -> float | None: started_at = transaction.get("timestamp_awaiting_finalization") - if started_at is None: - return None - if str(transaction.get("execution_mode") or "") in { + if started_at is not None and str(transaction.get("execution_mode") or "") in { "LEADER_ONLY", "LEADER_SELF_VALIDATOR", }: return float(started_at) + decision = latest_decision_metadata(transaction.get("consensus_history")) + if decision is not None: + try: + deadline = int(decision.get("appealDeadline") or 0) + except (TypeError, ValueError): + deadline = 0 + if deadline > 0: + return float(deadline) + if started_at is None: + return None finality_window = int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) failed_reduction = float( os.environ.get("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0") @@ -1837,6 +1889,10 @@ def _transaction_appeal_deadline(transaction: dict) -> float | None: ) +def _transaction_decision_id(transaction: dict) -> int: + return history_current_decision_id(transaction.get("consensus_history")) + + def get_transaction_lifecycle( transactions_processor: TransactionsProcessor, params: dict, @@ -1880,7 +1936,7 @@ def get_transaction_lifecycle( transaction.get("appealed") ) current_round = _current_fee_round(transaction.get("consensus_history")) - decision_id = 1 + ((current_round + 1) // 2) if decision_active else None + decision_id = _transaction_decision_id(transaction) if decision_active else None if not decision_active: resolution_source_code = 0 @@ -1888,11 +1944,10 @@ def get_transaction_lifecycle( resolution_source_code = 3 # LeaderReceiptTimeout else: appeal_attempt = current_round % 2 == 1 - reveal_timed_out = status == TransactionStatus.VALIDATORS_TIMEOUT.value - if appeal_attempt: - resolution_source_code = 10 if reveal_timed_out else 9 - else: - resolution_source_code = 7 if reveal_timed_out else 6 + # Studio materializes these outcomes only after its RevealingState has + # tallied the complete receipt set. A validator execution timeout is a + # VoteType.Timeout ballot, not a Consensus RevealDeadline trigger. + resolution_source_code = 9 if appeal_attempt else 6 resolution_action_code = 0 deadline = _transaction_appeal_deadline(transaction) @@ -1905,11 +1960,15 @@ def get_transaction_lifecycle( resolution_action_code = 6 # Finalize return { + "storedStatus": _PROTOCOL_TRANSACTION_STATUS_NAMES[stored_status_code], "storedStatusCode": stored_status_code, # ResolutionKernel leaves a decision in its stored status until the # separately reported Finalize action is actually committed. + "projectedStatus": _PROTOCOL_TRANSACTION_STATUS_NAMES[stored_status_code], "projectedStatusCode": stored_status_code, + "resolutionAction": _RESOLUTION_ACTION_NAMES[resolution_action_code], "resolutionActionCode": resolution_action_code, + "resolutionSource": _RESOLUTION_SOURCE_NAMES[resolution_source_code], "resolutionSourceCode": resolution_source_code, "decisionId": str(decision_id) if decision_id is not None else None, "decisionActive": decision_active, @@ -1964,11 +2023,34 @@ def estimate_latest_appeal_charge( ) ) current_round = _current_fee_round(transaction.get("consensus_history")) + available_appeal_validators = _available_appeal_validator_count( + transaction, + validator_count, + ) + if ( + status + in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, + } + and available_appeal_validators == 0 + ): + raise InvalidTransactionError("CanNotAppeal") + if ( + status == TransactionStatus.UNDETERMINED.value + and available_appeal_validators is not None + and available_appeal_validators + < VALIDATORS_PER_ROUND[min(current_round + 1, len(VALIDATORS_PER_ROUND) - 1)] + ): + raise InvalidTransactionError("CanNotAppeal") live_seats = None if status == TransactionStatus.LEADER_TIMEOUT.value: live_validators = transaction.get("leader_timeout_validators") if isinstance(live_validators, list): - live_seats = len(live_validators) + # Studio persists the survivors after removing the timed-out + # leader; Consensus applies its eligibility gate to the original + # committee before that removal. + live_seats = len(live_validators) + 1 charge = calculate_appeal_charge( fee_accounting["fees_distribution"], current_round=current_round, @@ -1978,10 +2060,11 @@ def estimate_latest_appeal_charge( validator_count - _normal_leader_count(transaction.get("consensus_history")), ), + available_appeal_validators=available_appeal_validators, leader_timeout_live_seats=live_seats, policy=StudioFeePolicy.from_env(), ) - decision_id = 1 + ((current_round + 1) // 2) + decision_id = _transaction_decision_id(transaction) return { "decisionId": str(decision_id), "bond": str(int(charge["bond"])), @@ -2081,6 +2164,9 @@ def _fee_metadata(decoded_rollup_transaction: DecodedRollupTransaction) -> dict: decoded_rollup_transaction.data is None or isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs) or isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs) + or isinstance( + decoded_rollup_transaction.data, DecodedFinalizeTransactionDataArgs + ) or not hasattr(decoded_rollup_transaction.data, "args") or decoded_rollup_transaction.data.args is None ): @@ -2125,7 +2211,14 @@ def _funded_max_rotations( data = decoded_rollup_transaction.data if ( data is None - or isinstance(data, (DecodedsubmitAppealDataArgs, DecodedTopUpFeesDataArgs)) + or isinstance( + data, + ( + DecodedsubmitAppealDataArgs, + DecodedTopUpFeesDataArgs, + DecodedFinalizeTransactionDataArgs, + ), + ) or not hasattr(data, "args") or data.args is None or data.args.fees_distribution is None @@ -2235,6 +2328,9 @@ def _validate_fee_envelope( decoded_rollup_transaction.data is None or isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs) or isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs) + or isinstance( + decoded_rollup_transaction.data, DecodedFinalizeTransactionDataArgs + ) or not hasattr(decoded_rollup_transaction.data, "args") or decoded_rollup_transaction.data.args is None ): @@ -2276,43 +2372,38 @@ def _handle_top_up_fees( ) -> str: assert isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs) tx_id = _tx_id_to_hex(decoded_rollup_transaction.data.tx_id) - tx = transactions_processor.get_transaction_by_hash(tx_id) - if tx is None: - raise NotFoundError(message=TRANSACTION_NOT_FOUND_MESSAGE, data={"hash": tx_id}) - - status = tx.get("status") - if status in { - TransactionStatus.ACCEPTED.value, - TransactionStatus.UNDETERMINED.value, - TransactionStatus.LEADER_TIMEOUT.value, - TransactionStatus.VALIDATORS_TIMEOUT.value, - TransactionStatus.FINALIZED.value, - TransactionStatus.CANCELED.value, - }: - raise InvalidTransactionError("InvalidTransactionStatus") - - fee_accounting = (tx.get("data") or {}).get(FEE_ACCOUNTING_KEY) - if fee_accounting is None: - raise InvalidTransactionError("FeeAccountingMissing") - + session = getattr(transactions_processor, "session", None) try: - updated = apply_fee_top_up( - fee_accounting, + transactions_processor.apply_transaction_fee_top_up( + tx_id, fees_distribution=decoded_rollup_transaction.data.fees_distribution, amount=decoded_rollup_transaction.total_spend, sender=decoded_rollup_transaction.from_address, - num_of_validators=int(tx.get("num_of_initial_validators") or 5), policy=StudioFeePolicy.from_env(), ) + _sandbox_debit_sender( + accounts_manager, + decoded_rollup_transaction.from_address, + decoded_rollup_transaction.total_spend, + ) + if session is not None: + session.commit() except FeeValidationError as exc: + if session is not None: + session.rollback() raise InvalidTransactionError(str(exc)) from exc - - _sandbox_debit_sender( - accounts_manager, - decoded_rollup_transaction.from_address, - decoded_rollup_transaction.total_spend, - ) - transactions_processor.update_transaction_fee_accounting(tx_id, updated) + except ValueError as exc: + if session is not None: + session.rollback() + if str(exc) == "TransactionNotFound": + raise NotFoundError( + message=TRANSACTION_NOT_FOUND_MESSAGE, data={"hash": tx_id} + ) from exc + raise InvalidTransactionError(str(exc)) from exc + except Exception: + if session is not None: + session.rollback() + raise return tx_id @@ -2349,10 +2440,9 @@ def _handle_appeal_or_top_up_and_submit( consensus_history = tx.get("consensus_history") expected_decision_id = decoded_rollup_transaction.data.expected_decision_id current_round = _current_fee_round(consensus_history) - # Studio has no on-chain DecisionRecord, so derive the equivalent - # monotonic ordinal from its alternating normal/appeal round history. - # Round 0 -> decision 1; rounds 1/2 -> decision 2; rounds 3/4 -> 3. - current_decision_id = 1 + ((current_round + 1) // 2) + # New Studio decisions persist their exact materialized ID. Legacy rows + # derive the same monotonic ordinal from alternating normal/appeal history. + current_decision_id = _transaction_decision_id(tx) # v0.6 binds every appeal to one exact materialized decision. Older SDKs # emitted selectors without this argument; Studio may still decode those # transactions to surface a canonical error, but must never admit the @@ -2361,6 +2451,8 @@ def _handle_appeal_or_top_up_and_submit( raise InvalidTransactionError("CanNotAppeal") fee_accounting = (tx.get("data") or {}).get(FEE_ACCOUNTING_KEY) + if fee_accounting is None: + raise InvalidTransactionError("FeeAccountingMissing") if fee_accounting is not None: session = getattr(accounts_manager, "session", None) frozen_pool_count = fee_accounting.get("selection_pool_count") @@ -2378,15 +2470,40 @@ def _handle_appeal_or_top_up_and_submit( ) ) normal_leader_count = _normal_leader_count(consensus_history) + available_appeal_validators = _available_appeal_validator_count( + tx, + validator_count, + ) + if ( + status + in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, + } + and available_appeal_validators == 0 + ): + raise InvalidTransactionError("CanNotAppeal") + if ( + status == TransactionStatus.UNDETERMINED.value + and available_appeal_validators is not None + and available_appeal_validators + < VALIDATORS_PER_ROUND[ + min(current_round + 1, len(VALIDATORS_PER_ROUND) - 1) + ] + ): + raise InvalidTransactionError("CanNotAppeal") replacement_rotations = None leader_timeout_live_seats = None if status == TransactionStatus.LEADER_TIMEOUT.value: live_validators = tx.get("leader_timeout_validators") if isinstance(live_validators, list): - leader_timeout_live_seats = len(live_validators) - try: + leader_timeout_live_seats = len(live_validators) + 1 + + def prepare_fee_accounting(current_fee_accounting): + if current_fee_accounting is None: + raise FeeValidationError("FeeAccountingMissing") updated = record_appeal_bond( - fee_accounting, + current_fee_accounting, amount=decoded_rollup_transaction.total_spend, appealer=decoded_rollup_transaction.from_address, current_round=current_round, @@ -2397,26 +2514,56 @@ def _handle_appeal_or_top_up_and_submit( 0, validator_count - normal_leader_count, ), + available_appeal_validators=available_appeal_validators, replacement_rotations=replacement_rotations, leader_timeout_live_seats=leader_timeout_live_seats, time_unit_overlay_bps=StudioFeePolicy.from_env().time_unit_overlay_bps, ) - except FeeValidationError as exc: - raise InvalidTransactionError(str(exc)) from exc + return updated, int(updated["appeal_bonds"][-1]["surplusRefund"]) + + submitted_at = int(time.time()) + failed_reduction = float( + os.environ.get("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0") + ) + failed_reduction = min(1.0, max(0.0, failed_reduction)) + session = getattr(transactions_processor, "session", None) + try: + surplus_refund = transactions_processor.admit_transaction_appeal( + tx_id, + expected_decision_id=current_decision_id, + submitted_at=submitted_at, + appeal_deadline=( + int(appeal_deadline) + if appeal_deadline is not None + else submitted_at + int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) + ), + retention_bps=round((1.0 - failed_reduction) * 10_000), + prepare_fee_accounting=prepare_fee_accounting, + ) _sandbox_debit_sender( accounts_manager, decoded_rollup_transaction.from_address, decoded_rollup_transaction.total_spend, ) - surplus_refund = int(updated["appeal_bonds"][-1]["surplusRefund"]) if surplus_refund > 0: accounts_manager.credit_account_balance( decoded_rollup_transaction.from_address, surplus_refund, ) - transactions_processor.update_transaction_fee_accounting(tx_id, updated) - - transactions_processor.set_transaction_appeal(tx_id, True) + if session is not None: + session.commit() + except FeeValidationError as exc: + if session is not None: + session.rollback() + raise InvalidTransactionError(str(exc)) from exc + except ValueError as exc: + if session is not None: + session.rollback() + raise InvalidTransactionError("CanNotAppeal") from exc + except Exception: + if session is not None: + session.rollback() + raise msg_handler.send_message( log_event=LogEvent( "transaction_appeal_updated", @@ -2432,6 +2579,47 @@ def _handle_appeal_or_top_up_and_submit( return tx_id +def _handle_finalize_transaction( + *, + transactions_processor: TransactionsProcessor, + decoded_rollup_transaction: DecodedRollupTransaction, +) -> str: + """Validate an exact-decision finalization request. + + Studio's worker owns the asynchronous state/contract commit. This boundary + mirrors v0.6 admission: it accepts only the active decision, at or after its + immutable deadline, and only at the recipient's finalization head. The + already-eligible row is then consumed by the worker without inventing a + second Studio-only lifecycle transition. + """ + assert isinstance( + decoded_rollup_transaction.data, DecodedFinalizeTransactionDataArgs + ) + if int(decoded_rollup_transaction.total_spend) != 0: + # ConsensusMain.finalizeTransaction is nonpayable. Do not silently + # accept value that an EVM call would reject (or leave it undebited). + raise InvalidTransactionError("NonPayableCall") + tx_id = _tx_id_to_hex(decoded_rollup_transaction.data.tx_id) + transaction = transactions_processor.get_transaction_by_hash(tx_id) + if transaction is None: + raise NotFoundError(message=TRANSACTION_NOT_FOUND_MESSAGE, data={"hash": tx_id}) + + status = str(transaction.get("status") or "") + if status not in _DECISION_STATUSES or bool(transaction.get("appealed")): + raise InvalidTransactionError("FinalizationNotAllowed") + current_decision_id = _transaction_decision_id(transaction) + expected_decision_id = decoded_rollup_transaction.data.expected_decision_id + if expected_decision_id is None or int(expected_decision_id) != current_decision_id: + raise InvalidTransactionError("FinalizationNotAllowed") + + deadline = _transaction_appeal_deadline(transaction) + if deadline is None or time.time() < deadline: + raise InvalidTransactionError("FinalizationNotAllowed") + if not transactions_processor.is_transaction_finalization_head(tx_id): + raise InvalidTransactionError("FinalizationNotAllowed") + return tx_id + + def _tx_id_to_hex(tx_id: str | bytes) -> str: return "0x" + tx_id.hex() if isinstance(tx_id, bytes) else tx_id @@ -2445,13 +2633,62 @@ def _normal_leader_count(consensus_history: dict | None) -> int: # replacement committee. Studio compacts a leader appeal's hidden appeal # round and replay into one history entry, so raw list parity is not a safe # way to identify normal rounds after the first leader appeal. + rotations_by_round = actual_leader_rotations_by_round(consensus_history) return sum( - 1 + 1 + int(rotations_by_round.get(logical_round, 0)) for logical_round, _entry in logical_fee_round_entries(consensus_history) if logical_round % 2 == 0 ) +def _receipt_validator_address(receipt: Any) -> str | None: + if not isinstance(receipt, dict): + return None + node_config = receipt.get("node_config") + if not isinstance(node_config, dict): + return None + address = node_config.get("address") + return str(address).lower() if address else None + + +def _available_appeal_validator_count( + transaction: dict, + frozen_pool_count: int, +) -> int | None: + """Mirror Studio's fresh-juror pool; unknown legacy state stays conservative.""" + used_addresses: set[str] = set() + consensus_data = transaction.get("consensus_data") + if isinstance(consensus_data, dict): + validator_receipts = consensus_data.get("validators") + if isinstance(validator_receipts, dict): + validator_receipts = [validator_receipts] + if isinstance(validator_receipts, list): + for receipt in validator_receipts: + address = _receipt_validator_address(receipt) + if address: + used_addresses.add(address) + + history = transaction.get("consensus_history") + results = history.get("consensus_results") if isinstance(history, dict) else None + if isinstance(results, list): + for entry in results: + if not isinstance(entry, dict): + continue + leader_receipts = entry.get("leader_result") + if isinstance(leader_receipts, dict): + leader_receipts = [leader_receipts] + if not isinstance(leader_receipts, list): + continue + for receipt in leader_receipts: + address = _receipt_validator_address(receipt) + if address: + used_addresses.add(address) + + if not used_addresses: + return None + return max(0, int(frozen_pool_count) - len(used_addresses)) + + def _simulation_fee_accounting( params: dict, *, @@ -2619,6 +2856,14 @@ def send_raw_transaction( transactions_processor=transactions_processor, decoded_rollup_transaction=decoded_rollup_transaction, ) + elif isinstance( + decoded_rollup_transaction.data, DecodedFinalizeTransactionDataArgs + ): + _reject_genvm_executor_selector_unless_deploy(sim_config, is_deploy=False) + return _handle_finalize_transaction( + transactions_processor=transactions_processor, + decoded_rollup_transaction=decoded_rollup_transaction, + ) else: _validate_fee_envelope(decoded_rollup_transaction) transaction_hash = consensus_service.generate_transaction_hash( @@ -2828,8 +3073,8 @@ def set_finality_window_time(consensus: ConsensusAlgorithm, time: int) -> None: def get_finality_window_time(consensus: ConsensusAlgorithm) -> int: if consensus is None: - # Return default finality window time when consensus is not initialized - return os.environ.get("VITE_FINALITY_WINDOW", 1800) # Default to 60 seconds + # Preserve the RPC's numeric contract even while consensus is starting. + return int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) return consensus.finality_window_time diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 0dd840bd0..b678e2ce6 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -682,6 +682,7 @@ def _calculate_settled_time_unit_fees( policy: StudioFeePolicy, consensus_history: dict[str, Any] | None, execution_mode: str = "NORMAL", + terminal_electorate_size: int | None = None, ) -> tuple[int, list[dict[str, Any]]]: """Aggregate the fee-funded round payouts made by FeesProcessor. @@ -756,6 +757,15 @@ def _calculate_settled_time_unit_fees( rotations_by_round = actual_leader_rotations_by_round(consensus_history) attempts_by_round = _consensus_attempt_entries_by_round(consensus_history) outcomes = _round_outcomes(consensus_history) + terminal_rounds = { + round_index + for round_index, predecessor in previous_completed_round.items() + if outcomes.get(predecessor) + in { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + } + } if policy.gen_per_time_unit > 0: leader_timeunits = ( int(fees["leaderTimeunitsAllocation"]) * policy.gen_per_time_unit @@ -771,6 +781,10 @@ def _calculate_settled_time_unit_fees( leader_only = str(execution_mode).upper() == "LEADER_ONLY" for round_index in range(capped_final_round + 1): + round_electorate_size = ( + terminal_electorate_size if round_index in terminal_rounds else None + ) + alignment_result: str | None = None outcome = outcomes.get(round_index, "") is_leader_appeal_replay = outcome in LEADER_APPEAL_CONSENSUS_ROUNDS is_leader_appeal_placeholder = ( @@ -791,10 +805,17 @@ def _calculate_settled_time_unit_fees( _entry_validator_receipts(attempt) for attempt in prior_attempts ): for attempt in prior_attempts: - aligned = _aligned_validator_count(attempt, exclude_leader=True) + aligned = _aligned_validator_count( + attempt, + exclude_leader=True, + electorate_size=round_electorate_size, + ) rotated_leader_fee = ( 0 - if _fee_alignment_result(attempt) == "deterministic_violation" + if _fee_alignment_result( + attempt, electorate_size=round_electorate_size + ) + == "deterministic_violation" else leader_timeunits // 2 ) prior_rotation_fee += aligned * validator_timeunits + rotated_leader_fee @@ -925,10 +946,14 @@ def _calculate_settled_time_unit_fees( rule = "leader_appeal" else: current_result = ( - _fee_alignment_result(current_attempt) + _fee_alignment_result( + current_attempt, + electorate_size=round_electorate_size, + ) if current_attempt is not None else "unknown" ) + alignment_result = current_result leader_fee = ( 0 if current_result == "deterministic_violation" else leader_timeunits ) @@ -944,7 +969,11 @@ def _calculate_settled_time_unit_fees( else: rule = "normal" aligned = ( - _aligned_validator_count(current_attempt) + _aligned_validator_count( + current_attempt, + forced_result=current_result, + electorate_size=round_electorate_size, + ) if current_attempt is not None and _entry_validator_receipts(current_attempt) else (0 if leader_only else validators) @@ -952,15 +981,19 @@ def _calculate_settled_time_unit_fees( round_fee = prior_rotation_fee + aligned * validator_timeunits + leader_fee base_total += round_fee - by_round.append( - { - "round": round_index, - "outcome": outcome, - "rule": rule, - "rotations": rotations, - "timeUnitAmount": round_fee, - } - ) + settlement_round = { + "round": round_index, + "outcome": outcome, + "rule": rule, + "rotations": rotations, + "timeUnitAmount": round_fee, + } + # Preserve the existing receipt shape for ordinary rounds. The + # terminal result is material because it was derived against the + # frozen transaction electorate rather than the local committee. + if round_electorate_size is not None and alignment_result is not None: + settlement_round["alignmentResult"] = alignment_result + by_round.append(settlement_round) return base_total, by_round @@ -1058,7 +1091,11 @@ def _entry_leader_address(entry: dict[str, Any] | None) -> str: return "" -def _fee_alignment_result(entry: dict[str, Any] | None) -> str: +def _fee_alignment_result( + entry: dict[str, Any] | None, + *, + electorate_size: int | None = None, +) -> str: votes = [ str(receipt.get("vote") or "").lower() for receipt in _entry_validator_receipts(entry) @@ -1066,7 +1103,10 @@ def _fee_alignment_result(entry: dict[str, Any] | None) -> str: total = len(votes) if total == 0: return "unknown" - majority = total / 2 + threshold_base = ( + total if electorate_size is None else max(total, int(electorate_size)) + ) + majority = threshold_base / 2 if votes.count("agree") > majority: return "agree" if votes.count("disagree") > majority: @@ -1087,6 +1127,7 @@ def _aligned_validator_count( *, exclude_leader: bool = False, forced_result: str | None = None, + electorate_size: int | None = None, ) -> int: receipts = _entry_validator_receipts(entry) if exclude_leader: @@ -1100,7 +1141,9 @@ def _aligned_validator_count( filtered.append(receipt) receipts = filtered - result = forced_result or _fee_alignment_result(entry) + result = forced_result or _fee_alignment_result( + entry, electorate_size=electorate_size + ) if result == "no_majority": return len(receipts) if result == "unknown": @@ -1123,6 +1166,7 @@ def _settlement_storage_recipient_count( fees_distribution: dict[str, Any], policy: StudioFeePolicy, execution_mode: str, + terminal_electorate_size: int | None = None, ) -> int: """Count the unique fee-ledger recipients that share persistent storage. @@ -1160,11 +1204,17 @@ def _settlement_storage_recipient_count( leader_only = str(execution_mode).upper() == "LEADER_ONLY" recipients: set[str] = set() - def add_leader(attempt: dict[str, Any], *, payable_value: int) -> None: + def add_leader( + attempt: dict[str, Any], + *, + payable_value: int, + electorate_size: int | None = None, + ) -> None: leader = _entry_leader_address(attempt) if leader and ( payable_value > 0 - or _fee_alignment_result(attempt) == "deterministic_violation" + or _fee_alignment_result(attempt, electorate_size=electorate_size) + == "deterministic_violation" ): recipients.add(leader) @@ -1174,6 +1224,7 @@ def validator_addresses( aligned_only: bool = False, exclude_leader: bool = False, forced_result: str | None = None, + electorate_size: int | None = None, ) -> list[str]: receipts = _entry_validator_receipts(attempt) if exclude_leader: @@ -1190,7 +1241,9 @@ def validator_addresses( continue filtered.append(receipt) receipts = filtered - result = forced_result or _fee_alignment_result(attempt) + result = forced_result or _fee_alignment_result( + attempt, electorate_size=electorate_size + ) addresses: list[str] = [] for receipt in receipts: vote = str(receipt.get("vote") or "").lower() @@ -1214,18 +1267,55 @@ def validator_addresses( current_attempt = attempts[-1] if attempts else None rotation_attempts = attempts[:-1] if attempts else [] + predecessor = max( + (candidate for candidate in outcomes if candidate < round_index), + default=None, + ) + round_electorate_size = ( + terminal_electorate_size + if predecessor is not None + and outcomes.get(predecessor) + in { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + } + else None + ) if int(settlement.get("rotations", 0) or 0) > 0: for attempt in rotation_attempts: - add_leader(attempt, payable_value=leader_value // 2) + add_leader( + attempt, + payable_value=leader_value // 2, + electorate_size=round_electorate_size, + ) if validator_value > 0: - recipients.update(validator_addresses(attempt, exclude_leader=True)) + recipients.update( + validator_addresses( + attempt, + exclude_leader=True, + electorate_size=round_electorate_size, + ) + ) if current_attempt is None: continue if rule in normal_rules: - add_leader(current_attempt, payable_value=leader_value) + alignment_result = settlement.get("alignmentResult") + add_leader( + current_attempt, + payable_value=leader_value, + electorate_size=round_electorate_size, + ) if validator_value > 0 and not leader_only: - recipients.update(validator_addresses(current_attempt)) + recipients.update( + validator_addresses( + current_attempt, + forced_result=( + str(alignment_result) if alignment_result else None + ), + electorate_size=round_electorate_size, + ) + ) elif rule in leader_timeout_rules: payable_value = ( 1 @@ -1909,6 +1999,7 @@ def record_appeal_bond( fees_distribution: dict[str, Any] | None = None, top_up_and_submit: bool = False, terminal_committee_upper_bound: int | None = None, + available_appeal_validators: int | None = None, replacement_rotations: int | None = None, leader_timeout_live_seats: int | None = None, time_unit_overlay_bps: int | None = None, @@ -1933,6 +2024,7 @@ def record_appeal_bond( current_round=current_round, status=status, terminal_committee_upper_bound=terminal_committee_upper_bound, + available_appeal_validators=available_appeal_validators, replacement_rotations=replacement_rotations, leader_timeout_live_seats=leader_timeout_live_seats, policy=policy, @@ -1940,8 +2032,10 @@ def record_appeal_bond( bond = int(charge["bond"]) funding = int(charge["funding"]) total_required = bond + funding - if bond <= 0 or amount < total_required: + if bond <= 0 or amount < bond: raise InvalidAppealBond("InvalidAppealBond") + if amount < total_required: + raise InsufficientFees("InsufficientFees") if bool(charge["extendsSchedule"]): merged = normalize_fees_distribution(updated.get("fees_distribution") or {}) @@ -1991,6 +2085,7 @@ def record_appeal_bond( "round": current_round if round is None else round, "sourceRound": current_round, "appealRound": int(charge["appealRound"]), + "juryCount": int(charge["juryCount"]), "status": status, "minimumRequired": bond, "topUpAndSubmit": bool(top_up_and_submit), @@ -2008,6 +2103,7 @@ def calculate_appeal_charge( current_round: int, status: str, terminal_committee_upper_bound: int | None = None, + available_appeal_validators: int | None = None, replacement_rotations: int | None = None, leader_timeout_live_seats: int | None = None, policy: StudioFeePolicy | None = None, @@ -2037,7 +2133,15 @@ def calculate_appeal_charge( appeal_round = ( current_round + 1 if current_round % 2 == 0 else current_round + 2 ) - jury_count = _validators_per_round_safe(current_round + 1) + scheduled_jury_count = _validators_per_round_safe(current_round + 1) + jury_count = ( + scheduled_jury_count + if available_appeal_validators is None + else min( + scheduled_jury_count, + max(0, int(available_appeal_validators)), + ) + ) replacement_rotations = 0 else: appeal_round = current_round + 2 @@ -2753,6 +2857,11 @@ def settle_fee_accounting( locked_policy, consensus_history, execution_mode, + terminal_electorate_size=( + int(updated["selection_pool_count"]) + if updated.get("selection_pool_count") is not None + else None + ), ) # Consensus settles storage + receipt/write consumption against the # complete unified reserve quoted at submission: @@ -2781,6 +2890,11 @@ def settle_fee_accounting( fees_distribution, locked_policy, execution_mode, + terminal_electorate_size=( + int(updated["selection_pool_count"]) + if updated.get("selection_pool_count") is not None + else None + ), ) if storage_recipients == 0 and time_unit_budget > 0 and not settlement_rounds: # Legacy/synthetic histories without receipt identities retain the @@ -2898,7 +3012,14 @@ def _record_historical_execution_fee_consumption( consensus_history: dict[str, Any] | None, policy: StudioFeePolicy, ) -> dict[str, Any]: - attempts = _historical_leader_proposal_receipts(consensus_history) + attempts = _historical_leader_proposal_receipts( + consensus_history, + terminal_electorate_size=( + int(accounting["selection_pool_count"]) + if accounting.get("selection_pool_count") is not None + else None + ), + ) if not attempts: return accounting @@ -2957,13 +3078,31 @@ def _record_historical_execution_fee_consumption( def _historical_leader_proposal_receipts( consensus_history: dict[str, Any] | None, + *, + terminal_electorate_size: int | None = None, ) -> list[tuple[int, int, dict[str, Any], bool]]: grouped = _consensus_attempt_entries_by_round(consensus_history) + outcomes = _round_outcomes(consensus_history) proposals: list[tuple[int, int, dict[str, Any], bool]] = [] for round_index in sorted(grouped): + predecessor = max( + (candidate for candidate in outcomes if candidate < round_index), + default=None, + ) + round_electorate_size = ( + terminal_electorate_size + if predecessor is not None + and outcomes.get(predecessor) + in { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + } + else None + ) for attempt_index, entry in enumerate(grouped[round_index]): deterministic_violation = ( - _fee_alignment_result(entry) == "deterministic_violation" + _fee_alignment_result(entry, electorate_size=round_electorate_size) + == "deterministic_violation" ) value = entry.get("leader_result") candidates = value if isinstance(value, list) else [value] diff --git a/backend/protocol_rpc/health.py b/backend/protocol_rpc/health.py index cac37fd1b..b451b12c7 100644 --- a/backend/protocol_rpc/health.py +++ b/backend/protocol_rpc/health.py @@ -635,12 +635,21 @@ async def _check_consensus_health() -> Dict[str, Any]: # top-level "issues" tag agree on what counts as degraded. DEGRADED_AT_STUCK_HEADS = int(os.environ.get("HEALTH_DEGRADED_AT_STUCK_HEADS", "3")) # Finalization-stall threshold: ACCEPTED/UNDETERMINED/*_TIMEOUT txs - # that haven't reached FINALIZED within this many seconds count as - # stuck. Default 600s (10 min) — finalization is supposed to be - # quick after the finality window opens. + # that remain unfinalized for this long *after their appeal deadline* + # count as stuck. Default 600s (10 min). STUCK_FINALIZATION_AFTER_SECONDS = int( os.environ.get("HEALTH_STUCK_FINALIZATION_AFTER_SECONDS", "600") ) + FINALITY_WINDOW_SECONDS = max( + 0, int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) + ) + APPEAL_FAILED_REDUCTION = min( + 1.0, + max( + 0.0, + float(os.environ.get("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0")), + ), + ) DEGRADED_AT_STUCK_FINALIZATIONS = int( os.environ.get("HEALTH_DEGRADED_AT_STUCK_FINALIZATIONS", "3") ) @@ -772,8 +781,8 @@ def _query_consensus(): # Stuck finalizations: ACCEPTED-class txs waiting too # long to reach FINALIZED. Two paths: - # 1. timestamp_awaiting_finalization set and stale - # (normal "finalizer not running" case) + # 1. the exact DecisionRecord appeal deadline (or the + # legacy reconstructed deadline) is stale # 2. timestamp_awaiting_finalization NULL and the row # is old (catches future bugs like the May 2026 # insufficient-balance SEND path, where a tx @@ -783,7 +792,7 @@ def _query_consensus(): stuck_fin_rows = conn.execute( text( f""" - WITH stuck_finalizations AS ( + WITH finalization_windows AS ( SELECT hash AS tx_hash, to_address, @@ -797,16 +806,45 @@ def _query_consensus(): THEN EXTRACT(EPOCH FROM NOW())::bigint - timestamp_awaiting_finalization ELSE EXTRACT(EPOCH FROM NOW() - created_at)::bigint - END AS waiting_seconds + END AS waiting_seconds, + CASE + WHEN timestamp_awaiting_finalization IS NOT NULL + AND execution_mode IN ( + 'LEADER_ONLY', + 'LEADER_SELF_VALIDATOR' + ) + THEN timestamp_awaiting_finalization::double precision + ELSE COALESCE( + NULLIF( + consensus_history->'latestDecision'->>'appealDeadline', + '' + )::double precision, + CASE + WHEN timestamp_awaiting_finalization IS NOT NULL + THEN timestamp_awaiting_finalization + + COALESCE(appeal_processing_time, 0) + + :finality_window_seconds * POWER( + 1 - :appeal_failed_reduction, + COALESCE(appeal_failed, 0) + ) + ELSE NULL + END + ) + END AS appeal_deadline FROM transactions WHERE status IN ({FINALIZATION_ELIGIBLE_STATUSES_SQL}) - AND ( - (timestamp_awaiting_finalization IS NOT NULL - AND EXTRACT(EPOCH FROM NOW())::bigint - - timestamp_awaiting_finalization - > :stuck_seconds) + AND appealed = false + ), + stuck_finalizations AS ( + SELECT * + FROM finalization_windows + WHERE ( + (appeal_deadline IS NOT NULL + AND EXTRACT(EPOCH FROM NOW()) + > appeal_deadline + :stuck_seconds) OR - (timestamp_awaiting_finalization IS NULL + (appeal_deadline IS NULL + AND timestamp_awaiting_finalization IS NULL AND created_at < NOW() - make_interval(secs => :stuck_seconds)) ) @@ -835,6 +873,8 @@ def _query_consensus(): ), { "stuck_seconds": STUCK_FINALIZATION_AFTER_SECONDS, + "finality_window_seconds": FINALITY_WINDOW_SECONDS, + "appeal_failed_reduction": APPEAL_FAILED_REDUCTION, "event_limit": STUCK_FINALIZATION_EVENT_LIMIT, }, ).fetchall() diff --git a/backend/protocol_rpc/transactions_parser.py b/backend/protocol_rpc/transactions_parser.py index 19c41f3d6..6142cbb82 100644 --- a/backend/protocol_rpc/transactions_parser.py +++ b/backend/protocol_rpc/transactions_parser.py @@ -23,6 +23,7 @@ DecodedGenlayerTransactionData, DecodedsubmitAppealDataArgs, DecodedTopUpFeesDataArgs, + DecodedFinalizeTransactionDataArgs, ZERO_ADDRESS, ) @@ -204,6 +205,28 @@ "name": "topUpAndSubmitAppeal", } +FEE_AWARE_FINALIZE_TRANSACTION_ABI = { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "internalType": "uint256", + "name": "_expectedDecisionId", + "type": "uint256", + }, + ], + "name": "finalizeTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", +} + +# Decode old SDK calls only so Studio returns the canonical guarded failure; +# admission below still requires the exact active DecisionId. +FEE_AWARE_LEGACY_FINALIZE_TRANSACTION_ABI = { + **FEE_AWARE_FINALIZE_TRANSACTION_ABI, + "inputs": [FEE_AWARE_FINALIZE_TRANSACTION_ABI["inputs"][0]], +} + FEES_DISTRIBUTION_FIELDS = [ "leaderTimeunitsAllocation", "validatorTimeunitsAllocation", @@ -469,6 +492,14 @@ def _to_int(value: bytes) -> int: ) fee_value = int(value) value = 0 + elif decoded_data["function"] == "finalizeTransaction": + params = decoded_data["params"] + decoded_data = DecodedFinalizeTransactionDataArgs( + tx_id=params["_txId"], + expected_decision_id=params.get( + "_expectedDecisionId" + ), + ) return DecodedRollupTransaction( from_address=sender, @@ -725,6 +756,8 @@ def _get_contract_abi(self) -> list: FEE_AWARE_TOP_UP_FEES_ABI, FEE_AWARE_TOP_UP_AND_SUBMIT_APPEAL_ABI, FEE_AWARE_LEGACY_TOP_UP_AND_SUBMIT_APPEAL_ABI, + FEE_AWARE_FINALIZE_TRANSACTION_ABI, + FEE_AWARE_LEGACY_FINALIZE_TRANSACTION_ABI, ] ) return contract_abi diff --git a/backend/protocol_rpc/types.py b/backend/protocol_rpc/types.py index b4c90e60a..c917981ac 100644 --- a/backend/protocol_rpc/types.py +++ b/backend/protocol_rpc/types.py @@ -41,6 +41,12 @@ class DecodedTopUpFeesDataArgs: fees_distribution: dict +@dataclass +class DecodedFinalizeTransactionDataArgs: + tx_id: str + expected_decision_id: int | None = None + + @dataclass class DecodedRollupTransactionDataArgs: sender: str @@ -70,6 +76,7 @@ class DecodedRollupTransaction: DecodedRollupTransactionData | DecodedsubmitAppealDataArgs | DecodedTopUpFeesDataArgs + | DecodedFinalizeTransactionDataArgs | None ) type: str diff --git a/backend/rollup/default_contracts/consensus_main.py b/backend/rollup/default_contracts/consensus_main.py index b1340801c..091313b1f 100644 --- a/backend/rollup/default_contracts/consensus_main.py +++ b/backend/rollup/default_contracts/consensus_main.py @@ -1337,9 +1337,155 @@ ]""" +_FEES_DISTRIBUTION_COMPONENTS = [ + {"internalType": "uint256", "name": "leaderTimeunitsAllocation", "type": "uint256"}, + { + "internalType": "uint256", + "name": "validatorTimeunitsAllocation", + "type": "uint256", + }, + {"internalType": "uint256", "name": "appealRounds", "type": "uint256"}, + {"internalType": "uint256", "name": "executionBudgetPerRound", "type": "uint256"}, + {"internalType": "uint256", "name": "executionConsumed", "type": "uint256"}, + {"internalType": "uint256", "name": "totalMessageFees", "type": "uint256"}, + {"internalType": "uint256[]", "name": "rotations", "type": "uint256[]"}, + {"internalType": "uint256", "name": "maxPriceGenPerTimeUnit", "type": "uint256"}, + {"internalType": "uint256", "name": "storageFeeMaxGasPrice", "type": "uint256"}, + {"internalType": "uint256", "name": "receiptFeeMaxGasPrice", "type": "uint256"}, +] + +_MESSAGE_ALLOCATION_COMPONENTS = [ + { + "internalType": "enum IMessages.MessageType", + "name": "messageType", + "type": "uint8", + }, + {"internalType": "bool", "name": "onAcceptance", "type": "bool"}, + {"internalType": "uint256", "name": "parentIndex", "type": "uint256"}, + {"internalType": "address", "name": "recipient", "type": "address"}, + {"internalType": "bytes32", "name": "callKey", "type": "bytes32"}, + {"internalType": "uint256", "name": "budget", "type": "uint256"}, + {"internalType": "bytes", "name": "feeParams", "type": "bytes"}, +] + +_ADD_TRANSACTION_PARAMS_COMPONENTS = [ + {"internalType": "address", "name": "sender", "type": "address"}, + {"internalType": "address", "name": "recipient", "type": "address"}, + {"internalType": "uint256", "name": "numOfInitialValidators", "type": "uint256"}, + {"internalType": "uint256", "name": "maxRotations", "type": "uint256"}, + {"internalType": "uint256", "name": "validUntil", "type": "uint256"}, + {"internalType": "uint256", "name": "saltNonce", "type": "uint256"}, + {"internalType": "uint256", "name": "userValue", "type": "uint256"}, + { + "components": _FEES_DISTRIBUTION_COMPONENTS, + "internalType": "struct IFeeManager.FeesDistribution", + "name": "feesDistribution", + "type": "tuple", + }, + {"internalType": "bytes", "name": "txCalldata", "type": "bytes"}, + { + "components": _MESSAGE_ALLOCATION_COMPONENTS, + "internalType": "struct IMessages.MessageFeeAllocationNode[]", + "name": "messageAllocations", + "type": "tuple[]", + }, +] + +_STUDIO_TRAIN_TRANSACTION_ABI = [ + { + "inputs": [ + { + "components": _ADD_TRANSACTION_PARAMS_COMPONENTS, + "internalType": "struct IConsensusMainWithFees.AddTransactionParams", + "name": "_params", + "type": "tuple", + } + ], + "name": function_name, + "outputs": [], + "stateMutability": "payable", + "type": "function", + } + for function_name in ("addTransaction", "deploySalted") +] + [ + { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "components": _FEES_DISTRIBUTION_COMPONENTS, + "internalType": "struct IFeeManager.FeesDistribution", + "name": "_feesDistribution", + "type": "tuple", + }, + ], + "name": "topUpFees", + "outputs": [], + "stateMutability": "payable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "internalType": "uint256", + "name": "_expectedDecisionId", + "type": "uint256", + }, + { + "components": _FEES_DISTRIBUTION_COMPONENTS, + "internalType": "struct IFeeManager.FeesDistribution", + "name": "_feesDistribution", + "type": "tuple", + }, + ], + "name": "topUpAndSubmitAppeal", + "outputs": [], + "stateMutability": "payable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "internalType": "uint256", + "name": "_expectedDecisionId", + "type": "uint256", + }, + ], + "name": "submitAppeal", + "outputs": [], + "stateMutability": "payable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "internalType": "uint256", + "name": "_expectedDecisionId", + "type": "uint256", + }, + ], + "name": "finalizeTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, +] + + def get_default_consensus_main_contract(): + abi = json.loads(DEFAULT_CONSENSUS_MAIN_ABI) + train_transaction_names = {entry["name"] for entry in _STUDIO_TRAIN_TRANSACTION_ABI} + abi = [ + entry + for entry in abi + if entry.get("type") != "function" + or entry.get("name") not in train_transaction_names + ] + abi.extend(_STUDIO_TRAIN_TRANSACTION_ABI) return { "address": DEFAULT_CONSENSUS_MAIN_ADDRESS, - "abi": json.loads(DEFAULT_CONSENSUS_MAIN_ABI), + "abi": abi, "bytecode": "0x", } diff --git a/frontend/src/components/Simulator/TransactionItem.vue b/frontend/src/components/Simulator/TransactionItem.vue index ab2d4a825..0e848d644 100644 --- a/frontend/src/components/Simulator/TransactionItem.vue +++ b/frontend/src/components/Simulator/TransactionItem.vue @@ -7,7 +7,7 @@ import type { TransactionItem, } from '@/types'; import TransactionStatusBadge from '@/components/Simulator/TransactionStatusBadge.vue'; -import { useTimeAgo } from '@vueuse/core'; +import { useTimeAgo, useTimestamp } from '@vueuse/core'; import ModalSection from '@/components/Simulator/ModalSection.vue'; import JsonViewer from '@/components/JsonViewer/json-viewer.vue'; import { useUIStore, useNodeStore, useTransactionsStore } from '@/stores'; @@ -78,6 +78,7 @@ const finalityWindowAppealFailedReduction = ref( ); const isDetailsModalOpen = ref(false); +const currentTimestamp = useTimestamp({ interval: 1000 }); const timeThreshold = 6; // Number of hours after which the date should be displayed instead of time ago @@ -447,6 +448,83 @@ const handleSetTransactionAppeal = async () => { }; const isAppealed = computed(() => props.transaction.data.appealed); +const appealableStatuses = new Set([ + 'ACCEPTED', + 'UNDETERMINED', + 'LEADER_TIMEOUT', + 'VALIDATORS_TIMEOUT', +]); +const terminalValidatorAppealRounds = new Set([ + 'Validator Appeal Successful', + 'Validator Timeout Appeal Successful', +]); + +const canAppeal = computed(() => { + const transaction = props.transaction; + const data = asRecord(transaction.data); + const status = String(transaction.statusName).toUpperCase(); + if ( + !data || + data.leader_only !== false || + Boolean(data.appealed) || + !appealableStatuses.has(status) + ) { + return false; + } + + const history = asRecord(data.consensus_history); + const results = history?.consensus_results; + if ( + Array.isArray(results) && + results.some((entry) => { + const round = asRecord(entry)?.consensus_round; + return terminalValidatorAppealRounds.has(String(round || '')); + }) + ) { + return false; + } + + // New Studio decisions carry the same immutable appeal deadline and exact + // decision identity as Consensus. Once this metadata exists it is the + // authority; falling back to the old reconstructed window would revive an + // expired or stale decision. + const latestDecision = asRecord(history?.latestDecision); + if (latestDecision) { + const decisionId = Number(latestDecision.decisionId); + const appealDeadline = Number(latestDecision.appealDeadline); + const decisionStatus = String(latestDecision.status || '').toUpperCase(); + if ( + !Number.isSafeInteger(decisionId) || + decisionId <= 0 || + !Number.isFinite(appealDeadline) || + appealDeadline <= 0 || + (decisionStatus && decisionStatus !== status) + ) { + return false; + } + return currentTimestamp.value / 1000 < appealDeadline; + } + + // Legacy rows do not have DecisionRecord metadata. Preserve their previous + // reconstructed window, but use Consensus' strict deadline boundary. + const startedAt = Number(data.timestamp_awaiting_finalization); + const processingTime = Number(data.appeal_processing_time || 0); + const failedAppeals = Number(data.appeal_failed || 0); + if ( + !Number.isFinite(startedAt) || + !Number.isFinite(processingTime) || + !Number.isInteger(failedAppeals) || + failedAppeals < 0 + ) { + return false; + } + const legacyDeadline = + startedAt + + processingTime + + props.finalityWindow * + (1 - finalityWindowAppealFailedReduction.value) ** failedAppeals; + return currentTimestamp.value / 1000 < legacyDeadline; +}); const isHosted = getRuntimeConfigBoolean('VITE_IS_HOSTED', false); const hasSenderAddress = computed(() => Boolean( @@ -654,19 +732,7 @@ const badgeColorClass = computed(() => {
({ useUIStore: vi.fn(() => ({ mode: 'light' })), useNodeStore: vi.fn(() => ({ searchFilter: '' })), useTransactionsStore: vi.fn(() => ({ cancelTransaction: vi.fn(), - setTransactionAppeal: vi.fn(), + setTransactionAppeal: setTransactionAppealMock, })), })); @@ -17,6 +19,7 @@ vi.mock('@kyvg/vue3-notification', () => ({ vi.mock('@vueuse/core', () => ({ useTimeAgo: vi.fn(() => ({ value: 'just now' })), + useTimestamp: vi.fn(() => ({ value: Date.now() })), })); vi.mock('@/utils/runtimeConfig', () => ({ @@ -268,4 +271,164 @@ describe('TransactionItem fee accounting display', () => { ); expect(text).toContain('55,000,000,000,000,000 wei'); }); + + it('uses the exact persisted decision deadline instead of the legacy reconstruction', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-28T12:00:00Z')); + const now = Date.now() / 1000; + const exactExpired = { + ...transaction, + data: { + ...transaction.data, + timestamp_awaiting_finalization: now, + consensus_history: { + latestDecision: { + decisionId: 1, + status: 'ACCEPTED', + materializedAt: now - 30, + appealDeadline: now, + }, + consensus_results: [], + }, + }, + }; + + const wrapper = mount(TransactionItem, { + props: { transaction: exactExpired, finalityWindow: 60 }, + global: { + directives: { tooltip: vi.fn() }, + stubs: { + Modal: ModalStub, + Btn: { + template: '', + }, + CopyTextButton: true, + JsonViewer: true, + Loader: true, + TransactionStatusBadge: { template: '' }, + CheckCircleIcon: true, + XCircleIcon: true, + EllipsisHorizontalCircleIcon: true, + FilterIcon: true, + GavelIcon: true, + UserPen: true, + UserSearch: true, + ExternalLink: true, + }, + }, + }); + + expect( + wrapper + .find(`[data-testid="appeal-transaction-btn-${transaction.hash}"]`) + .exists(), + ).toBe(false); + vi.useRealTimers(); + }); + + it('shows an exact active decision even when the legacy formula would be expired', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-28T12:00:00Z')); + const now = Date.now() / 1000; + const exactActive = { + ...transaction, + data: { + ...transaction.data, + timestamp_awaiting_finalization: now - 600, + consensus_history: { + latestDecision: { + decisionId: 2, + status: 'ACCEPTED', + materializedAt: now - 10, + appealDeadline: now + 20, + }, + consensus_results: [], + }, + }, + }; + + const wrapper = mount(TransactionItem, { + props: { transaction: exactActive, finalityWindow: 60 }, + global: { + directives: { tooltip: vi.fn() }, + stubs: { + Modal: ModalStub, + Btn: { + template: '', + }, + CopyTextButton: true, + JsonViewer: true, + Loader: true, + TransactionStatusBadge: { template: '' }, + CheckCircleIcon: true, + XCircleIcon: true, + EllipsisHorizontalCircleIcon: true, + FilterIcon: true, + GavelIcon: true, + UserPen: true, + UserSearch: true, + ExternalLink: true, + }, + }, + }); + + const appealButton = wrapper.find( + `[data-testid="appeal-transaction-btn-${transaction.hash}"]`, + ); + expect(appealButton.exists()).toBe(true); + await appealButton.trigger('click'); + expect(setTransactionAppealMock).toHaveBeenCalledWith(transaction.hash); + vi.useRealTimers(); + }); + + it('hides appeals after a terminal successful validator appeal', () => { + const terminal = { + ...transaction, + data: { + ...transaction.data, + consensus_history: { + latestDecision: { + decisionId: 2, + status: 'ACCEPTED', + materializedAt: Date.now() / 1000, + appealDeadline: Date.now() / 1000 + 60, + }, + consensus_results: [ + { consensus_round: 'Validator Appeal Successful' }, + ], + }, + }, + }; + + const wrapper = mount(TransactionItem, { + props: { transaction: terminal, finalityWindow: 60 }, + global: { + directives: { tooltip: vi.fn() }, + stubs: { + Modal: ModalStub, + Btn: { + template: '', + }, + CopyTextButton: true, + JsonViewer: true, + Loader: true, + TransactionStatusBadge: { template: '' }, + CheckCircleIcon: true, + XCircleIcon: true, + EllipsisHorizontalCircleIcon: true, + FilterIcon: true, + GavelIcon: true, + UserPen: true, + UserSearch: true, + ExternalLink: true, + }, + }, + }); + + expect( + wrapper + .find(`[data-testid="appeal-transaction-btn-${transaction.hash}"]`) + .exists(), + ).toBe(false); + }); }); diff --git a/tests/db-sqlalchemy/accounts_manager_test.py b/tests/db-sqlalchemy/accounts_manager_test.py index 740166670..af450d8a9 100644 --- a/tests/db-sqlalchemy/accounts_manager_test.py +++ b/tests/db-sqlalchemy/accounts_manager_test.py @@ -1,10 +1,12 @@ from datetime import datetime import time +import threading from eth_account.signers.local import ( LocalAccount, ) import pytest -from sqlalchemy.orm import Session +from sqlalchemy import Engine +from sqlalchemy.orm import Session, sessionmaker from backend.database_handler.accounts_manager import AccountsManager from backend.database_handler.errors import AccountNotFoundError @@ -216,6 +218,53 @@ def test_cancel_tx_fee_accounting_once_refunds_and_is_idempotent( assert fee_accounting["total_refunded"] == 1155 +def test_concurrent_cancel_refunds_fee_accounting_only_once(engine: Engine): + SessionFactory = sessionmaker(bind=engine, expire_on_commit=False) + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + tx_hash = "0x" + "ac" * 32 + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=55), + num_of_validators=5, + submitted_value=1_155, + user_value=0, + sender=sender, + ) + with SessionFactory() as setup_session: + _insert_fee_accounted_transaction( + setup_session, + sender=sender, + accounting=accounting, + tx_hash=tx_hash, + ) + + barrier = threading.Barrier(2, timeout=5) + refunds: list[int] = [] + errors: list[BaseException] = [] + + def cancel(): + try: + with SessionFactory() as worker_session: + manager = AccountsManager(worker_session) + barrier.wait() + refunds.append(manager.cancel_tx_fee_accounting_once(tx_hash, sender)) + worker_session.commit() + except BaseException as exc: + errors.append(exc) + + first = threading.Thread(target=cancel) + second = threading.Thread(target=cancel) + first.start() + second.start() + first.join(timeout=10) + second.join(timeout=10) + + assert not first.is_alive() and not second.is_alive() + assert errors == [] + assert sorted(refunds) == [0, 1_155] + with SessionFactory() as read_session: + assert AccountsManager(read_session).get_account_balance(sender) == 1_155 + + def test_settle_tx_fee_accounting_once_refunds_surplus_and_is_idempotent( accounts_manager: AccountsManager, session: Session, diff --git a/tests/db-sqlalchemy/test_fee_top_up_concurrency.py b/tests/db-sqlalchemy/test_fee_top_up_concurrency.py new file mode 100644 index 000000000..f2ed68e94 --- /dev/null +++ b/tests/db-sqlalchemy/test_fee_top_up_concurrency.py @@ -0,0 +1,156 @@ +"""PostgreSQL regression coverage for serial fee top-up accounting.""" + +import json +import threading +from copy import deepcopy + +from sqlalchemy import Engine, text +from sqlalchemy.orm import sessionmaker + +from backend.database_handler.transactions_processor import TransactionsProcessor +from backend.protocol_rpc.fees import StudioFeePolicy, create_fee_accounting + + +def _fees_distribution(*, leader=100, validator=200, rotations=None): + return { + "leaderTimeunitsAllocation": leader, + "validatorTimeunitsAllocation": validator, + "appealRounds": 0, + "executionBudgetPerRound": 0, + "executionConsumed": 0, + "totalMessageFees": 0, + "rotations": [0] if rotations is None else rotations, + "maxPriceGenPerTimeUnit": 0, + "storageFeeMaxGasPrice": 0, + "receiptFeeMaxGasPrice": 0, + } + + +def test_concurrent_fee_top_ups_compose_without_lost_accounting(engine: Engine): + Session = sessionmaker(bind=engine, expire_on_commit=False) + tx_hash = "0x" + "ab" * 32 + sender = "0x1111111111111111111111111111111111111111" + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(), + num_of_validators=5, + submitted_value=1_100, + user_value=0, + sender=sender, + policy=StudioFeePolicy(), + ) + with Session() as session: + session.execute( + text( + """ + INSERT INTO transactions ( + hash, status, from_address, to_address, data, value, type, + nonce, leader_only, execution_mode, appealed, appeal_failed, + appeal_undetermined, appeal_leader_timeout, + appeal_validators_timeout, appeal_processing_time, + value_credited, consensus_history, + num_of_initial_validators + ) VALUES ( + :hash, 'PENDING', :sender, '0xcontract', CAST(:data AS jsonb), + 0, 2, 0, false, 'NORMAL', false, 0, false, false, false, 0, + false, CAST('{}' AS jsonb), 5 + ) + """ + ), + { + "hash": tx_hash, + "sender": sender, + "data": json.dumps({"fee_accounting": accounting}), + }, + ) + session.commit() + + barrier = threading.Barrier(2, timeout=5) + errors: list[BaseException] = [] + + def top_up(amount: int): + try: + with Session() as session: + processor = TransactionsProcessor(session) + barrier.wait() + processor.apply_transaction_fee_top_up( + tx_hash, + fees_distribution=_fees_distribution( + leader=0, validator=0, rotations=[] + ), + amount=amount, + sender=sender, + policy=StudioFeePolicy(), + ) + session.commit() + except BaseException as exc: # surfaced in the parent test thread + errors.append(exc) + + first = threading.Thread(target=top_up, args=(7,)) + second = threading.Thread(target=top_up, args=(11,)) + first.start() + second.start() + first.join(timeout=10) + second.join(timeout=10) + + assert not first.is_alive() and not second.is_alive() + assert errors == [] + with Session() as session: + data = session.execute( + text("SELECT data FROM transactions WHERE hash = :hash"), + {"hash": tx_hash}, + ).scalar_one() + + updated = data["fee_accounting"] + assert updated["paid_fee_value"] == 1_118 + assert updated["primary_fee_budget"] == 1_118 + assert sorted(item["amount"] for item in updated["top_ups"]) == [7, 11, 1_100] + + # A worker mutation and a later permitted RPC top-up must also compose. + # Previously the worker could write its pre-top-up JSON snapshot after the + # payer had already been charged, silently erasing that contribution. + barrier = threading.Barrier(2, timeout=5) + errors.clear() + + def worker_mutation(): + try: + with Session() as session: + processor = TransactionsProcessor(session) + barrier.wait() + + def mark_execution(current): + changed = deepcopy(current) + changed["concurrency_test_execution_marker"] = 23 + return changed + + processor.mutate_transaction_fee_accounting( + tx_hash, + mark_execution, + ) + except BaseException as exc: + errors.append(exc) + + worker = threading.Thread(target=worker_mutation) + payer = threading.Thread(target=top_up, args=(13,)) + worker.start() + payer.start() + worker.join(timeout=10) + payer.join(timeout=10) + + assert not worker.is_alive() and not payer.is_alive() + assert errors == [] + with Session() as session: + data = session.execute( + text("SELECT data FROM transactions WHERE hash = :hash"), + {"hash": tx_hash}, + ).scalar_one() + + updated = data["fee_accounting"] + assert updated["paid_fee_value"] == 1_131 + assert updated["primary_fee_budget"] == 1_131 + assert updated["concurrency_test_execution_marker"] == 23 + assert sorted(item["amount"] for item in updated["top_ups"]) == [ + 7, + 11, + 13, + 1_100, + ] diff --git a/tests/db-sqlalchemy/test_health_orphan_detection.py b/tests/db-sqlalchemy/test_health_orphan_detection.py index 24b022b8b..70dd24698 100644 --- a/tests/db-sqlalchemy/test_health_orphan_detection.py +++ b/tests/db-sqlalchemy/test_health_orphan_detection.py @@ -74,6 +74,7 @@ def _insert_tx( blocked_at: datetime | None = None, worker_id: str | None = None, timestamp_awaiting_finalization: int | None = None, + appealed: bool = False, recovery_count: int = 0, consensus_history: dict | None = None, consensus_data: dict | None = None, @@ -94,7 +95,7 @@ def _insert_tx( ) VALUES ( :hash, CAST(:status AS transaction_status), '0xfromaddress', :to_addr, CAST('{}' AS jsonb), 0, 2, - :nonce, false, 'NORMAL', false, 0, + :nonce, false, 'NORMAL', :appealed, 0, false, false, false, 0, :recovery_count, false, CAST(:consensus_history AS jsonb), CAST(:consensus_data AS jsonb), :created_at, :blocked_at, :worker_id, @@ -111,6 +112,7 @@ def _insert_tx( "blocked_at": blocked_at, "worker_id": worker_id, "timestamp_awaiting_finalization": timestamp_awaiting_finalization, + "appealed": appealed, "recovery_count": recovery_count, "consensus_history": ( json.dumps(consensus_history) if consensus_history is not None else None @@ -576,9 +578,9 @@ async def test_status_threshold_matches_dashboard_alert_gate(engine: Engine): # only need finalization. They live under this metric. The detector # has two arms: # -# 1. timestamp_awaiting_finalization set + stale → finalizer not -# making progress on these rows (e.g., finalization scheduler -# starvation, finalization claim path broken). +# 1. exact/reconstructed appeal deadline + stale → finalizer not making +# progress on these rows (e.g., finalization scheduler starvation, +# finalization claim path broken). # 2. timestamp_awaiting_finalization NULL + row is old → defensive: # catches future bugs like the May 2026 insufficient-balance SEND # path, which reached UNDETERMINED without ever stamping the @@ -705,6 +707,118 @@ async def test_recent_finalization_eligible_row_is_not_flagged(engine: Engine): assert result["stuck_finalization_transactions"] == [] +@pytest.mark.asyncio +async def test_exact_decision_deadline_prevents_early_stuck_flag(engine: Engine): + """An old materialization timestamp is not a stall while the exact + DecisionRecord appeal window is still open.""" + import time + + Session_ = sessionmaker(bind=engine, expire_on_commit=False) + now = datetime.now(timezone.utc) + current_timestamp = int(time.time()) + + with Session_() as s: + _insert_tx( + s, + tx_hash="0x" + "f5" * 32, + to_address="0x" + "f5" * 20, + status="ACCEPTED", + nonce=0, + created_at=now - timedelta(days=1), + timestamp_awaiting_finalization=current_timestamp - 24 * 3600, + consensus_history={ + "latestDecision": { + "decisionId": 2, + "status": "ACCEPTED", + "appealDeadline": current_timestamp + 300, + } + }, + ) + s.commit() + + from backend.protocol_rpc import health as health_module + + result = await health_module._check_consensus_health() + + assert result["stuck_finalization_count"] == 0 + assert result["stuck_finalization_transactions"] == [] + + +@pytest.mark.asyncio +async def test_exact_decision_deadline_drives_stuck_flag(engine: Engine): + """The persisted deadline remains authoritative when legacy timestamps + would incorrectly describe the decision as recent.""" + import time + + Session_ = sessionmaker(bind=engine, expire_on_commit=False) + now = datetime.now(timezone.utc) + current_timestamp = int(time.time()) + + with Session_() as s: + _insert_tx( + s, + tx_hash="0x" + "f6" * 32, + to_address="0x" + "f6" * 20, + status="UNDETERMINED", + nonce=0, + created_at=now - timedelta(hours=1), + timestamp_awaiting_finalization=current_timestamp, + consensus_history={ + "latestDecision": { + "decisionId": 2, + "status": "UNDETERMINED", + "appealDeadline": current_timestamp - 601, + } + }, + ) + s.commit() + + from backend.protocol_rpc import health as health_module + + result = await health_module._check_consensus_health() + + assert result["stuck_finalization_count"] == 1 + assert result["stuck_finalization_transactions"][0]["hash"] == "0x" + "f6" * 32 + + +@pytest.mark.asyncio +async def test_submitted_appeal_is_not_a_stuck_finalization(engine: Engine): + """An appeal freezes finalization while its new round is being admitted; + the previous decision deadline must not create a false health alert.""" + import time + + Session_ = sessionmaker(bind=engine, expire_on_commit=False) + now = datetime.now(timezone.utc) + current_timestamp = int(time.time()) + + with Session_() as s: + _insert_tx( + s, + tx_hash="0x" + "f7" * 32, + to_address="0x" + "f7" * 20, + status="ACCEPTED", + nonce=0, + created_at=now - timedelta(hours=1), + timestamp_awaiting_finalization=current_timestamp - 3600, + appealed=True, + consensus_history={ + "latestDecision": { + "decisionId": 1, + "status": "ACCEPTED", + "appealDeadline": current_timestamp - 1800, + } + }, + ) + s.commit() + + from backend.protocol_rpc import health as health_module + + result = await health_module._check_consensus_health() + + assert result["stuck_finalization_count"] == 0 + assert result["stuck_finalization_transactions"] == [] + + @pytest.mark.asyncio async def test_stuck_finalization_flips_status_at_threshold(engine: Engine): """consensus.status flips to degraded when stuck_finalization_count diff --git a/tests/unit/consensus/test_decisions_revealing.py b/tests/unit/consensus/test_decisions_revealing.py index b7b7d531b..6e6626d56 100644 --- a/tests/unit/consensus/test_decisions_revealing.py +++ b/tests/unit/consensus/test_decisions_revealing.py @@ -76,7 +76,7 @@ def test_appeal_failed_0_appends_all(self): assert merged_votes == {"a": "agree", "b": "disagree"} assert merged_vals == ["v1", "v2", "v3", "v4"] - def test_appeal_failed_1_overwrites_half(self): + def test_appeal_failed_1_retains_consumed_validators(self): existing = list(range(5)) # 5 existing validators current = ["new1", "new2", "new3"] _, merged_vals = merge_appeal_validators( @@ -86,10 +86,9 @@ def test_appeal_failed_1_overwrites_half(self): current_validation_results=current, appeal_failed=1, ) - # n = (5-1)//2 = 2, keep existing[:1], then current - assert merged_vals == [0] + current + assert merged_vals == existing + current - def test_appeal_failed_2(self): + def test_appeal_failed_2_retains_consumed_validators(self): existing = list(range(7)) current = ["n1", "n2", "n3", "n4"] _, merged_vals = merge_appeal_validators( @@ -99,10 +98,7 @@ def test_appeal_failed_2(self): current_validation_results=current, appeal_failed=2, ) - # n = len(current) - (len(existing) + 1) = 4 - 8 = -4 - # existing[:n-1] = existing[:-5] = existing[:2] (negative index) - n = len(current) - (len(existing) + 1) - assert merged_vals == existing[: n - 1] + current + assert merged_vals == existing + current def test_votes_merged_with_pipe(self): merged_votes, _ = merge_appeal_validators( diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 14d09b06b..dbe0864cd 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -9,6 +9,7 @@ from web3 import Web3 from backend.consensus.base import ( + ConsensusAlgorithm, _apply_external_message_freeze_check, _apply_message_value_withdrawals_for_phase, _child_config_rotation_rounds, @@ -16,7 +17,11 @@ _get_messages_data, _runtime_rotation_limit, ) -from backend.domain.types import TransactionType +from backend.consensus.history import ( + materialize_decision_metadata, + prepare_appeal_decision_basis, +) +from backend.domain.types import TransactionExecutionMode, TransactionType from backend.errors.errors import InvalidTransactionError from backend.database_handler.accounts_manager import _infer_final_round from backend.database_handler.transactions_processor import ( @@ -28,6 +33,7 @@ _current_fee_round, _funded_max_rotations, _handle_appeal_or_top_up_and_submit, + _handle_finalize_transaction, _handle_top_up_fees, _normal_leader_count, estimate_latest_appeal_charge, @@ -140,6 +146,7 @@ DecodedRollupTransactionData, DecodedRollupTransactionDataArgs, DecodedTopUpFeesDataArgs, + DecodedFinalizeTransactionDataArgs, ) @@ -4854,6 +4861,93 @@ def test_successful_validator_appeal_with_no_majority_does_not_vindicate_origina assert settled["settlement_rounds"][1]["timeUnitAmount"] == 1_400 +def test_terminal_settlement_uses_frozen_electorate_threshold_not_local_majority(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + accounting["selection_pool_count"] = 22 + # Within-quota appeal funding still covers the ten seats above the + # scheduled 11-seat replacement: 1,400 bond + 2,000 funding. + accounting = record_appeal_bond( + accounting, + amount=3_400, + appealer="0x9999999999999999999999999999999999999999", + current_round=0, + status="ACCEPTED", + terminal_committee_upper_bound=21, + ) + original = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote="agree" if index <= 3 else "disagree", + ) + for index in range(1, 6) + ] + jury = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote="disagree", + ) + for index in range(30, 37) + ] + terminal = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote="agree" if offset < 11 else "disagree", + ) + for offset, index in enumerate(range(50, 71)) + ] + history = { + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x1111111111111111111111111111111111111111", + ), + original[0], + ], + "validator_results": original[1:], + }, + { + "consensus_round": "Validator Appeal Successful", + "leader_result": None, + "validator_results": jury, + }, + { + "consensus_round": "Undetermined", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x2222222222222222222222222222222222222222", + ), + terminal[0], + ], + "validator_results": terminal[1:], + }, + ] + } + + settled, _ = settle_fee_accounting( + accounting, + actual_final_round=2, + num_of_validators=5, + consensus_history=history, + ) + + terminal_round = settled["settlement_rounds"][2] + assert terminal_round["alignmentResult"] == "no_majority" + assert terminal_round["timeUnitAmount"] == 100 + 21 * 200 + + def test_failed_validator_appeal_charges_only_revealers_and_refunds_distribution_dust(): fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) accounting = create_fee_accounting( @@ -7722,9 +7816,10 @@ def test_create_child_fee_accounting_rejects_phase_mismatched_root_subtree(): class _MessageDispatchTxProcessor: - def __init__(self): + def __init__(self, fee_accounting): self.updated_fee_accounting = None self.updated_hash = None + self.fee_accounting = fee_accounting def get_transaction_count(self, address): return 3 @@ -7732,10 +7827,16 @@ def get_transaction_count(self, address): def update_transaction_fee_accounting(self, tx_hash, accounting): self.updated_hash = tx_hash self.updated_fee_accounting = accounting + self.fee_accounting = accounting + + def mutate_transaction_fee_accounting(self, tx_hash, mutator): + updated = mutator(self.fee_accounting) + self.update_transaction_fee_accounting(tx_hash, updated) + return updated def _message_dispatch_context(accounting): - processor = _MessageDispatchTxProcessor() + processor = _MessageDispatchTxProcessor(accounting) executor = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" tx = SimpleNamespace( hash="0x" + "ab" * 32, @@ -8725,11 +8826,51 @@ def update_transaction_fee_accounting(self, tx_hash, fee_accounting): self.updated_fee_accounting = fee_accounting self.transaction["data"]["fee_accounting"] = fee_accounting + def apply_transaction_fee_top_up(self, tx_hash, **kwargs): + assert tx_hash == self.transaction["hash"] + if self.transaction["status"] in { + "ACCEPTED", + "UNDETERMINED", + "LEADER_TIMEOUT", + "VALIDATORS_TIMEOUT", + "FINALIZED", + "CANCELED", + }: + raise ValueError("InvalidTransactionStatus") + current = self.transaction["data"].get("fee_accounting") + if current is None: + raise ValueError("FeeAccountingMissing") + updated = apply_fee_top_up( + current, + num_of_validators=int( + self.transaction.get("num_of_initial_validators") or 5 + ), + **kwargs, + ) + self.update_transaction_fee_accounting(tx_hash, updated) + return updated + def set_transaction_appeal(self, tx_hash, appeal): assert tx_hash == self.transaction["hash"] self.appeal_updates.append((tx_hash, appeal)) self.transaction["appealed"] = appeal + def admit_transaction_appeal(self, tx_hash, *, prepare_fee_accounting, **kwargs): + assert tx_hash == self.transaction["hash"] + if self.transaction.get("appealed"): + raise ValueError("CanNotAppeal") + self.transaction["consensus_history"] = prepare_appeal_decision_basis( + self.transaction.get("consensus_history"), + **kwargs, + ) + fee_accounting, surplus_refund = prepare_fee_accounting( + self.transaction["data"].get("fee_accounting") + ) + if fee_accounting is not None: + self.update_transaction_fee_accounting(tx_hash, fee_accounting) + self.set_transaction_appeal(tx_hash, True) + return surplus_refund + def is_transaction_finalization_head(self, tx_hash): assert tx_hash == self.transaction["hash"] return self.finalization_head @@ -8772,6 +8913,20 @@ def _decoded_appeal( ) +def _decoded_finalize(tx_id, *, expected_decision_id=1, amount=0): + return DecodedRollupTransaction( + from_address="0x1111111111111111111111111111111111111111", + to_address="0x0000000000000000000000000000000000000000", + data=DecodedFinalizeTransactionDataArgs( + tx_id=tx_id, + expected_decision_id=expected_decision_id, + ), + type="2", + nonce=0, + value=amount, + ) + + def _fee_accounted_tx(*, status="PENDING", accounting=None): tx_hash = "0x" + "12" * 32 return { @@ -8846,6 +9001,23 @@ def test_terminal_committee_excludes_compacted_leader_appeal_replay_leader(): assert _normal_leader_count(history) == 2 +def test_terminal_committee_excludes_every_rotated_normal_leader(): + history = { + "consensus_results": [ + {"consensus_round": "Leader Rotation"}, + {"consensus_round": "Leader Rotation"}, + {"consensus_round": "Accepted"}, + {"consensus_round": "Leader Rotation Appeal"}, + {"consensus_round": "Leader Appeal Failed"}, + ] + } + + # Consensus records the initial leader and every forked normal-round + # generation. Appeal-jury leaders are excluded, but a leader-appeal replay + # and its rotations are normal-round leaders too. + assert _normal_leader_count(history) == 5 + + @pytest.mark.parametrize( "status", [ @@ -8871,7 +9043,10 @@ def test_top_up_fees_endpoint_rejects_final_decided_transaction_status(status): assert transactions.updated_fee_accounting is None -def test_submit_appeal_endpoint_records_separate_bond_and_typed_funding(): +def test_submit_appeal_endpoint_records_separate_bond_and_typed_funding(monkeypatch): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setenv("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0.2") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_000) accounting = _env_fee_accounting() tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) accounts = _FakeAccountsManager(balance=0) @@ -8905,6 +9080,11 @@ def send_message(self, log_event, log_to_terminal=True): assert updated["appeal_bonds"][0]["minimumRequired"] == appeal_bond assert updated["appeal_bonds"][0]["topUpAndSubmit"] is False assert transactions.appeal_updates == [(tx["hash"], True)] + assert tx["consensus_history"]["activeAppealBasis"] == { + "decisionId": 1, + "submittedAt": 1_000, + "nextAppealWindow": 24, + } assert len(handler.events) == 1 assert accounts.credits == [ ("0x1111111111111111111111111111111111111111", submitted) @@ -8914,6 +9094,42 @@ def send_message(self, log_event, log_to_terminal=True): ] +def test_concurrent_appeal_loser_is_not_charged_or_recorded(monkeypatch): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_000) + accounting = _env_fee_accounting() + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) + accounts = _FakeAccountsManager(balance=10_000) + + class _RacingTransactionsProcessor(_FakeTransactionsProcessor): + def admit_transaction_appeal(self, tx_hash, **kwargs): + # The competing request won after this request's initial read but + # before its row-locked compare-and-set. + raise ValueError("CanNotAppeal") + + class _MsgHandler: + def send_message(self, log_event, log_to_terminal=True): + raise AssertionError("a losing admission must not emit success") + + transactions = _RacingTransactionsProcessor(tx) + charge = _env_appeal_charge(accounting) + + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + _handle_appeal_or_top_up_and_submit( + accounts_manager=accounts, + transactions_processor=transactions, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal( + tx["hash"], amount=charge["bond"] + charge["funding"] + ), + ) + + assert transactions.updated_fee_accounting is None + assert transactions.appeal_updates == [] + assert accounts.debits == [] + assert accounts.credits == [] + + def test_leader_timeout_appeal_uses_next_normal_fee_schedule(): fees = _env_fees_distribution(appeals=1, rotations=[3, 2]) accounting = _env_fee_accounting(fees) @@ -8924,7 +9140,6 @@ def test_leader_timeout_appeal_uses_next_normal_fee_schedule(): "0x2222222222222222222222222222222222222222", "0x3333333333333333333333333333333333333333", "0x4444444444444444444444444444444444444444", - "0x5555555555555555555555555555555555555555", ] charge = calculate_appeal_charge( fees, @@ -9062,7 +9277,7 @@ def send_message(self, log_event, log_to_terminal=True): assert transactions.appeal_updates == [(tx["hash"], True)] -def test_submit_appeal_endpoint_rejects_bond_below_required_minimum(): +def test_submit_appeal_endpoint_rejects_underfunded_induced_work(): accounting = _env_fee_accounting() tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) transactions = _FakeTransactionsProcessor(tx) @@ -9072,7 +9287,7 @@ class _MsgHandler: def send_message(self, log_event, log_to_terminal=True): pass - with pytest.raises(InvalidTransactionError, match="InvalidAppealBond"): + with pytest.raises(InvalidTransactionError, match="InsufficientFees"): _handle_appeal_or_top_up_and_submit( accounts_manager=_FakeAccountsManager(balance=5000), transactions_processor=transactions, @@ -9371,9 +9586,13 @@ def test_transaction_lifecycle_exposes_active_v06_decision_before_deadline( assert get_transaction_lifecycle( processor, {"txId": tx["hash"], "timestamp": 1_034} ) == { + "storedStatus": "Accepted", "storedStatusCode": 5, + "projectedStatus": "Accepted", "projectedStatusCode": 5, + "resolutionAction": "NoOp", "resolutionActionCode": 0, + "resolutionSource": "FullReveal", "resolutionSourceCode": 6, "decisionId": "1", "decisionActive": True, @@ -9398,9 +9617,13 @@ def test_transaction_lifecycle_projects_finalize_at_exact_deadline(monkeypatch): ) assert lifecycle["storedStatusCode"] == 11 + assert lifecycle["storedStatus"] == "ValidatorsTimeout" assert lifecycle["projectedStatusCode"] == 11 + assert lifecycle["projectedStatus"] == "ValidatorsTimeout" assert lifecycle["resolutionActionCode"] == 6 - assert lifecycle["resolutionSourceCode"] == 7 + assert lifecycle["resolutionAction"] == "Finalize" + assert lifecycle["resolutionSourceCode"] == 6 + assert lifecycle["resolutionSource"] == "FullReveal" assert lifecycle["decisionId"] == "1" assert lifecycle["decisionActive"] is True @@ -9448,6 +9671,67 @@ def test_transaction_lifecycle_uses_appeal_decision_ordinal_and_source(): assert lifecycle["decisionId"] == "2" assert lifecycle["resolutionSourceCode"] == 9 + assert lifecycle["resolutionSource"] == "AppealFullReveal" + + +def test_transaction_lifecycle_exposes_terminal_replacement_for_finalization(): + tx = _fee_accounted_tx(status="ACCEPTED") + tx.update( + consensus_history={ + "consensus_results": [ + {"consensus_round": "Accepted"}, + {"consensus_round": "Validator Appeal Successful"}, + {"consensus_round": "Accepted"}, + ] + }, + timestamp_awaiting_finalization=1_000, + appealed=False, + execution_mode="NORMAL", + ) + + lifecycle = get_transaction_lifecycle( + _FakeTransactionsProcessor(tx), + {"txId": tx["hash"], "timestamp": 10_000}, + ) + + # Consensus keeps the terminal replacement's DecisionRecord active so it + # can finalize; terminality suppresses only a further appeal. + assert lifecycle["decisionId"] == "2" + assert lifecycle["decisionActive"] is True + assert lifecycle["resolutionAction"] == "Finalize" + assert lifecycle["resolutionSource"] == "FullReveal" + + +def test_terminal_replacement_can_finalize_but_cannot_be_appealed(monkeypatch): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_030) + tx = _fee_accounted_tx(status="ACCEPTED", accounting=_env_fee_accounting()) + tx.update( + consensus_history={ + "consensus_results": [ + {"consensus_round": "Accepted"}, + {"consensus_round": "Validator Appeal Successful"}, + {"consensus_round": "Accepted"}, + ] + }, + timestamp_awaiting_finalization=1_000, + appeal_processing_time=0, + appealed=False, + execution_mode="NORMAL", + ) + processor = _FakeTransactionsProcessor(tx) + + assert ( + _handle_finalize_transaction( + transactions_processor=processor, + decoded_rollup_transaction=_decoded_finalize( + tx["hash"], expected_decision_id=2 + ), + ) + == tx["hash"] + ) + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + estimate_latest_appeal_charge(processor, {"txId": tx["hash"]}) def test_latest_appeal_charge_rpc_uses_admission_quote_and_decision_id( @@ -9476,6 +9760,575 @@ def test_latest_appeal_charge_rpc_uses_admission_quote_and_decision_id( } +def test_latest_appeal_charge_rpc_prices_capacity_limited_jury_exactly(monkeypatch): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_001) + accounting = _env_fee_accounting() + accounting["selection_pool_count"] = 10 + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=0, + appeal_failed=0, + appealed=False, + execution_mode="NORMAL", + consensus_data={ + "validators": [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + ) + for index in range(2, 6) + ] + }, + consensus_history={ + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x0000000000000000000000000000000000000001", + ) + ], + "validator_results": [], + } + ] + }, + ) + expected = calculate_appeal_charge( + accounting["fees_distribution"], + current_round=0, + status="ACCEPTED", + terminal_committee_upper_bound=9, + available_appeal_validators=5, + policy=StudioFeePolicy.from_env(), + ) + scheduled = calculate_appeal_charge( + accounting["fees_distribution"], + current_round=0, + status="ACCEPTED", + terminal_committee_upper_bound=9, + policy=StudioFeePolicy.from_env(), + ) + + quote = estimate_latest_appeal_charge( + _FakeTransactionsProcessor(tx), {"txId": tx["hash"]} + ) + + assert expected["juryCount"] == 5 + assert scheduled["juryCount"] == 7 + assert int(quote["funding"]) == expected["funding"] + assert expected["funding"] < scheduled["funding"] + + class _MsgHandler: + def send_message(self, log_event, log_to_terminal=True): + pass + + processor = _FakeTransactionsProcessor(tx) + total = int(quote["bond"]) + int(quote["funding"]) + with pytest.raises(InvalidTransactionError, match="InsufficientFees"): + _handle_appeal_or_top_up_and_submit( + accounts_manager=_FakeAccountsManager(balance=total), + transactions_processor=processor, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal( + tx["hash"], + amount=total - 1, + ), + ) + + _handle_appeal_or_top_up_and_submit( + accounts_manager=_FakeAccountsManager(balance=total), + transactions_processor=processor, + msg_handler=_MsgHandler(), + decoded_rollup_transaction=_decoded_appeal(tx["hash"], amount=total), + ) + assert processor.updated_fee_accounting["appeal_bonds"][0]["juryCount"] == 5 + + +def test_latest_appeal_charge_rejects_capacity_limited_undetermined_replay( + monkeypatch, +): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_001) + accounting = _env_fee_accounting() + accounting["selection_pool_count"] = 10 + tx = _fee_accounted_tx(status="UNDETERMINED", accounting=accounting) + tx.update( + timestamp_awaiting_finalization=1_000, + appealed=False, + consensus_data={ + "validators": [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + ) + for index in range(2, 6) + ] + }, + consensus_history={ + "consensus_results": [ + { + "consensus_round": "Undetermined", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x0000000000000000000000000000000000000001", + ) + ], + } + ] + }, + ) + + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + estimate_latest_appeal_charge( + _FakeTransactionsProcessor(tx), {"txId": tx["hash"]} + ) + + +def test_leader_appeal_requires_the_full_fresh_scheduled_set(): + all_validators = [ + {"address": f"0x{index:040x}", "stake": 1} for index in range(1, 11) + ] + consensus_data = SimpleNamespace( + leader_receipt=[ + SimpleNamespace( + node_config={"address": "0x0000000000000000000000000000000000000001"} + ) + ], + validators=[ + SimpleNamespace(node_config={"address": f"0x{index:040x}"}) + for index in range(2, 6) + ], + ) + history = { + "consensus_results": [ + { + "consensus_round": "Undetermined", + "leader_result": [ + { + "node_config": { + "address": "0x0000000000000000000000000000000000000001" + } + } + ], + } + ] + } + + with pytest.raises(ValueError, match="required 7, available 5"): + ConsensusAlgorithm.get_extra_validators( + all_validators, + history, + consensus_data, + 0, + required_extra_validators=7, + ) + + +def test_repeated_validator_appeal_selects_fresh_scheduled_jury(): + all_validators = [ + {"address": f"0x{index:040x}", "stake": 1} for index in range(1, 31) + ] + consumed = [ + SimpleNamespace(node_config={"address": f"0x{index:040x}"}) + for index in range(2, 13) + ] + consensus_data = SimpleNamespace(validators=consumed) + history = { + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [ + { + "node_config": { + "address": "0x0000000000000000000000000000000000000001" + } + } + ], + }, + {"consensus_round": "Validator Appeal Failed", "leader_result": None}, + ] + } + + current, jury = ConsensusAlgorithm.get_extra_validators( + all_validators, + history, + consensus_data, + appeal_failed=1, + required_extra_validators=11, + allow_short=True, + ) + + consumed_addresses = {item["address"] for item in current} + consumed_addresses.add("0x0000000000000000000000000000000000000001") + assert len(jury) == 11 + assert consumed_addresses.isdisjoint({item["address"] for item in jury}) + + +def test_repeated_validator_appeal_uses_every_remaining_fresh_validator(): + all_validators = [ + {"address": f"0x{index:040x}", "stake": 1} for index in range(1, 18) + ] + consensus_data = SimpleNamespace( + validators=[ + SimpleNamespace(node_config={"address": f"0x{index:040x}"}) + for index in range(2, 13) + ] + ) + history = { + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [ + { + "node_config": { + "address": "0x0000000000000000000000000000000000000001" + } + } + ], + }, + {"consensus_round": "Validator Appeal Failed", "leader_result": None}, + ] + } + + _, jury = ConsensusAlgorithm.get_extra_validators( + all_validators, + history, + consensus_data, + appeal_failed=1, + required_extra_validators=11, + allow_short=True, + ) + + assert len(jury) == 5 + + +def test_terminal_replacement_readmits_prior_participants_but_excludes_leaders(): + all_validators = [ + {"address": f"0x{index:040x}", "stake": 1} for index in range(1, 24) + ] + history = { + "consensus_results": [ + { + "consensus_round": "Leader Rotation", + "leader_result": [ + { + "node_config": { + "address": "0x0000000000000000000000000000000000000001" + } + } + ], + }, + { + "consensus_round": "Accepted", + "leader_result": [ + { + "node_config": { + "address": "0x0000000000000000000000000000000000000002" + } + } + ], + }, + {"consensus_round": "Validator Appeal Successful", "leader_result": None}, + ] + } + + terminal = ConsensusAlgorithm.get_terminal_replacement_validators( + all_validators, history + ) + + assert len(terminal) == 21 + assert {item["address"] for item in terminal} == { + item["address"] for item in all_validators[2:] + } + + +def test_terminal_replacement_keeps_full_frozen_electorate_threshold(): + from backend.consensus.types import ConsensusResult + from backend.consensus.utils import determine_consensus_from_votes + from backend.node.types import Vote + + votes = [Vote.AGREE.value] * 11 + [Vote.DISAGREE.value] * 10 + + assert determine_consensus_from_votes(votes) == ConsensusResult.MAJORITY_AGREE + assert ( + determine_consensus_from_votes(votes, electorate_size=23) + == ConsensusResult.NO_MAJORITY + ) + + +def test_finalize_request_accepts_exact_active_decision_at_deadline(monkeypatch): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_030) + tx = _fee_accounted_tx(status="ACCEPTED") + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=0, + appealed=False, + execution_mode="NORMAL", + ) + processor = _FakeTransactionsProcessor(tx) + + assert ( + _handle_finalize_transaction( + transactions_processor=processor, + decoded_rollup_transaction=_decoded_finalize( + tx["hash"], expected_decision_id=1 + ), + ) + == tx["hash"] + ) + + +def test_worker_finalization_is_ready_at_the_exact_consensus_deadline(monkeypatch): + algorithm = ConsensusAlgorithm.__new__(ConsensusAlgorithm) + algorithm.finality_window_time = 30 + algorithm.finality_window_appeal_failed_reduction = 0 + transaction = SimpleNamespace( + execution_mode=TransactionExecutionMode.NORMAL, + timestamp_awaiting_finalization=1_000, + appeal_processing_time=0, + appeal_failed=0, + ) + monkeypatch.setattr("backend.consensus.base.time.time", lambda: 1_030) + + assert algorithm.can_finalize_transaction(None, transaction, 0, []) is True + + +def test_failed_appeal_window_uses_remaining_time_at_submission_like_consensus(): + initial_history = { + "consensus_results": [{"consensus_round": "Accepted"}], + } + initial_history = materialize_decision_metadata( + initial_history, + status="ACCEPTED", + materialized_at=1_000, + default_appeal_window=30, + ) + assert initial_history["latestDecision"] == { + "decisionId": 1, + "status": "ACCEPTED", + "materializedAt": 1_000, + "appealDeadline": 1_030, + } + + # Ten seconds of the original window have already elapsed. Consensus + # freezes 80% of the remaining 20 seconds, then starts that exact 16-second + # window when the failed appeal materializes its replacement decision. + appealed_history = prepare_appeal_decision_basis( + initial_history, + expected_decision_id=1, + submitted_at=1_010, + appeal_deadline=1_030, + retention_bps=8_000, + ) + appealed_history["consensus_results"].append( + {"consensus_round": "Validator Appeal Failed"} + ) + failed_history = materialize_decision_metadata( + appealed_history, + status="ACCEPTED", + materialized_at=1_020, + default_appeal_window=30, + ) + + assert "activeAppealBasis" not in failed_history + assert failed_history["latestDecision"] == { + "decisionId": 2, + "status": "ACCEPTED", + "materializedAt": 1_020, + "appealDeadline": 1_036, + } + + +def test_successful_appeal_carries_frozen_window_to_terminal_decision(): + initial_history = materialize_decision_metadata( + {"consensus_results": [{"consensus_round": "Accepted"}]}, + status="ACCEPTED", + materialized_at=1_000, + default_appeal_window=30, + ) + appealed_history = prepare_appeal_decision_basis( + initial_history, + expected_decision_id=1, + submitted_at=1_010, + appeal_deadline=1_030, + retention_bps=8_000, + ) + appealed_history["consensus_results"].append( + {"consensus_round": "Validator Appeal Successful"} + ) + + # A successful validator appeal first opens the terminal normal round. It + # does not materialize a decision or consume the frozen basis by itself. + assert appealed_history["activeAppealBasis"]["nextAppealWindow"] == 16 + appealed_history["consensus_results"].append({"consensus_round": "Undetermined"}) + terminal_history = materialize_decision_metadata( + appealed_history, + status="UNDETERMINED", + materialized_at=1_025, + default_appeal_window=30, + ) + + assert "activeAppealBasis" not in terminal_history + assert terminal_history["latestDecision"] == { + "decisionId": 2, + "status": "UNDETERMINED", + "materializedAt": 1_025, + "appealDeadline": 1_041, + } + + +def test_worker_prefers_persisted_remaining_time_deadline(monkeypatch): + algorithm = ConsensusAlgorithm.__new__(ConsensusAlgorithm) + algorithm.finality_window_time = 30 + algorithm.finality_window_appeal_failed_reduction = 0.2 + transaction = SimpleNamespace( + execution_mode=TransactionExecutionMode.NORMAL, + timestamp_awaiting_finalization=1_000, + appeal_processing_time=10, + appeal_failed=1, + consensus_history={ + "latestDecision": { + "decisionId": 2, + "appealDeadline": 1_036, + } + }, + ) + + # The retired shortcut would finalize at 1000 + 10 + 30*0.8 = 1034. + monkeypatch.setattr("backend.consensus.base.time.time", lambda: 1_035) + assert algorithm.can_finalize_transaction(None, transaction, 0, []) is False + monkeypatch.setattr("backend.consensus.base.time.time", lambda: 1_036) + assert algorithm.can_finalize_transaction(None, transaction, 0, []) is True + + +def test_lifecycle_and_finalize_use_persisted_decision_deadline(monkeypatch): + tx = _fee_accounted_tx(status="ACCEPTED") + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=10, + appeal_failed=1, + appealed=False, + execution_mode="NORMAL", + ) + tx["consensus_history"] = { + "consensus_results": [ + {"consensus_round": "Accepted"}, + {"consensus_round": "Validator Appeal Failed"}, + ], + "latestDecision": { + "decisionId": 2, + "status": "ACCEPTED", + "materializedAt": 1_020, + "appealDeadline": 1_036, + }, + } + processor = _FakeTransactionsProcessor(tx) + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setenv("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0.2") + + before = get_transaction_lifecycle( + processor, + {"txId": tx["hash"], "timestamp": 1_035}, + ) + at_deadline = get_transaction_lifecycle( + processor, + {"txId": tx["hash"], "timestamp": 1_036}, + ) + assert before["decisionId"] == "2" + assert before["resolutionAction"] == "NoOp" + assert at_deadline["resolutionAction"] == "Finalize" + + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_036) + assert ( + _handle_finalize_transaction( + transactions_processor=processor, + decoded_rollup_transaction=_decoded_finalize( + tx["hash"], expected_decision_id=2 + ), + ) + == tx["hash"] + ) + + +@pytest.mark.parametrize("expected_decision_id", [None, 2]) +def test_finalize_request_rejects_missing_or_stale_decision_before_mutation( + monkeypatch, expected_decision_id +): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_030) + tx = _fee_accounted_tx(status="ACCEPTED") + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=0, + appealed=False, + execution_mode="NORMAL", + ) + processor = _FakeTransactionsProcessor(tx) + + with pytest.raises(InvalidTransactionError, match="FinalizationNotAllowed"): + _handle_finalize_transaction( + transactions_processor=processor, + decoded_rollup_transaction=_decoded_finalize( + tx["hash"], expected_decision_id=expected_decision_id + ), + ) + + assert processor.updated_fee_accounting is None + assert processor.appeal_updates == [] + + +def test_finalize_request_rejects_value_like_nonpayable_consensus_call(monkeypatch): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_030) + tx = _fee_accounted_tx(status="ACCEPTED") + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=0, + appealed=False, + execution_mode="NORMAL", + ) + + with pytest.raises(InvalidTransactionError, match="NonPayableCall"): + _handle_finalize_transaction( + transactions_processor=_FakeTransactionsProcessor(tx), + decoded_rollup_transaction=_decoded_finalize(tx["hash"], amount=1), + ) + + +def test_finalize_request_rejects_before_deadline_or_behind_older_transaction( + monkeypatch, +): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + tx = _fee_accounted_tx(status="ACCEPTED") + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=0, + appealed=False, + execution_mode="NORMAL", + ) + processor = _FakeTransactionsProcessor(tx) + + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_029) + with pytest.raises(InvalidTransactionError, match="FinalizationNotAllowed"): + _handle_finalize_transaction( + transactions_processor=processor, + decoded_rollup_transaction=_decoded_finalize(tx["hash"]), + ) + + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_030) + processor.finalization_head = False + with pytest.raises(InvalidTransactionError, match="FinalizationNotAllowed"): + _handle_finalize_transaction( + transactions_processor=processor, + decoded_rollup_transaction=_decoded_finalize(tx["hash"]), + ) + + @pytest.mark.parametrize( ("status", "code"), [ diff --git a/tests/unit/test_transactions_parser.py b/tests/unit/test_transactions_parser.py index 862b1dbdd..4f8c2717a 100644 --- a/tests/unit/test_transactions_parser.py +++ b/tests/unit/test_transactions_parser.py @@ -12,6 +12,9 @@ from rlp import encode from web3 import Web3 import backend.node.genvm.origin.calldata as calldata +from backend.rollup.default_contracts.consensus_main import ( + get_default_consensus_main_contract, +) @pytest.fixture @@ -24,6 +27,70 @@ def transaction_parser(): return TransactionParser(consensus_service) +def test_default_consensus_abi_exports_exact_decision_guarded_actions(): + abi = get_default_consensus_main_contract()["abi"] + functions = { + entry["name"]: entry for entry in abi if entry.get("type") == "function" + } + + assert [item["type"] for item in functions["submitAppeal"]["inputs"]] == [ + "bytes32", + "uint256", + ] + assert [item["type"] for item in functions["topUpAndSubmitAppeal"]["inputs"]] == [ + "bytes32", + "uint256", + "tuple", + ] + assert [item["type"] for item in functions["finalizeTransaction"]["inputs"]] == [ + "bytes32", + "uint256", + ] + for function_name in ("addTransaction", "deploySalted"): + assert [item["type"] for item in functions[function_name]["inputs"]] == [ + "tuple" + ] + assert functions[function_name]["stateMutability"] == "payable" + assert [item["type"] for item in functions["topUpFees"]["inputs"]] == [ + "bytes32", + "tuple", + ] + add_params = functions["addTransaction"]["inputs"][0]["components"] + assert [item["type"] for item in add_params] == [ + "address", + "address", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "tuple", + "bytes", + "tuple[]", + ] + assert [item["type"] for item in add_params[7]["components"]] == [ + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256[]", + "uint256", + "uint256", + "uint256", + ] + assert [item["type"] for item in add_params[9]["components"]] == [ + "uint8", + "bool", + "uint256", + "address", + "bytes32", + "uint256", + "bytes", + ] + + def test_transaction_rpc_payload_stringifies_unsafe_fee_integers(): payload = TransactionsProcessor._json_safe_numbers( { @@ -656,3 +723,41 @@ def test_decode_latest_top_up_and_submit_appeal_preserves_decision_id(monkeypatc assert decoded.data.expected_decision_id == 9 assert decoded.data.top_up_and_submit is True assert decoded.data.fees_distribution["rotations"] == [3, 4, 5] + + +@pytest.mark.parametrize( + ("arguments", "input_count", "expected_decision_id"), + [ + ([b"\x9a" * 32, 17], 2, 17), + ([b"\x9a" * 32], 1, None), + ], +) +def test_decode_finalize_transaction_preserves_optional_decision_guard( + monkeypatch, arguments, input_count, expected_decision_id +): + monkeypatch.setattr( + "backend.protocol_rpc.transactions_parser.Account.recover_transaction", + lambda raw: "0x3333333333333333333333333333333333333333", + ) + consensus_service = Mock() + consensus_service.web3 = Web3() + consensus_service.load_contract = Mock(return_value={"abi": []}) + parser = TransactionParser(consensus_service) + + raw = _build_eip1559_raw( + nonce=12, + to=bytes.fromhex("0000000000000000000000000000000000000000"), + value=0, + data=_contract_call_data( + parser, + "finalizeTransaction", + arguments, + input_count=input_count, + ), + ) + + decoded = parser.decode_signed_transaction(raw) + + assert decoded is not None + assert decoded.data.tx_id == b"\x9a" * 32 + assert decoded.data.expected_decision_id == expected_decision_id From b6c731a977d90ae249a7c274841145a6a124c62b Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 05:11:09 +0100 Subject: [PATCH 11/55] fix(consensus): pin appeal selection authority --- backend/consensus/base.py | 130 ++++++++++++++++++++---- backend/protocol_rpc/endpoints.py | 75 +++++++------- backend/protocol_rpc/fees.py | 14 ++- tests/unit/test_studio_fees.py | 162 +++++++++++++++++++++++++++++- 4 files changed, 325 insertions(+), 56 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 72743b625..1c269b66d 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -109,6 +109,28 @@ from backend.node.genvm.error_codes import GenVMInternalError, GenVMErrorCode from backend.node.base import Manager as GenVMManager + +def _validators_in_frozen_selection_pool( + all_validators: list[dict], + fee_accounting: dict | None, +) -> list[dict]: + """Apply Consensus's activation-pinned selection-pool identity gate.""" + + frozen_pool = ( + fee_accounting.get("selection_pool_addresses") + if isinstance(fee_accounting, dict) + else None + ) + if not isinstance(frozen_pool, list) or not frozen_pool: + return list(all_validators) + frozen_addresses = {str(address).lower() for address in frozen_pool if address} + return [ + validator + for validator in all_validators + if str(validator.get("address") or "").lower() in frozen_addresses + ] + + # Cap on concurrently executing validators per transaction. Bounds GenVM # subprocess memory, fd, and DB-session usage; larger committees run through # this window. See issue #1721. @@ -963,13 +985,24 @@ async def process_leader_appeal( transactions_processor.set_transaction_appeal(transaction.hash, False) transaction.appealed = False - used_leader_addresses = ( - ConsensusAlgorithm.get_used_leader_addresses_from_consensus_history( - context.transactions_processor.get_transaction_by_hash( - context.transaction.hash - )["consensus_history"] - ) + consumed_addresses = ConsensusAlgorithm.get_consumed_validator_addresses( + context.transactions_processor.get_transaction_by_hash( + context.transaction.hash + )["consensus_history"], + transaction.consensus_data, ) + fee_accounting = (transaction.data or {}).get(FEE_ACCOUNTING_KEY) or {} + live_validators = [ + node.validator.to_dict() for node in validators_snapshot.nodes + ] + selection_pool = { + str(validator.get("address") or "").lower() + for validator in _validators_in_frozen_selection_pool( + live_validators, + fee_accounting, + ) + if validator.get("address") + } logical_entries = logical_fee_round_entries(transaction.consensus_history) current_round = logical_entries[-1][0] if logical_entries else 0 @@ -978,9 +1011,7 @@ async def process_leader_appeal( ] available_fresh = max( 0, - len(validators_snapshot.nodes) - - len(transaction.consensus_data.validators) - - len(used_leader_addresses), + len(selection_pool - consumed_addresses), ) if available_fresh < required_fresh: @@ -1400,15 +1431,16 @@ def get_extra_validators( ) ) - # Remove used leaders from validator_map - used_leader_addresses = ( - ConsensusAlgorithm.get_used_leader_addresses_from_consensus_history( - consensus_history - ) + # Consensus excludes every consumed registry index from a fresh appeal + # selection. Do not rely on consensus_data retaining every prior jury: + # legacy rows and chained failures can leave receipts only in history. + consumed_addresses = ConsensusAlgorithm.get_consumed_validator_addresses( + consensus_history, + consensus_data, ) - for used_leader_address in used_leader_addresses: - if used_leader_address in validator_map: - validator_map.pop(used_leader_address) + for address in list(validator_map): + if address.lower() in consumed_addresses: + validator_map.pop(address) # Set not_used_validators to the remaining validators in validator_map not_used_validators = list(validator_map.values()) @@ -1587,6 +1619,52 @@ def get_used_leader_addresses_from_consensus_history( return used_leader_addresses + @staticmethod + def get_consumed_validator_addresses( + consensus_history: dict | None, + current_consensus_data: ConsensusData | dict | None = None, + ) -> set[str]: + """Return every address consumed by the transaction's fresh pool.""" + + consumed: set[str] = set() + + def add_receipts(value) -> None: + receipts = value if isinstance(value, list) else [value] + for receipt in receipts: + if receipt is None: + continue + node_config = ( + receipt.get("node_config") + if isinstance(receipt, dict) + else getattr(receipt, "node_config", None) + ) + if not isinstance(node_config, dict): + continue + address = node_config.get("address") + if address: + consumed.add(str(address).lower()) + + results = ( + consensus_history.get("consensus_results") + if isinstance(consensus_history, dict) + else None + ) + if isinstance(results, list): + for entry in results: + if not isinstance(entry, dict): + continue + add_receipts(entry.get("leader_result")) + add_receipts(entry.get("validator_results")) + + if isinstance(current_consensus_data, dict): + add_receipts(current_consensus_data.get("leader_receipt")) + add_receipts(current_consensus_data.get("validators")) + elif current_consensus_data is not None: + add_receipts(getattr(current_consensus_data, "leader_receipt", None)) + add_receipts(getattr(current_consensus_data, "validators", None)) + + return consumed + def set_finality_window_time(self, time: int): """ Set the finality window time. @@ -2208,6 +2286,14 @@ def activate_latest(current_fee_accounting): if context.validators_snapshot is not None else None ), + selection_pool_addresses=( + [ + node.validator.address + for node in context.validators_snapshot.nodes + ] + if context.validators_snapshot is not None + else None + ), ) activation_result["should_cancel"] = should_cancel return updated @@ -2285,6 +2371,16 @@ def activate_latest(current_fee_accounting): n.validator.to_dict() for n in context.validators_snapshot.nodes ] + # Consensus executes every later round against the activation-pinned + # selection pool. Validators added afterwards are not eligible; a + # removed validator is simply unavailable and can reduce capacity. + fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + if all_validators: + all_validators = _validators_in_frozen_selection_pool( + all_validators, + fee_accounting, + ) + if not all_validators: context.msg_handler.send_message( LogEvent( diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index bb0953e9e..934d5fe67 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -2008,6 +2008,15 @@ def estimate_latest_appeal_charge( if fee_accounting is None: raise InvalidTransactionError("FeeAccountingMissing") session = getattr(transactions_processor, "session", None) + frozen_pool_addresses = fee_accounting.get("selection_pool_addresses") + live_pool_addresses = None + if session is not None and isinstance(frozen_pool_addresses, list): + live_pool_addresses = [ + validator.get("address") + for validator in ValidatorsRegistry(session).get_all_validators( + include_private_key=False + ) + ] frozen_pool_count = fee_accounting.get("selection_pool_count") validator_count = ( int(frozen_pool_count) @@ -2026,6 +2035,8 @@ def estimate_latest_appeal_charge( available_appeal_validators = _available_appeal_validator_count( transaction, validator_count, + frozen_pool_addresses=frozen_pool_addresses, + live_pool_addresses=live_pool_addresses, ) if ( status @@ -2456,6 +2467,15 @@ def _handle_appeal_or_top_up_and_submit( if fee_accounting is not None: session = getattr(accounts_manager, "session", None) frozen_pool_count = fee_accounting.get("selection_pool_count") + frozen_pool_addresses = fee_accounting.get("selection_pool_addresses") + live_pool_addresses = None + if session is not None and isinstance(frozen_pool_addresses, list): + live_pool_addresses = [ + validator.get("address") + for validator in ValidatorsRegistry(session).get_all_validators( + include_private_key=False + ) + ] validator_count = ( int(frozen_pool_count) if frozen_pool_count is not None @@ -2473,6 +2493,8 @@ def _handle_appeal_or_top_up_and_submit( available_appeal_validators = _available_appeal_validator_count( tx, validator_count, + frozen_pool_addresses=frozen_pool_addresses, + live_pool_addresses=live_pool_addresses, ) if ( status @@ -2641,51 +2663,30 @@ def _normal_leader_count(consensus_history: dict | None) -> int: ) -def _receipt_validator_address(receipt: Any) -> str | None: - if not isinstance(receipt, dict): - return None - node_config = receipt.get("node_config") - if not isinstance(node_config, dict): - return None - address = node_config.get("address") - return str(address).lower() if address else None - - def _available_appeal_validator_count( transaction: dict, frozen_pool_count: int, + *, + frozen_pool_addresses: list[str] | None = None, + live_pool_addresses: list[str] | None = None, ) -> int | None: """Mirror Studio's fresh-juror pool; unknown legacy state stays conservative.""" - used_addresses: set[str] = set() - consensus_data = transaction.get("consensus_data") - if isinstance(consensus_data, dict): - validator_receipts = consensus_data.get("validators") - if isinstance(validator_receipts, dict): - validator_receipts = [validator_receipts] - if isinstance(validator_receipts, list): - for receipt in validator_receipts: - address = _receipt_validator_address(receipt) - if address: - used_addresses.add(address) - - history = transaction.get("consensus_history") - results = history.get("consensus_results") if isinstance(history, dict) else None - if isinstance(results, list): - for entry in results: - if not isinstance(entry, dict): - continue - leader_receipts = entry.get("leader_result") - if isinstance(leader_receipts, dict): - leader_receipts = [leader_receipts] - if not isinstance(leader_receipts, list): - continue - for receipt in leader_receipts: - address = _receipt_validator_address(receipt) - if address: - used_addresses.add(address) + used_addresses = ConsensusAlgorithm.get_consumed_validator_addresses( + transaction.get("consensus_history"), + transaction.get("consensus_data"), + ) if not used_addresses: return None + if isinstance(frozen_pool_addresses, list): + eligible = { + str(address).lower() for address in frozen_pool_addresses if address + } + if isinstance(live_pool_addresses, list): + eligible &= { + str(address).lower() for address in live_pool_addresses if address + } + return len(eligible - used_addresses) return max(0, int(frozen_pool_count) - len(used_addresses)) diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index b678e2ce6..83d8c116a 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -1661,6 +1661,7 @@ def activate_fee_accounting( policy: StudioFeePolicy | None = None, *, selection_pool_count: int | None = None, + selection_pool_addresses: list[str] | None = None, ) -> tuple[dict[str, Any], bool]: """Lock v0.6 execution prices on first activation. @@ -1674,7 +1675,18 @@ def activate_fee_accounting( if updated.get("activation_prices_locked"): return updated, False - if selection_pool_count is not None: + if selection_pool_addresses is not None: + # Consensus pins the activation selection-pool authority, including + # identities. Later registry additions must not become eligible for an + # already-activated transaction. + frozen_addresses = list( + dict.fromkeys( + str(address).lower() for address in selection_pool_addresses if address + ) + ) + updated["selection_pool_addresses"] = frozen_addresses + updated["selection_pool_count"] = len(frozen_addresses) + elif selection_pool_count is not None: # Consensus appeal reservations use the activation epoch's frozen # selection-pool authority, never the live staking population. updated["selection_pool_count"] = max(0, int(selection_pool_count)) diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index dbe0864cd..d8fb0e69f 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -16,6 +16,7 @@ _emit_messages, _get_messages_data, _runtime_rotation_limit, + _validators_in_frozen_selection_pool, ) from backend.consensus.history import ( materialize_decision_metadata, @@ -30,6 +31,7 @@ ) from backend.protocol_rpc.exceptions import JSONRPCError, NotFoundError from backend.protocol_rpc.endpoints import ( + _available_appeal_validator_count, _current_fee_round, _funded_max_rotations, _handle_appeal_or_top_up_and_submit, @@ -1014,7 +1016,7 @@ def test_activation_checks_live_gen_cap_and_locks_all_live_prices(): storage_unit_price=7, receipt_gas_price=8, ), - selection_pool_count=11, + selection_pool_addresses=[f"0x{index:040x}" for index in range(1, 12)], ) assert should_cancel is False assert activated["activation_prices_locked"] is True @@ -1027,6 +1029,9 @@ def test_activation_checks_live_gen_cap_and_locks_all_live_prices(): assert activated["policy_snapshot"]["storage_unit_price"] == 7 assert activated["policy_snapshot"]["receipt_gas_price"] == 8 assert activated["selection_pool_count"] == 11 + assert activated["selection_pool_addresses"] == [ + f"0x{index:040x}" for index in range(1, 12) + ] relocked, should_cancel = activate_fee_accounting( activated, @@ -9847,6 +9852,115 @@ def send_message(self, log_event, log_to_terminal=True): assert processor.updated_fee_accounting["appeal_bonds"][0]["juryCount"] == 5 +def test_latest_appeal_charge_excludes_historical_jurors_from_fresh_capacity( + monkeypatch, +): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_001) + accounting = _env_fee_accounting() + accounting["selection_pool_count"] = 10 + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) + tx.update( + timestamp_awaiting_finalization=1_000, + appealed=False, + consensus_data={ + "leader_receipt": _history_receipt( + mode="leader", + address="0x0000000000000000000000000000000000000001", + ), + "validators": [ + _history_receipt(mode="validator", address=f"0x{index:040x}") + for index in range(2, 5) + ], + }, + consensus_history={ + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x0000000000000000000000000000000000000001", + ) + ], + "validator_results": [], + }, + { + "consensus_round": "Validator Appeal Failed", + "leader_result": [], + "validator_results": [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + ) + for index in range(5, 9) + ], + }, + ] + }, + ) + + quote = estimate_latest_appeal_charge( + _FakeTransactionsProcessor(tx), {"txId": tx["hash"]} + ) + expected = calculate_appeal_charge( + accounting["fees_distribution"], + current_round=1, + status="ACCEPTED", + terminal_committee_upper_bound=9, + available_appeal_validators=2, + policy=StudioFeePolicy.from_env(), + ) + + assert expected["juryCount"] == 2 + assert int(quote["funding"]) == expected["funding"] + + +def test_appeal_capacity_uses_frozen_pool_identities_and_live_availability(): + tx = { + "consensus_data": { + "leader_receipt": _history_receipt( + mode="leader", + address="0x0000000000000000000000000000000000000001", + ), + "validators": [ + _history_receipt( + mode="validator", + address="0x0000000000000000000000000000000000000002", + ) + ], + }, + "consensus_history": None, + } + + available = _available_appeal_validator_count( + tx, + 5, + frozen_pool_addresses=[f"0x{index:040x}" for index in range(1, 6)], + # Frozen validator 5 was removed; new validator 6 is ineligible. + live_pool_addresses=[f"0x{index:040x}" for index in (1, 2, 3, 4, 6)], + ) + + assert available == 2 + + +def test_execution_selection_rejects_post_activation_validator_additions(): + validators = [ + {"address": f"0x{index:040x}", "stake": 1} for index in (1, 2, 3, 4, 6) + ] + accounting = { + "selection_pool_addresses": [f"0x{index:040x}" for index in (1, 2, 3, 4, 5)] + } + + eligible = _validators_in_frozen_selection_pool(validators, accounting) + + # Removed frozen validator 5 is unavailable; newly-added validator 6 is + # not part of this transaction's activation authority. + assert {validator["address"] for validator in eligible} == { + f"0x{index:040x}" for index in (1, 2, 3, 4) + } + + def test_latest_appeal_charge_rejects_capacity_limited_undetermined_replay( monkeypatch, ): @@ -9928,6 +10042,52 @@ def test_leader_appeal_requires_the_full_fresh_scheduled_set(): ) +def test_appeal_selection_excludes_jurors_retained_only_in_history(): + all_validators = [ + {"address": f"0x{index:040x}", "stake": 1} for index in range(1, 11) + ] + consensus_data = SimpleNamespace( + leader_receipt=[ + SimpleNamespace( + node_config={"address": "0x0000000000000000000000000000000000000001"} + ) + ], + validators=[ + SimpleNamespace(node_config={"address": f"0x{index:040x}"}) + for index in range(2, 5) + ], + ) + history = { + "consensus_results": [ + { + "consensus_round": "Validator Appeal Failed", + "leader_result": [], + "validator_results": [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + ) + for index in range(5, 9) + ], + } + ] + } + + _current, selected = ConsensusAlgorithm.get_extra_validators( + all_validators, + history, + consensus_data, + 0, + required_extra_validators=2, + allow_short=True, + ) + + assert {item["address"] for item in selected} == { + "0x0000000000000000000000000000000000000009", + "0x000000000000000000000000000000000000000a", + } + + def test_repeated_validator_appeal_selects_fresh_scheduled_jury(): all_validators = [ {"address": f"0x{index:040x}", "stake": 1} for index in range(1, 31) From 7bf782f50f04aac2cb493cebb6a9d61f8efadd8e Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 05:31:38 +0100 Subject: [PATCH 12/55] fix(consensus): make appeal admission failure value-safe --- backend/consensus/base.py | 54 ++++--- backend/database_handler/accounts_manager.py | 48 +++++- backend/protocol_rpc/fees.py | 69 +++++++++ tests/db-sqlalchemy/accounts_manager_test.py | 140 ++++++++++++++++++ .../consensus/test_appeal_state_restore.py | 52 +++++++ tests/unit/test_studio_fees.py | 84 +++++++++++ 6 files changed, 428 insertions(+), 19 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 1c269b66d..952844ef7 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -982,9 +982,6 @@ async def process_leader_appeal( genvm_manager=self.genvm_manager, ) - transactions_processor.set_transaction_appeal(transaction.hash, False) - transaction.appealed = False - consumed_addresses = ConsensusAlgorithm.get_consumed_validator_addresses( context.transactions_processor.get_transaction_by_hash( context.transaction.hash @@ -1015,6 +1012,11 @@ async def process_leader_appeal( ) if available_fresh < required_fresh: + accounts_manager.abort_tx_appeal_admission_once( + transaction.hash, + "appeal_committee_unavailable", + ) + transaction.appealed = False self.msg_handler.send_message( LogEvent( "consensus_event", @@ -1041,6 +1043,8 @@ async def process_leader_appeal( ) else: + transactions_processor.set_transaction_appeal(transaction.hash, False) + transaction.appealed = False # Appeal data member is used in the frontend for all types of appeals # Here the type is refined based on the status transactions_processor.set_transaction_appeal_undetermined( @@ -1098,9 +1102,6 @@ async def process_leader_timeout_appeal( genvm_manager=self.genvm_manager, ) - transactions_processor.set_transaction_appeal(transaction.hash, False) - transaction.appealed = False - if context.transaction.appeal_undetermined: context.transactions_processor.set_transaction_appeal_undetermined( context.transaction.hash, False @@ -1108,6 +1109,11 @@ async def process_leader_timeout_appeal( context.transaction.appeal_undetermined = False if not transaction.leader_timeout_validators: + accounts_manager.abort_tx_appeal_admission_once( + transaction.hash, + "appeal_committee_unavailable", + ) + transaction.appealed = False self.msg_handler.send_message( LogEvent( "consensus_event", @@ -1134,6 +1140,8 @@ async def process_leader_timeout_appeal( ) else: + transactions_processor.set_transaction_appeal(transaction.hash, False) + transaction.appealed = False # Appeal data member is used in the frontend for all types of appeals # Here the type is refined based on the status transaction.appeal_leader_timeout = ( @@ -1196,10 +1204,15 @@ async def process_validator_appeal( ) logical_entries = logical_fee_round_entries(transaction.consensus_history) current_round = logical_entries[-1][0] if logical_entries else 0 + fee_accounting = (transaction.data or {}).get(FEE_ACCOUNTING_KEY) or {} + eligible_validators = _validators_in_frozen_selection_pool( + [x.validator.to_dict() for x in validators_snapshot.nodes], + fee_accounting, + ) try: # Attempt to get extra validators for the appeal process _, context.remaining_validators = ConsensusAlgorithm.get_extra_validators( - [x.validator.to_dict() for x in validators_snapshot.nodes], + eligible_validators, transaction.consensus_history, transaction.consensus_data, transaction.appeal_failed, @@ -1226,8 +1239,9 @@ async def process_validator_appeal( transaction_hash=context.transaction.hash, ) ) - context.transactions_processor.set_transaction_appeal( - context.transaction.hash, False + accounts_manager.abort_tx_appeal_admission_once( + context.transaction.hash, + "appeal_committee_unavailable", ) context.transaction.appealed = False self.msg_handler.send_message( @@ -1506,8 +1520,12 @@ def get_validators_from_consensus_data( return [], {} # Create a dictionary to map addresses to a validator + # Solidity addresses are values, not case-sensitive strings. Studio's + # registry and serialized receipts can legitimately disagree only in + # checksum casing, so every identity comparison uses one normalized + # key. validator_map = { - validator["address"]: validator for validator in all_validators + str(validator["address"]).lower(): validator for validator in all_validators } # Extract address of the leader from consensus data @@ -1525,9 +1543,9 @@ def get_validators_from_consensus_data( # Return validators whose addresses are in the receipt addresses validators = [ - validator_map.pop(receipt_address) + validator_map.pop(str(receipt_address).lower()) for receipt_address in receipt_addresses - if receipt_address in validator_map + if str(receipt_address).lower() in validator_map ] return validators, validator_map @@ -1552,14 +1570,14 @@ def add_new_validator( raise ValueError("No more validators found to add a new validator") # Extract a set of addresses of validators and leaders - addresses = {validator["address"] for validator in validators} - addresses.update(leader_addresses) + addresses = {str(validator["address"]).lower() for validator in validators} + addresses.update(str(address).lower() for address in leader_addresses) # Get not used validators not_used_validators = [ validator for validator in all_validators - if validator["address"] not in addresses + if str(validator["address"]).lower() not in addresses ] # Get new validator @@ -1580,7 +1598,7 @@ def get_terminal_replacement_validators( return [ validator for validator in all_validators - if validator["address"] not in used_leaders + if str(validator["address"]).lower() not in used_leaders ] @staticmethod @@ -1609,12 +1627,12 @@ def get_used_leader_addresses_from_consensus_history( else None ) if address: - used_leader_addresses.add(address) + used_leader_addresses.add(str(address).lower()) # consensus_history does not contain the latest consensus_data if current_leader_receipt: used_leader_addresses.update( - [current_leader_receipt.node_config["address"]] + [str(current_leader_receipt.node_config["address"]).lower()] ) return used_leader_addresses diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index 312356d21..ef2d9c565 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -7,10 +7,14 @@ from backend.database_handler.errors import AccountNotFoundError from backend.protocol_rpc.fees import ( FEE_ACCOUNTING_KEY, + abort_latest_appeal_admission, cancel_fee_accounting, settle_fee_accounting, ) -from backend.consensus.history import completed_consensus_round_index +from backend.consensus.history import ( + ACTIVE_APPEAL_BASIS_KEY, + completed_consensus_round_index, +) from sqlalchemy.orm import Session from sqlalchemy import text @@ -213,6 +217,48 @@ def cancel_tx_fee_accounting_once( self._credit_appeal_bond_payouts(updated) return refund + def abort_tx_appeal_admission_once( + self, + tx_hash: str, + reason: str = "appeal_committee_unavailable", + ) -> int: + """Atomically undo a paid appeal that never formed its committee.""" + + transaction = ( + self.session.query(Transactions) + .filter_by(hash=tx_hash) + .populate_existing() + .with_for_update() + .one_or_none() + ) + if transaction is None or not isinstance(transaction.data, dict): + return 0 + data = dict(transaction.data) + accounting = data.get(FEE_ACCOUNTING_KEY) + if not isinstance(accounting, dict): + return 0 + updated, recipient, refund = abort_latest_appeal_admission( + accounting, + reason=reason, + ) + if refund <= 0 or not recipient: + return 0 + + data[FEE_ACCOUNTING_KEY] = updated + transaction.data = data + history = dict(transaction.consensus_history or {}) + history.pop(ACTIVE_APPEAL_BASIS_KEY, None) + transaction.consensus_history = history + transaction.appealed = False + transaction.timestamp_appeal = None + # Persist the ORM rollback before the raw-SQL credit and cache expiry; + # expiring an unflushed row would resurrect the paid appeal and make a + # retry refund it a second time. + self.session.flush() + self.credit_account_balance(str(recipient), refund) + self.session.expire_all() + return refund + def settle_tx_fee_accounting_once( self, tx_hash: str, diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 83d8c116a..7c09b9974 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -2018,6 +2018,20 @@ def record_appeal_bond( policy: StudioFeePolicy | None = None, ) -> dict[str, Any]: updated = copy.deepcopy(accounting) + admission_rollback = { + key: copy.deepcopy(updated.get(key)) + for key in ( + "fees_distribution", + "execution_budget_total", + "primary_fee_budget", + "paid_fee_value", + "appeal_funding_total", + "time_unit_overlay_budget", + "appeal_bonds_total", + "contributions", + "untracked_contributions", + ) + } policy = _accounting_policy(updated, policy) if time_unit_overlay_bps is not None: # Consensus prices the developer/DAO overlay at appeal admission; @@ -2103,12 +2117,67 @@ def record_appeal_bond( "topUpAndSubmit": bool(top_up_and_submit), "feesDistributionIgnored": fees_distribution is not None, "extendsSchedule": bool(charge["extendsSchedule"]), + # Studio forms the actual jury asynchronously. If the frozen/live + # pool changes before the worker can start, Consensus would have + # reverted the atomic submission. Preserve the exact pre-admission + # accounting state so Studio can make that failure equally + # non-value-bearing instead of silently forfeiting the bond. + "admissionRollback": admission_rollback, } ) _refresh_message_fee_accounting_report_if_present(updated, policy) return updated +def abort_latest_appeal_admission( + accounting: dict[str, Any], + *, + reason: str, +) -> tuple[dict[str, Any], str | None, int]: + """Undo an admitted appeal that never formed a committee.""" + + updated = copy.deepcopy(accounting) + bonds = updated.get("appeal_bonds") + if not isinstance(bonds, list) or not bonds: + return updated, None, 0 + bond = bonds[-1] + if not isinstance(bond, dict): + return updated, None, 0 + rollback = bond.get("admissionRollback") + if not isinstance(rollback, dict): + return updated, None, 0 + + recipient = bond.get("appealer") + refund = max(0, int(bond.get("amount", 0) or 0)) + max( + 0, int(bond.get("funding", 0) or 0) + ) + for key, value in rollback.items(): + updated[key] = copy.deepcopy(value) + updated["appeal_bonds"] = bonds[:-1] + updated.setdefault("aborted_appeals", []).append( + { + "appealer": recipient, + "amount": int(bond.get("amount", 0) or 0), + "funding": int(bond.get("funding", 0) or 0), + "sourceRound": int(bond.get("sourceRound", 0) or 0), + "reason": str(reason), + "refund": refund, + } + ) + updated["total_refunded"] = int(updated.get("total_refunded", 0) or 0) + refund + updated.setdefault("refunds", []).append( + { + "reason": str(reason), + "primary": int(bond.get("funding", 0) or 0), + "message": 0, + "appealBond": int(bond.get("amount", 0) or 0), + "amount": refund, + } + ) + _refresh_message_fee_accounting_report_if_present(updated) + return updated, recipient, refund + + def calculate_appeal_charge( fees_distribution: dict[str, Any], *, diff --git a/tests/db-sqlalchemy/accounts_manager_test.py b/tests/db-sqlalchemy/accounts_manager_test.py index af450d8a9..bac758cea 100644 --- a/tests/db-sqlalchemy/accounts_manager_test.py +++ b/tests/db-sqlalchemy/accounts_manager_test.py @@ -12,9 +12,12 @@ from backend.database_handler.errors import AccountNotFoundError from backend.database_handler.models import Transactions from backend.database_handler.transactions_processor import TransactionsProcessor +from backend.consensus.history import ACTIVE_APPEAL_BASIS_KEY from backend.protocol_rpc.fees import ( FEE_ACCOUNTING_KEY, + calculate_appeal_charge, create_fee_accounting, + record_appeal_bond, required_fee_deposit, ) @@ -218,6 +221,143 @@ def test_cancel_tx_fee_accounting_once_refunds_and_is_idempotent( assert fee_accounting["total_refunded"] == 1155 +def test_abort_tx_appeal_admission_refunds_charge_and_restores_decision( + accounts_manager: AccountsManager, + session: Session, +): + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + appealer = "0x1111111111111111111111111111111111111111" + tx_hash = "0x" + "ad" * 32 + fees = _fees_distribution(appeals=0, rotations=[0]) + accounting = create_fee_accounting( + fees_distribution=fees, + num_of_validators=5, + submitted_value=required_fee_deposit(fees, 5), + user_value=0, + sender=sender, + ) + charge = calculate_appeal_charge( + accounting["fees_distribution"], + current_round=0, + status="ACCEPTED", + ) + recorded = record_appeal_bond( + accounting, + amount=charge["bond"] + charge["funding"], + appealer=appealer, + current_round=0, + status="ACCEPTED", + ) + _insert_fee_accounted_transaction( + session, + sender=sender, + accounting=recorded, + tx_hash=tx_hash, + ) + tx_model = session.query(Transactions).filter_by(hash=tx_hash).one() + tx_model.appealed = True + tx_model.timestamp_appeal = 123 + tx_model.consensus_history = { + ACTIVE_APPEAL_BASIS_KEY: { + "decisionId": 1, + "submittedAt": 123, + "nextAppealWindow": 10, + } + } + session.commit() + + expected_refund = charge["bond"] + charge["funding"] + assert accounts_manager.abort_tx_appeal_admission_once(tx_hash) == expected_refund + session.commit() + assert accounts_manager.abort_tx_appeal_admission_once(tx_hash) == 0 + session.expire_all() + + assert accounts_manager.get_account_balance(appealer) == expected_refund + tx = session.query(Transactions).filter_by(hash=tx_hash).one() + restored = tx.data[FEE_ACCOUNTING_KEY] + assert tx.appealed is False + assert tx.timestamp_appeal is None + assert ACTIVE_APPEAL_BASIS_KEY not in tx.consensus_history + assert restored["appeal_bonds"] == [] + assert restored["primary_fee_budget"] == accounting["primary_fee_budget"] + assert restored["fees_distribution"] == accounting["fees_distribution"] + + +def test_concurrent_appeal_admission_abort_refunds_only_once(engine: Engine): + SessionFactory = sessionmaker(bind=engine, expire_on_commit=False) + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + appealer = "0x1111111111111111111111111111111111111111" + tx_hash = "0x" + "ae" * 32 + fees = _fees_distribution(appeals=0, rotations=[0]) + accounting = create_fee_accounting( + fees_distribution=fees, + num_of_validators=5, + submitted_value=required_fee_deposit(fees, 5), + user_value=0, + sender=sender, + ) + charge = calculate_appeal_charge( + accounting["fees_distribution"], current_round=0, status="ACCEPTED" + ) + recorded = record_appeal_bond( + accounting, + amount=charge["bond"] + charge["funding"], + appealer=appealer, + current_round=0, + status="ACCEPTED", + ) + with SessionFactory() as setup_session: + _insert_fee_accounted_transaction( + setup_session, + sender=sender, + accounting=recorded, + tx_hash=tx_hash, + ) + tx = setup_session.query(Transactions).filter_by(hash=tx_hash).one() + tx.appealed = True + tx.consensus_history = { + ACTIVE_APPEAL_BASIS_KEY: { + "decisionId": 1, + "submittedAt": 123, + "nextAppealWindow": 10, + } + } + setup_session.commit() + + barrier = threading.Barrier(2, timeout=5) + refunds: list[int] = [] + errors: list[BaseException] = [] + + def abort(): + try: + with SessionFactory() as worker_session: + manager = AccountsManager(worker_session) + barrier.wait() + refunds.append(manager.abort_tx_appeal_admission_once(tx_hash)) + worker_session.commit() + except BaseException as exc: + errors.append(exc) + + first = threading.Thread(target=abort) + second = threading.Thread(target=abort) + first.start() + second.start() + first.join(timeout=10) + second.join(timeout=10) + + expected_refund = charge["bond"] + charge["funding"] + assert not first.is_alive() and not second.is_alive() + assert errors == [] + assert sorted(refunds) == [0, expected_refund] + with SessionFactory() as read_session: + assert ( + AccountsManager(read_session).get_account_balance(appealer) + == expected_refund + ) + tx = read_session.query(Transactions).filter_by(hash=tx_hash).one() + assert tx.data[FEE_ACCOUNTING_KEY]["appeal_bonds"] == [] + + def test_concurrent_cancel_refunds_fee_accounting_only_once(engine: Engine): SessionFactory = sessionmaker(bind=engine, expire_on_commit=False) sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" diff --git a/tests/unit/consensus/test_appeal_state_restore.py b/tests/unit/consensus/test_appeal_state_restore.py index e3e3d12ae..41364a9de 100644 --- a/tests/unit/consensus/test_appeal_state_restore.py +++ b/tests/unit/consensus/test_appeal_state_restore.py @@ -27,6 +27,7 @@ from backend.database_handler.contract_snapshot import ContractSnapshot from backend.database_handler.transactions_processor import TransactionStatus from backend.domain.types import Transaction, TransactionType +from backend.protocol_rpc.fees import FEE_ACCOUNTING_KEY TX_HASH = "0xtxhash" CONTRACT_ADDRESS = "0xcontract" @@ -227,6 +228,57 @@ async def test_in_memory_snapshot_restores_saved_state(self, monkeypatch): ) transactions_processor.get_transaction_by_hash.assert_not_called() + @pytest.mark.asyncio + async def test_validator_appeal_filters_live_registry_through_frozen_pool( + self, monkeypatch + ): + """A validator registered after activation cannot join an old appeal.""" + frozen = ["0x0000000000000000000000000000000000000001"] + added_later = "0x0000000000000000000000000000000000000002" + transaction = _make_transaction(contract_snapshot=None) + transaction.data = {FEE_ACCOUNTING_KEY: {"selection_pool_addresses": frozen}} + + captured = {} + + def capture_eligible(validators, *_args, **_kwargs): + captured["validators"] = validators + raise ValueError("stop after selection authority check") + + monkeypatch.setattr( + ConsensusAlgorithm, + "get_extra_validators", + staticmethod(capture_eligible), + ) + + validators_snapshot = Mock() + validators_snapshot.nodes = [ + SimpleNamespace( + validator=SimpleNamespace( + to_dict=lambda address=address: {"address": address} + ) + ) + for address in [*frozen, added_later] + ] + transactions_processor = Mock() + accounts_manager = Mock() + + await _make_algorithm().process_validator_appeal( + transaction=transaction, + transactions_processor=transactions_processor, + chain_snapshot=None, + accounts_manager=accounts_manager, + contract_snapshot_factory=Mock(), + contract_processor=Mock(), + node_factory=Mock(), + validators_snapshot=validators_snapshot, + ) + + assert captured["validators"] == [{"address": frozen[0]}] + accounts_manager.abort_tx_appeal_admission_once.assert_called_once_with( + TX_HASH, + "appeal_committee_unavailable", + ) + def _make_worker(): """ConsensusWorker without __init__ (which spins up polling infrastructure).""" diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index d8fb0e69f..d538a2ffb 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -101,6 +101,7 @@ StudioFeePolicy, TopUpCannotExtendSchedule, TooManyMessages, + abort_latest_appeal_admission, apply_fee_top_up, activate_fee_accounting, calculate_appeal_charge, @@ -7271,6 +7272,52 @@ def test_cancel_fee_accounting_returns_unadjudicated_appeal_bond(): assert canceled["appeal_bond_settlements"][0]["payout"] == 1400 +def test_abort_unstarted_appeal_restores_accounting_and_returns_typed_charge(): + fees_distribution = _fees_distribution(appeals=0, rotations=[0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + sender="0x2222222222222222222222222222222222222222", + ) + appealer = "0x1111111111111111111111111111111111111111" + charge = calculate_appeal_charge( + accounting["fees_distribution"], + current_round=0, + status="ACCEPTED", + ) + recorded = record_appeal_bond( + accounting, + amount=charge["bond"] + charge["funding"], + appealer=appealer, + current_round=0, + status="ACCEPTED", + ) + + restored, recipient, refund = abort_latest_appeal_admission( + recorded, + reason="appeal_committee_unavailable", + ) + + assert recipient == appealer + assert refund == charge["bond"] + charge["funding"] + for key in ( + "fees_distribution", + "execution_budget_total", + "primary_fee_budget", + "paid_fee_value", + "appeal_funding_total", + "time_unit_overlay_budget", + "appeal_bonds_total", + "contributions", + "untracked_contributions", + ): + assert restored[key] == accounting[key] + assert restored["appeal_bonds"] == [] + assert restored["aborted_appeals"][0]["refund"] == refund + + def test_top_up_and_submit_appeal_records_only_typed_funding_in_fee_budget(): fees_distribution = _fees_distribution( max_price_gen_per_time_unit=100, @@ -10206,6 +10253,43 @@ def test_terminal_replacement_readmits_prior_participants_but_excludes_leaders() } +def test_terminal_replacement_excludes_prior_leader_case_insensitively(): + leader = "0xAbCdEf0000000000000000000000000000000001" + validator = "0x0000000000000000000000000000000000000002" + history = { + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [{"node_config": {"address": leader.lower()}}], + } + ] + } + + terminal = ConsensusAlgorithm.get_terminal_replacement_validators( + [{"address": leader}, {"address": validator}], history + ) + + assert terminal == [{"address": validator}] + + +def test_consensus_data_validator_lookup_is_case_insensitive(): + leader = "0xAbCdEf0000000000000000000000000000000001" + validator = "0xFeDcBa0000000000000000000000000000000002" + consensus_data = SimpleNamespace( + leader_receipt=[SimpleNamespace(node_config={"address": leader.lower()})], + validators=[SimpleNamespace(node_config={"address": validator.lower()})], + ) + + selected, remaining = ConsensusAlgorithm.get_validators_from_consensus_data( + [{"address": leader}, {"address": validator}], + consensus_data, + include_leader=True, + ) + + assert selected == [{"address": leader}, {"address": validator}] + assert remaining == {} + + def test_terminal_replacement_keeps_full_frozen_electorate_threshold(): from backend.consensus.types import ConsensusResult from backend.consensus.utils import determine_consensus_from_votes From bebbe348caa8a37c836b8183c9d17cf67b774157 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 06:01:13 +0100 Subject: [PATCH 13/55] fix(consensus): preserve paid appeals across worker failures --- backend/consensus/history.py | 5 + backend/consensus/worker.py | 281 ++++++++++++++++-- backend/database_handler/accounts_manager.py | 4 + .../transactions_processor.py | 94 +++++- tests/db-sqlalchemy/accounts_manager_test.py | 150 +++++++++- .../test_finalization_starvation.py | 92 ++++++ .../unit/test_contract_not_found_handling.py | 8 +- tests/unit/test_worker_transient_fatal.py | 201 +++++++++++++ 8 files changed, 810 insertions(+), 25 deletions(-) diff --git a/backend/consensus/history.py b/backend/consensus/history.py index 23c1ef60a..86ea1227a 100644 --- a/backend/consensus/history.py +++ b/backend/consensus/history.py @@ -46,6 +46,11 @@ # existing transaction reads carry the authority without a schema migration. LATEST_DECISION_KEY = "latestDecision" ACTIVE_APPEAL_BASIS_KEY = "activeAppealBasis" +# Studio processes an admitted appeal asynchronously, unlike the atomic +# on-chain call. Preserve the exact agreed transaction state so worker or +# process failures can retry the paid appeal without resetting the original +# decision or stranding its custody. +APPEAL_RECOVERY_SNAPSHOT_KEY = "appealRecoverySnapshot" def latest_decision_metadata( diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index 3ba51bd99..e63c084cf 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -16,6 +16,7 @@ from backend.domain.types import Transaction from backend.node.genvm.error_codes import GenVMInternalError from backend.consensus.base import ConsensusAlgorithm, NoValidatorsAvailableError +from backend.consensus.history import APPEAL_RECOVERY_SNAPSHOT_KEY # Alias for use in context manager (avoids circular import issues) _NoValidatorsError = NoValidatorsAvailableError @@ -761,6 +762,11 @@ async def _transaction_context( Control to the processing logic """ transaction_reset = False + raw_transaction_data = tx_data.get("data") + protected_appeal_work = tx_type == "appeal" or ( + isinstance(raw_transaction_data, dict) + and APPEAL_RECOVERY_SNAPSHOT_KEY in raw_transaction_data + ) try: # Track current transaction for health monitoring self.current_transactions[tx_hash] = { @@ -776,11 +782,58 @@ async def _transaction_context( f"is_leader={e.is_leader}, message={e}, detail={e.detail}, ctx={e.ctx}" ) session.rollback() - - # Only reset transaction and stop worker for leader errors (or unknown origin) - # Validator errors can be handled by the consensus algorithm - # (continue with remaining validators) - if e.is_leader is False: + if protected_appeal_work: + self._restore_admitted_appeal_for_retry(tx_hash) + + # A paid appeal is already bound to an agreed decision. Reusing the + # ordinary transaction-reset path here would erase that decision and + # its consensus history while leaving the admitted appeal custody in + # ``transaction.data``. Preserve the appeal unchanged and release its + # claim so another worker can retry it instead. This is the Studio + # equivalent of an on-chain appeal call reverting before it mutates + # the transaction. + if protected_appeal_work: + if e.is_leader is False: + logger.warning( + f"[Worker {self.worker_id}] GenVM internal error in appeal validator " + f"for {tx_hash}; preserving the admitted appeal for retry" + ) + elif e.is_fatal: + transient_causes = _TRANSIENT_LEADER_FATAL_CAUSES.intersection( + e.causes or [] + ) + if transient_causes: + await asyncio.sleep( + float( + os.environ.get("GENVM_TRANSIENT_FATAL_BACKOFF_S", "3") + ) + ) + logger.warning( + f"[Worker {self.worker_id}] Transient fatal GenVM error in appeal " + f"leader ({', '.join(sorted(transient_causes))}); preserving the " + f"admitted appeal and keeping the worker alive" + ) + else: + logger.warning( + f"[Worker {self.worker_id}] Fatal GenVM error in appeal leader; " + f"preserving the admitted appeal for another worker and stopping " + f"this worker" + ) + self.running = False + else: + logger.warning( + f"[Worker {self.worker_id}] Retryable GenVM error in appeal leader for " + f"{tx_hash}; preserving the admitted appeal for retry" + ) + await self._handle_generic_error_retry( + tx_hash, + e, + cancel_on_exhaustion=False, + ) + # Only reset an ordinary transaction for leader errors (or unknown + # origin). Validator errors can be handled by the consensus + # algorithm (continue with remaining validators). + elif e.is_leader is False: # Validator error - don't reset, consensus will continue with remaining validators logger.warning( f"[Worker {self.worker_id}] GenVM internal error in validator for {tx_hash}, " @@ -863,7 +916,13 @@ async def _transaction_context( f"[Worker {self.worker_id}] Error processing {tx_type} {tx_hash}: {e}" ) session.rollback() - await self._handle_generic_error_retry(tx_hash, e) + if protected_appeal_work: + self._restore_admitted_appeal_for_retry(tx_hash) + await self._handle_generic_error_retry( + tx_hash, + e, + cancel_on_exhaustion=not protected_appeal_work, + ) finally: # Clear current transaction tracking self.current_transactions.pop(tx_hash, None) @@ -878,6 +937,27 @@ async def _transaction_context( exc_info=True, ) + def _restore_admitted_appeal_for_retry(self, tx_hash: str) -> bool: + """Undo partial, independently committed appeal state transitions.""" + + try: + with self.get_session() as restore_session: + restored = TransactionsProcessor( + restore_session + ).restore_transaction_appeal_for_retry(tx_hash) + if restored: + restore_session.commit() + else: + restore_session.rollback() + return restored + except Exception as restore_error: + logger.error( + f"[Worker {self.worker_id}] Failed to restore admitted appeal " + f"{tx_hash} for retry: {restore_error}", + exc_info=True, + ) + return False + # Statuses where the consensus result has been agreed and the tx is # awaiting finalization. Recovery must NOT wipe consensus_data / # consensus_history for these — that state is what gets promoted to @@ -916,6 +996,104 @@ async def recover_stuck_transactions(self, session: Session) -> int: Number of transactions reset (escalations are logged but not counted as recoveries). """ + # Step 0: an interrupted paid appeal must resume from the exact agreed + # decision captured at admission. Appeal state handlers commit their + # progress incrementally, so an ordinary PENDING reset here would erase + # the decision while leaving its bond custody behind. + restore_appeals_query = text( + """ + UPDATE transactions + SET status = CAST(data->'appealRecoverySnapshot'->>'status' AS transaction_status), + consensus_history = data->'appealRecoverySnapshot'->'consensusHistory', + consensus_data = NULLIF( + data->'appealRecoverySnapshot'->'consensusData', + 'null'::jsonb + ), + contract_snapshot = NULLIF( + data->'appealRecoverySnapshot'->'contractSnapshot', + 'null'::jsonb + ), + appealed = TRUE, + appeal_failed = COALESCE( + (data->'appealRecoverySnapshot'->>'appealFailed')::integer, + 0 + ), + appeal_undetermined = COALESCE( + (data->'appealRecoverySnapshot'->>'appealUndetermined')::boolean, + FALSE + ), + appeal_leader_timeout = COALESCE( + (data->'appealRecoverySnapshot'->>'appealLeaderTimeout')::boolean, + FALSE + ), + appeal_validators_timeout = COALESCE( + (data->'appealRecoverySnapshot'->>'appealValidatorsTimeout')::boolean, + FALSE + ), + appeal_processing_time = COALESCE( + (data->'appealRecoverySnapshot'->>'appealProcessingTime')::integer, + 0 + ), + rotation_count = COALESCE( + (data->'appealRecoverySnapshot'->>'rotationCount')::integer, + 0 + ), + leader_timeout_validators = NULLIF( + data->'appealRecoverySnapshot'->'leaderTimeoutValidators', + 'null'::jsonb + ), + timestamp_awaiting_finalization = ( + data->'appealRecoverySnapshot'->>'timestampAwaitingFinalization' + )::bigint, + timestamp_appeal = ( + data->'appealRecoverySnapshot'->>'timestampAppeal' + )::bigint, + worker_id = NULL, + blocked_at = NULL + WHERE data ? 'appealRecoverySnapshot' + AND status IN :consensus_statuses + AND ( + ( + blocked_at IS NOT NULL + AND blocked_at < NOW() - CAST(:timeout AS INTERVAL) + AND COALESCE( + ( + SELECT to_timestamp(MAX(progress.value::double precision)) + FROM jsonb_each_text( + COALESCE( + transactions.consensus_history->'current_monitoring', + '{}'::jsonb + ) + ) AS progress(key, value) + WHERE progress.value ~ '^[0-9]+(\\.[0-9]+)?$' + ), + blocked_at + ) < NOW() - CAST(:timeout AS INTERVAL) + ) + OR + (blocked_at IS NULL + AND status IN ('PROPOSING', 'COMMITTING', 'REVEALING') + AND created_at < NOW() - CAST(:orphan_timeout AS INTERVAL)) + ) + RETURNING hash, status; + """ + ).bindparams(bindparam("consensus_statuses", expanding=True)) + restored_appeals = session.execute( + restore_appeals_query, + { + "timeout": f"{self.transaction_timeout_minutes} minutes", + "orphan_timeout": "5 minutes", + "consensus_statuses": list(self._CONSENSUS_RECOVERABLE_STATUSES), + }, + ).fetchall() + if restored_appeals: + session.commit() + for row in restored_appeals: + logger.info( + f"[Worker {self.worker_id}] Restored interrupted paid appeal " + f"{row.hash} to admitted status {row.status}" + ) + # Step 1: escalate txs that have already hit the recovery limit. # These have been reset N times and keep getting stuck — cancel # them so the per-contract queue can drain. @@ -937,6 +1115,7 @@ async def recover_stuck_transactions(self, session: Session) -> int: ) WHERE recovery_count >= :max_cycles AND status IN :consensus_statuses + AND NOT (data ? 'appealRecoverySnapshot') AND ( ( blocked_at IS NOT NULL @@ -1031,6 +1210,7 @@ async def recover_stuck_transactions(self, session: Session) -> int: recovery_count = recovery_count + 1 WHERE recovery_count < :max_cycles AND status IN :consensus_statuses + AND NOT (data ? 'appealRecoverySnapshot') AND ( -- Case 1: Transactions with expired blocks ( @@ -1125,7 +1305,7 @@ async def recover_stuck_transactions(self, session: Session) -> int: f"Likely finalization-starvation — investigate." ) - return len(recovered) + return len(restored_appeals) + len(recovered) async def process_transaction(self, transaction_data: dict, session: Session): """ @@ -1235,6 +1415,9 @@ async def process_transaction(self, transaction_data: dict, session: Session): validators_snapshot, ) + TransactionsProcessor( + session + ).clear_transaction_appeal_recovery_snapshot(transaction.hash) session.commit() logger.info( f"[Worker {self.worker_id}] Successfully processed transaction {transaction.hash}" @@ -1253,7 +1436,19 @@ async def process_transaction(self, transaction_data: dict, session: Session): logger.warning( f"[Worker {self.worker_id}] No validators available for transaction {transaction_data['hash']}" ) - await self._handle_no_validators_retry(transaction_data, session) + raw_data = transaction_data.get("data") + if isinstance(raw_data, dict) and APPEAL_RECOVERY_SNAPSHOT_KEY in raw_data: + session.rollback() + self._restore_admitted_appeal_for_retry(tx_hash) + await self._handle_generic_error_retry( + tx_hash, + NoValidatorsAvailableError( + "No validators available for appeal recomputation" + ), + cancel_on_exhaustion=False, + ) + else: + await self._handle_no_validators_retry(transaction_data, session) except ContractNotFoundError as e: # Handle contract not found - mark as ACCEPTED with ERROR execution result @@ -1376,10 +1571,19 @@ async def _handle_no_validators_retry( f"next attempt in {backoff}s" ) - async def _handle_generic_error_retry(self, tx_hash: str, error: Exception): + async def _handle_generic_error_retry( + self, + tx_hash: str, + error: Exception, + *, + cancel_on_exhaustion: bool = True, + ): """ Handle retry logic for generic errors during transaction processing. - Implements exponential backoff and cancels after MAX_GENERIC_ERROR_RETRIES. + Implements exponential backoff and normally cancels after + MAX_GENERIC_ERROR_RETRIES. Paid appeals disable cancellation because + an infrastructure failure must not invalidate an already-agreed + decision or strand its appeal custody. Args: tx_hash: Transaction hash @@ -1393,7 +1597,10 @@ async def _handle_generic_error_retry(self, tx_hash: str, error: Exception): retry_info["last_error"] = str(error) self._generic_error_retries[tx_hash] = retry_info - if retry_info["count"] >= self.MAX_GENERIC_ERROR_RETRIES: + if ( + retry_info["count"] >= self.MAX_GENERIC_ERROR_RETRIES + and cancel_on_exhaustion + ): # Cancel the transaction after max retries logger.error( f"[Worker {self.worker_id}] Transaction {tx_hash} canceled after " @@ -1438,11 +1645,21 @@ async def _handle_generic_error_retry(self, tx_hash: str, error: Exception): del self._generic_error_retries[tx_hash] else: backoff = self._generic_error_base_backoff * ( - 2 ** (retry_info["count"] - 1) + 2 + ** min( + retry_info["count"] - 1, + self.MAX_GENERIC_ERROR_RETRIES - 1, + ) ) logger.warning( f"[Worker {self.worker_id}] Generic error for {tx_hash}, " - f"retry {retry_info['count']}/{self.MAX_GENERIC_ERROR_RETRIES}, " + f"retry {retry_info['count']}" + + ( + f"/{self.MAX_GENERIC_ERROR_RETRIES}" + if cancel_on_exhaustion + else " (appeal preserved)" + ) + + ", " f"next attempt in {backoff}s - error: {error}" ) @@ -1852,7 +2069,12 @@ async def process_appeal(self, appeal_data: dict, session: Session): validators_snapshot, ) + transactions_processor.clear_transaction_appeal_recovery_snapshot( + transaction.hash, include_pending=False + ) session.commit() + self._generic_error_retries.pop(transaction.hash, None) + self._leader_crash_retries.pop(transaction.hash, None) logger.info( f"[Worker {self.worker_id}] Successfully processed appeal for transaction {transaction.hash}" ) @@ -1865,6 +2087,16 @@ async def process_appeal(self, appeal_data: dict, session: Session): with self.get_session() as error_session: transactions_processor = TransactionsProcessor(error_session) + accounts_manager = AccountsManager(error_session) + + # The appeal never produced an outcome. Finalizing the original + # decision must not make its admitted bond look like a failed + # appeal: unwind the exact admission first, refunding both bond + # principal and induced-work funding to the appealer. + accounts_manager.abort_tx_appeal_admission_once( + tx_hash, + "contract_not_found_during_appeal", + ) await ConsensusAlgorithm.dispatch_transaction_status_update( transactions_processor, @@ -1875,7 +2107,7 @@ async def process_appeal(self, appeal_data: dict, session: Session): tx = error_session.query(Transactions).filter_by(hash=tx_hash).one() refund_recipient = tx.origin_address or tx.from_address if refund_recipient: - AccountsManager(error_session).settle_tx_fee_accounting_once( + accounts_manager.settle_tx_fee_accounting_once( tx_hash, refund_recipient, reason="finalized_contract_not_found_during_appeal", @@ -1943,7 +2175,11 @@ def _is_in_backoff(self, transaction_data: dict) -> bool: generic_info = self._generic_error_retries.get(tx_hash) if generic_info: backoff = self._generic_error_base_backoff * ( - 2 ** (generic_info["count"] - 1) + 2 + ** min( + generic_info["count"] - 1, + self.MAX_GENERIC_ERROR_RETRIES - 1, + ) ) time_since_last = time.time() - generic_info["last_attempt"] if time_since_last < backoff: @@ -1984,12 +2220,15 @@ async def _try_claim_work(self, session: Session) -> bool: appeal_data = await self.claim_next_appeal(session) if appeal_data: - logger.debug( - f"[Worker {self.worker_id}] Claimed appeal for transaction {appeal_data['hash']}" - ) - task = asyncio.create_task(self._process_appeal_task(appeal_data)) - self._active_tasks.add(task) - return True + tx_hash = appeal_data["hash"] + if not self._is_in_backoff(appeal_data): + logger.debug( + f"[Worker {self.worker_id}] Claimed appeal for transaction {tx_hash}" + ) + task = asyncio.create_task(self._process_appeal_task(appeal_data)) + self._active_tasks.add(task) + return True + self.release_transaction(session, tx_hash) finalization_data = await self.claim_next_finalization(session) if finalization_data: diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index ef2d9c565..fe33086e8 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -13,6 +13,7 @@ ) from backend.consensus.history import ( ACTIVE_APPEAL_BASIS_KEY, + APPEAL_RECOVERY_SNAPSHOT_KEY, completed_consensus_round_index, ) @@ -209,6 +210,7 @@ def cancel_tx_fee_accounting_once( was_terminal = accounting.get("status") in {"settled", "canceled"} updated, refund = cancel_fee_accounting(accounting, reason=reason) data[FEE_ACCOUNTING_KEY] = updated + data.pop(APPEAL_RECOVERY_SNAPSHOT_KEY, None) transaction.data = data if refund > 0: self._credit_fee_refund_settlements(updated, sender_address, refund) @@ -245,6 +247,7 @@ def abort_tx_appeal_admission_once( return 0 data[FEE_ACCOUNTING_KEY] = updated + data.pop(APPEAL_RECOVERY_SNAPSHOT_KEY, None) transaction.data = data history = dict(transaction.consensus_history or {}) history.pop(ACTIVE_APPEAL_BASIS_KEY, None) @@ -296,6 +299,7 @@ def settle_tx_fee_accounting_once( ), ) data[FEE_ACCOUNTING_KEY] = updated + data.pop(APPEAL_RECOVERY_SNAPSHOT_KEY, None) transaction.data = data if refund > 0: self._credit_fee_refund_settlements(updated, sender_address, refund) diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index b34c1fabd..285842120 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -14,6 +14,7 @@ import base64 import time import os +import copy from typing import Callable from backend.domain.types import TransactionType from web3 import Web3 @@ -24,6 +25,7 @@ consensus_vote_type_code, ) from backend.consensus.history import ( + APPEAL_RECOVERY_SNAPSHOT_KEY, completed_consensus_round_index, completed_consensus_rounds, materialize_decision_metadata, @@ -1574,7 +1576,12 @@ def admit_transaction_appeal( row = ( self.session.execute( text( - "SELECT status, appealed, consensus_history, data FROM transactions" + "SELECT status, appealed, consensus_history, consensus_data," + " contract_snapshot, appeal_failed, appeal_undetermined," + " appeal_leader_timeout, appeal_validators_timeout," + " appeal_processing_time, rotation_count," + " leader_timeout_validators, timestamp_awaiting_finalization, data" + " FROM transactions" " WHERE hash = :hash FOR UPDATE" ), {"hash": transaction_hash}, @@ -1609,6 +1616,26 @@ def admit_transaction_appeal( ) if fee_accounting is not None: data[FEE_ACCOUNTING_KEY] = fee_accounting + status_value = ( + row["status"].value + if hasattr(row["status"], "value") + else str(row["status"]) + ) + data[APPEAL_RECOVERY_SNAPSHOT_KEY] = { + "status": status_value, + "consensusHistory": copy.deepcopy(updated), + "consensusData": copy.deepcopy(row["consensus_data"]), + "contractSnapshot": copy.deepcopy(row["contract_snapshot"]), + "appealFailed": int(row["appeal_failed"] or 0), + "appealUndetermined": bool(row["appeal_undetermined"]), + "appealLeaderTimeout": bool(row["appeal_leader_timeout"]), + "appealValidatorsTimeout": bool(row["appeal_validators_timeout"]), + "appealProcessingTime": int(row["appeal_processing_time"] or 0), + "rotationCount": int(row["rotation_count"] or 0), + "leaderTimeoutValidators": copy.deepcopy(row["leader_timeout_validators"]), + "timestampAwaitingFinalization": row["timestamp_awaiting_finalization"], + "timestampAppeal": admitted_at, + } self.session.execute( text( "UPDATE transactions" @@ -1627,6 +1654,71 @@ def admit_transaction_appeal( ) return int(surplus_refund) + def restore_transaction_appeal_for_retry(self, transaction_hash: str) -> bool: + """Restore the exact admitted state after interrupted appeal work.""" + + transaction = ( + self.session.query(Transactions) + .filter_by(hash=transaction_hash) + .populate_existing() + .with_for_update() + .one_or_none() + ) + if transaction is None or not isinstance(transaction.data, dict): + return False + snapshot = transaction.data.get(APPEAL_RECOVERY_SNAPSHOT_KEY) + if not isinstance(snapshot, dict): + return False + try: + transaction.status = TransactionStatus(str(snapshot["status"])) + except (KeyError, TypeError, ValueError): + return False + + transaction.consensus_history = copy.deepcopy(snapshot.get("consensusHistory")) + transaction.consensus_data = copy.deepcopy(snapshot.get("consensusData")) + transaction.contract_snapshot = copy.deepcopy(snapshot.get("contractSnapshot")) + transaction.appealed = True + transaction.appeal_failed = int(snapshot.get("appealFailed", 0) or 0) + transaction.appeal_undetermined = bool( + snapshot.get("appealUndetermined", False) + ) + transaction.appeal_leader_timeout = bool( + snapshot.get("appealLeaderTimeout", False) + ) + transaction.appeal_validators_timeout = bool( + snapshot.get("appealValidatorsTimeout", False) + ) + transaction.appeal_processing_time = int( + snapshot.get("appealProcessingTime", 0) or 0 + ) + transaction.rotation_count = int(snapshot.get("rotationCount", 0) or 0) + transaction.leader_timeout_validators = copy.deepcopy( + snapshot.get("leaderTimeoutValidators") + ) + transaction.timestamp_awaiting_finalization = snapshot.get( + "timestampAwaitingFinalization" + ) + transaction.timestamp_appeal = int(snapshot.get("timestampAppeal", 0) or 0) + self.session.flush() + return True + + def clear_transaction_appeal_recovery_snapshot( + self, transaction_hash: str, *, include_pending: bool = True + ) -> None: + """Drop the transient snapshot once an appeal has completed.""" + + pending_guard = "" if include_pending else " AND status != 'PENDING'" + self.session.execute( + text( + "UPDATE transactions SET data = data - :snapshot_key" + " WHERE hash = :hash AND data ? :snapshot_key" + pending_guard + ), + { + "hash": transaction_hash, + "snapshot_key": APPEAL_RECOVERY_SNAPSHOT_KEY, + }, + ) + def set_transaction_timestamp_awaiting_finalization( self, transaction_hash: str, timestamp_awaiting_finalization: int = None ): diff --git a/tests/db-sqlalchemy/accounts_manager_test.py b/tests/db-sqlalchemy/accounts_manager_test.py index bac758cea..1bbb5bb7a 100644 --- a/tests/db-sqlalchemy/accounts_manager_test.py +++ b/tests/db-sqlalchemy/accounts_manager_test.py @@ -10,9 +10,12 @@ from backend.database_handler.accounts_manager import AccountsManager from backend.database_handler.errors import AccountNotFoundError -from backend.database_handler.models import Transactions +from backend.database_handler.models import Transactions, TransactionStatus from backend.database_handler.transactions_processor import TransactionsProcessor -from backend.consensus.history import ACTIVE_APPEAL_BASIS_KEY +from backend.consensus.history import ( + ACTIVE_APPEAL_BASIS_KEY, + APPEAL_RECOVERY_SNAPSHOT_KEY, +) from backend.protocol_rpc.fees import ( FEE_ACCOUNTING_KEY, calculate_appeal_charge, @@ -264,6 +267,10 @@ def test_abort_tx_appeal_admission_refunds_charge_and_restores_decision( "nextAppealWindow": 10, } } + tx_model.data = { + **tx_model.data, + APPEAL_RECOVERY_SNAPSHOT_KEY: {"status": "ACCEPTED"}, + } session.commit() expected_refund = charge["bond"] + charge["funding"] @@ -278,11 +285,150 @@ def test_abort_tx_appeal_admission_refunds_charge_and_restores_decision( assert tx.appealed is False assert tx.timestamp_appeal is None assert ACTIVE_APPEAL_BASIS_KEY not in tx.consensus_history + assert APPEAL_RECOVERY_SNAPSHOT_KEY not in tx.data assert restored["appeal_bonds"] == [] assert restored["primary_fee_budget"] == accounting["primary_fee_budget"] assert restored["fees_distribution"] == accounting["fees_distribution"] +def test_admitted_appeal_snapshot_restores_exact_agreed_state_for_retry( + session: Session, +): + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + tx_hash = "0x" + "af" * 32 + fees = _fees_distribution(appeals=0, rotations=[0]) + accounting = create_fee_accounting( + fees_distribution=fees, + num_of_validators=5, + submitted_value=required_fee_deposit(fees, 5), + user_value=0, + sender=sender, + ) + original_history = { + "latestDecision": { + "decisionId": 1, + "status": "ACCEPTED", + "materializedAt": 100, + "appealDeadline": 1_000_000_000_000, + }, + "consensus_results": [{"consensus_round": "ACCEPTED"}], + } + original_consensus_data = {"leader_receipt": [{"result": "original"}]} + original_contract_snapshot = {"states": {"accepted": {"slot": "original"}}} + _insert_fee_accounted_transaction( + session, + sender=sender, + accounting=accounting, + tx_hash=tx_hash, + consensus_history=original_history, + ) + tx = session.query(Transactions).filter_by(hash=tx_hash).one() + tx.status = TransactionStatus.ACCEPTED + tx.consensus_data = original_consensus_data + tx.contract_snapshot = original_contract_snapshot + tx.appeal_failed = 2 + tx.appeal_processing_time = 7 + tx.rotation_count = 1 + tx.timestamp_awaiting_finalization = 99 + session.commit() + + processor = TransactionsProcessor(session) + processor.admit_transaction_appeal( + tx_hash, + expected_decision_id=1, + submitted_at=200, + appeal_deadline=1_000_000_000_000, + retention_bps=8_000, + prepare_fee_accounting=lambda current: (current, 0), + ) + session.commit() + session.expire_all() + + tx = session.query(Transactions).filter_by(hash=tx_hash).one() + admitted_history = tx.consensus_history + assert APPEAL_RECOVERY_SNAPSHOT_KEY in tx.data + tx.status = TransactionStatus.COMMITTING + tx.consensus_history = {"consensus_results": [{"partial": True}]} + tx.consensus_data = {"partial": True} + tx.contract_snapshot = {"states": {"accepted": {"slot": "partial"}}} + tx.appealed = False + tx.appeal_failed = 99 + tx.appeal_undetermined = True + tx.appeal_leader_timeout = True + tx.appeal_validators_timeout = True + tx.appeal_processing_time = 999 + tx.rotation_count = 999 + tx.leader_timeout_validators = [{"address": "partial"}] + tx.timestamp_awaiting_finalization = None + tx.timestamp_appeal = None + session.commit() + + assert processor.restore_transaction_appeal_for_retry(tx_hash) is True + session.commit() + session.expire_all() + + restored = session.query(Transactions).filter_by(hash=tx_hash).one() + assert restored.status == TransactionStatus.ACCEPTED + assert restored.consensus_history == admitted_history + assert restored.consensus_data == original_consensus_data + assert restored.contract_snapshot == original_contract_snapshot + assert restored.appealed is True + assert restored.appeal_failed == 2 + assert restored.appeal_undetermined is False + assert restored.appeal_leader_timeout is False + assert restored.appeal_validators_timeout is False + assert restored.appeal_processing_time == 7 + assert restored.rotation_count == 1 + assert restored.leader_timeout_validators is None + assert restored.timestamp_awaiting_finalization == 99 + assert restored.timestamp_appeal is not None + + +def test_appeal_recovery_snapshot_survives_pending_terminal_recomputation( + session: Session, +): + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + tx_hash = "0x" + "ae" * 32 + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(appeals=0, rotations=[0]), + num_of_validators=5, + submitted_value=required_fee_deposit( + _fees_distribution(appeals=0, rotations=[0]), 5 + ), + user_value=0, + sender=sender, + ) + _insert_fee_accounted_transaction( + session, + sender=sender, + accounting=accounting, + tx_hash=tx_hash, + consensus_history={"consensus_results": []}, + ) + tx = session.query(Transactions).filter_by(hash=tx_hash).one() + tx.status = TransactionStatus.PENDING + tx.data = { + **tx.data, + APPEAL_RECOVERY_SNAPSHOT_KEY: {"status": "ACCEPTED"}, + } + session.commit() + + processor = TransactionsProcessor(session) + processor.clear_transaction_appeal_recovery_snapshot(tx_hash, include_pending=False) + session.commit() + session.expire_all() + pending = session.query(Transactions).filter_by(hash=tx_hash).one() + assert APPEAL_RECOVERY_SNAPSHOT_KEY in pending.data + + pending.status = TransactionStatus.ACCEPTED + session.commit() + processor.clear_transaction_appeal_recovery_snapshot(tx_hash, include_pending=False) + session.commit() + session.expire_all() + completed = session.query(Transactions).filter_by(hash=tx_hash).one() + assert APPEAL_RECOVERY_SNAPSHOT_KEY not in completed.data + + def test_concurrent_appeal_admission_abort_refunds_only_once(engine: Engine): SessionFactory = sessionmaker(bind=engine, expire_on_commit=False) sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" diff --git a/tests/db-sqlalchemy/test_finalization_starvation.py b/tests/db-sqlalchemy/test_finalization_starvation.py index 13c8b9fe4..b9dcaaa07 100644 --- a/tests/db-sqlalchemy/test_finalization_starvation.py +++ b/tests/db-sqlalchemy/test_finalization_starvation.py @@ -25,6 +25,7 @@ import time import os +import json import pytest from unittest.mock import MagicMock from sqlalchemy import Engine, text @@ -447,6 +448,97 @@ async def test_recover_still_resets_stuck_consensus_txs( assert row.consensus_history is None +@pytest.mark.asyncio +async def test_recover_restores_interrupted_paid_appeal_instead_of_resetting_it( + engine: Engine, worker: ConsensusWorker, session: Session +): + _setup_contract(engine) + tx_hash = "0x" + "78" * 32 + admitted_history = { + "latestDecision": {"decisionId": 1, "status": "ACCEPTED"}, + "activeAppealBasis": { + "decisionId": 1, + "submittedAt": 123, + "nextAppealWindow": 10, + }, + "consensus_results": [{"consensus_round": "ACCEPTED"}], + } + original_consensus_data = {"leader_receipt": [{"result": "original"}]} + original_contract_snapshot = {"states": {"accepted": {"slot": "original"}}} + snapshot = { + "status": "ACCEPTED", + "consensusHistory": admitted_history, + "consensusData": original_consensus_data, + "contractSnapshot": original_contract_snapshot, + "appealFailed": 2, + "appealUndetermined": False, + "appealLeaderTimeout": False, + "appealValidatorsTimeout": False, + "appealProcessingTime": 7, + "rotationCount": 1, + "leaderTimeoutValidators": None, + "timestampAwaitingFinalization": 99, + "timestampAppeal": 123, + } + _insert_tx( + session, + tx_hash=tx_hash, + status="COMMITTING", + nonce=0, + consensus_data={"partial": True}, + consensus_history={"current_monitoring": {"COMMITTING": 1}}, + blocked_at_offset_seconds=-25 * 60, + worker_id="dead-appeal-worker", + ) + session.execute( + text( + "UPDATE transactions SET data = CAST(:data AS jsonb)," + " contract_snapshot = CAST(:snapshot AS jsonb), appealed = false," + " appeal_failed = 99, appeal_undetermined = true," + " appeal_leader_timeout = true, appeal_validators_timeout = true," + " appeal_processing_time = 999, rotation_count = 999" + " WHERE hash = :hash" + ), + { + "hash": tx_hash, + "data": json.dumps({"appealRecoverySnapshot": snapshot}), + "snapshot": json.dumps({"states": {"accepted": {"slot": "partial"}}}), + }, + ) + session.commit() + + recovered = await worker.recover_stuck_transactions(session) + + row = session.execute( + text( + "SELECT status, blocked_at, worker_id, recovery_count, appealed," + " appeal_failed, appeal_undetermined, appeal_leader_timeout," + " appeal_validators_timeout, appeal_processing_time, rotation_count," + " timestamp_appeal, timestamp_awaiting_finalization," + " consensus_data, consensus_history, contract_snapshot" + " FROM transactions WHERE hash = :hash" + ), + {"hash": tx_hash}, + ).one() + assert recovered == 1 + assert row.status == TransactionStatus.ACCEPTED.value + assert row.blocked_at is None + assert row.worker_id is None + assert row.recovery_count == 0 + assert row.appealed is True + assert row.appeal_failed == 2 + assert row.appeal_undetermined is False + assert row.appeal_leader_timeout is False + assert row.appeal_validators_timeout is False + assert row.appeal_processing_time == 7 + assert row.rotation_count == 1 + assert row.timestamp_appeal == 123 + assert row.timestamp_awaiting_finalization == 99 + assert row.consensus_data == original_consensus_data + assert row.consensus_history == admitted_history + assert row.contract_snapshot == original_contract_snapshot + + def test_direct_genvm_reset_increments_recovery_count( engine: Engine, worker: ConsensusWorker, session: Session ): diff --git a/tests/unit/test_contract_not_found_handling.py b/tests/unit/test_contract_not_found_handling.py index 0117f4ff5..3f66006d2 100644 --- a/tests/unit/test_contract_not_found_handling.py +++ b/tests/unit/test_contract_not_found_handling.py @@ -187,7 +187,9 @@ def get_session_side_effect(): new_callable=AsyncMock, side_effect=ContractNotFoundError(contract_address), ): - with patch.object(worker, "release_transaction"): + with patch.object(worker, "release_transaction"), patch( + "backend.consensus.worker.AccountsManager" + ) as accounts_manager_cls: with patch( "backend.consensus.base.ConsensusAlgorithm.dispatch_transaction_status_update", new_callable=AsyncMock, @@ -199,6 +201,10 @@ def get_session_side_effect(): assert call_args.args[1] == "0xtest_appeal_hash" assert call_args.args[2] == TransactionStatus.FINALIZED assert call_args.args[3] == mock_msg_handler + accounts_manager_cls.return_value.abort_tx_appeal_admission_once.assert_called_once_with( + "0xtest_appeal_hash", + "contract_not_found_during_appeal", + ) class TestGenCallContractNotFoundHandling: diff --git a/tests/unit/test_worker_transient_fatal.py b/tests/unit/test_worker_transient_fatal.py index e5ada0d82..a4cabb75b 100644 --- a/tests/unit/test_worker_transient_fatal.py +++ b/tests/unit/test_worker_transient_fatal.py @@ -101,3 +101,204 @@ async def test_validator_fatal_keeps_worker_alive_without_reset(): reset_transaction.assert_not_called() release_transaction.assert_called_once() sleep.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_transient_leader_fatal_during_appeal_preserves_admission_for_retry(): + worker = _make_worker() + session = MagicMock(spec=Session) + + with ( + patch.dict(os.environ, {"GENVM_TRANSIENT_FATAL_BACKOFF_S": "3.0"}), + patch.object(worker, "reset_transaction") as reset_transaction, + patch.object(worker, "release_transaction") as release_transaction, + patch.object( + worker, "_handle_leader_crash_retry", new_callable=AsyncMock + ) as handle_leader_crash_retry, + patch.object( + worker, "_handle_generic_error_retry", new_callable=AsyncMock + ) as handle_generic_error_retry, + patch( + "backend.consensus.worker.asyncio.sleep", new_callable=AsyncMock + ) as sleep, + ): + async with worker._transaction_context( + "0xtx", {"appealed": True}, session, "appeal" + ): + raise _fatal_error(causes=["NO_PROVIDER_FOR_PROMPT"]) + + session.rollback.assert_called_once() + reset_transaction.assert_not_called() + handle_leader_crash_retry.assert_not_awaited() + handle_generic_error_retry.assert_awaited_once() + assert handle_generic_error_retry.await_args.kwargs == { + "cancel_on_exhaustion": False + } + release_transaction.assert_called_once() + sleep.assert_awaited_once_with(3.0) + assert worker.running is True + + +@pytest.mark.asyncio +async def test_non_transient_leader_fatal_during_appeal_stops_without_reset(): + worker = _make_worker() + session = MagicMock(spec=Session) + + with ( + patch.object(worker, "reset_transaction") as reset_transaction, + patch.object(worker, "release_transaction") as release_transaction, + patch.object( + worker, "_handle_leader_crash_retry", new_callable=AsyncMock + ) as handle_leader_crash_retry, + patch.object( + worker, "_handle_generic_error_retry", new_callable=AsyncMock + ) as handle_generic_error_retry, + ): + async with worker._transaction_context( + "0xtx", {"appealed": True}, session, "appeal" + ): + raise _fatal_error(causes=["SOMETHING_ELSE"]) + + session.rollback.assert_called_once() + reset_transaction.assert_not_called() + handle_leader_crash_retry.assert_not_awaited() + handle_generic_error_retry.assert_awaited_once() + assert handle_generic_error_retry.await_args.kwargs == { + "cancel_on_exhaustion": False + } + release_transaction.assert_called_once() + assert worker.running is False + + +@pytest.mark.asyncio +async def test_unclassified_leader_crash_during_appeal_never_synthesizes_result(): + worker = _make_worker() + session = MagicMock(spec=Session) + error = GenVMInternalError( + message="hard crash", + error_code=None, + causes=[], + is_fatal=False, + is_leader=True, + ctx=None, + detail="trap", + ) + + with ( + patch.object(worker, "reset_transaction") as reset_transaction, + patch.object(worker, "release_transaction") as release_transaction, + patch.object( + worker, "_handle_leader_crash_retry", new_callable=AsyncMock + ) as handle_leader_crash_retry, + patch.object( + worker, "_handle_generic_error_retry", new_callable=AsyncMock + ) as handle_generic_error_retry, + ): + async with worker._transaction_context( + "0xtx", {"appealed": True}, session, "appeal" + ): + raise error + + session.rollback.assert_called_once() + reset_transaction.assert_not_called() + handle_leader_crash_retry.assert_not_awaited() + handle_generic_error_retry.assert_awaited_once() + assert handle_generic_error_retry.await_args.kwargs == { + "cancel_on_exhaustion": False + } + release_transaction.assert_called_once() + assert worker.running is True + + +@pytest.mark.asyncio +async def test_generic_appeal_failure_never_cancels_the_agreed_transaction(): + worker = _make_worker() + worker._generic_error_base_backoff = 0 + + for attempt in range(worker.MAX_GENERIC_ERROR_RETRIES + 3): + await worker._handle_generic_error_retry( + "0xtx", + RuntimeError(f"appeal infrastructure failure {attempt}"), + cancel_on_exhaustion=False, + ) + + assert worker._generic_error_retries["0xtx"]["count"] == ( + worker.MAX_GENERIC_ERROR_RETRIES + 3 + ) + + +@pytest.mark.asyncio +async def test_paid_appeal_claim_honors_retry_backoff(): + worker = _make_worker() + session = MagicMock(spec=Session) + worker.claim_next_appeal = AsyncMock(return_value={"hash": "0xtx"}) + worker.claim_next_finalization = AsyncMock(return_value=None) + worker.claim_next_transaction = AsyncMock(return_value=None) + worker._generic_error_retries["0xtx"] = { + "count": 1, + "last_attempt": 100.0, + "last_error": "boom", + } + + with ( + patch("backend.consensus.worker.time.time", return_value=100.0), + patch.object(worker, "release_transaction") as release_transaction, + patch("backend.consensus.worker.asyncio.create_task") as create_task, + ): + assert await worker._try_claim_work(session) is False + + release_transaction.assert_called_once_with(session, "0xtx") + create_task.assert_not_called() + + +@pytest.mark.asyncio +async def test_generic_appeal_error_is_retryable_without_cancellation(): + worker = _make_worker() + session = MagicMock(spec=Session) + + with ( + patch.object(worker, "release_transaction") as release_transaction, + patch.object( + worker, "_handle_generic_error_retry", new_callable=AsyncMock + ) as handle_generic_error_retry, + ): + async with worker._transaction_context( + "0xtx", {"appealed": True}, session, "appeal" + ): + raise RuntimeError("temporary worker failure") + + session.rollback.assert_called_once() + handle_generic_error_retry.assert_awaited_once() + assert handle_generic_error_retry.await_args.kwargs == { + "cancel_on_exhaustion": False + } + release_transaction.assert_called_once() + + +@pytest.mark.asyncio +async def test_terminal_recomputation_error_restores_paid_appeal_without_cancellation(): + worker = _make_worker() + session = MagicMock(spec=Session) + transaction_data = {"data": {"appealRecoverySnapshot": {"status": "ACCEPTED"}}} + + with ( + patch.object( + worker, "_restore_admitted_appeal_for_retry", return_value=True + ) as restore_appeal, + patch.object(worker, "release_transaction") as release_transaction, + patch.object( + worker, "_handle_generic_error_retry", new_callable=AsyncMock + ) as handle_generic_error_retry, + ): + async with worker._transaction_context( + "0xtx", transaction_data, session, "transaction" + ): + raise RuntimeError("terminal recomputation crashed") + + session.rollback.assert_called_once() + restore_appeal.assert_called_once_with("0xtx") + handle_generic_error_retry.assert_awaited_once() + assert handle_generic_error_retry.await_args.kwargs == { + "cancel_on_exhaustion": False + } + release_transaction.assert_called_once() From 975059e4d572cd34e2a3052875ee99b9714cf493 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 06:10:44 +0100 Subject: [PATCH 14/55] fix(consensus): make internal message effects replay-safe --- backend/rollup/consensus_service.py | 21 +++ .../contracts/v2_contracts/ConsensusMain.sol | 75 ++++++++-- hardhat/contracts/v2_contracts/Queues.sol | 15 +- .../v2_contracts/interfaces/IQueues.sol | 4 +- .../ConsensusMain.InternalMessages.test.js | 137 ++++++++++++++++++ .../test_consensus_service_message_replay.py | 73 ++++++++++ 6 files changed, 309 insertions(+), 16 deletions(-) create mode 100644 hardhat/test/consensus/ConsensusMain.InternalMessages.test.js create mode 100644 tests/unit/test_consensus_service_message_replay.py diff --git a/backend/rollup/consensus_service.py b/backend/rollup/consensus_service.py index dd52e7847..eef27a23c 100644 --- a/backend/rollup/consensus_service.py +++ b/backend/rollup/consensus_service.py @@ -298,6 +298,27 @@ def emit_transaction_event(self, event_name: str, account: dict, *args): "0x" + tx_id.hex() if isinstance(tx_id, bytes) else tx_id ) + # The Studio helper bridge makes each parent/phase/payload + # emission one-shot. A worker retry emits no new events, but + # it can recover the exact child ids stored by the first call. + # Always prefer that durable view when the deployed helper + # exposes it; the event-derived list remains compatible with + # older local deployments. + try: + stored_tx_ids = ( + consensus_main_contract.functions.getInternalMessageTxIds( + args[0], + event_name == "emitTransactionAccepted", + args[1], + ).call() + ) + tx_ids_hex = [ + "0x" + tx_id.hex() if isinstance(tx_id, bytes) else tx_id + for tx_id in stored_tx_ids + ] + except Exception: + pass + return { "receipt": receipt, "tx_ids_hex": tx_ids_hex, diff --git a/hardhat/contracts/v2_contracts/ConsensusMain.sol b/hardhat/contracts/v2_contracts/ConsensusMain.sol index 41f15b4bc..5674b2c77 100644 --- a/hardhat/contracts/v2_contracts/ConsensusMain.sol +++ b/hardhat/contracts/v2_contracts/ConsensusMain.sol @@ -26,14 +26,23 @@ contract ConsensusMain is ReentrancyGuardUpgradeable, AccessControlUpgradeable { + struct InternalMessageEmission { + bool processed; + bytes32[] txIds; + } + /// @notice Consolidated external contract addresses used in ConsensusMain IConsensusMain.ExternalContracts public contracts; /// @notice Mapping of ghost contracts mapping(address addr => bool isGhost) public ghostContracts; + /// @notice Durable one-shot child emission keyed by parent, phase, and payload. + mapping(bytes32 parentTxId => mapping(bool onAcceptance => mapping(bytes32 descriptor => InternalMessageEmission emission))) + private internalMessageEmissions; + /// @notice Gap for future upgrades - uint256[50] private __gap; + uint256[49] private __gap; // EVENTS // In: addTransaction @@ -178,7 +187,10 @@ contract ConsensusMain is emit VoteRevealed(txId, validator, voteType, isLastVote, result); } - function _processInternalMessages(internalMessageData[] calldata internalMessages) internal { + function _processInternalMessages( + internalMessageData[] calldata internalMessages + ) internal returns (bytes32[] memory txIds) { + txIds = new bytes32[](internalMessages.length); for (uint256 i = 0; i < internalMessages.length; i++) { if (!ghostContracts[internalMessages[i].recipient]) { _storeGhost(internalMessages[i].recipient); @@ -193,6 +205,7 @@ contract ConsensusMain is 0, // or pass as part of internalMessages.data internalMessages[i].data ); + txIds[i] = generated_txId; emit InternalMessageProcessed( generated_txId, internalMessages[i].recipient, @@ -205,20 +218,24 @@ contract ConsensusMain is bytes32 txId, internalMessageData[] calldata internalMessages ) external { - if (internalMessages.length > 0) { - _processInternalMessages(internalMessages); - } - emit TransactionAccepted(txId); + _processTransactionMessagesOnce(txId, true, internalMessages); } function emitTransactionFinalized( bytes32 txId, internalMessageData[] calldata internalMessages ) external { - if (internalMessages.length > 0) { - _processInternalMessages(internalMessages); - } - emit TransactionFinalized(txId); + _processTransactionMessagesOnce(txId, false, internalMessages); + } + + function getInternalMessageTxIds( + bytes32 parentTxId, + bool onAcceptance, + internalMessageData[] calldata internalMessages + ) external view returns (bytes32[] memory) { + bytes32 descriptor = keccak256(abi.encode(internalMessages)); + return + internalMessageEmissions[parentTxId][onAcceptance][descriptor].txIds; } function emitAppealStarted( @@ -576,6 +593,34 @@ contract ConsensusMain is // INTERNAL FUNCTIONS + function _processTransactionMessagesOnce( + bytes32 parentTxId, + bool onAcceptance, + internalMessageData[] calldata internalMessages + ) internal { + bytes32 descriptor = keccak256(abi.encode(internalMessages)); + InternalMessageEmission storage emission = internalMessageEmissions[ + parentTxId + ][onAcceptance][descriptor]; + if (emission.processed) { + return; + } + + // Install the replay guard before creating children. Any downstream + // revert rolls this write back with the entire EVM transaction. + emission.processed = true; + bytes32[] memory txIds = _processInternalMessages(internalMessages); + for (uint256 i = 0; i < txIds.length; i++) { + emission.txIds.push(txIds[i]); + } + + if (onAcceptance) { + emit TransactionAccepted(parentTxId); + } else { + emit TransactionFinalized(parentTxId); + } + } + function _addTransaction( address _sender, address _recipient, @@ -647,8 +692,14 @@ contract ConsensusMain is bytes32 _randomSeed, bytes memory _txData ) internal returns (bytes32 txId, address activator) { + uint256 issuedTxCount = contracts.genQueue.getIssuedTxCount(_recipient); txId = keccak256( - abi.encodePacked(_recipient, block.timestamp, _randomSeed) + abi.encodePacked( + _recipient, + block.timestamp, + _randomSeed, + issuedTxCount + ) ); (uint256 txSlot, ) = contracts.genQueue.addTransactionToPendingQueue( _recipient, @@ -783,4 +834,4 @@ contract ConsensusMain is revert Errors.InvalidAddress(); } } -} \ No newline at end of file +} diff --git a/hardhat/contracts/v2_contracts/Queues.sol b/hardhat/contracts/v2_contracts/Queues.sol index 37d182922..a7179878c 100644 --- a/hardhat/contracts/v2_contracts/Queues.sol +++ b/hardhat/contracts/v2_contracts/Queues.sol @@ -28,6 +28,7 @@ contract Queues is uint issuedTxCount; mapping(bytes32 => uint) txIdToFinalizedSlot; mapping(bytes32 => IQueues.QueueType) txIdToQueueType; + mapping(uint => bytes32) issuedIndexToTxId; } address public genConsensus; @@ -100,7 +101,13 @@ contract Queues is address recipient, uint256 slot ) external view returns (bytes32) { - return recipientQueues[recipient].accepted.slotToTxId[slot]; + return recipientQueues[recipient].issuedIndexToTxId[slot]; + } + + function getIssuedTxCount( + address recipient + ) external view returns (uint256) { + return recipientQueues[recipient].issuedTxCount; } function addTransactionToPendingQueue( @@ -121,7 +128,9 @@ contract Queues is pendingQueue.txIdToSlot[txId] = slot; pendingQueue.tail++; - queues.txIdToFinalizedSlot[txId] = queues.issuedTxCount++; + uint256 issuedIndex = queues.issuedTxCount++; + queues.txIdToFinalizedSlot[txId] = issuedIndex; + queues.issuedIndexToTxId[issuedIndex] = txId; emit QueueOperationPerformed( recipient, @@ -305,4 +314,4 @@ contract Queues is ]; } } -} \ No newline at end of file +} diff --git a/hardhat/contracts/v2_contracts/interfaces/IQueues.sol b/hardhat/contracts/v2_contracts/interfaces/IQueues.sol index 250d1c5d7..f98e53eb9 100644 --- a/hardhat/contracts/v2_contracts/interfaces/IQueues.sol +++ b/hardhat/contracts/v2_contracts/interfaces/IQueues.sol @@ -78,4 +78,6 @@ interface IQueues { address recipient, uint256 slot ) external view returns (bytes32); -} \ No newline at end of file + + function getIssuedTxCount(address recipient) external view returns (uint256); +} diff --git a/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js b/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js new file mode 100644 index 000000000..901495a1e --- /dev/null +++ b/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js @@ -0,0 +1,137 @@ +const { expect } = require("chai"); +const { deployments, ethers } = require("hardhat"); + +describe("ConsensusMain internal message bridge", function () { + let consensusMain; + let ghostFactory; + let queues; + let owner; + let recipient; + let deploymentTxId; + + beforeEach(async function () { + await deployments.fixture(); + [owner] = await ethers.getSigners(); + consensusMain = await ethers.getContractAt( + "ConsensusMain", + (await deployments.get("ConsensusMain")).address, + ); + ghostFactory = await ethers.getContractAt( + "GhostFactory", + (await deployments.get("GhostFactory")).address, + ); + queues = await ethers.getContractAt( + "Queues", + (await deployments.get("Queues")).address, + ); + + const deploymentTx = await consensusMain.addTransaction( + owner.address, + ethers.ZeroAddress, + 5, + 0, + "0x01", + ); + const deploymentReceipt = await deploymentTx.wait(); + const creationEvent = deploymentReceipt.logs + .map((log) => { + try { + return consensusMain.interface.parseLog(log); + } catch (_error) { + return null; + } + }) + .find((event) => event && event.name === "NewTransaction"); + deploymentTxId = creationEvent.args.txId; + recipient = await ghostFactory.latestGhost(); + }); + + it("keeps duplicate-recipient children distinct and replays a phase once", async function () { + const parentTxId = ethers.keccak256(ethers.toUtf8Bytes("parent")); + const messages = [ + { + sender: recipient, + recipient, + data: "0x0102", + }, + { + sender: recipient, + recipient, + data: "0x0304", + }, + ]; + const issuedBefore = await queues.getIssuedTxCount(recipient); + + await consensusMain.emitTransactionAccepted(parentTxId, messages); + + const firstIds = await consensusMain.getInternalMessageTxIds( + parentTxId, + true, + messages, + ); + expect(firstIds).to.have.length(2); + expect(firstIds[0]).to.not.equal(firstIds[1]); + expect(await queues.getIssuedTxCount(recipient)).to.equal( + issuedBefore + 2n, + ); + + await consensusMain.emitTransactionAccepted(parentTxId, messages); + + expect( + await consensusMain.getInternalMessageTxIds( + parentTxId, + true, + messages, + ), + ).to.deep.equal(firstIds); + expect(await queues.getIssuedTxCount(recipient)).to.equal( + issuedBefore + 2n, + ); + }); + + it("seats a distinct decision payload once without duplicating its retries", async function () { + const parentTxId = ethers.keccak256(ethers.toUtf8Bytes("drift-parent")); + const messages = [ + { + sender: recipient, + recipient, + data: "0x0102", + }, + ]; + await consensusMain.emitTransactionFinalized(parentTxId, messages); + const drifted = [{ ...messages[0], data: "0x9999" }]; + const issuedBeforeDrift = await queues.getIssuedTxCount(recipient); + + await consensusMain.emitTransactionFinalized(parentTxId, drifted); + const driftedIds = await consensusMain.getInternalMessageTxIds( + parentTxId, + false, + drifted, + ); + expect(driftedIds).to.have.length(1); + expect(await queues.getIssuedTxCount(recipient)).to.equal( + issuedBeforeDrift + 1n, + ); + + await consensusMain.emitTransactionFinalized(parentTxId, drifted); + expect( + await consensusMain.getInternalMessageTxIds( + parentTxId, + false, + drifted, + ), + ).to.deep.equal(driftedIds); + expect(await queues.getIssuedTxCount(recipient)).to.equal( + issuedBeforeDrift + 1n, + ); + }); + + it("indexes finalized history by recipient issuance order", async function () { + await consensusMain.cancelTransaction(deploymentTxId); + + expect(await queues.getFinalizedCount(recipient)).to.equal(1n); + expect(await queues.getFinalizedTxId(recipient, 0)).to.equal( + deploymentTxId, + ); + }); +}); diff --git a/tests/unit/test_consensus_service_message_replay.py b/tests/unit/test_consensus_service_message_replay.py new file mode 100644 index 000000000..348ccf043 --- /dev/null +++ b/tests/unit/test_consensus_service_message_replay.py @@ -0,0 +1,73 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from backend.rollup.consensus_service import ConsensusService + + +def _service_with_contract(contract): + service = ConsensusService.__new__(ConsensusService) + service.web3 = MagicMock() + service.web3.is_connected.return_value = True + service.web3.eth.get_transaction_count.return_value = 7 + service.web3.eth.account.sign_transaction.return_value = SimpleNamespace( + raw_transaction=b"signed" + ) + service._get_contract = MagicMock(return_value=contract) + service.forward_transaction = MagicMock(return_value={"status": 1}) + return service + + +def test_retried_message_phase_recovers_stored_child_ids_without_new_events(): + contract = MagicMock() + contract.functions.emitTransactionAccepted.return_value.build_transaction.return_value = ( + {} + ) + contract.events.NewTransaction.return_value.process_receipt.return_value = [] + child_ids = [bytes.fromhex("11" * 32), bytes.fromhex("22" * 32)] + contract.functions.getInternalMessageTxIds.return_value.call.return_value = ( + child_ids + ) + service = _service_with_contract(contract) + parent_id = "0x" + "aa" * 32 + + result = service.emit_transaction_event( + "emitTransactionAccepted", + {"address": "0x" + "01" * 20, "private_key": "0x" + "02" * 32}, + parent_id, + [], + ) + + assert result["tx_ids_hex"] == [ + "0x" + "11" * 32, + "0x" + "22" * 32, + ] + contract.functions.getInternalMessageTxIds.assert_called_once_with( + parent_id, True, [] + ) + + +def test_legacy_bridge_falls_back_to_child_ids_from_receipt_events(): + contract = MagicMock() + contract.functions.emitTransactionFinalized.return_value.build_transaction.return_value = ( + {} + ) + contract.events.NewTransaction.return_value.process_receipt.return_value = [ + {"args": {"txId": bytes.fromhex("33" * 32)}} + ] + contract.functions.getInternalMessageTxIds.return_value.call.side_effect = ( + RuntimeError("legacy deployment") + ) + service = _service_with_contract(contract) + parent_id = "0x" + "bb" * 32 + + result = service.emit_transaction_event( + "emitTransactionFinalized", + {"address": "0x" + "01" * 20, "private_key": "0x" + "02" * 32}, + parent_id, + [], + ) + + assert result["tx_ids_hex"] == ["0x" + "33" * 32] + contract.functions.getInternalMessageTxIds.assert_called_once_with( + parent_id, False, [] + ) From ab31fb0956db2932c6bb5f7f5e8f8fceac18ebb7 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 06:21:05 +0100 Subject: [PATCH 15/55] fix(consensus): reject unregistered internal recipients --- .../contracts/v2_contracts/ConsensusMain.sol | 3 --- .../ConsensusMain.InternalMessages.test.js | 21 +++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/hardhat/contracts/v2_contracts/ConsensusMain.sol b/hardhat/contracts/v2_contracts/ConsensusMain.sol index 5674b2c77..eb9feab24 100644 --- a/hardhat/contracts/v2_contracts/ConsensusMain.sol +++ b/hardhat/contracts/v2_contracts/ConsensusMain.sol @@ -192,9 +192,6 @@ contract ConsensusMain is ) internal returns (bytes32[] memory txIds) { txIds = new bytes32[](internalMessages.length); for (uint256 i = 0; i < internalMessages.length; i++) { - if (!ghostContracts[internalMessages[i].recipient]) { - _storeGhost(internalMessages[i].recipient); - } ( bytes32 generated_txId, address newActivator diff --git a/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js b/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js index 901495a1e..0dcf31f92 100644 --- a/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js +++ b/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js @@ -134,4 +134,25 @@ describe("ConsensusMain internal message bridge", function () { deploymentTxId, ); }); + + it("rejects an internal child targeting an unregistered nonzero recipient", async function () { + const parentTxId = ethers.keccak256( + ethers.toUtf8Bytes("non-ghost-parent"), + ); + let rejected = false; + try { + await consensusMain.emitTransactionAccepted(parentTxId, [ + { + sender: recipient, + recipient: owner.address, + data: "0x0102", + }, + ]); + } catch (error) { + rejected = String(error).includes("NonGenVMContract"); + } + + expect(rejected).to.equal(true); + expect(await consensusMain.ghostContracts(owner.address)).to.equal(false); + }); }); From 0bd2b9dad98e614c039fa256ddc64e402ff63004 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 06:44:25 +0100 Subject: [PATCH 16/55] fix(consensus): recover message effects atomically --- backend/consensus/base.py | 402 +++++++++++++++--- backend/consensus/worker.py | 84 +++- .../transactions_processor.py | 3 + backend/protocol_rpc/endpoints.py | 14 + backend/protocol_rpc/fees.py | 36 +- .../components/Simulator/TransactionItem.vue | 10 + .../TransactionItem.fees.behavioral.test.ts | 59 +++ tests/db-sqlalchemy/accounts_manager_test.py | 52 +++ .../test_finalization_starvation.py | 101 ++++- tests/unit/test_studio_fees.py | 181 ++++++++ tests/unit/test_worker_transient_fatal.py | 44 ++ 11 files changed, 903 insertions(+), 83 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 952844ef7..a03c5c9ac 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -65,6 +65,7 @@ discard_active_message_generation, fill_message_fee_payload_from_allocation, mark_message_effects_delivered, + message_effect_identities, message_novelty_mask, prepare_reveal_message_generation, record_external_message_execution_fees, @@ -160,6 +161,10 @@ class NoValidatorsAvailableError(Exception): """Raised when no validators are available to process a transaction.""" +class InternalMessageEmissionError(RuntimeError): + """Raised when the helper EVM did not durably accept a message phase.""" + + def _redact_consensus_data_for_log(consensus_data_dict: dict) -> dict: """ Return a redacted copy of the consensus data suitable for logging. @@ -905,6 +910,41 @@ def can_finalize_transaction( else: return False + async def repair_accepted_message_delivery( + self, + transaction: Transaction, + transactions_processor: TransactionsProcessor, + accounts_manager: AccountsManager, + contract_snapshot_factory: Callable[[str], ContractSnapshot], + contract_processor: ContractProcessor, + node_factory: NodeFactory, + ) -> None: + """Replay an agreed acceptance phase without finalizing the tx.""" + + context = TransactionContext( + transaction=transaction, + transactions_processor=transactions_processor, + chain_snapshot=None, + accounts_manager=accounts_manager, + contract_snapshot_factory=contract_snapshot_factory, + contract_processor=contract_processor, + node_factory=node_factory, + msg_handler=self.msg_handler, + consensus_service=self.consensus_service, + validators_snapshot=None, + genvm_manager=self.genvm_manager, + ) + leader_receipt = transaction.consensus_data.leader_receipt[0] + delivered = _dispatch_messages_for_phase(context, leader_receipt, "accepted") + if delivered: + await self.dispatch_transaction_status_update( + transactions_processor, + transaction.hash, + TransactionStatus.ACCEPTED, + self.msg_handler, + update_current_status_changes=False, + ) + async def process_finalization( self, transaction: Transaction, @@ -2232,7 +2272,7 @@ def _adjust_unbacked_message_values( return adjusted_pending_transactions -def _apply_message_value_withdrawals_for_phase( +def _apply_untracked_message_value_withdrawals_for_phase( context: TransactionContext, pending_transactions: Iterable[PendingTransaction], on: Literal["accepted", "finalized"], @@ -2260,6 +2300,179 @@ def _apply_message_value_withdrawals_for_phase( ) +def _message_value_effect_record( + pending_transaction: PendingTransaction, + descriptor: str, + on: Literal["accepted", "finalized"], + *, + external_value_backed: bool, + internal_value_backed: bool, + use_balance_funding_backed: bool, +) -> dict[str, Any]: + value = int(pending_transaction.value or 0) + include = True + adjusted_value = value + adjusted_budget = int(pending_transaction.declared_budget or 0) + + if pending_transaction.use_balance and not use_balance_funding_backed: + adjusted_value = 0 + adjusted_budget = 0 + elif value > 0 and pending_transaction.is_eth_send and not external_value_backed: + include = False + elif ( + value > 0 and not pending_transaction.is_eth_send and not internal_value_backed + ): + adjusted_value = 0 + + return { + "descriptor": descriptor, + "phase": on, + "include": include, + "value": adjusted_value, + "declaredBudget": adjusted_budget, + } + + +def _apply_recorded_message_value_effects( + pending_transactions: list[PendingTransaction], + identities: list[tuple[str, str]], + records: dict[str, Any], + on: Literal["accepted", "finalized"], +) -> list[PendingTransaction]: + adjusted: list[PendingTransaction] = [] + for pending_transaction, (occurrence, descriptor) in zip( + pending_transactions, identities + ): + if pending_transaction.on != on: + adjusted.append(pending_transaction) + continue + + record = records.get(occurrence) + if not isinstance(record, dict): + raise RuntimeError(f"MessageValueEffectMissing({occurrence})") + if record.get("descriptor") != descriptor or record.get("phase") != on: + raise RuntimeError( + "MessageValueEffectDescriptorMismatch" + f"({occurrence},{record.get('descriptor')},{descriptor})" + ) + if not bool(record.get("include", False)): + continue + + value = int(record.get("value", 0) or 0) + declared_budget = int(record.get("declaredBudget", 0) or 0) + if value == int(pending_transaction.value or 0) and declared_budget == int( + pending_transaction.declared_budget or 0 + ): + adjusted.append(pending_transaction) + continue + + replayed = _pending_transaction_with_value(pending_transaction, value) + replayed.declared_budget = declared_budget + adjusted.append(replayed) + return adjusted + + +def _apply_message_value_withdrawals_for_phase( + context: TransactionContext, + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> list[PendingTransaction]: + """Reserve message value once and replay the exact result after a crash. + + Studio's account database and helper EVM cannot share one atomic + transaction. The reservation therefore lives in the parent's locked fee + accounting row: a worker may retry the helper call, but it cannot debit the + contract a second time or change an earlier insufficient-balance outcome. + """ + + pending_list = list(pending_transactions) + parent_fee_accounting = (getattr(context.transaction, "data", None) or {}).get( + FEE_ACCOUNTING_KEY + ) + mutate_accounting = getattr( + context.transactions_processor, + "mutate_transaction_fee_accounting", + None, + ) + if not isinstance(parent_fee_accounting, dict) or not callable(mutate_accounting): + return _apply_untracked_message_value_withdrawals_for_phase( + context, pending_list, on + ) + + def reserve_latest(current_fee_accounting: dict[str, Any]) -> dict[str, Any]: + updated = deepcopy(current_fee_accounting) + payloads = _reveal_message_fee_payloads(updated, pending_list) + identities = message_effect_identities(context.transaction.hash, payloads) + records = updated.setdefault("message_value_effects", {}) + + unrecorded_indexes: set[int] = set() + for index, (pending_transaction, (occurrence, descriptor)) in enumerate( + zip(pending_list, identities) + ): + if pending_transaction.on != on: + continue + record = records.get(occurrence) + if record is None: + unrecorded_indexes.add(index) + continue + if ( + not isinstance(record, dict) + or record.get("descriptor") != descriptor + or record.get("phase") != on + ): + raise RuntimeError( + "MessageValueEffectDescriptorMismatch" + f"({occurrence}," + f"{record.get('descriptor') if isinstance(record, dict) else None}," + f"{descriptor})" + ) + + if unrecorded_indexes: + debit_candidates = [ + pending_transaction + for index, pending_transaction in enumerate(pending_list) + if pending_transaction.on != on or index in unrecorded_indexes + ] + external_value_backed = _debit_external_message_value_for_phase( + context, debit_candidates, on + ) + use_balance_funding_backed = _debit_use_balance_funding_for_phase( + context, debit_candidates, on + ) + internal_value_backed = _debit_internal_message_value_for_phase( + context, debit_candidates, on + ) + + for index in unrecorded_indexes: + pending_transaction = pending_list[index] + occurrence, descriptor = identities[index] + records[occurrence] = _message_value_effect_record( + pending_transaction, + descriptor, + on, + external_value_backed=external_value_backed, + internal_value_backed=internal_value_backed, + use_balance_funding_backed=use_balance_funding_backed, + ) + + return updated + + updated_accounting = mutate_accounting( + context.transaction.hash, + reserve_latest, + ) + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting + payloads = _reveal_message_fee_payloads(updated_accounting, pending_list) + identities = message_effect_identities(context.transaction.hash, payloads) + return _apply_recorded_message_value_effects( + pending_list, + identities, + updated_accounting.get("message_value_effects") or {}, + on, + ) + + class PendingState(TransactionState): """ Class representing the pending state of a transaction. @@ -3375,38 +3588,15 @@ async def handle(self, context): executor = EffectExecutor(context) await executor.execute(pre_effects) - # Impure: triggered transaction processing (needs DB reads for nonce/accounts). - # Consensus tombstones each tx-scoped logical message occurrence, so a - # successful appeal may re-reveal it but cannot charge or emit it twice. + # Consensus tombstones each tx-scoped logical message occurrence. The + # Studio bridge additionally makes the helper call, value reservation, + # and local child insertion replay-safe across worker/process failure. if execution_success: - novel_pending_transactions = _novel_pending_transactions( - context, - leader_receipt.pending_transactions, - ) - internal_messages_data, insert_transactions_data = _get_messages_data( + _dispatch_messages_for_phase( context, - _apply_message_value_withdrawals_for_phase( - context, - novel_pending_transactions, - "accepted", - ), - "accepted", - ) - - rollup_receipt = context.consensus_service.emit_transaction_event( - "emitTransactionAccepted", - leader_receipt.node_config, - context.transaction.hash, - internal_messages_data, - ) - - _emit_messages( - context, insert_transactions_data, rollup_receipt, "accepted" - ) - _mark_message_phase_delivered( - context, - leader_receipt.pending_transactions, + leader_receipt, "accepted", + force_phase_event=True, ) # Execute post-effects (status update + appeal cleanup) @@ -3528,6 +3718,11 @@ class FinalizingState(TransactionState): async def handle(self, context): leader_receipt = context.transaction.consensus_data.leader_receipt[0] + # Acceptance is a separate message lifecycle phase. If the helper EVM + # or worker died after the agreed decision was committed, repair that + # phase before allowing finalization to overtake it. + _dispatch_messages_for_phase(context, leader_receipt, "accepted") + pre_effects, post_effects, should_finalize_contract = decide_finalizing( tx_hash=context.transaction.hash, tx_status_accepted=( @@ -3561,34 +3756,11 @@ async def handle(self, context): finalized_state=accepted_state, ) - novel_pending_transactions = _novel_pending_transactions( - context, - leader_receipt.pending_transactions, - ) - internal_messages_data, insert_transactions_data = _get_messages_data( - context, - _apply_message_value_withdrawals_for_phase( - context, - novel_pending_transactions, - "finalized", - ), - "finalized", - ) - - rollup_receipt = context.consensus_service.emit_transaction_event( - "emitTransactionFinalized", - leader_receipt.node_config, - context.transaction.hash, - internal_messages_data, - ) - - _emit_messages( - context, insert_transactions_data, rollup_receipt, "finalized" - ) - _mark_message_phase_delivered( + _dispatch_messages_for_phase( context, - leader_receipt.pending_transactions, + leader_receipt, "finalized", + force_phase_event=True, ) await executor.execute(post_effects) @@ -3923,10 +4095,7 @@ def _sync_reveal_message_fee_accounting( context: TransactionContext, leader_receipt: Receipt, ) -> None: - if ( - leader_receipt.execution_result != ExecutionResultStatus.SUCCESS - or not leader_receipt.pending_transactions - ): + if leader_receipt.execution_result != ExecutionResultStatus.SUCCESS: _unwind_discarded_reveal_message_fee_accounting(context) return @@ -3939,8 +4108,6 @@ def prepare_latest(current_fee_accounting): current_fee_accounting, leader_receipt.pending_transactions, ) - if not message_fee_payloads: - return current_fee_accounting try: return prepare_reveal_message_generation( current_fee_accounting, @@ -4026,8 +4193,6 @@ def mark_latest(current_fee_accounting): current_fee_accounting, pending_list, ) - if not payloads: - return current_fee_accounting try: return mark_message_effects_delivered( current_fee_accounting, @@ -4048,6 +4213,98 @@ def mark_latest(current_fee_accounting): context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting +def _message_phase_delivery_required( + context: TransactionContext, + pending_transactions: Iterable[Any], + on: Literal["accepted", "finalized"], + *, + force_phase_event: bool = False, +) -> bool: + if force_phase_event: + return True + + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + if not isinstance(parent_fee_accounting, dict): + return False + + pending_list = [ + _coerce_pending_transaction(pending_transaction) + for pending_transaction in pending_transactions + ] + if pending_list: + payloads = _reveal_message_fee_payloads(parent_fee_accounting, pending_list) + novelty = message_novelty_mask( + parent_fee_accounting, + context.transaction.hash, + payloads, + ) + if any( + is_novel and pending_transaction.on == on + for pending_transaction, is_novel in zip(pending_list, novelty) + ): + return True + + if on == "accepted": + generation = parent_fee_accounting.get("active_message_generation") + if isinstance(generation, dict): + pending_generation = bool( + generation.get("acceptanceDispatchRequired", False) + ) and not bool(generation.get("acceptanceDispatched", False)) + if pending_generation: + return True + if bool((parent_fee_accounting.get("message_phase_emitted") or {}).get(on, False)): + return False + return False + + +def _dispatch_messages_for_phase( + context: TransactionContext, + leader_receipt: Receipt, + on: Literal["accepted", "finalized"], + *, + force_phase_event: bool = False, +) -> bool: + if leader_receipt.execution_result != ExecutionResultStatus.SUCCESS: + return False + if not _message_phase_delivery_required( + context, + leader_receipt.pending_transactions, + on, + force_phase_event=force_phase_event, + ): + return False + + novel_pending_transactions = _novel_pending_transactions( + context, + leader_receipt.pending_transactions, + ) + internal_messages_data, insert_transactions_data = _get_messages_data( + context, + _apply_message_value_withdrawals_for_phase( + context, + novel_pending_transactions, + on, + ), + on, + ) + event_name = ( + "emitTransactionAccepted" if on == "accepted" else "emitTransactionFinalized" + ) + rollup_receipt = context.consensus_service.emit_transaction_event( + event_name, + leader_receipt.node_config, + context.transaction.hash, + internal_messages_data, + ) + _emit_messages(context, insert_transactions_data, rollup_receipt, on) + _mark_message_phase_delivered( + context, + leader_receipt.pending_transactions, + on, + ) + return True + + def _unwind_discarded_reveal_message_fee_accounting( context: TransactionContext, ) -> None: @@ -4231,10 +4488,21 @@ def _emit_messages( receipt: dict, triggered_on: Literal["accepted", "finalized"], ): - for i, insert_transaction_data in enumerate(insert_transactions_data): - transaction_hash = ( - receipt["tx_ids_hex"][i] if receipt and "tx_ids_hex" in receipt else None + if not isinstance(receipt, dict): + raise InternalMessageEmissionError( + f"InternalMessageEmissionFailed({context.transaction.hash},{triggered_on})" + ) + tx_ids = receipt.get("tx_ids_hex") + if not isinstance(tx_ids, list) or len(tx_ids) != len(insert_transactions_data): + raise InternalMessageEmissionError( + "InternalMessageEmissionCountMismatch" + f"({context.transaction.hash},{triggered_on}," + f"{len(insert_transactions_data)}," + f"{len(tx_ids) if isinstance(tx_ids, list) else 'missing'})" ) + + for i, insert_transaction_data in enumerate(insert_transactions_data): + transaction_hash = tx_ids[i] # Determine execution_mode to cascade from parent transaction execution_mode_str = ( context.transaction.execution_mode.value diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index e63c084cf..6259535f6 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -15,8 +15,13 @@ from backend.database_handler.errors import ContractNotFoundError from backend.domain.types import Transaction from backend.node.genvm.error_codes import GenVMInternalError -from backend.consensus.base import ConsensusAlgorithm, NoValidatorsAvailableError +from backend.consensus.base import ( + ConsensusAlgorithm, + InternalMessageEmissionError, + NoValidatorsAvailableError, +) from backend.consensus.history import APPEAL_RECOVERY_SNAPSHOT_KEY +from backend.protocol_rpc.fees import acceptance_dispatch_pending # Alias for use in context manager (avoids circular import issues) _NoValidatorsError = NoValidatorsAvailableError @@ -34,6 +39,17 @@ # stop-the-worker circuit breaker (see _transaction_context). _TRANSIENT_LEADER_FATAL_CAUSES = frozenset({"NO_PROVIDER_FOR_PROMPT"}) + +def _acceptance_dispatch_pending(transaction_data: dict[str, Any]) -> bool: + data = transaction_data.get("data") + if not isinstance(data, dict): + return False + accounting = data.get("fee_accounting") + if not isinstance(accounting, dict): + return False + return acceptance_dispatch_pending(accounting) + + # --------------------------------------------------------------------------- # Claim column manifest # @@ -307,6 +323,17 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: WHERE t.status IN ('ACCEPTED', 'UNDETERMINED', 'LEADER_TIMEOUT', 'VALIDATORS_TIMEOUT') AND t.appealed = false AND ( + -- Repair an acceptance-phase helper emission before + -- the appeal deadline; finalization must not be the + -- first later opportunity to deliver accepted children. + (t.data->'fee_accounting'->'active_message_generation' + ->>'acceptanceDispatchRequired' = 'true' + AND COALESCE( + t.data->'fee_accounting'->'active_message_generation' + ->>'acceptanceDispatched', + 'false' + ) != 'true') + OR -- Normal: timestamp set and finality window elapsed (t.timestamp_awaiting_finalization IS NOT NULL AND ( @@ -517,18 +544,27 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: AND t3.created_at < t.created_at AND t3.status IN ('ACCEPTED', 'UNDETERMINED', 'LEADER_TIMEOUT', 'VALIDATORS_TIMEOUT') AND t3.appealed = false - AND t3.timestamp_awaiting_finalization IS NOT NULL AND ( - t3.execution_mode IN ('LEADER_ONLY', 'LEADER_SELF_VALIDATOR') + (t3.data->'fee_accounting'->'active_message_generation' + ->>'acceptanceDispatchRequired' = 'true' + AND COALESCE( + t3.data->'fee_accounting'->'active_message_generation' + ->>'acceptanceDispatched', + 'false' + ) != 'true') OR ( - EXTRACT(EPOCH FROM NOW()) >= COALESCE( - NULLIF(t3.consensus_history->'latestDecision'->>'appealDeadline', '')::double precision, - t3.timestamp_awaiting_finalization - + COALESCE(t3.appeal_processing_time, 0) - + :finality_window_seconds * POWER( - 1 - :appeal_failed_reduction, - COALESCE(t3.appeal_failed, 0) - ) + t3.timestamp_awaiting_finalization IS NOT NULL + AND ( + t3.execution_mode IN ('LEADER_ONLY', 'LEADER_SELF_VALIDATOR') + OR EXTRACT(EPOCH FROM NOW()) >= COALESCE( + NULLIF(t3.consensus_history->'latestDecision'->>'appealDeadline', '')::double precision, + t3.timestamp_awaiting_finalization + + COALESCE(t3.appeal_processing_time, 0) + + :finality_window_seconds * POWER( + 1 - :appeal_failed_reduction, + COALESCE(t3.appeal_failed, 0) + ) + ) ) ) ) @@ -921,7 +957,10 @@ async def _transaction_context( await self._handle_generic_error_retry( tx_hash, e, - cancel_on_exhaustion=not protected_appeal_work, + cancel_on_exhaustion=( + not protected_appeal_work + and not isinstance(e, InternalMessageEmissionError) + ), ) finally: # Clear current transaction tracking @@ -1919,6 +1958,27 @@ async def process_finalization(self, finalization_data: dict, session: Session): transactions_processor = transactions_processor_factory(session) + if _acceptance_dispatch_pending(finalization_data): + logger.info( + f"[Worker {self.worker_id}] Repairing accepted message delivery " + f"for transaction {transaction.hash}" + ) + await self.consensus_algorithm.repair_accepted_message_delivery( + transaction, + transactions_processor, + accounts_manager_factory(session), + lambda contract_address: contract_snapshot_factory( + contract_address, session, transaction + ), + contract_processor_factory(session), + node_factory, + ) + latest_transaction = transactions_processor.get_transaction_by_hash( + transaction.hash + ) + if latest_transaction is not None: + transaction = Transaction.from_dict(latest_transaction) + # Check if can finalize (appeal window expired) can_finalize = self.consensus_algorithm.can_finalize_transaction( transactions_processor, diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index 285842120..da131c5e9 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -35,6 +35,7 @@ from backend.consensus.utils import determine_consensus_from_votes from backend.protocol_rpc.fees import ( FEE_ACCOUNTING_KEY, + acceptance_dispatch_pending, apply_fee_top_up, normalize_fees_distribution, runtime_rotations_for_round, @@ -1598,6 +1599,8 @@ def admit_transaction_appeal( TransactionStatus.VALIDATORS_TIMEOUT.value, } or bool(row["appealed"]): raise ValueError("CanNotAppeal") + if acceptance_dispatch_pending((row["data"] or {}).get(FEE_ACCOUNTING_KEY)): + raise ValueError("CanNotAppeal") # The RPC may have waited for this row lock. Consensus evaluates the # deadline at transaction execution, not when the caller began # submitting it, so re-sample after lock acquisition and reject an diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 934d5fe67..12cdbbf9e 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -53,6 +53,7 @@ VALIDATORS_PER_ROUND, FeeValidationError, StudioFeePolicy, + acceptance_dispatch_pending, calculate_appeal_charge, calculate_round_fees, decode_internal_message_fee_params, @@ -1935,6 +1936,9 @@ def get_transaction_lifecycle( decision_active = status in _DECISION_STATUSES and not bool( transaction.get("appealed") ) + effects_pending = acceptance_dispatch_pending( + (transaction.get("data") or {}).get(FEE_ACCOUNTING_KEY) + ) current_round = _current_fee_round(transaction.get("consensus_history")) decision_id = _transaction_decision_id(transaction) if decision_active else None @@ -1953,6 +1957,7 @@ def get_transaction_lifecycle( deadline = _transaction_appeal_deadline(transaction) if ( decision_active + and not effects_pending and deadline is not None and evaluated_at >= deadline and transactions_processor.is_transaction_finalization_head(transaction_hash) @@ -1972,6 +1977,7 @@ def get_transaction_lifecycle( "resolutionSourceCode": resolution_source_code, "decisionId": str(decision_id) if decision_id is not None else None, "decisionActive": decision_active, + "effectsPending": effects_pending, "evaluatedAt": evaluated_at, } @@ -2007,6 +2013,8 @@ def estimate_latest_appeal_charge( fee_accounting = (transaction.get("data") or {}).get(FEE_ACCOUNTING_KEY) if fee_accounting is None: raise InvalidTransactionError("FeeAccountingMissing") + if acceptance_dispatch_pending(fee_accounting): + raise InvalidTransactionError("CanNotAppeal") session = getattr(transactions_processor, "session", None) frozen_pool_addresses = fee_accounting.get("selection_pool_addresses") live_pool_addresses = None @@ -2464,6 +2472,8 @@ def _handle_appeal_or_top_up_and_submit( fee_accounting = (tx.get("data") or {}).get(FEE_ACCOUNTING_KEY) if fee_accounting is None: raise InvalidTransactionError("FeeAccountingMissing") + if acceptance_dispatch_pending(fee_accounting): + raise InvalidTransactionError("CanNotAppeal") if fee_accounting is not None: session = getattr(accounts_manager, "session", None) frozen_pool_count = fee_accounting.get("selection_pool_count") @@ -2629,6 +2639,10 @@ def _handle_finalize_transaction( status = str(transaction.get("status") or "") if status not in _DECISION_STATUSES or bool(transaction.get("appealed")): raise InvalidTransactionError("FinalizationNotAllowed") + if acceptance_dispatch_pending( + (transaction.get("data") or {}).get(FEE_ACCOUNTING_KEY) + ): + raise InvalidTransactionError("FinalizationNotAllowed") current_decision_id = _transaction_decision_id(transaction) expected_decision_id = decoded_rollup_transaction.data.expected_decision_id if expected_decision_id is None or int(expected_decision_id) != current_decision_id: diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 7c09b9974..30ac6d165 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -5396,6 +5396,33 @@ def _message_effect_identities( return identities +def message_effect_identities( + tx_id: str, + messages: list[dict[str, Any]], +) -> list[tuple[str, str]]: + """Return Consensus-compatible stable occurrence and descriptor pairs. + + Message delivery, value reservation, and fee accounting all need to share + one logical identity. Keeping that identity authority here prevents the + asynchronous Studio worker from inventing a second retry key. + """ + + return _message_effect_identities(tx_id, messages) + + +def acceptance_dispatch_pending(accounting: dict[str, Any] | None) -> bool: + """Whether the current agreed decision still owes its acceptance phase.""" + + if not isinstance(accounting, dict): + return False + generation = accounting.get("active_message_generation") + return ( + isinstance(generation, dict) + and bool(generation.get("acceptanceDispatchRequired", False)) + and not bool(generation.get("acceptanceDispatched", False)) + ) + + def message_novelty_mask( accounting: dict[str, Any], tx_id: str, @@ -5454,6 +5481,10 @@ def prepare_reveal_message_generation( "messages": _message_accounting_json_safe(messages), "occurrences": [occurrence for occurrence, _ in identities], "novelty": novelty, + # Acceptance itself is a durable helper-chain phase, including when + # the receipt contains no accepted children. The worker clears this + # only after the helper call and local child insertion both succeed. + "acceptanceDispatchRequired": True, "acceptanceDispatched": False, } return updated @@ -5501,7 +5532,6 @@ def mark_message_effects_delivered( # Reuse descriptor validation before installing an irreversible tombstone. message_novelty_mask(updated, tx_id, messages) delivered = updated.setdefault("message_effect_delivered", {}) - delivered_any = False for message, (occurrence, descriptor) in zip(messages, identities): if bool(message.get("onAcceptance", False)) != (on == "accepted"): continue @@ -5509,10 +5539,10 @@ def mark_message_effects_delivered( occurrence, descriptor ) delivered[occurrence] = True - delivered_any = True + updated.setdefault("message_phase_emitted", {})[on] = True generation = updated.get("active_message_generation") - if delivered_any and on == "accepted" and isinstance(generation, dict): + if on == "accepted" and isinstance(generation, dict): generation["acceptanceDispatched"] = True return updated diff --git a/frontend/src/components/Simulator/TransactionItem.vue b/frontend/src/components/Simulator/TransactionItem.vue index 0e848d644..5271462fb 100644 --- a/frontend/src/components/Simulator/TransactionItem.vue +++ b/frontend/src/components/Simulator/TransactionItem.vue @@ -472,6 +472,16 @@ const canAppeal = computed(() => { return false; } + const messageGeneration = asRecord( + asRecord(feeAccounting.value)?.active_message_generation, + ); + if ( + messageGeneration?.acceptanceDispatchRequired === true && + messageGeneration?.acceptanceDispatched !== true + ) { + return false; + } + const history = asRecord(data.consensus_history); const results = history?.consensus_results; if ( diff --git a/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts b/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts index ce7a5d56c..231b9bd60 100644 --- a/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts +++ b/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts @@ -381,6 +381,65 @@ describe('TransactionItem fee accounting display', () => { vi.useRealTimers(); }); + it('hides appeals while the accepted message phase is still being repaired', () => { + const now = Date.now() / 1000; + const effectsPending = { + ...transaction, + data: { + ...transaction.data, + data: { + fee_accounting: { + ...feeAccounting, + active_message_generation: { + acceptanceDispatchRequired: true, + acceptanceDispatched: false, + }, + }, + }, + consensus_history: { + latestDecision: { + decisionId: 2, + status: 'ACCEPTED', + materializedAt: now - 10, + appealDeadline: now + 20, + }, + consensus_results: [], + }, + }, + }; + + const wrapper = mount(TransactionItem, { + props: { transaction: effectsPending, finalityWindow: 60 }, + global: { + directives: { tooltip: vi.fn() }, + stubs: { + Modal: ModalStub, + Btn: { + template: '', + }, + CopyTextButton: true, + JsonViewer: true, + Loader: true, + TransactionStatusBadge: { template: '' }, + CheckCircleIcon: true, + XCircleIcon: true, + EllipsisHorizontalCircleIcon: true, + FilterIcon: true, + GavelIcon: true, + UserPen: true, + UserSearch: true, + ExternalLink: true, + }, + }, + }); + + expect( + wrapper + .find(`[data-testid="appeal-transaction-btn-${transaction.hash}"]`) + .exists(), + ).toBe(false); + }); + it('hides appeals after a terminal successful validator appeal', () => { const terminal = { ...transaction, diff --git a/tests/db-sqlalchemy/accounts_manager_test.py b/tests/db-sqlalchemy/accounts_manager_test.py index 1bbb5bb7a..1ead9e555 100644 --- a/tests/db-sqlalchemy/accounts_manager_test.py +++ b/tests/db-sqlalchemy/accounts_manager_test.py @@ -291,6 +291,58 @@ def test_abort_tx_appeal_admission_refunds_charge_and_restores_decision( assert restored["fees_distribution"] == accounting["fees_distribution"] +def test_appeal_admission_rechecks_pending_acceptance_effects_under_lock( + session: Session, +): + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + tx_hash = "0x" + "ae" * 32 + fees = _fees_distribution(appeals=0, rotations=[0]) + accounting = create_fee_accounting( + fees_distribution=fees, + num_of_validators=5, + submitted_value=required_fee_deposit(fees, 5), + user_value=0, + sender=sender, + ) + accounting["active_message_generation"] = { + "acceptanceDispatchRequired": True, + "acceptanceDispatched": False, + } + history = { + "latestDecision": { + "decisionId": 1, + "status": "ACCEPTED", + "materializedAt": 100, + "appealDeadline": 1_000_000_000_000, + }, + "consensus_results": [{"consensus_round": "ACCEPTED"}], + } + _insert_fee_accounted_transaction( + session, + sender=sender, + accounting=accounting, + tx_hash=tx_hash, + consensus_history=history, + ) + tx = session.query(Transactions).filter_by(hash=tx_hash).one() + tx.status = TransactionStatus.ACCEPTED + session.commit() + + with pytest.raises(ValueError, match="CanNotAppeal"): + TransactionsProcessor(session).admit_transaction_appeal( + tx_hash, + expected_decision_id=1, + submitted_at=200, + appeal_deadline=1_000_000_000_000, + retention_bps=8_000, + prepare_fee_accounting=lambda current: (current, 0), + ) + + session.rollback() + session.expire_all() + assert session.query(Transactions).filter_by(hash=tx_hash).one().appealed is False + + def test_admitted_appeal_snapshot_restores_exact_agreed_state_for_retry( session: Session, ): diff --git a/tests/db-sqlalchemy/test_finalization_starvation.py b/tests/db-sqlalchemy/test_finalization_starvation.py index b9dcaaa07..1b147cbb6 100644 --- a/tests/db-sqlalchemy/test_finalization_starvation.py +++ b/tests/db-sqlalchemy/test_finalization_starvation.py @@ -27,6 +27,7 @@ import os import json import pytest +from types import SimpleNamespace from unittest.mock import MagicMock from sqlalchemy import Engine, text from sqlalchemy.orm import sessionmaker, Session @@ -39,7 +40,11 @@ os.environ.setdefault("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0.2") from backend.database_handler.models import CurrentState, TransactionStatus +from backend.database_handler.accounts_manager import AccountsManager +from backend.database_handler.transactions_processor import TransactionsProcessor +from backend.consensus.base import _apply_message_value_withdrawals_for_phase from backend.consensus.worker import ConsensusWorker +from backend.node.types import PendingTransaction CONTRACT_ADDRESS = "0xfinalization_starvation_test_contract" @@ -77,6 +82,7 @@ def _insert_tx( created_at_offset_seconds: int | None = None, # negative = past worker_id: str | None = None, recovery_count: int = 0, + data: dict | None = None, ): """Insert a transaction directly, bypassing TransactionsProcessor's PENDING-default and other defaults — we want full control over the row @@ -120,7 +126,7 @@ def _insert_tx( "status": status, "from_addr": SENDER, "to_addr": to_address, - "data": '{"calldata": "test"}', + "data": json.dumps(data or {"calldata": "test"}), "nonce": nonce, "timestamp_awaiting_finalization": timestamp_awaiting_finalization, "consensus_data": ( @@ -283,6 +289,99 @@ async def test_claim_next_transaction_proceeds_when_finalization_window_not_yet_ assert claimed["hash"] == "0x" + "55" * 32 +@pytest.mark.asyncio +async def test_pending_acceptance_delivery_is_repaired_before_deadline( + engine: Engine, worker: ConsensusWorker, session: Session +): + """A helper failure is repair work now, not delayed until finalization.""" + + _setup_contract(engine) + accepted_hash = "0x" + "45" * 32 + _insert_tx( + session, + tx_hash=accepted_hash, + status="ACCEPTED", + nonce=0, + timestamp_awaiting_finalization=int(time.time()) - 1, + consensus_data={"leader_receipt": [{}], "votes": {}, "validators": []}, + data={ + "fee_accounting": { + "active_message_generation": { + "acceptanceDispatchRequired": True, + "acceptanceDispatched": False, + } + } + }, + ) + _insert_tx(session, tx_hash="0x" + "46" * 32, status="PENDING", nonce=1) + + assert await worker.claim_next_transaction(session) is None + repair = await worker.claim_next_finalization(session) + + assert repair is not None + assert repair["hash"] == accepted_hash + + +def test_message_value_reservation_and_retry_marker_commit_atomically( + engine: Engine, session: Session +): + """The contract debit and its retry tombstone share one DB commit.""" + + _setup_contract(engine) + tx_hash = "0x" + "47" * 32 + accounting = {"paid_fee_value": 0} + _insert_tx( + session, + tx_hash=tx_hash, + status="ACCEPTED", + nonce=0, + data={"fee_accounting": accounting}, + ) + session.execute( + text("UPDATE current_state SET balance = 9 WHERE id = :address"), + {"address": CONTRACT_ADDRESS}, + ) + session.commit() + + transaction = SimpleNamespace( + hash=tx_hash, + to_address=CONTRACT_ADDRESS, + data={"fee_accounting": accounting}, + ) + context = SimpleNamespace( + transaction=transaction, + transactions_processor=TransactionsProcessor(session), + accounts_manager=AccountsManager(session), + ) + message = PendingTransaction( + address="0x" + "48" * 20, + calldata=b"", + code=None, + salt_nonce=0, + on="accepted", + value=5, + is_eth_send=True, + ) + + assert _apply_message_value_withdrawals_for_phase( + context, [message], "accepted" + ) == [message] + assert _apply_message_value_withdrawals_for_phase( + context, [message], "accepted" + ) == [message] + + balance = session.execute( + text("SELECT balance FROM current_state WHERE id = :address"), + {"address": CONTRACT_ADDRESS}, + ).scalar_one() + stored_data = session.execute( + text("SELECT data FROM transactions WHERE hash = :hash"), + {"hash": tx_hash}, + ).scalar_one() + assert balance == 4 + assert len(stored_data["fee_accounting"]["message_value_effects"]) == 1 + + @pytest.mark.asyncio async def test_claim_next_transaction_prefers_unrecovered_contract_head( engine: Engine, worker: ConsensusWorker, session: Session diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index d538a2ffb..bcf8f1a04 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -1292,6 +1292,40 @@ def insert_transaction(self, *args, **kwargs): assert calls[0][1]["config_rotation_rounds"] == 0 +def test_internal_child_insert_requires_exact_helper_chain_ids(): + calls = [] + + class _Processor: + def insert_transaction(self, *args, **kwargs): + calls.append((args, kwargs)) + + context = SimpleNamespace( + transaction=SimpleNamespace( + to_address="0x1111111111111111111111111111111111111111", + execution_mode="NORMAL", + hash="0x" + "12" * 32, + config_rotation_rounds=0, + sim_config=None, + origin_address=None, + ), + transactions_processor=_Processor(), + ) + child = ( + "0x2222222222222222222222222222222222222222", + {}, + TransactionType.RUN_CONTRACT.value, + 0, + 0, + ) + + with pytest.raises(RuntimeError, match="InternalMessageEmissionFailed"): + _emit_messages(context, [child], None, "accepted") + with pytest.raises(RuntimeError, match="InternalMessageEmissionCountMismatch"): + _emit_messages(context, [child], {"tx_ids_hex": []}, "accepted") + + assert calls == [] + + def test_simulation_fee_accounting_accepts_sdk_style_fee_options(): policy = StudioFeePolicy.from_env() fees_distribution = _env_fees_distribution( @@ -6891,6 +6925,18 @@ def test_identical_accepted_message_rereveal_keeps_single_lifetime_charge(): json.dumps(second["active_message_generation"]) +def test_empty_acceptance_phase_remains_repairable_until_helper_acknowledges_it(): + tx_id = "0x" + ("ac" * 32) + + prepared = prepare_reveal_message_generation({}, tx_id, []) + assert prepared["active_message_generation"]["acceptanceDispatchRequired"] is True + assert prepared["active_message_generation"]["acceptanceDispatched"] is False + + delivered = mark_message_effects_delivered(prepared, tx_id, [], "accepted") + assert delivered["active_message_generation"]["acceptanceDispatched"] is True + assert delivered["message_phase_emitted"]["accepted"] is True + + def test_message_occurrence_descriptor_rejects_value_or_fee_drift(): tx_id = "0x" + ("bc" * 32) message = { @@ -7927,6 +7973,15 @@ def debit_account_balance(self, address, amount): return True +class _MessageValueReservationProcessor: + def __init__(self, fee_accounting): + self.fee_accounting = fee_accounting + + def mutate_transaction_fee_accounting(self, tx_hash, mutator): + self.fee_accounting = mutator(self.fee_accounting) + return self.fee_accounting + + class _ExternalFreezeQuery: def __init__(self, *, scalar_value=None, rows=None): self.scalar_value = scalar_value @@ -7970,6 +8025,21 @@ def _message_value_context(balance, *, session=None): ) +def _recorded_message_value_context(balance): + address = "0x9999999999999999999999999999999999999999" + accounting = {"paid_fee_value": 0} + processor = _MessageValueReservationProcessor(accounting) + return SimpleNamespace( + transaction=SimpleNamespace( + hash="0x" + "cd" * 32, + to_address=address, + data={FEE_ACCOUNTING_KEY: accounting}, + ), + transactions_processor=processor, + accounts_manager=_MessageValueAccountsManager(balance), + ) + + def _pending_external_value(value, *, on="accepted", recipient=None): return PendingTransaction( address=recipient or "0x4444444444444444444444444444444444444444", @@ -8200,6 +8270,55 @@ def test_message_value_withdrawal_drops_unbacked_external_value(): ] +def test_message_value_reservation_is_replayed_without_a_second_debit(): + context = _recorded_message_value_context(balance=9) + message = _pending_external_value(5) + + first = _apply_message_value_withdrawals_for_phase( + context, + [message], + "accepted", + ) + second = _apply_message_value_withdrawals_for_phase( + context, + [message], + "accepted", + ) + + assert first == [message] + assert second == [message] + assert context.accounts_manager.balance == 4 + assert context.accounts_manager.debits == [ + (context.transaction.to_address, 5), + ] + assert ( + len(context.transactions_processor.fee_accounting["message_value_effects"]) == 1 + ) + + +def test_unbacked_message_value_outcome_is_stable_across_worker_retry(): + context = _recorded_message_value_context(balance=3) + message = _pending_external_value(5) + + first = _apply_message_value_withdrawals_for_phase( + context, + [message], + "accepted", + ) + second = _apply_message_value_withdrawals_for_phase( + context, + [message], + "accepted", + ) + + assert first == [] + assert second == [] + assert context.accounts_manager.balance == 3 + assert context.accounts_manager.debits == [ + (context.transaction.to_address, 5), + ] + + def test_message_value_withdrawal_debits_use_balance_fee_and_value_from_ghost(): context = _message_value_context(balance=20) use_balance_child = _pending_internal_value( @@ -9146,6 +9265,32 @@ def send_message(self, log_event, log_to_terminal=True): ] +def test_submit_appeal_rejects_until_acceptance_effects_are_acknowledged( + monkeypatch, +): + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_000) + accounting = _env_fee_accounting() + accounting["active_message_generation"] = { + "acceptanceDispatchRequired": True, + "acceptanceDispatched": False, + } + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) + accounts = _FakeAccountsManager(balance=10_000) + + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + _handle_appeal_or_top_up_and_submit( + accounts_manager=accounts, + transactions_processor=_FakeTransactionsProcessor(tx), + msg_handler=SimpleNamespace(), + decoded_rollup_transaction=_decoded_appeal( + tx["hash"], + amount=10_000, + ), + ) + + assert accounts.debits == [] + + def test_concurrent_appeal_loser_is_not_charged_or_recorded(monkeypatch): monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_000) @@ -9648,10 +9793,46 @@ def test_transaction_lifecycle_exposes_active_v06_decision_before_deadline( "resolutionSourceCode": 6, "decisionId": "1", "decisionActive": True, + "effectsPending": False, "evaluatedAt": 1_034, } +def test_transaction_lifecycle_blocks_actions_while_acceptance_effects_are_pending( + monkeypatch, +): + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + accounting = _env_fee_accounting() + accounting["active_message_generation"] = { + "acceptanceDispatchRequired": True, + "acceptanceDispatched": False, + } + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=0, + appeal_failed=0, + appealed=False, + execution_mode="NORMAL", + ) + processor = _FakeTransactionsProcessor(tx) + + lifecycle = get_transaction_lifecycle( + processor, {"txId": tx["hash"], "timestamp": 2_000} + ) + assert lifecycle["decisionActive"] is True + assert lifecycle["effectsPending"] is True + assert lifecycle["resolutionAction"] == "NoOp" + + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + estimate_latest_appeal_charge(processor, {"txId": tx["hash"]}) + with pytest.raises(InvalidTransactionError, match="FinalizationNotAllowed"): + _handle_finalize_transaction( + transactions_processor=processor, + decoded_rollup_transaction=_decoded_finalize(tx["hash"]), + ) + + def test_transaction_lifecycle_projects_finalize_at_exact_deadline(monkeypatch): monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") tx = _fee_accounted_tx(status="VALIDATORS_TIMEOUT") diff --git a/tests/unit/test_worker_transient_fatal.py b/tests/unit/test_worker_transient_fatal.py index a4cabb75b..d6dc459f7 100644 --- a/tests/unit/test_worker_transient_fatal.py +++ b/tests/unit/test_worker_transient_fatal.py @@ -4,6 +4,7 @@ import pytest from sqlalchemy.orm import Session +from backend.consensus.base import InternalMessageEmissionError from backend.node.genvm.error_codes import GenVMInternalError @@ -39,6 +40,27 @@ def _fatal_error(*, causes, is_leader=True): ) +def test_acceptance_dispatch_pending_requires_current_unacknowledged_generation(): + from backend.consensus.worker import _acceptance_dispatch_pending + + pending = { + "data": { + "fee_accounting": { + "active_message_generation": { + "acceptanceDispatchRequired": True, + "acceptanceDispatched": False, + } + } + } + } + + assert _acceptance_dispatch_pending(pending) is True + pending["data"]["fee_accounting"]["active_message_generation"][ + "acceptanceDispatched" + ] = True + assert _acceptance_dispatch_pending(pending) is False + + @pytest.mark.asyncio async def test_transient_leader_fatal_resets_and_keeps_worker_alive(): worker = _make_worker() @@ -227,6 +249,28 @@ async def test_generic_appeal_failure_never_cancels_the_agreed_transaction(): ) +@pytest.mark.asyncio +async def test_helper_message_failure_never_cancels_the_agreed_transaction(): + worker = _make_worker() + session = MagicMock(spec=Session) + + with ( + patch.object(worker, "release_transaction") as release_transaction, + patch.object( + worker, "_handle_generic_error_retry", new_callable=AsyncMock + ) as handle_generic_error_retry, + ): + async with worker._transaction_context("0xtx", {}, session, "transaction"): + raise InternalMessageEmissionError("helper unavailable") + + session.rollback.assert_called_once() + handle_generic_error_retry.assert_awaited_once() + assert handle_generic_error_retry.await_args.kwargs == { + "cancel_on_exhaustion": False + } + release_transaction.assert_called_once() + + @pytest.mark.asyncio async def test_paid_appeal_claim_honors_retry_backoff(): worker = _make_worker() From eaa57af79d40c12b16de51abbaed80913dd411a8 Mon Sep 17 00:00:00 2001 From: kirilaa Date: Fri, 28 Aug 2026 09:11:01 +0200 Subject: [PATCH 17/55] fix(rpc): keep pre-fee consensus entrypoints decodable --- .../default_contracts/consensus_main.py | 38 +++++-- tests/unit/test_transactions_parser.py | 100 ++++++++++++++++++ 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/backend/rollup/default_contracts/consensus_main.py b/backend/rollup/default_contracts/consensus_main.py index 091313b1f..992d743f5 100644 --- a/backend/rollup/default_contracts/consensus_main.py +++ b/backend/rollup/default_contracts/consensus_main.py @@ -1277,11 +1277,6 @@ "internalType": "bytes32", "name": "_txId", "type": "bytes32" - }, - { - "internalType": "uint256", - "name": "_expectedDecisionId", - "type": "uint256" } ], "name": "submitAppeal", @@ -1474,14 +1469,43 @@ ] +def _abi_function_signature(entry: dict) -> str: + """Canonical ``name(type,...)`` signature, i.e. the 4-byte selector identity.""" + + def canonical(component: dict) -> str: + component_type = component["type"] + if not component_type.startswith("tuple"): + return component_type + inner = ",".join( + canonical(nested) for nested in component.get("components", []) + ) + return f"({inner}){component_type[len('tuple'):]}" + + inputs = ",".join(canonical(entry_input) for entry_input in entry.get("inputs", [])) + return f"{entry['name']}({inputs})" + + def get_default_consensus_main_contract(): abi = json.loads(DEFAULT_CONSENSUS_MAIN_ABI) - train_transaction_names = {entry["name"] for entry in _STUDIO_TRAIN_TRANSACTION_ABI} + # The v0.6 fee-aware entrypoints are *overloads* of the deployed surface, + # never substitutes for it: Studio's own ConsensusMain and the pinned + # clients still call the pre-fee signatures, and this default ABI is what + # TransactionParser decodes against whenever the hardhat deployment + # artifacts are absent (hosted Studio and CI both hit that path). + # Deduplicating by *name* dropped + # addTransaction(address,address,uint256,uint256,bytes) — so every deploy + # and method call failed to match a selector, decoded as None, and was + # stored as a plain SEND to the ConsensusMain address with no + # consensus_data. Deduplicate on the selector signature instead, so both + # encodings stay decodable side by side. + train_transaction_signatures = { + _abi_function_signature(entry) for entry in _STUDIO_TRAIN_TRANSACTION_ABI + } abi = [ entry for entry in abi if entry.get("type") != "function" - or entry.get("name") not in train_transaction_names + or _abi_function_signature(entry) not in train_transaction_signatures ] abi.extend(_STUDIO_TRAIN_TRANSACTION_ABI) return { diff --git a/tests/unit/test_transactions_parser.py b/tests/unit/test_transactions_parser.py index 4f8c2717a..b3419a9d2 100644 --- a/tests/unit/test_transactions_parser.py +++ b/tests/unit/test_transactions_parser.py @@ -12,7 +12,9 @@ from rlp import encode from web3 import Web3 import backend.node.genvm.origin.calldata as calldata +from backend.domain.types import TransactionType from backend.rollup.default_contracts.consensus_main import ( + _abi_function_signature, get_default_consensus_main_contract, ) @@ -761,3 +763,101 @@ def test_decode_finalize_transaction_preserves_optional_decision_guard( assert decoded is not None assert decoded.data.tx_id == b"\x9a" * 32 assert decoded.data.expected_decision_id == expected_decision_id + + +def _default_abi_signatures() -> set[str]: + return { + _abi_function_signature(entry) + for entry in get_default_consensus_main_contract()["abi"] + if entry.get("type") == "function" + } + + +def test_default_consensus_abi_keeps_pre_fee_entrypoints_alongside_v06(): + """The v0.6 entrypoints are overloads, not replacements. + + Studio's deployed ConsensusMain still exposes the pre-fee signatures, and + this ABI is what TransactionParser decodes against whenever the hardhat + deployment artifacts are missing. Dropping them made every deploy and + method call undecodable, so it was stored as a plain SEND. + """ + signatures = _default_abi_signatures() + + assert "addTransaction(address,address,uint256,uint256,bytes)" in signatures + assert "submitAppeal(bytes32)" in signatures + assert "finalizeTransaction(bytes32)" in signatures + + assert "submitAppeal(bytes32,uint256)" in signatures + assert "finalizeTransaction(bytes32,uint256)" in signatures + assert any( + signature.startswith("addTransaction((") for signature in signatures + ), signatures + + +def test_default_consensus_abi_has_no_duplicate_selectors(): + functions = [ + entry + for entry in get_default_consensus_main_contract()["abi"] + if entry.get("type") == "function" + ] + signatures = [_abi_function_signature(entry) for entry in functions] + + assert len(signatures) == len(set(signatures)) + + +def test_pre_fee_deploy_decodes_when_deployment_artifacts_are_missing(monkeypatch): + """A pre-fee deploy must stay a DEPLOY_CONTRACT, never degrade to a SEND. + + ``ConsensusService.load_contract`` returns the default contract when + ``hardhat/deployments/genlayer_network/ConsensusMain.json`` is absent. When + the default ABI lost the pre-fee ``addTransaction`` overload, no selector + matched, ``decode_signed_transaction`` produced ``data=None``, and + ``get_genlayer_transaction`` fell back to a value transfer addressed to + ConsensusMain — finalized immediately with no consensus_data. + """ + sender = "0x715A17BA32a50bC11DADC257cb7c360FcaeE9dFA" + monkeypatch.setattr( + "backend.protocol_rpc.transactions_parser.Account.recover_transaction", + lambda raw: sender, + ) + + consensus_service = Mock() + consensus_service.web3 = Web3() + consensus_service.load_contract = Mock( + return_value=get_default_consensus_main_contract() + ) + parser = TransactionParser(consensus_service) + + contract_code = b"class Storage: pass" + calldata = b"\xc3\x01" + raw = _build_eip1559_raw( + nonce=0, + to=bytes.fromhex("b7278a61aa25c888815afc32ad3cc52ff24fe575"), + value=0, + data=_contract_call_data( + parser, + "addTransaction", + [ + sender, + "0x0000000000000000000000000000000000000000", + 5, + 3, + encode([contract_code, calldata]), + ], + input_count=5, + ), + ) + + decoded = parser.decode_signed_transaction(raw) + assert decoded is not None + assert decoded.data is not None + + genlayer_transaction = parser.get_genlayer_transaction(decoded) + + assert genlayer_transaction.type == TransactionType.DEPLOY_CONTRACT + assert ( + genlayer_transaction.to_address == "0x0000000000000000000000000000000000000000" + ) + assert genlayer_transaction.num_of_initial_validators == 5 + assert genlayer_transaction.data.contract_code == contract_code + assert genlayer_transaction.data.calldata == calldata From e7f5a6fd22462df7a0172354ce5f1033b2949cd8 Mon Sep 17 00:00:00 2001 From: kirilaa Date: Fri, 28 Aug 2026 09:31:58 +0200 Subject: [PATCH 18/55] fix(rpc): serve a name-unique consensus ABI --- .../default_contracts/consensus_main.py | 55 +++++++---- tests/unit/test_transactions_parser.py | 94 ++++++++++++++----- 2 files changed, 110 insertions(+), 39 deletions(-) diff --git a/backend/rollup/default_contracts/consensus_main.py b/backend/rollup/default_contracts/consensus_main.py index 992d743f5..cc2a89f52 100644 --- a/backend/rollup/default_contracts/consensus_main.py +++ b/backend/rollup/default_contracts/consensus_main.py @@ -1485,29 +1485,52 @@ def canonical(component: dict) -> str: return f"{entry['name']}({inputs})" +# Names kept at their pre-fee signature on the *served* surface. +# +# This ABI has two consumers with opposite requirements: +# +# * Decoding. TransactionParser matches an incoming selector against +# this ABI plus the FEE_AWARE_* overloads it appends itself, so it wants +# every generation of every entrypoint. That union lives in +# TransactionParser._get_contract_abi, not here. +# * Serving. sim_getConsensusContract hands this ABI to clients, and +# genlayer-py resolves entrypoints with web3's get_function_by_name(), +# which raises Web3ValueError on an overloaded name. The served surface +# must therefore be name-unique, and each shared name must carry the +# generation its callers actually encode. +# +# addTransaction is the one name where those callers disagree. Every client +# that reaches Studio through sim_getConsensusContract — released genlayer-py +# (so gltest and the load tests) and the train genlayer-py alike — builds five +# positional arguments and encodes them against contract_fn.argument_types. +# The single-tuple fee-aware form makes that raise, and it is also not a +# function Studio's deployed ConsensusMain.sol exposes. Train clients detect +# the pre-fee shape and fall back to the same five-argument path, so serving +# it satisfies both generations; the fee-aware form stays decodable through +# FEE_AWARE_ADD_TRANSACTION_ABI. +# +# submitAppeal and finalizeTransaction keep their v0.6 decision-bound form: +# no Studio client encodes them from the served ABI (genlayer-js embeds its +# own train ABI, and released genlayer-py's appeal path is unreachable here), +# while the train genlayer-py requires the DecisionId argument. +_SERVED_PRE_FEE_FUNCTION_NAMES = frozenset({"addTransaction"}) + + def get_default_consensus_main_contract(): abi = json.loads(DEFAULT_CONSENSUS_MAIN_ABI) - # The v0.6 fee-aware entrypoints are *overloads* of the deployed surface, - # never substitutes for it: Studio's own ConsensusMain and the pinned - # clients still call the pre-fee signatures, and this default ABI is what - # TransactionParser decodes against whenever the hardhat deployment - # artifacts are absent (hosted Studio and CI both hit that path). - # Deduplicating by *name* dropped - # addTransaction(address,address,uint256,uint256,bytes) — so every deploy - # and method call failed to match a selector, decoded as None, and was - # stored as a plain SEND to the ConsensusMain address with no - # consensus_data. Deduplicate on the selector signature instead, so both - # encodings stay decodable side by side. - train_transaction_signatures = { - _abi_function_signature(entry) for entry in _STUDIO_TRAIN_TRANSACTION_ABI - } + served_train_abi = [ + entry + for entry in _STUDIO_TRAIN_TRANSACTION_ABI + if entry["name"] not in _SERVED_PRE_FEE_FUNCTION_NAMES + ] + served_train_names = {entry["name"] for entry in served_train_abi} abi = [ entry for entry in abi if entry.get("type") != "function" - or _abi_function_signature(entry) not in train_transaction_signatures + or entry.get("name") not in served_train_names ] - abi.extend(_STUDIO_TRAIN_TRANSACTION_ABI) + abi.extend(served_train_abi) return { "address": DEFAULT_CONSENSUS_MAIN_ADDRESS, "abi": abi, diff --git a/tests/unit/test_transactions_parser.py b/tests/unit/test_transactions_parser.py index b3419a9d2..17246ada8 100644 --- a/tests/unit/test_transactions_parser.py +++ b/tests/unit/test_transactions_parser.py @@ -48,16 +48,23 @@ def test_default_consensus_abi_exports_exact_decision_guarded_actions(): "bytes32", "uint256", ] - for function_name in ("addTransaction", "deploySalted"): - assert [item["type"] for item in functions[function_name]["inputs"]] == [ - "tuple" - ] - assert functions[function_name]["stateMutability"] == "payable" + assert [item["type"] for item in functions["deploySalted"]["inputs"]] == ["tuple"] + assert functions["deploySalted"]["stateMutability"] == "payable" + # addTransaction is deliberately served at its pre-fee signature; the + # fee-aware overload stays on the decode surface only. See + # _SERVED_PRE_FEE_FUNCTION_NAMES. + assert [item["type"] for item in functions["addTransaction"]["inputs"]] == [ + "address", + "address", + "uint256", + "uint256", + "bytes", + ] assert [item["type"] for item in functions["topUpFees"]["inputs"]] == [ "bytes32", "tuple", ] - add_params = functions["addTransaction"]["inputs"][0]["components"] + add_params = functions["deploySalted"]["inputs"][0]["components"] assert [item["type"] for item in add_params] == [ "address", "address", @@ -765,23 +772,36 @@ def test_decode_finalize_transaction_preserves_optional_decision_guard( assert decoded.data.expected_decision_id == expected_decision_id -def _default_abi_signatures() -> set[str]: +def _served_abi_functions() -> list: + return [ + entry + for entry in get_default_consensus_main_contract()["abi"] + if entry.get("type") == "function" + ] + + +def _decode_abi_signatures() -> set[str]: + consensus_service = Mock() + consensus_service.web3 = Web3() + consensus_service.load_contract = Mock( + return_value=get_default_consensus_main_contract() + ) + parser = TransactionParser(consensus_service) return { _abi_function_signature(entry) - for entry in get_default_consensus_main_contract()["abi"] + for entry in parser._get_contract_abi() if entry.get("type") == "function" } -def test_default_consensus_abi_keeps_pre_fee_entrypoints_alongside_v06(): - """The v0.6 entrypoints are overloads, not replacements. +def test_decode_surface_keeps_pre_fee_entrypoints_alongside_v06(): + """Decoding must accept every generation of every entrypoint. - Studio's deployed ConsensusMain still exposes the pre-fee signatures, and - this ABI is what TransactionParser decodes against whenever the hardhat - deployment artifacts are missing. Dropping them made every deploy and - method call undecodable, so it was stored as a plain SEND. + This is the union TransactionParser matches selectors against. Losing the + pre-fee addTransaction made every deploy and method call undecodable, so + it was stored as a plain SEND. """ - signatures = _default_abi_signatures() + signatures = _decode_abi_signatures() assert "addTransaction(address,address,uint256,uint256,bytes)" in signatures assert "submitAppeal(bytes32)" in signatures @@ -794,15 +814,43 @@ def test_default_consensus_abi_keeps_pre_fee_entrypoints_alongside_v06(): ), signatures -def test_default_consensus_abi_has_no_duplicate_selectors(): - functions = [ - entry - for entry in get_default_consensus_main_contract()["abi"] - if entry.get("type") == "function" - ] - signatures = [_abi_function_signature(entry) for entry in functions] +def test_served_abi_function_names_are_unique(): + """The served ABI must be name-unique. + + Clients build a web3 contract from sim_getConsensusContract and resolve + entrypoints with get_function_by_name(), which raises Web3ValueError as + soon as a name is overloaded. + """ + names = [entry["name"] for entry in _served_abi_functions()] + + assert len(names) == len(set(names)), sorted( + name for name in names if names.count(name) > 1 + ) + + +def test_served_abi_resolves_the_entrypoints_clients_look_up_by_name(): + """Mirrors genlayer_py._encode_add_transaction_data's exact lookup.""" + contract = Web3().eth.contract(abi=get_default_consensus_main_contract()["abi"]) + + add_transaction = contract.get_function_by_name("addTransaction") + assert add_transaction.signature == ( + "addTransaction(address,address,uint256,uint256,bytes)" + ) + # Released and train genlayer-py both build five positional arguments and + # encode them against argument_types; the fee-aware tuple form would make + # that raise. + assert len(add_transaction.argument_types) == 5 - assert len(signatures) == len(set(signatures)) + # No Studio client encodes these from the served ABI, so they keep the + # v0.6 decision-bound form the train genlayer-py requires. + assert ( + contract.get_function_by_name("submitAppeal").signature + == "submitAppeal(bytes32,uint256)" + ) + assert ( + contract.get_function_by_name("finalizeTransaction").signature + == "finalizeTransaction(bytes32,uint256)" + ) def test_pre_fee_deploy_decodes_when_deployment_artifacts_are_missing(monkeypatch): From dbe41f0ad70429f5a5583e06ca9c124d72cbae64 Mon Sep 17 00:00:00 2001 From: kirilaa Date: Fri, 28 Aug 2026 10:10:39 +0200 Subject: [PATCH 19/55] fix(consensus): treat a rollup-free deployment as a valid emission path --- backend/consensus/base.py | 51 ++++-- backend/rollup/consensus_service.py | 32 ++-- .../test_consensus_service_message_replay.py | 37 +++++ tests/unit/test_studio_fees.py | 156 ++++++++++++++++++ 4 files changed, 251 insertions(+), 25 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index a03c5c9ac..db7c03b3e 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -4296,7 +4296,21 @@ def _dispatch_messages_for_phase( context.transaction.hash, internal_messages_data, ) - _emit_messages(context, insert_transactions_data, rollup_receipt, on) + # A None receipt is ambiguous: it means both "this deployment has no + # rollup" and "forwarding failed". Only the latter may abort the commit. + rollup_skipped = ( + rollup_receipt is None + and context.consensus_service.transaction_forwarding_skipped( + leader_receipt.node_config + ) + ) + _emit_messages( + context, + insert_transactions_data, + rollup_receipt, + on, + rollup_skipped=rollup_skipped, + ) _mark_message_phase_delivered( context, leader_receipt.pending_transactions, @@ -4487,19 +4501,30 @@ def _emit_messages( insert_transactions_data: list, receipt: dict, triggered_on: Literal["accepted", "finalized"], + *, + rollup_skipped: bool = False, ): - if not isinstance(receipt, dict): - raise InternalMessageEmissionError( - f"InternalMessageEmissionFailed({context.transaction.hash},{triggered_on})" - ) - tx_ids = receipt.get("tx_ids_hex") - if not isinstance(tx_ids, list) or len(tx_ids) != len(insert_transactions_data): - raise InternalMessageEmissionError( - "InternalMessageEmissionCountMismatch" - f"({context.transaction.hash},{triggered_on}," - f"{len(insert_transactions_data)}," - f"{len(tx_ids) if isinstance(tx_ids, list) else 'missing'})" - ) + if rollup_skipped: + # No rollup to mint child ids: this deployment never forwards to one + # (see ConsensusService.transaction_forwarding_skipped). Fall back to + # the pre-fee behaviour and let insert_transaction derive each child + # hash locally, exactly as a NULL tx_ids_hex entry did before. The + # strict checks below stay in force whenever a rollup IS attached — + # there a missing or short receipt really is a lost emission. + tx_ids = [None] * len(insert_transactions_data) + else: + if not isinstance(receipt, dict): + raise InternalMessageEmissionError( + f"InternalMessageEmissionFailed({context.transaction.hash},{triggered_on})" + ) + tx_ids = receipt.get("tx_ids_hex") + if not isinstance(tx_ids, list) or len(tx_ids) != len(insert_transactions_data): + raise InternalMessageEmissionError( + "InternalMessageEmissionCountMismatch" + f"({context.transaction.hash},{triggered_on}," + f"{len(insert_transactions_data)}," + f"{len(tx_ids) if isinstance(tx_ids, list) else 'missing'})" + ) for i, insert_transaction_data in enumerate(insert_transactions_data): transaction_hash = tx_ids[i] diff --git a/backend/rollup/consensus_service.py b/backend/rollup/consensus_service.py index eef27a23c..b660ecd0e 100644 --- a/backend/rollup/consensus_service.py +++ b/backend/rollup/consensus_service.py @@ -234,6 +234,19 @@ def add_transaction( print(f"[CONSENSUS_SERVICE]: Error forwarding transaction: {error_str}") return None + def transaction_forwarding_skipped(self, account: dict) -> bool: + """Reports whether emit_transaction_event is a deliberate no-op. + + Studio runs in deployments that have no rollup at all — the load-test + compose brings up only jsonrpc + consensus-worker, and hosted Studio + may hold an account with no private key. In both modes + emit_transaction_event returns None *by design*, which callers must + not confuse with a forwarding failure (which also returns None). + """ + if not self.web3.is_connected(): + return True + return account.get("private_key") is None + def emit_transaction_event(self, event_name: str, account: dict, *args): """ Generic method to emit transaction events @@ -243,20 +256,15 @@ def emit_transaction_event(self, event_name: str, account: dict, *args): account (dict): Account object containing address and private key *args: Arguments to pass to the event function """ - if not self.web3.is_connected(): - # print( - # "[CONSENSUS_SERVICE]: Not connected to Hardhat node, skipping transaction forwarding" - # ) + if self.transaction_forwarding_skipped(account): + if self.web3.is_connected(): + print( + f"[CONSENSUS_SERVICE]: Error emitting {event_name}: Account object must contain private_key" + ) return None - if account.get("private_key") is not None: - account_address = account["address"] - account_private_key = account["private_key"] - else: - print( - f"[CONSENSUS_SERVICE]: Error emitting {event_name}: Account object must contain private_key" - ) - return None + account_address = account["address"] + account_private_key = account["private_key"] consensus_main_contract = self._get_contract("ConsensusMain") diff --git a/tests/unit/test_consensus_service_message_replay.py b/tests/unit/test_consensus_service_message_replay.py index 348ccf043..997b1f27e 100644 --- a/tests/unit/test_consensus_service_message_replay.py +++ b/tests/unit/test_consensus_service_message_replay.py @@ -71,3 +71,40 @@ def test_legacy_bridge_falls_back_to_child_ids_from_receipt_events(): contract.functions.getInternalMessageTxIds.assert_called_once_with( parent_id, False, [] ) + + +def test_forwarding_is_skipped_when_no_rollup_is_attached(): + """The load-test compose runs jsonrpc + consensus-worker with no hardhat. + + emit_transaction_event returns None there by design, and consensus must + be able to tell that apart from a forwarding failure. + """ + service = ConsensusService.__new__(ConsensusService) + service.web3 = MagicMock() + service.web3.is_connected.return_value = False + + account = {"address": "0x" + "01" * 20, "private_key": "0x" + "02" * 32} + + assert service.transaction_forwarding_skipped(account) is True + assert service.emit_transaction_event("emitTransactionAccepted", account) is None + + +def test_forwarding_is_skipped_when_the_account_has_no_private_key(): + service = ConsensusService.__new__(ConsensusService) + service.web3 = MagicMock() + service.web3.is_connected.return_value = True + + assert service.transaction_forwarding_skipped({"address": "0x" + "01" * 20}) is True + + +def test_forwarding_is_active_for_a_connected_node_with_a_key(): + service = ConsensusService.__new__(ConsensusService) + service.web3 = MagicMock() + service.web3.is_connected.return_value = True + + assert ( + service.transaction_forwarding_skipped( + {"address": "0x" + "01" * 20, "private_key": "0x" + "02" * 32} + ) + is False + ) diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index bcf8f1a04..fc781c29d 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -13,6 +13,7 @@ _apply_external_message_freeze_check, _apply_message_value_withdrawals_for_phase, _child_config_rotation_rounds, + _dispatch_messages_for_phase, _emit_messages, _get_messages_data, _runtime_rotation_limit, @@ -1326,6 +1327,80 @@ def insert_transaction(self, *args, **kwargs): assert calls == [] +def _rollup_free_context(calls): + class _Processor: + def insert_transaction(self, *args, **kwargs): + calls.append((args, kwargs)) + + return SimpleNamespace( + transaction=SimpleNamespace( + to_address="0x1111111111111111111111111111111111111111", + execution_mode="NORMAL", + hash="0x" + "12" * 32, + config_rotation_rounds=0, + sim_config=None, + origin_address=None, + ), + transactions_processor=_Processor(), + ) + + +def test_message_phase_without_a_rollup_commits_when_there_are_no_children(): + """A rollup-free deployment must not turn an empty phase into a retry loop. + + Studio deployments with no hardhat node get None from + emit_transaction_event by design. Treating that as a lost emission + stalled every accepted transaction forever, children or not. + """ + calls = [] + context = _rollup_free_context(calls) + + _emit_messages(context, [], None, "accepted", rollup_skipped=True) + + assert calls == [] + + +def test_message_phase_without_a_rollup_derives_child_ids_locally(): + """Pre-fee behaviour: a NULL child id lets insert_transaction derive one.""" + calls = [] + context = _rollup_free_context(calls) + child = ( + "0x2222222222222222222222222222222222222222", + {}, + TransactionType.RUN_CONTRACT.value, + 0, + 0, + ) + + _emit_messages(context, [child], None, "accepted", rollup_skipped=True) + + assert len(calls) == 1 + assert calls[0][1]["transaction_hash"] is None + assert calls[0][1]["triggered_by_hash"] == "0x" + "12" * 32 + + +def test_message_phase_with_a_rollup_still_rejects_a_malformed_receipt(): + """The strict checks stay in force wherever a rollup is actually attached.""" + calls = [] + context = _rollup_free_context(calls) + child = ( + "0x2222222222222222222222222222222222222222", + {}, + TransactionType.RUN_CONTRACT.value, + 0, + 0, + ) + + with pytest.raises(RuntimeError, match="InternalMessageEmissionFailed"): + _emit_messages(context, [child], None, "accepted", rollup_skipped=False) + with pytest.raises(RuntimeError, match="InternalMessageEmissionCountMismatch"): + _emit_messages( + context, [child], {"tx_ids_hex": []}, "accepted", rollup_skipped=False + ) + + assert calls == [] + + def test_simulation_fee_accounting_accepts_sdk_style_fee_options(): policy = StudioFeePolicy.from_env() fees_distribution = _env_fees_distribution( @@ -11168,3 +11243,84 @@ def test_transaction_payload_fees_null_when_fee_accounting_disabled(): assert parsed["fees"] is None assert parsed["txExecutionResult"] == 0 assert parsed["txExecutionResultName"] == "NOT_VOTED" + + +def _acceptance_dispatch_context(calls, *, forwarding_skipped): + """A parent owing an acceptance-phase emission that carries no children.""" + + accounting = { + "active_message_generation": { + "acceptanceDispatchRequired": True, + "acceptanceDispatched": False, + } + } + + class _Processor: + def insert_transaction(self, *args, **kwargs): + calls.append((args, kwargs)) + + def get_transaction_count(self, _address): + return 0 + + def mutate_transaction_fee_accounting(self, _hash, mutate): + transaction.data[FEE_ACCOUNTING_KEY] = mutate( + transaction.data[FEE_ACCOUNTING_KEY] + ) + return transaction.data[FEE_ACCOUNTING_KEY] + + transaction = SimpleNamespace( + to_address="0x1111111111111111111111111111111111111111", + execution_mode="NORMAL", + hash="0x" + "12" * 32, + config_rotation_rounds=0, + sim_config=None, + origin_address=None, + data={FEE_ACCOUNTING_KEY: accounting}, + ) + consensus_service = SimpleNamespace( + emit_transaction_event=lambda *args, **kwargs: None, + transaction_forwarding_skipped=lambda _account: forwarding_skipped, + ) + return SimpleNamespace( + transaction=transaction, + transactions_processor=_Processor(), + consensus_service=consensus_service, + ) + + +def _empty_success_receipt(): + return SimpleNamespace( + execution_result=ExecutionResultStatus.SUCCESS, + pending_transactions=[], + node_config={"address": "0x" + "01" * 20, "private_key": "0x" + "02" * 32}, + ) + + +def test_acceptance_dispatch_completes_without_a_rollup(): + """Regression: this looped forever instead of committing. + + A parent owing an acceptance emission but carrying no children still + reaches _emit_messages. With no rollup attached the receipt is None by + design, which the strict check read as a lost emission and retried + forever, so acceptanceDispatched was never set. + """ + calls = [] + context = _acceptance_dispatch_context(calls, forwarding_skipped=True) + + assert ( + _dispatch_messages_for_phase(context, _empty_success_receipt(), "accepted") + is True + ) + assert calls == [] + generation = context.transaction.data[FEE_ACCOUNTING_KEY][ + "active_message_generation" + ] + assert generation["acceptanceDispatched"] is True + + +def test_acceptance_dispatch_still_fails_loudly_when_a_rollup_is_attached(): + calls = [] + context = _acceptance_dispatch_context(calls, forwarding_skipped=False) + + with pytest.raises(RuntimeError, match="InternalMessageEmissionFailed"): + _dispatch_messages_for_phase(context, _empty_success_receipt(), "accepted") From 1f423e7fd822f0f5d5f23ceccff8e08b70ad5e80 Mon Sep 17 00:00:00 2001 From: kirilaa Date: Fri, 28 Aug 2026 10:28:10 +0200 Subject: [PATCH 20/55] chore(genvm): repin to the merged leader-data commit on v0.6-dev The pin inherited from v0.123-dev references the pre-merge feature branch feat/leader-data-to-genvm, whose branch was deleted after squash-merge; the commit is unreachable from any ref, so the source build's clone cannot read it (fatal: unable to read tree). The same work is v0.6-dev head 3c96be6de (feat(manager): carry opaque leader public data, #29), which also contains the previous pin d07f21da. v0.123-dev itself needs the same repin. --- third_party/genvm/version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/genvm/version b/third_party/genvm/version index 09d75c98f..f79049fbe 100644 --- a/third_party/genvm/version +++ b/third_party/genvm/version @@ -1 +1 @@ -feat/leader-data-to-genvm:201dc510c61c0fe86371cd40518f3e40fd3fb8c7 +v0.6-dev:3c96be6defe30fd9843d47bedc0c66bec324d0fa From 0d13a5bb384a08a30d0195478675e93dda7c6c56 Mon Sep 17 00:00:00 2001 From: kirilaa Date: Fri, 28 Aug 2026 12:36:24 +0200 Subject: [PATCH 21/55] fix(consensus): carry the parent rotation schedule onto claims --- backend/consensus/base.py | 15 ++++++- backend/consensus/worker.py | 5 +++ .../consensus/test_appeal_state_restore.py | 1 + .../consensus/test_claim_column_manifest.py | 14 ++++++- tests/unit/test_studio_fees.py | 41 +++++++++++++++++++ 5 files changed, 73 insertions(+), 3 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index db7c03b3e..eab5b389c 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -4424,9 +4424,20 @@ def _pending_transaction_fee_payload( } -def _child_config_rotation_rounds(parent_rotations: int, data: dict[str, Any]) -> int: - """Clamp a triggered child's runtime rotations to its funded schedule.""" +def _child_config_rotation_rounds( + parent_rotations: int | None, data: dict[str, Any] +) -> int: + """Clamp a triggered child's runtime rotations to its funded schedule. + + ``transactions.config_rotation_rounds`` is nullable, so a parent may carry + no explicit schedule at all — rows written before the column was claimed, + or any row that legitimately stored NULL. Resolve that to the same default + Transaction itself uses rather than raising, which would fail every child + insert and retry the parent until it was cancelled. + """ + if parent_rotations is None: + parent_rotations = int(os.getenv("VITE_MAX_ROTATIONS", 3)) fees = data.get("fees_distribution") if not isinstance(fees, dict): return max(0, int(parent_rotations)) diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index 6259535f6..eb116d1a3 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -80,6 +80,11 @@ def _acceptance_dispatch_pending(transaction_data: dict[str, Any]) -> bool: ("v", "v"), ("leader_only", "leader_only"), ("execution_mode", "execution_mode"), + # Carried because the emission path clamps every triggered child to its + # parent's funded rotation schedule. Omitting it made + # Transaction.from_dict default the field to None, so each child insert + # raised TypeError and the parent retried until it was cancelled. + ("config_rotation_rounds", "config_rotation_rounds"), ("sim_config", "sim_config"), ("status", "status"), ("consensus_data", "consensus_data"), diff --git a/tests/unit/consensus/test_appeal_state_restore.py b/tests/unit/consensus/test_appeal_state_restore.py index 41364a9de..d0f57753e 100644 --- a/tests/unit/consensus/test_appeal_state_restore.py +++ b/tests/unit/consensus/test_appeal_state_restore.py @@ -305,6 +305,7 @@ def _make_appeal_row(contract_snapshot, consensus_history): v=None, leader_only=False, execution_mode="NORMAL", + config_rotation_rounds=3, sim_config=None, status=TransactionStatus.ACCEPTED.value, consensus_data=None, diff --git a/tests/unit/consensus/test_claim_column_manifest.py b/tests/unit/consensus/test_claim_column_manifest.py index 7744df4eb..f96b713cc 100644 --- a/tests/unit/consensus/test_claim_column_manifest.py +++ b/tests/unit/consensus/test_claim_column_manifest.py @@ -63,7 +63,10 @@ # from_dict defaults are correct for every claim path: _COMMON_OMISSIONS = { "appeal_processing_time", # accounting value, only read for finality math from DB - "config_rotation_rounds", # config, re-read from DB where consumed + # config_rotation_rounds is NOT omitted: the message-emission path clamps + # every triggered child to its parent's funded schedule, reading the value + # straight off the claimed transaction rather than from the DB. The + # from_dict default (None) is therefore wrong for all three claims. "num_of_initial_validators", # config, re-read from DB where consumed "last_vote_timestamp", # monitoring bookkeeping, rewritten during processing "rotation_count", # reset on PENDING entry, tracked in-context afterwards @@ -185,6 +188,15 @@ def test_appeal_claim_includes_state_columns(self): assert "contract_snapshot" in keys assert "consensus_history" in keys + def test_every_claim_provides_config_rotation_rounds(self): + # The message-emission path clamps each triggered child to its + # parent's funded schedule, reading config_rotation_rounds off the + # claimed transaction. When the claims omitted it, from_dict defaulted + # it to None and every child insert raised TypeError, retrying until + # the parent was cancelled. + for claim_name, groups in CLAIM_MANIFESTS.items(): + assert "config_rotation_rounds" in _manifest_keys(groups), claim_name + def test_triggered_by_alias_preserved(self): # Transaction.from_dict consumes "triggered_by", not the SQL column # name "triggered_by_hash". diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index fc781c29d..fd769fe68 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -11324,3 +11324,44 @@ def test_acceptance_dispatch_still_fails_loudly_when_a_rollup_is_attached(): with pytest.raises(RuntimeError, match="InternalMessageEmissionFailed"): _dispatch_messages_for_phase(context, _empty_success_receipt(), "accepted") + + +def test_child_rotation_clamp_tolerates_a_null_parent_schedule(): + """transactions.config_rotation_rounds is nullable. + + int(None) raised TypeError for every triggered child, which the worker + retried until it cancelled the parent. + """ + assert _child_config_rotation_rounds(None, {}) >= 0 + assert ( + _child_config_rotation_rounds( + None, {"fees_distribution": _fees_distribution(rotations=[2])} + ) + == 2 + ) + # A known parent schedule still clamps the funded one. + assert ( + _child_config_rotation_rounds( + 1, {"fees_distribution": _fees_distribution(rotations=[5])} + ) + == 1 + ) + + +def test_triggered_child_insert_survives_a_parent_without_a_rotation_schedule(): + """End-to-end over _emit_messages, the frame that actually crashed.""" + calls = [] + context = _rollup_free_context(calls) + context.transaction.config_rotation_rounds = None + child = ( + "0x2222222222222222222222222222222222222222", + {}, + TransactionType.RUN_CONTRACT.value, + 0, + 0, + ) + + _emit_messages(context, [child], None, "finalized", rollup_skipped=True) + + assert len(calls) == 1 + assert isinstance(calls[0][1]["config_rotation_rounds"], int) From e7d5000731b945a15588792c1ebff2b7858a18f0 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 07:10:22 +0100 Subject: [PATCH 22/55] fix(consensus): bind message effects to protocol recipients --- backend/consensus/base.py | 146 ++++++++++++------ backend/rollup/consensus_service.py | 17 ++ .../contracts/v2_contracts/ConsensusMain.sol | 36 ++++- .../v2_contracts/ConsensusMainWithFees.sol | 7 +- .../ghost_contracts/GhostFactory.sol | 34 ++-- .../v2_contracts/interfaces/IGhostFactory.sol | 7 +- .../ConsensusMain.InternalMessages.test.js | 59 +++++++ .../test_consensus_service_message_replay.py | 25 ++- tests/unit/test_studio_fees.py | 141 ++++++++++++++++- 9 files changed, 405 insertions(+), 67 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index eab5b389c..d6aa8d011 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -17,7 +17,7 @@ import json import base64 -from eth_utils import to_checksum_address +from eth_utils import is_address, to_checksum_address from sqlalchemy import text from sqlalchemy.orm import Session from backend.consensus.vrf import get_validators_for_transaction @@ -53,6 +53,7 @@ EventType, EventScope, ) +from backend.protocol_rpc.types import ZERO_ADDRESS from backend.protocol_rpc.fees import ( FEE_ACCOUNTING_KEY, VALIDATORS_PER_ROUND, @@ -3796,9 +3797,22 @@ def _get_messages_data( base_nonce = context.transactions_processor.get_transaction_count( context.transaction.to_address ) - for nonce_offset, pending_transaction in enumerate( + phase_pending_transactions = list( _pending_transactions_for_phase(pending_transactions, on) - ): + ) + identity_payloads = ( + _reveal_message_fee_payloads(parent_fee_accounting, phase_pending_transactions) + if parent_fee_accounting + else [ + _pending_transaction_fee_payload(pending_transaction, on) + for pending_transaction in phase_pending_transactions + ] + ) + effect_identities = message_effect_identities( + context.transaction.hash, + identity_payloads, + ) + for nonce_offset, pending_transaction in enumerate(phase_pending_transactions): nonce = base_nonce + nonce_offset transaction_type, data = _child_transaction_payload( context, pending_transaction @@ -3815,17 +3829,23 @@ def _get_messages_data( insert_transactions_data.append( [ - pending_transaction.address, + ( + ZERO_ADDRESS + if pending_transaction.is_deploy() + else pending_transaction.address + ), data, transaction_type.value, nonce, pending_transaction.value, + effect_identities[nonce_offset][0], ] ) - internal_messages_data.append( - _internal_message_event_data(context, pending_transaction, data) - ) + if not pending_transaction.is_eth_send: + internal_messages_data.append( + _internal_message_event_data(context, pending_transaction, data) + ) _record_parent_message_fee_consumption( context, @@ -3863,40 +3883,19 @@ def _deploy_child_transaction_payload( context: TransactionContext, pending_transaction: PendingTransaction, ) -> tuple[TransactionType, dict]: - new_contract_address = _child_contract_address(context, pending_transaction) - pending_transaction.address = new_contract_address return ( TransactionType.DEPLOY_CONTRACT, { - "contract_address": new_contract_address, + # Consensus is the address authority for internal deployments. + # Keep the payload stable across retries and bind the local child + # to the returned CREATE/CREATE2 recipient after helper delivery. + "contract_address": ZERO_ADDRESS, "contract_code": pending_transaction.code, "calldata": pending_transaction.calldata, }, ) -def _child_contract_address( - context: TransactionContext, - pending_transaction: PendingTransaction, -) -> str: - if pending_transaction.salt_nonce == 0: - # NOTE: this address is random, which doesn't 100% align with consensus spec - return context.accounts_manager.create_new_account().address - - from eth_utils.crypto import keccak - from backend.node.types import Address - from backend.node.base import get_simulator_chain_id - - arr = bytearray() - arr.append(1) - arr.extend(Address(context.transaction.to_address).as_bytes) - arr.extend(pending_transaction.salt_nonce.to_bytes(32, "big", signed=False)) - arr.extend(get_simulator_chain_id().to_bytes(32, "big", signed=False)) - new_contract_address = Address(keccak(arr)[:20]).as_hex - context.accounts_manager.create_new_account_with_address(new_contract_address) - return new_contract_address - - def _append_message_fee_payload( context: TransactionContext, pending_transaction: PendingTransaction, @@ -3987,7 +3986,12 @@ def _internal_message_event_data( ) -> dict[str, Any]: return { "sender": context.transaction.to_address, - "recipient": pending_transaction.address, + "recipient": ( + ZERO_ADDRESS + if pending_transaction.is_deploy() + else pending_transaction.address + ), + "saltNonce": pending_transaction.salt_nonce, "data": json.dumps(_serializable_message_data(data)).encode(), } @@ -4515,30 +4519,84 @@ def _emit_messages( *, rollup_skipped: bool = False, ): + helper_transaction_count = sum( + insert_transaction_data[2] != TransactionType.SEND.value + for insert_transaction_data in insert_transactions_data + ) if rollup_skipped: - # No rollup to mint child ids: this deployment never forwards to one - # (see ConsensusService.transaction_forwarding_skipped). Fall back to - # the pre-fee behaviour and let insert_transaction derive each child - # hash locally, exactly as a NULL tx_ids_hex entry did before. The - # strict checks below stay in force whenever a rollup IS attached — - # there a missing or short receipt really is a lost emission. - tx_ids = [None] * len(insert_transactions_data) + # No helper chain exists to mint child ids or deployment recipients. + # Preserve the pre-rollup fallback: the local insertion layer derives + # both, while external effects keep their durable occurrence id. + tx_ids = [None] * helper_transaction_count + recipients = [ + insert_transaction_data[0] + for insert_transaction_data in insert_transactions_data + if insert_transaction_data[2] != TransactionType.SEND.value + ] else: if not isinstance(receipt, dict): raise InternalMessageEmissionError( f"InternalMessageEmissionFailed({context.transaction.hash},{triggered_on})" ) tx_ids = receipt.get("tx_ids_hex") - if not isinstance(tx_ids, list) or len(tx_ids) != len(insert_transactions_data): + if not isinstance(tx_ids, list) or len(tx_ids) != helper_transaction_count: raise InternalMessageEmissionError( "InternalMessageEmissionCountMismatch" f"({context.transaction.hash},{triggered_on}," - f"{len(insert_transactions_data)}," + f"{helper_transaction_count}," f"{len(tx_ids) if isinstance(tx_ids, list) else 'missing'})" ) + recipients = receipt.get("recipients") + if ( + not isinstance(recipients, list) + or len(recipients) != helper_transaction_count + ): + raise InternalMessageEmissionError( + "InternalMessageEmissionRecipientCountMismatch" + f"({context.transaction.hash},{triggered_on}," + f"{helper_transaction_count}," + f"{len(recipients) if isinstance(recipients, list) else 'missing'})" + ) + helper_index = 0 for i, insert_transaction_data in enumerate(insert_transactions_data): - transaction_hash = tx_ids[i] + is_external = insert_transaction_data[2] == TransactionType.SEND.value + if is_external: + # Consensus external messages are effects, not child consensus + # transactions. The logical occurrence is their durable local id. + transaction_hash = insert_transaction_data[5] + actual_recipient = insert_transaction_data[0] + else: + transaction_hash = tx_ids[helper_index] + actual_recipient = recipients[helper_index] + helper_index += 1 + is_deploy = insert_transaction_data[2] == TransactionType.DEPLOY_CONTRACT.value + if not (rollup_skipped and is_deploy) and ( + not isinstance(actual_recipient, str) or not is_address(actual_recipient) + ): + raise InternalMessageEmissionError( + "InternalMessageEmissionInvalidRecipient" + f"({context.transaction.hash},{triggered_on},{i})" + ) + if not (rollup_skipped and is_deploy): + actual_recipient = to_checksum_address(actual_recipient) + if is_deploy and not rollup_skipped: + if actual_recipient == to_checksum_address(ZERO_ADDRESS): + raise InternalMessageEmissionError( + "InternalMessageEmissionZeroDeploymentRecipient" + f"({context.transaction.hash},{triggered_on},{i})" + ) + insert_transaction_data[1]["contract_address"] = actual_recipient + context.accounts_manager.create_new_account_with_address(actual_recipient) + elif ( + not is_deploy + and actual_recipient.lower() != str(insert_transaction_data[0]).lower() + ): + raise InternalMessageEmissionError( + "InternalMessageEmissionRecipientMismatch" + f"({context.transaction.hash},{triggered_on},{i}," + f"{insert_transaction_data[0]},{actual_recipient})" + ) # Determine execution_mode to cascade from parent transaction execution_mode_str = ( context.transaction.execution_mode.value @@ -4550,7 +4608,7 @@ def _emit_messages( context.transactions_processor.insert_transaction( context.transaction.to_address, # new calls are done by the contract - insert_transaction_data[0], + actual_recipient, insert_transaction_data[1], value=insert_transaction_data[4], type=insert_transaction_data[2], diff --git a/backend/rollup/consensus_service.py b/backend/rollup/consensus_service.py index b660ecd0e..aa68644cb 100644 --- a/backend/rollup/consensus_service.py +++ b/backend/rollup/consensus_service.py @@ -300,11 +300,13 @@ def emit_transaction_event(self, event_name: str, account: dict, *args): ) tx_ids_hex = [] + recipients = [] for new_tx_event in new_tx_events: tx_id = new_tx_event["args"]["txId"] tx_ids_hex.append( "0x" + tx_id.hex() if isinstance(tx_id, bytes) else tx_id ) + recipients.append(new_tx_event["args"]["recipient"]) # The Studio helper bridge makes each parent/phase/payload # emission one-shot. A worker retry emits no new events, but @@ -327,9 +329,24 @@ def emit_transaction_event(self, event_name: str, account: dict, *args): except Exception: pass + # Deploy messages intentionally submit recipient zero. The + # helper returns the actual CREATE/CREATE2 ghost address, and + # retries recover that same address from durable storage. + try: + recipients = list( + consensus_main_contract.functions.getInternalMessageRecipients( + args[0], + event_name == "emitTransactionAccepted", + args[1], + ).call() + ) + except Exception: + pass + return { "receipt": receipt, "tx_ids_hex": tx_ids_hex, + "recipients": recipients, } return receipt diff --git a/hardhat/contracts/v2_contracts/ConsensusMain.sol b/hardhat/contracts/v2_contracts/ConsensusMain.sol index eb9feab24..60f7e10ac 100644 --- a/hardhat/contracts/v2_contracts/ConsensusMain.sol +++ b/hardhat/contracts/v2_contracts/ConsensusMain.sol @@ -29,6 +29,7 @@ contract ConsensusMain is struct InternalMessageEmission { bool processed; bytes32[] txIds; + address[] recipients; } /// @notice Consolidated external contract addresses used in ConsensusMain @@ -127,6 +128,7 @@ contract ConsensusMain is struct internalMessageData { address sender; address recipient; + uint256 saltNonce; bytes data; } @@ -189,8 +191,12 @@ contract ConsensusMain is function _processInternalMessages( internalMessageData[] calldata internalMessages - ) internal returns (bytes32[] memory txIds) { + ) + internal + returns (bytes32[] memory txIds, address[] memory recipients) + { txIds = new bytes32[](internalMessages.length); + recipients = new address[](internalMessages.length); for (uint256 i = 0; i < internalMessages.length; i++) { ( bytes32 generated_txId, @@ -200,12 +206,16 @@ contract ConsensusMain is internalMessages[i].recipient, 5, // or pass as part of internalMessages.data 0, // or pass as part of internalMessages.data + internalMessages[i].saltNonce, internalMessages[i].data ); txIds[i] = generated_txId; + recipients[i] = contracts.genTransactions.getTransactionRecipient( + generated_txId + ); emit InternalMessageProcessed( generated_txId, - internalMessages[i].recipient, + recipients[i], newActivator ); } @@ -235,6 +245,17 @@ contract ConsensusMain is internalMessageEmissions[parentTxId][onAcceptance][descriptor].txIds; } + function getInternalMessageRecipients( + bytes32 parentTxId, + bool onAcceptance, + internalMessageData[] calldata internalMessages + ) external view returns (address[] memory) { + bytes32 descriptor = keccak256(abi.encode(internalMessages)); + return + internalMessageEmissions[parentTxId][onAcceptance][descriptor] + .recipients; + } + function emitAppealStarted( bytes32 txId, address appealer, @@ -265,6 +286,7 @@ contract ConsensusMain is _recipient, _numOfInitialValidators, _maxRotations, + 0, _txData ); } else { @@ -606,9 +628,13 @@ contract ConsensusMain is // Install the replay guard before creating children. Any downstream // revert rolls this write back with the entire EVM transaction. emission.processed = true; - bytes32[] memory txIds = _processInternalMessages(internalMessages); + ( + bytes32[] memory txIds, + address[] memory recipients + ) = _processInternalMessages(internalMessages); for (uint256 i = 0; i < txIds.length; i++) { emission.txIds.push(txIds[i]); + emission.recipients.push(recipients[i]); } if (onAcceptance) { @@ -623,6 +649,7 @@ contract ConsensusMain is address _recipient, uint256 _numOfInitialValidators, uint256 _maxRotations, + uint256 _saltNonce, bytes memory _txData ) internal returns (bytes32 txId, address activator) { if (_sender == address(0)) { @@ -633,7 +660,7 @@ contract ConsensusMain is : contracts.genManager.recipientRandomSeed(_recipient); if (_recipient == address(0)) { // Contract deployment transaction - contracts.ghostFactory.createGhost(); + contracts.ghostFactory.createGhost(_saltNonce); address ghost = contracts.ghostFactory.latestGhost(); _storeGhost(ghost); _recipient = ghost; @@ -811,6 +838,7 @@ contract ConsensusMain is message.recipient, // recipient (target ghost) 5, // or pass as part of message.data 0, // or pass as part of message.data + 0, message.data // transaction data ); emit InternalMessageProcessed( diff --git a/hardhat/contracts/v2_contracts/ConsensusMainWithFees.sol b/hardhat/contracts/v2_contracts/ConsensusMainWithFees.sol index 9e9b5b5e3..7c8481208 100644 --- a/hardhat/contracts/v2_contracts/ConsensusMainWithFees.sol +++ b/hardhat/contracts/v2_contracts/ConsensusMainWithFees.sol @@ -89,6 +89,7 @@ contract ConsensusMainWithFees is _recipient, _numOfInitialValidators, _maxRotations, + 0, _txData ); feeManager.topUpFees( @@ -412,6 +413,7 @@ contract ConsensusMainWithFees is address _recipient, uint256 _numOfInitialValidators, uint256 _maxRotations, + uint256 _saltNonce, bytes memory _txData ) internal returns (bytes32 tx_id, address activator) { if (_sender == address(0)) { @@ -422,7 +424,7 @@ contract ConsensusMainWithFees is : genManager.recipientRandomSeed(_recipient); if (_recipient == address(0)) { // Contract deployment transaction - ghostFactory.createGhost(); + ghostFactory.createGhost(_saltNonce); address ghost = ghostFactory.latestGhost(); ghostContracts[ghost] = true; _recipient = ghost; @@ -606,6 +608,7 @@ contract ConsensusMainWithFees is message.recipient, // recipient (target ghost) 5, // or pass as part of message.data 0, // or pass as part of message.data + 0, message.data // transaction data ); emit InternalMessageProcessed( @@ -721,4 +724,4 @@ contract ConsensusMainWithFees is ); event TransactionCancelled(bytes32 indexed tx_id, address indexed sender); event FeeManagerSet(address indexed feeManager); -} \ No newline at end of file +} diff --git a/hardhat/contracts/v2_contracts/ghost_contracts/GhostFactory.sol b/hardhat/contracts/v2_contracts/ghost_contracts/GhostFactory.sol index 8e37fa0d5..cf253f2bb 100644 --- a/hardhat/contracts/v2_contracts/ghost_contracts/GhostFactory.sol +++ b/hardhat/contracts/v2_contracts/ghost_contracts/GhostFactory.sol @@ -32,14 +32,30 @@ contract GhostFactory is __AccessControl_init(); } - function createGhost() external onlyGenConsensus returns (address) { - BeaconProxy beacon = new BeaconProxy( - ghostBeaconProxy, - abi.encodeWithSelector( - GhostBlueprint.initialize.selector, - address(this) - ) - ); + function createGhost( + uint256 _saltNonce + ) external onlyGenConsensus returns (address) { + BeaconProxy beacon; + if (_saltNonce == 0) { + beacon = new BeaconProxy( + ghostBeaconProxy, + abi.encodeWithSelector( + GhostBlueprint.initialize.selector, + address(this) + ) + ); + } else { + bytes32 salt = keccak256( + abi.encodePacked(msg.sender, _saltNonce) + ); + beacon = new BeaconProxy{ salt: salt }( + ghostBeaconProxy, + abi.encodeWithSelector( + GhostBlueprint.initialize.selector, + address(this) + ) + ); + } emit GhostCreated(address(beacon)); GhostBlueprint(payable(address(beacon))).transferOwnership(msg.sender); latestGhost = address(beacon); @@ -91,4 +107,4 @@ contract GhostFactory is event BeaconUpgraded(address indexed implementation); event GenConsensusSet(address indexed genConsensus); event GhostManagerSet(address indexed ghostManager); -} \ No newline at end of file +} diff --git a/hardhat/contracts/v2_contracts/interfaces/IGhostFactory.sol b/hardhat/contracts/v2_contracts/interfaces/IGhostFactory.sol index 4be5d2d80..65d498d82 100644 --- a/hardhat/contracts/v2_contracts/interfaces/IGhostFactory.sol +++ b/hardhat/contracts/v2_contracts/interfaces/IGhostFactory.sol @@ -4,9 +4,10 @@ pragma solidity ^0.8.20; /// @title IGhostFactory /// @notice Interface for creating and managing ghost contracts in the GenVM system interface IGhostFactory { - /// @notice Creates a new ghost contract + /// @notice Creates a new ghost contract using CREATE or CREATE2 + /// @param saltNonce Salt nonce; zero selects CREATE /// @return The address where the contract will be deployed - function createGhost() external returns (address); + function createGhost(uint256 saltNonce) external returns (address); /// @notice Checks if an address is a ghost contract /// @param contractAddress The address to check @@ -16,4 +17,4 @@ interface IGhostFactory { /// @notice Returns the latest ghost contract address /// @return The address of the latest ghost contract function latestGhost() external view returns (address); -} \ No newline at end of file +} diff --git a/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js b/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js index 0dcf31f92..fede1da88 100644 --- a/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js +++ b/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js @@ -5,6 +5,7 @@ describe("ConsensusMain internal message bridge", function () { let consensusMain; let ghostFactory; let queues; + let transactions; let owner; let recipient; let deploymentTxId; @@ -24,6 +25,10 @@ describe("ConsensusMain internal message bridge", function () { "Queues", (await deployments.get("Queues")).address, ); + transactions = await ethers.getContractAt( + "Transactions", + (await deployments.get("Transactions")).address, + ); const deploymentTx = await consensusMain.addTransaction( owner.address, @@ -52,11 +57,13 @@ describe("ConsensusMain internal message bridge", function () { { sender: recipient, recipient, + saltNonce: 0, data: "0x0102", }, { sender: recipient, recipient, + saltNonce: 0, data: "0x0304", }, ]; @@ -95,6 +102,7 @@ describe("ConsensusMain internal message bridge", function () { { sender: recipient, recipient, + saltNonce: 0, data: "0x0102", }, ]; @@ -126,6 +134,56 @@ describe("ConsensusMain internal message bridge", function () { ); }); + for (const saltNonce of [0n, 42n]) { + it(`lets the helper authoritatively deploy and durably replay a child ghost with salt ${saltNonce}`, async function () { + const parentTxId = ethers.keccak256( + ethers.toUtf8Bytes(`deployment-parent-${saltNonce}`), + ); + const messages = [ + { + sender: recipient, + recipient: ethers.ZeroAddress, + saltNonce, + data: "0xdeadbeef", + }, + ]; + + await consensusMain.emitTransactionAccepted(parentTxId, messages); + const childIds = await consensusMain.getInternalMessageTxIds( + parentTxId, + true, + messages, + ); + const childRecipients = + await consensusMain.getInternalMessageRecipients( + parentTxId, + true, + messages, + ); + + expect(childIds).to.have.length(1); + expect(childRecipients).to.have.length(1); + expect(childRecipients[0]).to.not.equal(ethers.ZeroAddress); + expect(await consensusMain.ghostContracts(childRecipients[0])).to.equal( + true, + ); + expect( + await transactions.getTransactionRecipient(childIds[0]), + ).to.equal(childRecipients[0]); + expect(await queues.getIssuedTxCount(childRecipients[0])).to.equal(1n); + + await consensusMain.emitTransactionAccepted(parentTxId, messages); + expect( + await consensusMain.getInternalMessageRecipients( + parentTxId, + true, + messages, + ), + ).to.deep.equal(childRecipients); + expect(await queues.getIssuedTxCount(childRecipients[0])).to.equal(1n); + }); + } + it("indexes finalized history by recipient issuance order", async function () { await consensusMain.cancelTransaction(deploymentTxId); @@ -145,6 +203,7 @@ describe("ConsensusMain internal message bridge", function () { { sender: recipient, recipient: owner.address, + saltNonce: 0, data: "0x0102", }, ]); diff --git a/tests/unit/test_consensus_service_message_replay.py b/tests/unit/test_consensus_service_message_replay.py index 997b1f27e..c6ade2a34 100644 --- a/tests/unit/test_consensus_service_message_replay.py +++ b/tests/unit/test_consensus_service_message_replay.py @@ -27,6 +27,13 @@ def test_retried_message_phase_recovers_stored_child_ids_without_new_events(): contract.functions.getInternalMessageTxIds.return_value.call.return_value = ( child_ids ) + child_recipients = [ + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222", + ] + contract.functions.getInternalMessageRecipients.return_value.call.return_value = ( + child_recipients + ) service = _service_with_contract(contract) parent_id = "0x" + "aa" * 32 @@ -41,9 +48,13 @@ def test_retried_message_phase_recovers_stored_child_ids_without_new_events(): "0x" + "11" * 32, "0x" + "22" * 32, ] + assert result["recipients"] == child_recipients contract.functions.getInternalMessageTxIds.assert_called_once_with( parent_id, True, [] ) + contract.functions.getInternalMessageRecipients.assert_called_once_with( + parent_id, True, [] + ) def test_legacy_bridge_falls_back_to_child_ids_from_receipt_events(): @@ -52,11 +63,19 @@ def test_legacy_bridge_falls_back_to_child_ids_from_receipt_events(): {} ) contract.events.NewTransaction.return_value.process_receipt.return_value = [ - {"args": {"txId": bytes.fromhex("33" * 32)}} + { + "args": { + "txId": bytes.fromhex("33" * 32), + "recipient": "0x3333333333333333333333333333333333333333", + } + } ] contract.functions.getInternalMessageTxIds.return_value.call.side_effect = ( RuntimeError("legacy deployment") ) + contract.functions.getInternalMessageRecipients.return_value.call.side_effect = ( + RuntimeError("legacy deployment") + ) service = _service_with_contract(contract) parent_id = "0x" + "bb" * 32 @@ -68,9 +87,13 @@ def test_legacy_bridge_falls_back_to_child_ids_from_receipt_events(): ) assert result["tx_ids_hex"] == ["0x" + "33" * 32] + assert result["recipients"] == ["0x3333333333333333333333333333333333333333"] contract.functions.getInternalMessageTxIds.assert_called_once_with( parent_id, False, [] ) + contract.functions.getInternalMessageRecipients.assert_called_once_with( + parent_id, False, [] + ) def test_forwarding_is_skipped_when_no_rollup_is_attached(): diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index fd769fe68..fc5d6f025 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -1285,7 +1285,10 @@ def insert_transaction(self, *args, **kwargs): 0, ) ], - {"tx_ids_hex": ["0x" + "34" * 32]}, + { + "tx_ids_hex": ["0x" + "34" * 32], + "recipients": ["0x2222222222222222222222222222222222222222"], + }, "accepted", ) @@ -1324,6 +1327,16 @@ def insert_transaction(self, *args, **kwargs): with pytest.raises(RuntimeError, match="InternalMessageEmissionCountMismatch"): _emit_messages(context, [child], {"tx_ids_hex": []}, "accepted") + with pytest.raises( + RuntimeError, match="InternalMessageEmissionRecipientCountMismatch" + ): + _emit_messages( + context, + [child], + {"tx_ids_hex": ["0x" + "34" * 32]}, + "accepted", + ) + assert calls == [] @@ -1345,6 +1358,103 @@ def insert_transaction(self, *args, **kwargs): ) +def test_internal_deployment_binds_local_child_to_helper_created_recipient(): + calls = [] + created_accounts = [] + + class _Processor: + def insert_transaction(self, *args, **kwargs): + calls.append((args, kwargs)) + + class _Accounts: + def create_new_account_with_address(self, address): + created_accounts.append(address) + + context = SimpleNamespace( + transaction=SimpleNamespace( + to_address="0x1111111111111111111111111111111111111111", + execution_mode="NORMAL", + hash="0x" + "12" * 32, + config_rotation_rounds=0, + sim_config=None, + origin_address=None, + ), + transactions_processor=_Processor(), + accounts_manager=_Accounts(), + ) + child_data = { + "contract_address": "0x0000000000000000000000000000000000000000", + "contract_code": b"contract", + "calldata": b"", + } + helper_recipient = "0x3333333333333333333333333333333333333333" + + _emit_messages( + context, + [ + ( + "0x0000000000000000000000000000000000000000", + child_data, + TransactionType.DEPLOY_CONTRACT.value, + 0, + 0, + ) + ], + { + "tx_ids_hex": ["0x" + "34" * 32], + "recipients": [helper_recipient], + }, + "accepted", + ) + + assert created_accounts == [Web3.to_checksum_address(helper_recipient)] + assert calls[0][0][1] == Web3.to_checksum_address(helper_recipient) + assert child_data["contract_address"] == Web3.to_checksum_address(helper_recipient) + + +def test_external_message_does_not_require_a_helper_child_transaction(): + calls = [] + + class _Processor: + def insert_transaction(self, *args, **kwargs): + calls.append((args, kwargs)) + + context = SimpleNamespace( + transaction=SimpleNamespace( + to_address="0x1111111111111111111111111111111111111111", + execution_mode="NORMAL", + hash="0x" + "12" * 32, + config_rotation_rounds=0, + sim_config=None, + origin_address=None, + ), + transactions_processor=_Processor(), + ) + + external_recipient = "0x4444444444444444444444444444444444444444" + occurrence = "0x" + "56" * 32 + + _emit_messages( + context, + [ + ( + external_recipient, + {}, + TransactionType.SEND.value, + 0, + 7, + occurrence, + ) + ], + {"tx_ids_hex": [], "recipients": []}, + "finalized", + ) + + assert calls[0][0][1] == Web3.to_checksum_address(external_recipient) + assert calls[0][1]["transaction_hash"] == occurrence + assert calls[0][1]["value"] == 7 + + def test_message_phase_without_a_rollup_commits_when_there_are_no_children(): """A rollup-free deployment must not turn an empty phase into a retry loop. @@ -8475,11 +8585,12 @@ def test_message_dispatch_creates_mode1_child_fee_accounting_from_pending_metada internal_messages, inserts = _get_messages_data(context, [pending], "accepted") assert len(inserts) == 1 - recipient, data, tx_type, nonce, value = inserts[0] + recipient, data, tx_type, nonce, value, effect_identity = inserts[0] assert recipient == pending.address assert tx_type == TransactionType.RUN_CONTRACT.value assert nonce == 3 assert value == 7 + assert effect_identity.startswith("0x") assert data["calldata"] == b"\x12\x34" assert data["fee_value"] == child_budget assert data["user_value"] == 7 @@ -8505,6 +8616,27 @@ def test_message_dispatch_creates_mode1_child_fee_accounting_from_pending_metada } +def test_internal_deployment_descriptor_stays_zero_address_and_carries_salt(): + context, _ = _message_dispatch_context({}) + pending = PendingTransaction( + address="0x", + calldata=b"\x12\x34", + code=b"contract source", + salt_nonce=42, + on="accepted", + value=0, + ) + + internal_messages, inserts = _get_messages_data(context, [pending], "accepted") + + zero = "0x0000000000000000000000000000000000000000" + assert inserts[0][0] == zero + assert inserts[0][1]["contract_address"] == zero + assert internal_messages[0]["recipient"] == zero + assert internal_messages[0]["saltNonce"] == 42 + assert pending.address == "0x" + + def test_message_dispatch_fills_mode2_child_fee_accounting_from_allocation_subtree( monkeypatch, ): @@ -8642,8 +8774,9 @@ def test_message_dispatch_records_revealed_external_message_execution_fees( internal_messages, inserts = _get_messages_data(context, [pending], "finalized") assert len(inserts) == 1 - assert inserts[0] == [recipient, {}, TransactionType.SEND.value, 3, 0] - assert json.loads(internal_messages[0]["data"]) == {} + assert inserts[0][:5] == [recipient, {}, TransactionType.SEND.value, 3, 0] + assert inserts[0][5].startswith("0x") + assert internal_messages == [] updated = processor.updated_fee_accounting assert updated["message_fees_recorded_at_reveal"] is True assert updated["allocation_consumed"] == {"0": 700} From 776f6dfefc3d6ac62c1cb267a24e54fa1431a81b Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 07:28:58 +0100 Subject: [PATCH 23/55] fix(consensus): align child admission with v0.6 --- backend/consensus/base.py | 89 +++++++++++ backend/protocol_rpc/fees.py | 37 +++++ .../contracts/v2_contracts/ConsensusMain.sol | 150 +++++++++++++++--- .../ConsensusMain.InternalMessages.test.js | 149 +++++++++++++++-- tests/unit/test_studio_fees.py | 106 ++++++++++++- 5 files changed, 501 insertions(+), 30 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index d6aa8d011..94255d0f5 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -70,6 +70,7 @@ message_novelty_mask, prepare_reveal_message_generation, record_external_message_execution_fees, + refund_failed_internal_message_fee, runtime_rotations_for_round, stamp_receipt_execution_policy, unwind_reveal_message_fees, @@ -3839,6 +3840,7 @@ def _get_messages_data( nonce, pending_transaction.value, effect_identities[nonce_offset][0], + identity_payloads[nonce_offset], ] ) @@ -4511,6 +4513,86 @@ def _runtime_rotation_limit( ) +def _is_zero_transaction_hash(transaction_hash: Any) -> bool: + if not isinstance(transaction_hash, str): + return False + try: + return int(transaction_hash, 16) == 0 + except ValueError: + return False + + +def _refund_skipped_internal_message( + context: TransactionContext, + insert_transaction_data: list, + triggered_on: Literal["accepted", "finalized"], +) -> None: + occurrence = insert_transaction_data[5] + message_payload = insert_transaction_data[6] + refund_amount = int(insert_transaction_data[4] or 0) + if bool(message_payload.get("useBalance", False)): + refund_amount += int(message_payload.get("declaredBudget", 0) or 0) + + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + mutate_accounting = getattr( + context.transactions_processor, + "mutate_transaction_fee_accounting", + None, + ) + if not isinstance(parent_fee_accounting, dict) or not callable(mutate_accounting): + if refund_amount > 0: + context.accounts_manager.credit_account_balance( + context.transaction.to_address, + refund_amount, + ) + return + + expected_descriptor = message_effect_identities( + context.transaction.hash, + [message_payload], + )[0][1] + + def refund_latest(current_fee_accounting: dict[str, Any]) -> dict[str, Any]: + current_record = ( + current_fee_accounting.get("message_value_effects") or {} + ).get(occurrence) + if not isinstance(current_record, dict): + raise RuntimeError(f"MessageValueEffectMissing({occurrence})") + if ( + current_record.get("descriptor") != expected_descriptor + or current_record.get("phase") != triggered_on + ): + raise RuntimeError( + "MessageValueEffectDescriptorMismatch" + f"({occurrence},{current_record.get('descriptor')}," + f"{expected_descriptor})" + ) + if bool(current_record.get("skippedRefunded", False)): + return current_fee_accounting + + updated = refund_failed_internal_message_fee( + current_fee_accounting, + message_payload, + ) + updated_record = updated.setdefault("message_value_effects", {})[occurrence] + updated_record["skipped"] = True + updated_record["skippedRefunded"] = True + updated_record["skippedRefundAmount"] = refund_amount + if refund_amount > 0: + context.accounts_manager.credit_account_balance( + context.transaction.to_address, + refund_amount, + ) + return updated + + updated_accounting = mutate_accounting( + context.transaction.hash, + refund_latest, + ) + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting + + def _emit_messages( context: TransactionContext, insert_transactions_data: list, @@ -4580,6 +4662,13 @@ def _emit_messages( ) if not (rollup_skipped and is_deploy): actual_recipient = to_checksum_address(actual_recipient) + if not is_external and _is_zero_transaction_hash(transaction_hash): + _refund_skipped_internal_message( + context, + insert_transaction_data, + triggered_on, + ) + continue if is_deploy and not rollup_skipped: if actual_recipient == to_checksum_address(ZERO_ADDRESS): raise InternalMessageEmissionError( diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 30ac6d165..e41a7449c 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -2718,6 +2718,43 @@ def refund_failed_external_message_fee( return updated +def refund_failed_internal_message_fee( + accounting: dict[str, Any], + message: dict[str, Any], +) -> dict[str, Any]: + """Return a skipped internal child's allowance to the parent bucket. + + Consensus does not create a transaction for a non-ghost internal + recipient. Its declared budget therefore never moves to a child escrow. + Contract-funded (useBalance) budgets live outside the parent bucket and + are refunded by the account-value bridge instead. + """ + + updated = copy.deepcopy(accounting) + if int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) != MESSAGE_TYPE_INTERNAL: + return updated + if bool(message.get("useBalance", False)): + return updated + + declared_budget = int(message.get("declaredBudget", 0) or 0) + if declared_budget <= 0: + return updated + _decrement_allocation_consumed(updated, message, declared_budget) + updated["message_fee_consumed"] = max( + 0, + int(updated.get("message_fee_consumed", 0)) - declared_budget, + ) + updated.setdefault("failed_internal_message_refunds", []).append( + { + "recipient": str(message.get("recipient", "")).lower(), + "callKey": _normalize_call_key(message.get("callKey", EMPTY_CALL_KEY)), + "declaredBudget": declared_budget, + } + ) + _refresh_message_fee_accounting_report_if_present(updated) + return updated + + def unwind_reveal_message_fees( accounting: dict[str, Any], messages: list[dict[str, Any]], diff --git a/hardhat/contracts/v2_contracts/ConsensusMain.sol b/hardhat/contracts/v2_contracts/ConsensusMain.sol index 60f7e10ac..c42d84fa1 100644 --- a/hardhat/contracts/v2_contracts/ConsensusMain.sol +++ b/hardhat/contracts/v2_contracts/ConsensusMain.sol @@ -105,6 +105,7 @@ contract ConsensusMain is address indexed recipient, address indexed activator ); + event InternalMessageSkipped(address indexed recipient); event TransactionCancelled(bytes32 indexed txId, address indexed sender); event TransactionIdleValidatorReplacementFailed( bytes32 indexed txId, @@ -132,6 +133,14 @@ contract ConsensusMain is bytes data; } + bytes4 private constant FEE_AWARE_ADD_TRANSACTION_SELECTOR = 0x35a251fb; + bytes4 private constant FEE_AWARE_DEPLOY_SALTED_SELECTOR = 0x98863702; + uint256 private constant FEE_AWARE_PARAMS_HEAD_SIZE = 10 * 32; + uint256 private constant FEE_AWARE_TX_CALLDATA_OFFSET = 8 * 32; + + error InvalidFeeAwareTransactionEncoding(); + error InvalidSalt(); + /** * @notice Initializes the contract */ @@ -198,29 +207,43 @@ contract ConsensusMain is txIds = new bytes32[](internalMessages.length); recipients = new address[](internalMessages.length); for (uint256 i = 0; i < internalMessages.length; i++) { - ( - bytes32 generated_txId, - address newActivator - ) = _addTransaction( - internalMessages[i].sender, - internalMessages[i].recipient, - 5, // or pass as part of internalMessages.data - 0, // or pass as part of internalMessages.data - internalMessages[i].saltNonce, - internalMessages[i].data - ); - txIds[i] = generated_txId; - recipients[i] = contracts.genTransactions.getTransactionRecipient( - generated_txId - ); - emit InternalMessageProcessed( - generated_txId, - recipients[i], - newActivator - ); + try this.addInternalMessageTransaction(internalMessages[i]) returns ( + bytes32 generatedTxId, + address newActivator, + address actualRecipient + ) { + txIds[i] = generatedTxId; + recipients[i] = actualRecipient; + emit InternalMessageProcessed( + generatedTxId, + actualRecipient, + newActivator + ); + } catch { + recipients[i] = internalMessages[i].recipient; + emit InternalMessageSkipped(internalMessages[i].recipient); + } } } + /// @dev Isolates each internal child creation so one invalid child does not + /// revert its siblings or strand the parent phase. + function addInternalMessageTransaction( + internalMessageData calldata internalMessage + ) external returns (bytes32 txId, address activator, address recipient) { + require(msg.sender == address(this), "Only self"); + if (internalMessage.data.length == 0) revert Errors.EmptyTransaction(); + (txId, activator) = _addTransaction( + internalMessage.sender, + internalMessage.recipient, + 5, + 0, + internalMessage.saltNonce, + internalMessage.data + ); + recipient = contracts.genTransactions.getTransactionRecipient(txId); + } + function emitTransactionAccepted( bytes32 txId, internalMessageData[] calldata internalMessages @@ -295,6 +318,93 @@ contract ConsensusMain is // TODO: Fee verification handling } + /// @dev Studio forwards the user's already-signed v0.6 fee-aware payload to + /// this local shadow helper. Decoding the full nested fee tuple with a typed + /// Solidity entrypoint exceeds the EVM contract-size limit, so the bridge + /// reads only the transaction fields needed to author the protocol id and + /// deployment address. The Python RPC layer validates the complete envelope + /// before forwarding it here. + fallback() external payable { + if (msg.sig == FEE_AWARE_ADD_TRANSACTION_SELECTOR) { + _addFeeAwareTransaction(false); + return; + } + if (msg.sig == FEE_AWARE_DEPLOY_SALTED_SELECTOR) { + _addFeeAwareTransaction(true); + return; + } + revert InvalidFeeAwareTransactionEncoding(); + } + + function _addFeeAwareTransaction(bool isSaltedDeployment) internal { + uint256 tupleOffset; + assembly { + tupleOffset := calldataload(4) + } + if (tupleOffset != 32) revert InvalidFeeAwareTransactionEncoding(); + + uint256 tupleStart = 4 + tupleOffset; + if (msg.data.length < tupleStart + FEE_AWARE_PARAMS_HEAD_SIZE) { + revert InvalidFeeAwareTransactionEncoding(); + } + + uint256 senderWord; + uint256 recipientWord; + uint256 numOfInitialValidators; + uint256 maxRotations; + uint256 saltNonce; + uint256 txCalldataOffset; + assembly { + senderWord := calldataload(tupleStart) + recipientWord := calldataload(add(tupleStart, 32)) + numOfInitialValidators := calldataload(add(tupleStart, 64)) + maxRotations := calldataload(add(tupleStart, 96)) + saltNonce := calldataload(add(tupleStart, 160)) + txCalldataOffset := calldataload( + add(tupleStart, FEE_AWARE_TX_CALLDATA_OFFSET) + ) + } + if ( + senderWord > type(uint160).max || + recipientWord > type(uint160).max || + txCalldataOffset < FEE_AWARE_PARAMS_HEAD_SIZE || + txCalldataOffset % 32 != 0 + ) revert InvalidFeeAwareTransactionEncoding(); + + uint256 txCalldataLengthOffset = tupleStart + txCalldataOffset; + if (txCalldataLengthOffset > msg.data.length - 32) { + revert InvalidFeeAwareTransactionEncoding(); + } + uint256 txCalldataLength; + assembly { + txCalldataLength := calldataload(txCalldataLengthOffset) + } + uint256 txCalldataStart = txCalldataLengthOffset + 32; + if (txCalldataLength > msg.data.length - txCalldataStart) { + revert InvalidFeeAwareTransactionEncoding(); + } + if (txCalldataLength == 0) revert Errors.EmptyTransaction(); + + bytes memory txCalldata = new bytes(txCalldataLength); + assembly { + calldatacopy(add(txCalldata, 32), txCalldataStart, txCalldataLength) + } + if (isSaltedDeployment) { + if (saltNonce == 0) revert InvalidSalt(); + recipientWord = 0; + } else { + saltNonce = 0; + } + _addTransaction( + address(uint160(senderWord)), + address(uint160(recipientWord)), + numOfInitialValidators, + maxRotations, + saltNonce, + txCalldata + ); + } + /** * @notice Activates a pending transaction by updating its random seed and moving it to the active state * @dev Only callable by the designated activator for the transaction diff --git a/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js b/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js index fede1da88..403c78bc8 100644 --- a/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js +++ b/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js @@ -96,6 +96,77 @@ describe("ConsensusMain internal message bridge", function () { ); }); + it("accepts the v0.6 fee-aware tuple selectors and authors salted recipients", async function () { + const feeAwareInterface = new ethers.Interface([ + "function addTransaction((address sender,address recipient,uint256 numOfInitialValidators,uint256 maxRotations,uint256 validUntil,uint256 saltNonce,uint256 userValue,(uint256 leaderTimeunitsAllocation,uint256 validatorTimeunitsAllocation,uint256 appealRounds,uint256 executionBudgetPerRound,uint256 executionConsumed,uint256 totalMessageFees,uint256[] rotations,uint256 maxPriceGenPerTimeUnit,uint256 storageFeeMaxGasPrice,uint256 receiptFeeMaxGasPrice) feesDistribution,bytes txCalldata,(uint8 messageType,bool onAcceptance,uint256 parentIndex,address recipient,bytes32 callKey,uint256 budget,bytes feeParams)[] messageAllocations) params) payable", + "function deploySalted((address sender,address recipient,uint256 numOfInitialValidators,uint256 maxRotations,uint256 validUntil,uint256 saltNonce,uint256 userValue,(uint256 leaderTimeunitsAllocation,uint256 validatorTimeunitsAllocation,uint256 appealRounds,uint256 executionBudgetPerRound,uint256 executionConsumed,uint256 totalMessageFees,uint256[] rotations,uint256 maxPriceGenPerTimeUnit,uint256 storageFeeMaxGasPrice,uint256 receiptFeeMaxGasPrice) feesDistribution,bytes txCalldata,(uint8 messageType,bool onAcceptance,uint256 parentIndex,address recipient,bytes32 callKey,uint256 budget,bytes feeParams)[] messageAllocations) params) payable", + ]); + const feesDistribution = { + leaderTimeunitsAllocation: 1, + validatorTimeunitsAllocation: 1, + appealRounds: 0, + executionBudgetPerRound: 0, + executionConsumed: 0, + totalMessageFees: 0, + rotations: [0], + maxPriceGenPerTimeUnit: 1, + storageFeeMaxGasPrice: 0, + receiptFeeMaxGasPrice: 0, + }; + const baseParams = { + sender: owner.address, + recipient, + numOfInitialValidators: 5, + maxRotations: 0, + validUntil: 0, + saltNonce: 0, + userValue: 0, + feesDistribution, + txCalldata: "0x0102", + messageAllocations: [], + }; + const issuedBefore = await queues.getIssuedTxCount(recipient); + + await owner.sendTransaction({ + to: await consensusMain.getAddress(), + data: feeAwareInterface.encodeFunctionData("addTransaction", [ + baseParams, + ]), + }); + expect(await queues.getIssuedTxCount(recipient)).to.equal( + issuedBefore + 1n, + ); + + const saltedTx = await owner.sendTransaction({ + to: await consensusMain.getAddress(), + data: feeAwareInterface.encodeFunctionData("deploySalted", [ + { + ...baseParams, + recipient: ethers.ZeroAddress, + saltNonce: 777n, + }, + ]), + }); + const saltedReceipt = await saltedTx.wait(); + const newTransaction = saltedReceipt.logs + .map((log) => { + try { + return consensusMain.interface.parseLog(log); + } catch (_error) { + return null; + } + }) + .find((event) => event && event.name === "NewTransaction"); + + expect(newTransaction.args.recipient).to.not.equal(ethers.ZeroAddress); + expect(newTransaction.args.recipient).to.equal( + await ghostFactory.latestGhost(), + ); + expect( + await consensusMain.ghostContracts(newTransaction.args.recipient), + ).to.equal(true); + }); + it("seats a distinct decision payload once without duplicating its retries", async function () { const parentTxId = ethers.keccak256(ethers.toUtf8Bytes("drift-parent")); const messages = [ @@ -184,6 +255,40 @@ describe("ConsensusMain internal message bridge", function () { }); } + it("keeps a successful salted deployment when a colliding sibling is skipped", async function () { + const parentTxId = ethers.keccak256( + ethers.toUtf8Bytes("deployment-collision-parent"), + ); + const messages = [0, 1].map((index) => ({ + sender: recipient, + recipient: ethers.ZeroAddress, + saltNonce: 99n, + data: index === 0 ? "0x0102" : "0x0304", + })); + + await consensusMain.emitTransactionAccepted(parentTxId, messages); + const childIds = await consensusMain.getInternalMessageTxIds( + parentTxId, + true, + messages, + ); + const childRecipients = + await consensusMain.getInternalMessageRecipients( + parentTxId, + true, + messages, + ); + + expect(childIds[0]).to.not.equal(ethers.ZeroHash); + expect(childIds[1]).to.equal(ethers.ZeroHash); + expect(childRecipients[0]).to.not.equal(ethers.ZeroAddress); + expect(childRecipients[1]).to.equal(ethers.ZeroAddress); + expect(await queues.getIssuedTxCount(childRecipients[0])).to.equal(1n); + + await consensusMain.emitTransactionAccepted(parentTxId, messages); + expect(await queues.getIssuedTxCount(childRecipients[0])).to.equal(1n); + }); + it("indexes finalized history by recipient issuance order", async function () { await consensusMain.cancelTransaction(deploymentTxId); @@ -193,25 +298,51 @@ describe("ConsensusMain internal message bridge", function () { ); }); - it("rejects an internal child targeting an unregistered nonzero recipient", async function () { + it("skips an unregistered internal recipient without stranding valid siblings", async function () { const parentTxId = ethers.keccak256( ethers.toUtf8Bytes("non-ghost-parent"), ); - let rejected = false; - try { - await consensusMain.emitTransactionAccepted(parentTxId, [ + const messages = [ { sender: recipient, recipient: owner.address, saltNonce: 0, data: "0x0102", }, - ]); - } catch (error) { - rejected = String(error).includes("NonGenVMContract"); - } + { + sender: recipient, + recipient, + saltNonce: 0, + data: "0x0304", + }, + ]; + const issuedBefore = await queues.getIssuedTxCount(recipient); - expect(rejected).to.equal(true); + await consensusMain.emitTransactionAccepted(parentTxId, messages); + + const childIds = await consensusMain.getInternalMessageTxIds( + parentTxId, + true, + messages, + ); + const childRecipients = + await consensusMain.getInternalMessageRecipients( + parentTxId, + true, + messages, + ); + + expect(childIds[0]).to.equal(ethers.ZeroHash); + expect(childIds[1]).to.not.equal(ethers.ZeroHash); + expect(childRecipients).to.deep.equal([owner.address, recipient]); expect(await consensusMain.ghostContracts(owner.address)).to.equal(false); + expect(await queues.getIssuedTxCount(recipient)).to.equal( + issuedBefore + 1n, + ); + + await consensusMain.emitTransactionAccepted(parentTxId, messages); + expect(await queues.getIssuedTxCount(recipient)).to.equal( + issuedBefore + 1n, + ); }); }); diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index fc5d6f025..7dad87d0a 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -79,6 +79,7 @@ MessageFeesReportMismatch, MessageNoMatchingAllocation, Mode1MessageFeesRequireGenVMPerEmissionSupport, + message_effect_identities, PhaseTimeoutOutOfBounds, SubmittedMessagesTooLarge, CALL_KEY_WILDCARD, @@ -1511,6 +1512,108 @@ def test_message_phase_with_a_rollup_still_rejects_a_malformed_receipt(): assert calls == [] +@pytest.mark.parametrize( + ("use_balance", "initial_consumed", "expected_account_refund"), + [(False, 55, 7), (True, 0, 62)], +) +def test_non_ghost_internal_message_is_skipped_and_refunded_once( + use_balance, + initial_consumed, + expected_account_refund, +): + insert_calls = [] + account_refunds = [] + recipient = "0x4444444444444444444444444444444444444444" + parent_hash = "0x" + "12" * 32 + payload = { + "messageType": 1, + "recipient": recipient, + "value": 7, + "data": b"\x12\x34", + "onAcceptance": True, + "saltNonce": 0, + "feeParams": b"", + "declaredBudget": 55, + "allocationSubtree": [], + "callKey": "0x" + "00" * 32, + "useBalance": use_balance, + } + occurrence, descriptor = message_effect_identities(parent_hash, [payload])[0] + accounting = { + "message_fee_budget": 55, + "message_fee_consumed": initial_consumed, + "message_value_effects": { + occurrence: { + "descriptor": descriptor, + "phase": "accepted", + "include": True, + "value": 7, + "declaredBudget": 55, + } + }, + } + + class _Processor: + def insert_transaction(self, *args, **kwargs): + insert_calls.append((args, kwargs)) + + def mutate_transaction_fee_accounting(self, tx_hash, mutator): + assert tx_hash == parent_hash + updated = mutator(self.accounting) + self.accounting = updated + return updated + + processor = _Processor() + processor.accounting = accounting + + class _Accounts: + def credit_account_balance(self, address, amount): + account_refunds.append((address, amount)) + + context = SimpleNamespace( + transaction=SimpleNamespace( + to_address="0x1111111111111111111111111111111111111111", + execution_mode="NORMAL", + hash=parent_hash, + config_rotation_rounds=0, + sim_config=None, + origin_address=None, + data={FEE_ACCOUNTING_KEY: accounting}, + ), + transactions_processor=processor, + accounts_manager=_Accounts(), + ) + skipped_child = [ + recipient, + {}, + TransactionType.RUN_CONTRACT.value, + 0, + 7, + occurrence, + payload, + ] + helper_result = { + "tx_ids_hex": ["0x" + "00" * 32], + "recipients": [recipient], + } + + _emit_messages(context, [skipped_child], helper_result, "accepted") + _emit_messages(context, [skipped_child], helper_result, "accepted") + + assert insert_calls == [] + assert account_refunds == [ + (context.transaction.to_address, expected_account_refund) + ] + assert processor.accounting["message_fee_consumed"] == 0 + assert len(processor.accounting.get("failed_internal_message_refunds", [])) == ( + 0 if use_balance else 1 + ) + record = processor.accounting["message_value_effects"][occurrence] + assert record["skipped"] is True + assert record["skippedRefunded"] is True + assert record["skippedRefundAmount"] == expected_account_refund + + def test_simulation_fee_accounting_accepts_sdk_style_fee_options(): policy = StudioFeePolicy.from_env() fees_distribution = _env_fees_distribution( @@ -8585,12 +8688,13 @@ def test_message_dispatch_creates_mode1_child_fee_accounting_from_pending_metada internal_messages, inserts = _get_messages_data(context, [pending], "accepted") assert len(inserts) == 1 - recipient, data, tx_type, nonce, value, effect_identity = inserts[0] + recipient, data, tx_type, nonce, value, effect_identity, effect_payload = inserts[0] assert recipient == pending.address assert tx_type == TransactionType.RUN_CONTRACT.value assert nonce == 3 assert value == 7 assert effect_identity.startswith("0x") + assert effect_payload["recipient"] == pending.address assert data["calldata"] == b"\x12\x34" assert data["fee_value"] == child_budget assert data["user_value"] == 7 From c3ebf60cb3a51dc5841e65d3a6705a39025dc804 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 10:37:12 +0100 Subject: [PATCH 24/55] fix(consensus): complete v0.6 studio parity --- .env.example | 24 +- README.md | 24 +- backend/consensus/base.py | 261 +++++- backend/consensus/worker.py | 28 +- backend/database_handler/accounts_manager.py | 16 +- backend/database_handler/chain_snapshot.py | 4 +- .../e4f6a8c0d2b4_add_evm_envelope_ledger.py | 71 ++ ...5a7c9e1b3d5_add_transaction_queue_order.py | 75 ++ backend/database_handler/models.py | 44 + .../transactions_processor.py | 272 +++++- backend/node/genvm/base.py | 9 +- backend/node/types.py | 5 +- backend/protocol_rpc/endpoints.py | 587 +++++++++---- backend/protocol_rpc/fees.py | 351 +++++--- backend/protocol_rpc/ghost_factory.py | 123 +++ backend/protocol_rpc/transactions_parser.py | 20 +- backend/protocol_rpc/types.py | 2 + backend/rollup/consensus_service.py | 122 ++- .../default_contracts/consensus_main.py | 25 +- docker-compose.yml | 11 +- .../contracts/v2_contracts/ConsensusData.sol | 54 +- .../ConsensusMain.InternalMessages.test.js | 8 + .../test_finalization_ordering.py | 33 + .../test_queue_depth_admission.py | 149 +++- .../transactions_processor_test.py | 307 +++++++ .../consensus/test_payable_balance_flow.py | 81 +- .../test_consensus_contract_soft_not_found.py | 16 + .../test_consensus_service_message_replay.py | 37 + tests/unit/test_ghost_factory.py | 50 ++ tests/unit/test_node_state_proxy_metrics.py | 11 +- tests/unit/test_rpc_methods.py | 33 + tests/unit/test_simulator_sessions.py | 521 ++++++++++++ tests/unit/test_studio_fees.py | 773 +++++++++++++++--- tests/unit/test_transactions_parser.py | 54 +- ...est_transactions_processor_improvements.py | 65 +- 35 files changed, 3705 insertions(+), 561 deletions(-) create mode 100644 backend/database_handler/migration/versions/e4f6a8c0d2b4_add_evm_envelope_ledger.py create mode 100644 backend/database_handler/migration/versions/f5a7c9e1b3d5_add_transaction_queue_order.py create mode 100644 backend/protocol_rpc/ghost_factory.py create mode 100644 tests/unit/test_ghost_factory.py diff --git a/.env.example b/.env.example index 7d472805e..b216cf4e1 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,6 @@ DBPASSWORD='postgres' DBPORT='5432' DATABASE_POOL_SIZE='20' # Database connection pool size DATABASE_MAX_OVERFLOW='10' # Maximum overflow connections -# Set the compose profile to 'hardhat' to use the hardhat network REMOTE_DATABASE='false' ######################################## @@ -27,7 +26,6 @@ SERVER_NAME='studio.genlayer.com' ######################################## # Compose and Build -# Set the compose profile to 'hardhat' to use the hardhat network COMPOSE_PROFILES='' FRONTEND_BUILD_TARGET='dev' # change to 'dev' to run in dev mode BACKEND_BUILD_TARGET='debug' # change to 'prod' or remove to run in prod mode @@ -73,6 +71,13 @@ GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES='0' GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS='210000' GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS='100000' GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES='1024' +# Studio's virtual v0.6 GhostFactory deployment. These defaults are stable +# across RPC replicas. Override all three only when intentionally mirroring a +# concrete Consensus deployment (GhostFactory, CreationPhase, bytecodeEVM). +GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS='0x130e09c996462963A6398CA04e1011e6ef9d68a6' +GENLAYER_STUDIO_CREATION_PHASE_ADDRESS='0x6AF00387f985684f159d673b15265E651B9E29f8' +GENLAYER_STUDIO_GHOST_BYTECODE_HASH='0x4249189346902cb6f0afc196d5719e1b4cbc861a700ae3b0c6dcbe51e5e566de' +GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE='2' # Ollama Server Configuration OLAMAPORT='11434' @@ -87,11 +92,10 @@ WEBDRIVER_TIMEOUT_MS="60000" WEB_RENDER_RETRIES="2" ######################################## -# Consensus Rollup -# Hardhat port -HARDHAT_URL='http://hardhat' +# Optional legacy Hardhat inspection bridge. Studio protocol state and ghost +# addresses are DB-authoritative and do not require this service. +HARDHAT_URL='' HARDHAT_PORT='8545' -HARDHAT_PRIVATE_KEY='0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' GENLAYER_CHAIN_ID='61999' ######################################## @@ -134,10 +138,10 @@ RATE_LIMIT_ANON_PER_DAY='5000' # Anonymous per-day limit RATE_LIMIT_READ_MULTIPLIER='10' # Cheap reads (no GenVM) get this multiple of the tier limits # PENDING-tx queue depth caps for eth_sendRawTransaction (admission control). -# Empty / unset = no cap (the default for self-hosted). Public shared -# deployments should set both to prevent one user filling the queue. -MAX_PENDING_PER_CONTRACT_DEFAULT='' # Cap PENDING txs per contract (e.g. 50) -MAX_PENDING_PER_SENDER_DEFAULT='' # Cap PENDING txs per sender (e.g. 20) +# The per-recipient cap defaults to Consensus Queues' 20 even when empty; +# the Studio-only per-sender cap remains disabled unless explicitly set. +MAX_PENDING_PER_CONTRACT_DEFAULT='' # Empty = protocol default 20 +MAX_PENDING_PER_SENDER_DEFAULT='' # Empty = disabled # Daily per-contract snapshot-byte budget (empty = off). Hosted sandbox # example: 1073741824 (1 GiB). First write of the UTC day always allowed. MAX_CONTRACT_SNAPSHOT_BYTES_PER_DAY='' diff --git a/README.md b/README.md index 06846d3d8..9c8a8061f 100644 --- a/README.md +++ b/README.md @@ -31,27 +31,11 @@ $ genlayer up ``` After executing those commands a new tab will open in your browser with the GenLayer Studio. Additional installation instructions can be found [here](https://docs.genlayer.com/simulator/installation) -### Enabling Hardhat Node -If you need to interact with a local Hardhat node for transaction processing, make sure to add the following to your `.env` file: +### Hardhat protocol tests -``` -HARDHAT_URL=http://hardhat -HARDHAT_PORT=8545 -COMPOSE_PROFILES=hardhat -``` - -This will enable the Hardhat service when running `genlayer up`. - -### Disabling Hardhat Node -If you need to disable the Hardhat node, make sure to remove the following from your `.env` file: - -``` -HARDHAT_URL= -HARDHAT_PORT= -COMPOSE_PROFILES= -``` - -This will disable the Hardhat service when running `genlayer up`. +Studio transaction processing no longer depends on a separate Hardhat node. +The contracts under `hardhat/` remain a protocol-parity test harness and can +be tested directly from that directory. ## 🚀 Key Features * 🖥️ **Test Locally:** Developers can test Intelligent Contracts in a local environment, replicating the GenLayer network without the need for deployment. This speeds up the development cycle and reduces the risk of errors in the live environment. diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 94255d0f5..364ccbe1e 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -17,7 +17,7 @@ import json import base64 -from eth_utils import is_address, to_checksum_address +from eth_utils import is_address, keccak, to_bytes, to_checksum_address from sqlalchemy import text from sqlalchemy.orm import Session from backend.consensus.vrf import get_validators_for_transaction @@ -54,6 +54,7 @@ EventScope, ) from backend.protocol_rpc.types import ZERO_ADDRESS +from backend.protocol_rpc.ghost_factory import GhostFactoryConfig from backend.protocol_rpc.fees import ( FEE_ACCOUNTING_KEY, VALIDATORS_PER_ROUND, @@ -1914,8 +1915,10 @@ def _external_message_pending_freeze_total(context: TransactionContext) -> int: if not contract_address or not hasattr(context.transactions_processor, "session"): return 0 - current_created_at = ( - context.transactions_processor.session.query(Transactions.created_at) + current_queue_order = ( + context.transactions_processor.session.query( + Transactions.queue_order, + ) .filter(Transactions.hash == context.transaction.hash) .scalar() ) @@ -1925,8 +1928,8 @@ def _external_message_pending_freeze_total(context: TransactionContext) -> int: Transactions.hash != context.transaction.hash, Transactions.consensus_data.isnot(None), ] - if current_created_at is not None: - filters.append(Transactions.created_at < current_created_at) + if current_queue_order is not None: + filters.append(Transactions.queue_order < int(current_queue_order)) rows = ( context.transactions_processor.session.query( @@ -2462,6 +2465,7 @@ def reserve_latest(current_fee_accounting: dict[str, Any]) -> dict[str, Any]: updated_accounting = mutate_accounting( context.transaction.hash, reserve_latest, + commit=False, ) context.transaction.data = dict(context.transaction.data or {}) context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting @@ -2475,6 +2479,27 @@ def reserve_latest(current_fee_accounting: dict[str, Any]) -> dict[str, Any]: ) +def _pending_valid_until_expired( + transaction: Transaction, + *, + now: float | None = None, +) -> bool: + # Appeals/recomputation reuse Studio's PENDING state after the transaction + # has already activated. Consensus deliberately ignores validUntil on that + # re-entry path (the original activation deadline is not a lifetime cap). + if ( + bool(getattr(transaction, "consensus_history", None)) + or bool(getattr(transaction, "appealed", False)) + or bool(getattr(transaction, "appeal_undetermined", False)) + or bool(getattr(transaction, "appeal_leader_timeout", False)) + or bool(getattr(transaction, "appeal_validators_timeout", False)) + ): + return False + valid_until = int((transaction.data or {}).get("valid_until", 0) or 0) + # EVM block timestamps are integer seconds; equality remains valid. + return valid_until > 0 and int(time.time() if now is None else now) > valid_until + + class PendingState(TransactionState): """ Class representing the pending state of a transaction. @@ -2494,6 +2519,37 @@ async def handle(self, context): ) context.active_fee_round = _active_execution_fee_round(context.transaction) + # v0.6 validUntil is an activation deadline. Consensus leaves an + # expired transaction queued until it reaches the pending head, then + # terminalizes it without activating or executing it. Studio used to + # decode and persist this field but ignored it entirely. + if _pending_valid_until_expired(context.transaction): + value_sender = context.transaction.from_address + if value_sender: + context.accounts_manager.refund_tx_value( + context.transaction.hash, + value_sender, + ) + fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + fee_sender = ( + fee_accounting.get("sender") + if isinstance(fee_accounting, dict) + else None + ) or value_sender + if fee_sender: + context.accounts_manager.cancel_tx_fee_accounting_once( + context.transaction.hash, + fee_sender, + "valid_until_expired", + ) + await ConsensusAlgorithm.dispatch_transaction_status_update( + context.transactions_processor, + context.transaction.hash, + TransactionStatus.CANCELED, + context.msg_handler, + ) + return None + # Pre-effects: timestamp + reset rotation count pre_effects = decide_pending_pre( tx_hash=context.transaction.hash, @@ -3795,7 +3851,7 @@ def _get_messages_data( parent_fee_accounting and parent_fee_accounting.get("message_fees_recorded_at_reveal") ) - base_nonce = context.transactions_processor.get_transaction_count( + base_nonce = context.transactions_processor.get_genlayer_transaction_count( context.transaction.to_address ) phase_pending_transactions = list( @@ -3956,7 +4012,11 @@ def _attach_child_fee_accounting( child_fees, child_fee_accounting = create_child_fee_accounting( message=message_payload, parent_fees_distribution=parent_fee_accounting.get("fees_distribution"), - message_allocations=message_payload.get("allocationSubtree") or [], + message_allocations=( + message_payload.get("_studioResolvedAllocationSubtree") + or message_payload.get("allocationSubtree") + or [] + ), sender=( context.transaction.to_address if pending_transaction.use_balance @@ -4027,6 +4087,7 @@ def _record_parent_message_fee_consumption( reveal_recorded, _message_execution_fee_recipient(context), ), + commit=False, ) ) context.transaction.data = dict(context.transaction.data or {}) @@ -4127,6 +4188,7 @@ def prepare_latest(current_fee_accounting): context.transactions_processor.mutate_transaction_fee_accounting( context.transaction.hash, prepare_latest, + commit=False, ) ) @@ -4213,6 +4275,7 @@ def mark_latest(current_fee_accounting): context.transactions_processor.mutate_transaction_fee_accounting( context.transaction.hash, mark_latest, + commit=False, ) ) context.transaction.data = dict(context.transaction.data or {}) @@ -4284,7 +4347,7 @@ def _dispatch_messages_for_phase( context, leader_receipt.pending_transactions, ) - internal_messages_data, insert_transactions_data = _get_messages_data( + _internal_messages_data, insert_transactions_data = _get_messages_data( context, _apply_message_value_withdrawals_for_phase( context, @@ -4293,29 +4356,20 @@ def _dispatch_messages_for_phase( ), on, ) - event_name = ( - "emitTransactionAccepted" if on == "accepted" else "emitTransactionFinalized" - ) - rollup_receipt = context.consensus_service.emit_transaction_event( - event_name, - leader_receipt.node_config, - context.transaction.hash, - internal_messages_data, - ) - # A None receipt is ambiguous: it means both "this deployment has no - # rollup" and "forwarding failed". Only the latter may abort the commit. - rollup_skipped = ( - rollup_receipt is None - and context.consensus_service.transaction_forwarding_skipped( - leader_receipt.node_config - ) + # Studio's normal topology intentionally has no Hardhat node. Make the DB + # transaction the sole authority for child ids, CREATE/CREATE2 recipients, + # and per-child admission failure instead of allowing an optional, older + # shadow deployment to decide protocol state differently across replicas. + rollup_receipt = _author_message_phase_locally( + context, + insert_transactions_data, + on, ) _emit_messages( context, insert_transactions_data, rollup_receipt, on, - rollup_skipped=rollup_skipped, ) _mark_message_phase_delivered( context, @@ -4325,6 +4379,152 @@ def _dispatch_messages_for_phase( return True +def _author_message_phase_locally( + context: TransactionContext, + insert_transactions_data: list, + on: Literal["accepted", "finalized"], +) -> dict[str, list[str]]: + """Mirror MessagePayments + CreationPhase in the request DB transaction.""" + + context.transactions_processor.lock_ghost_factory() + factory = GhostFactoryConfig.from_env() + successful_deployments = ( + context.transactions_processor.get_successful_ghost_creation_count() + ) + batch_deployments: set[str] = set() + tx_ids: list[str] = [] + recipients: list[str] = [] + zero_tx_id = "0x" + ("00" * 32) + prepared_children: list[tuple[list, int, str, bool]] = [] + + try: + pending_cap = int(os.environ.get("MAX_PENDING_PER_CONTRACT_DEFAULT", "20")) + except (TypeError, ValueError): + pending_cap = 20 + if pending_cap <= 0: + pending_cap = 20 + + for insert_transaction_data in insert_transactions_data: + transaction_type = insert_transaction_data[2] + if transaction_type == TransactionType.SEND.value: + continue + + actual_recipient = insert_transaction_data[0] + message_payload = insert_transaction_data[6] + # MessagePayments rejects an empty internal message before calling + # CreationPhase. Keep it a per-child contained failure: no ghost, + # queue slot, child id, or factory nonce is consumed. + payload_data = message_payload.get("data") + creation_failed = payload_data in {b"", "", "0x", None} + if ( + not creation_failed + and transaction_type == TransactionType.DEPLOY_CONTRACT.value + ): + salt_nonce = int(message_payload.get("saltNonce", 0) or 0) + actual_recipient = factory.address_for( + salt_nonce, + successful_deployments, + ) + normalized = actual_recipient.lower() + if ( + normalized in batch_deployments + or context.transactions_processor.is_genvm_contract_address( + actual_recipient + ) + ): + creation_failed = True + else: + batch_deployments.add(normalized) + successful_deployments += 1 + elif ( + not creation_failed + and not context.transactions_processor.is_genvm_contract_address( + actual_recipient + ) + ): + creation_failed = True + + prepared_children.append( + ( + insert_transaction_data, + transaction_type, + actual_recipient, + creation_failed, + ) + ) + + # A single Consensus transaction serializes every Queues.enqueueNewPending + # call. Studio workers processing unrelated parents do not, so lock all + # recipient queues in deterministic order before observing their depths. + context.transactions_processor.lock_pending_recipients( + actual_recipient + for _data, _type, actual_recipient, creation_failed in prepared_children + if not creation_failed + ) + + batch_pending: dict[str, int] = {} + for ( + insert_transaction_data, + transaction_type, + actual_recipient, + creation_failed, + ) in prepared_children: + + normalized_recipient = str(actual_recipient).lower() + if not creation_failed: + pending = context.transactions_processor.get_pending_transaction_count_for_address( + actual_recipient + ) + batch_pending.get( + normalized_recipient, 0 + ) + if pending >= pending_cap: + creation_failed = True + + if creation_failed: + tx_ids.append(zero_tx_id) + recipients.append( + ZERO_ADDRESS + if transaction_type == TransactionType.DEPLOY_CONTRACT.value + else to_checksum_address(actual_recipient) + ) + continue + + batch_pending[normalized_recipient] = ( + batch_pending.get(normalized_recipient, 0) + 1 + ) + occurrence = insert_transaction_data[5] + tx_ids.append( + _studio_child_transaction_id( + context.transaction.hash, + on, + occurrence, + ) + ) + recipients.append(to_checksum_address(actual_recipient)) + + return {"tx_ids_hex": tx_ids, "recipients": recipients} + + +def _studio_child_transaction_id( + parent_tx_id: str, + on: Literal["accepted", "finalized"], + occurrence: str, +) -> str: + """Stable Studio tx id for one Consensus message occurrence.""" + + domain = keccak(text="GenLayer/Studio/child-tx/v1") + phase = b"\x01" if on == "accepted" else b"\x00" + return ( + "0x" + + keccak( + domain + + to_bytes(hexstr=parent_tx_id).rjust(32, b"\x00") + + phase + + to_bytes(hexstr=occurrence).rjust(32, b"\x00") + ).hex() + ) + + def _unwind_discarded_reveal_message_fee_accounting( context: TransactionContext, ) -> None: @@ -4370,6 +4570,7 @@ def unwind_latest(current_fee_accounting): context.transactions_processor.mutate_transaction_fee_accounting( context.transaction.hash, unwind_latest, + commit=False, ) ) context.transaction.data = dict(context.transaction.data or {}) @@ -4588,6 +4789,7 @@ def refund_latest(current_fee_accounting: dict[str, Any]) -> dict[str, Any]: updated_accounting = mutate_accounting( context.transaction.hash, refund_latest, + commit=False, ) context.transaction.data = dict(context.transaction.data or {}) context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting @@ -4676,7 +4878,10 @@ def _emit_messages( f"({context.transaction.hash},{triggered_on},{i})" ) insert_transaction_data[1]["contract_address"] = actual_recipient - context.accounts_manager.create_new_account_with_address(actual_recipient) + context.accounts_manager.create_new_account_with_address( + actual_recipient, + commit=False, + ) elif ( not is_deploy and actual_recipient.lower() != str(insert_transaction_data[0]).lower() @@ -4721,4 +4926,8 @@ def _emit_messages( triggered_on=triggered_on, execution_mode=execution_mode_str, # Cascade execution mode origin_address=context.transaction.origin_address, + # MessagePayments creates the whole batch in one EVM transaction. + # Do not commit each Studio child independently; the worker commits + # the parent phase, child rows, accounts, values, and fees together. + commit=False, ) diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index eb116d1a3..0f43b0f72 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -366,7 +366,7 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: -- Ordering invariant: no older non-terminal tx on the same contract SELECT 1 FROM transactions earlier WHERE earlier.to_address IS NOT DISTINCT FROM t.to_address - AND earlier.created_at < t.created_at + AND earlier.queue_order < t.queue_order AND earlier.status NOT IN ('FINALIZED', 'CANCELED') AND earlier.hash != t.hash ) @@ -379,13 +379,13 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: AND t2.hash != t.hash ) AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) - ORDER BY t.created_at ASC + ORDER BY t.queue_order ASC FOR UPDATE SKIP LOCKED ), ready_for_finalization AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY to_address - ORDER BY created_at ASC + ORDER BY queue_order ASC ) as rn FROM locked_finalizations ), @@ -394,7 +394,7 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: SELECT * FROM ready_for_finalization WHERE rn = 1 - ORDER BY created_at ASC + ORDER BY queue_order ASC LIMIT 1 ) UPDATE transactions @@ -443,7 +443,7 @@ async def claim_next_appeal(self, session: Session) -> Optional[dict]: query = text( f""" WITH locked_appeals AS ( - SELECT t.hash, t.to_address, t.created_at + SELECT t.hash, t.to_address, t.queue_order FROM transactions t WHERE t.appealed = true AND t.status IN ('ACCEPTED', 'UNDETERMINED', 'LEADER_TIMEOUT', 'VALIDATORS_TIMEOUT') @@ -459,13 +459,13 @@ async def claim_next_appeal(self, session: Session) -> Optional[dict]: AND t2.hash != t.hash ) AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) - ORDER BY t.created_at ASC + ORDER BY t.queue_order ASC FOR UPDATE SKIP LOCKED ), available_appeals AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY to_address - ORDER BY created_at ASC + ORDER BY queue_order ASC ) as rn FROM locked_appeals ), @@ -474,7 +474,7 @@ async def claim_next_appeal(self, session: Session) -> Optional[dict]: SELECT * FROM available_appeals WHERE rn = 1 - ORDER BY created_at ASC + ORDER BY queue_order ASC LIMIT 1 ) UPDATE transactions @@ -518,7 +518,7 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: query = text( f""" WITH candidate_transactions AS ( - SELECT t.hash, t.to_address, t.type, t.created_at, t.recovery_count + SELECT t.hash, t.to_address, t.type, t.queue_order, t.recovery_count FROM transactions t WHERE t.status IN ('PENDING', 'ACTIVATED') AND (t.blocked_at IS NULL @@ -546,7 +546,7 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: AND NOT EXISTS ( SELECT 1 FROM transactions t3 WHERE t3.to_address IS NOT DISTINCT FROM t.to_address - AND t3.created_at < t.created_at + AND t3.queue_order < t.queue_order AND t3.status IN ('ACCEPTED', 'UNDETERMINED', 'LEADER_TIMEOUT', 'VALIDATORS_TIMEOUT') AND t3.appealed = false AND ( @@ -582,14 +582,15 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: -- COALESCE handles NULL to_address (e.g. burn transactions) by -- falling back to the tx hash, giving each such tx its own lock. AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) - ORDER BY CASE WHEN t.type = 3 THEN 0 ELSE 1 END, t.created_at ASC + ORDER BY CASE WHEN t.type = 3 THEN 0 ELSE 1 END, + t.queue_order ASC FOR UPDATE SKIP LOCKED ), oldest_per_contract AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY to_address ORDER BY CASE WHEN type = 3 THEN 0 ELSE 1 END, - created_at ASC + queue_order ASC ) as rn FROM candidate_transactions ), @@ -604,8 +605,7 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: WHERE rn = 1 ORDER BY CASE WHEN recovery_count > 0 THEN 1 ELSE 0 END, CASE WHEN type = 3 THEN 0 ELSE 1 END, - created_at ASC, - hash ASC + queue_order ASC LIMIT 1 ) UPDATE transactions diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index fe33086e8..2cd287d42 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -33,16 +33,21 @@ def _parse_account_data(self, account_data: CurrentState) -> dict: "updated_at": account_data.updated_at.isoformat(), } - def create_new_account(self) -> Account: + def create_new_account(self, *, commit: bool = True) -> Account: """ Used when generating intelligent contract's accounts or sending funds to a new account. Users should create their accounts client-side """ account = Account.create() - self.create_new_account_with_address(account.address) + self.create_new_account_with_address(account.address, commit=commit) return account - def create_new_account_with_address(self, address: str) -> Account: + def create_new_account_with_address( + self, + address: str, + *, + commit: bool = True, + ) -> Account: # Check if account already exists if not is_address(address): raise ValueError(f"Invalid address: {address}") @@ -61,7 +66,10 @@ def create_new_account_with_address(self, address: str) -> Account: # If account doesn't exist, create it account = CurrentState(id=address, data={}, balance=0) self.session.add(account) - self.session.commit() + if commit: + self.session.commit() + else: + self.session.flush() return account def is_valid_address(self, address: str) -> bool: diff --git a/backend/database_handler/chain_snapshot.py b/backend/database_handler/chain_snapshot.py index 6afb8a6dc..8acc55daa 100644 --- a/backend/database_handler/chain_snapshot.py +++ b/backend/database_handler/chain_snapshot.py @@ -32,7 +32,7 @@ def _load_pending_transactions(self) -> List[dict]: Transactions.hash ) ) - .order_by(Transactions.created_at) + .order_by(Transactions.queue_order) .all() ) return [ @@ -61,7 +61,7 @@ def _load_awaiting_finalization_transactions(self) -> dict[str, List[dict]]: Transactions.hash ) ) - .order_by(Transactions.created_at) + .order_by(Transactions.queue_order) .all() ) diff --git a/backend/database_handler/migration/versions/e4f6a8c0d2b4_add_evm_envelope_ledger.py b/backend/database_handler/migration/versions/e4f6a8c0d2b4_add_evm_envelope_ledger.py new file mode 100644 index 000000000..dd853354a --- /dev/null +++ b/backend/database_handler/migration/versions/e4f6a8c0d2b4_add_evm_envelope_ledger.py @@ -0,0 +1,71 @@ +"""add durable EVM envelope nonce and replay ledger + +Revision ID: e4f6a8c0d2b4 +Revises: d2e3f4a5b6c7 +Create Date: 2026-08-28 12:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "e4f6a8c0d2b4" +down_revision: Union[str, None] = "d2e3f4a5b6c7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "evm_envelopes", + sa.Column("hash", sa.String(length=66), nullable=False), + sa.Column("from_address", sa.String(length=255), nullable=False), + sa.Column("nonce", sa.Numeric(precision=78, scale=0), nullable=False), + sa.Column("result", sa.String(length=66), nullable=False), + sa.Column("to_address", sa.String(length=255), nullable=True), + sa.Column( + "success", + sa.Boolean(), + server_default=sa.text("true"), + nullable=False, + ), + sa.Column("error", sa.String(length=1024), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + sa.CheckConstraint("nonce >= 0", name="evm_envelopes_nonce_unsigned"), + sa.PrimaryKeyConstraint("hash", name="evm_envelopes_pkey"), + sa.UniqueConstraint( + "from_address", + "nonce", + name="evm_envelopes_from_address_nonce_key", + ), + ) + + # Existing top-level transaction rows were admitted through signed EVM + # envelopes. Preserve the highest observable nonce history while resolving + # old Studio duplicates deterministically (latest row wins per nonce). + op.execute( + """ + INSERT INTO evm_envelopes + (hash, from_address, nonce, result, success, created_at) + SELECT DISTINCT ON (lower(from_address), nonce) + hash, lower(from_address), nonce, hash, true, created_at + FROM transactions + WHERE triggered_by_hash IS NULL + AND from_address IS NOT NULL + AND nonce IS NOT NULL + AND nonce >= 0 + ORDER BY lower(from_address), nonce, created_at DESC, hash DESC + ON CONFLICT DO NOTHING + """ + ) + + +def downgrade() -> None: + op.drop_table("evm_envelopes") diff --git a/backend/database_handler/migration/versions/f5a7c9e1b3d5_add_transaction_queue_order.py b/backend/database_handler/migration/versions/f5a7c9e1b3d5_add_transaction_queue_order.py new file mode 100644 index 000000000..06bdaf22f --- /dev/null +++ b/backend/database_handler/migration/versions/f5a7c9e1b3d5_add_transaction_queue_order.py @@ -0,0 +1,75 @@ +"""add durable transaction queue issuance order + +Revision ID: f5a7c9e1b3d5 +Revises: e4f6a8c0d2b4 +Create Date: 2026-08-28 15:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "f5a7c9e1b3d5" +down_revision: Union[str, None] = "e4f6a8c0d2b4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("CREATE SEQUENCE transactions_queue_order_seq") + op.add_column( + "transactions", + sa.Column("queue_order", sa.BigInteger(), nullable=True), + ) + # Preserve the best order available for historical rows. New rows receive + # their sequence value at INSERT, after the recipient admission lock. + op.execute( + """ + WITH ordered AS ( + SELECT hash, + ROW_NUMBER() OVER ( + ORDER BY created_at ASC, + nonce ASC NULLS LAST, + hash ASC + ) AS queue_order + FROM transactions + ) + UPDATE transactions AS t + SET queue_order = ordered.queue_order + FROM ordered + WHERE t.hash = ordered.hash + """ + ) + op.execute( + """ + SELECT setval( + 'transactions_queue_order_seq', + COALESCE((SELECT MAX(queue_order) + 1 FROM transactions), 1), + false + ) + """ + ) + op.alter_column( + "transactions", + "queue_order", + existing_type=sa.BigInteger(), + nullable=False, + server_default=sa.text("nextval('transactions_queue_order_seq'::regclass)"), + ) + op.create_index( + "ix_transactions_to_address_queue_order", + "transactions", + ["to_address", "queue_order"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_transactions_to_address_queue_order", + table_name="transactions", + ) + op.drop_column("transactions", "queue_order") + op.execute("DROP SEQUENCE transactions_queue_order_seq") diff --git a/backend/database_handler/models.py b/backend/database_handler/models.py index a3ccf52ed..73680461f 100644 --- a/backend/database_handler/models.py +++ b/backend/database_handler/models.py @@ -90,6 +90,40 @@ class CurrentState(Base): ) +transactions_queue_order_seq = Sequence("transactions_queue_order_seq") + + +class EvmEnvelope(Base): + """Mined signed EVM envelopes, including execution reverts. + + Consensus relies on the execution-chain account nonce and transaction hash + for replay protection. Studio records the same boundary explicitly because + lifecycle calls do not create GenLayer ``transactions`` rows. + """ + + __tablename__ = "evm_envelopes" + __table_args__ = ( + PrimaryKeyConstraint("hash", name="evm_envelopes_pkey"), + UniqueConstraint( + "from_address", + "nonce", + name="evm_envelopes_from_address_nonce_key", + ), + CheckConstraint("nonce >= 0", name="evm_envelopes_nonce_unsigned"), + ) + + hash: Mapped[str] = mapped_column(String(66), primary_key=True) + from_address: Mapped[str] = mapped_column(String(255), nullable=False) + nonce: Mapped[int] = mapped_column(IntNumeric(), nullable=False) + result: Mapped[str] = mapped_column(String(66), nullable=False) + to_address: Mapped[Optional[str]] = mapped_column(String(255), default=None) + success: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + error: Mapped[Optional[str]] = mapped_column(String(1024), default=None) + created_at: Mapped[Optional[datetime.datetime]] = mapped_column( + DateTime(True), server_default=func.current_timestamp(), init=False + ) + + class Transactions(Base): __tablename__ = "transactions" __table_args__ = ( @@ -121,6 +155,16 @@ class Transactions(Base): created_at: Mapped[Optional[datetime.datetime]] = mapped_column( DateTime(True), server_default=func.current_timestamp(), init=False ) + # Durable issuance order. Consensus queues are slot-ordered; timestamps are + # only metadata and PostgreSQL CURRENT_TIMESTAMP is fixed at transaction + # start, so it cannot safely order concurrent admissions. + queue_order: Mapped[int] = mapped_column( + BigInteger, + transactions_queue_order_seq, + server_default=transactions_queue_order_seq.next_value(), + nullable=False, + init=False, + ) leader_only: Mapped[bool] = mapped_column(Boolean) r: Mapped[Optional[int]] = mapped_column(Integer) s: Mapped[Optional[int]] = mapped_column(Integer) diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index da131c5e9..55af5e690 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -5,17 +5,17 @@ import re import random from sqlalchemy.orm import Session, defer, selectinload -from sqlalchemy import or_, desc, and_, JSON, type_coerce, text +from sqlalchemy import or_, desc, and_, func, JSON, type_coerce, text from backend.node.types import Receipt, ExecutionResultStatus -from .models import Transactions, TransactionStatus +from .models import EvmEnvelope, Transactions, TransactionStatus from eth_utils import to_bytes, keccak, is_address, to_checksum_address import json import base64 import time import os import copy -from typing import Callable +from typing import Callable, Iterable from backend.domain.types import TransactionType from web3 import Web3 from backend.consensus.types import ( @@ -48,6 +48,17 @@ MAX_JSON_SAFE_INTEGER = (2**53) - 1 + +def _transaction_order_tuple(model=Transactions): + """Durable Studio queue-slot authority.""" + + return model.queue_order + + +def _transaction_order_value(transaction: Transactions): + return int(transaction.queue_order) + + # Canonical v0.6 ITransactions.TransactionStatus ordinals (0-13). Studio-only # states map to their on-chain equivalents (ACTIVATED -> Proposing: activation # transitions the on-chain tx into Proposing). @@ -233,6 +244,11 @@ def _parse_transaction_data( "triggered_transactions": [ transaction.hash for transaction in transaction_data.triggered_transactions + # Consensus exposes only child consensus transactions through + # getTriggeredTransactionIds. Studio keeps external SEND rows + # as local execution effects, but they are not protocol child + # transactions and must not leak into that shared surface. + if transaction.type != TransactionType.SEND.value ], "appealed": transaction_data.appealed, "timestamp_awaiting_finalization": transaction_data.timestamp_awaiting_finalization, @@ -614,10 +630,11 @@ def insert_transaction( triggered_on: str | None = None, # "accepted" or "finalized" execution_mode: str = "NORMAL", # "NORMAL", "LEADER_ONLY", or "LEADER_SELF_VALIDATOR" origin_address: str | None = None, + commit: bool = True, ) -> str: if transaction_hash is None: - current_nonce = self.get_transaction_count(from_address) + current_nonce = self.get_genlayer_transaction_count(from_address) transaction_hash = self._generate_transaction_hash( from_address, to_address, data, value, type, current_nonce ) @@ -676,7 +693,8 @@ def insert_transaction( self.session.add(new_transaction) self.session.flush() # So that `created_at` gets set - self.session.commit() # Persist the transaction to the database + if commit: + self.session.commit() # Persist the transaction to the database return transaction_hash @@ -1388,6 +1406,8 @@ def mutate_transaction_fee_accounting( self, transaction_hash: str, mutator: Callable[[dict], dict], + *, + commit: bool = True, ) -> dict: """Mutate the latest fee accounting while holding the transaction row. @@ -1420,7 +1440,8 @@ def mutate_transaction_fee_accounting( ), {"hash": transaction_hash, "data": json.dumps(data)}, ) - self.session.commit() + if commit: + self.session.commit() return updated def apply_transaction_fee_top_up( @@ -1492,14 +1513,201 @@ def get_transaction_count(self, address: str) -> int: except: checksum_address = address - # Always use database count as source of truth - # Our transactions are stored in PostgreSQL, not on Hardhat blockchain - count = ( + if address is not None: + ledger_address = str(checksum_address).lower() + highest_nonce = ( + self.session.query(func.max(EvmEnvelope.nonce)) + .filter(EvmEnvelope.from_address == ledger_address) + .scalar() + ) + return 0 if highest_nonce is None else int(highest_nonce) + 1 + + # Studio-only system transactions (funding) are not signed EVM + # envelopes and retain their historical database-count nonce. + return int( self.session.query(Transactions) - .filter(Transactions.from_address == checksum_address) + .filter(Transactions.from_address.is_(None)) .count() ) - return count + + def get_genlayer_transaction_count(self, address: str | None) -> int: + """Count Studio transaction rows authored by one logical sender. + + This is deliberately separate from ``eth_getTransactionCount``. Ghost + contracts do not submit signed EVM envelopes when MessagePayments + creates children, but Studio still needs a stable monotonically + increasing child metadata nonce and fallback-hash input. + """ + + if address is None: + return int( + self.session.query(Transactions) + .filter(Transactions.from_address.is_(None)) + .count() + ) + try: + address = self.web3.to_checksum_address(address) + except Exception: + pass + return int( + self.session.query(Transactions) + .filter(Transactions.from_address == address) + .count() + ) + + def begin_evm_envelope( + self, + transaction_hash: str, + from_address: str, + nonce: int, + ) -> str | None: + """Lock one sender and validate exact EVM nonce/replay semantics. + + Returns the prior successful response for an identical raw envelope. + The caller records a new success only after all protocol mutations have + completed, in the same DB transaction. + """ + + from_address = to_checksum_address(from_address).lower() + nonce = int(nonce) + if nonce < 0: + raise ValueError("InvalidNonce") + + bind = self.session.get_bind() + if bind.dialect.name == "postgresql": + self.session.execute( + text( + "SELECT pg_advisory_xact_lock(" "hashtextextended(:sender_key, 0))" + ), + {"sender_key": f"evm-sender:{from_address.lower()}"}, + ) + + existing = self.session.get(EvmEnvelope, transaction_hash) + if existing is not None: + return existing.result + + expected = self.get_transaction_count(from_address) + if nonce < expected: + raise ValueError(f"NonceTooLow(expected={expected},actual={nonce})") + if nonce > expected: + raise ValueError(f"NonceTooHigh(expected={expected},actual={nonce})") + return None + + def record_evm_envelope( + self, + transaction_hash: str, + from_address: str, + nonce: int, + result: str, + *, + to_address: str | None = None, + success: bool = True, + error: str | None = None, + ) -> None: + self.session.add( + EvmEnvelope( + hash=transaction_hash, + from_address=to_checksum_address(from_address).lower(), + nonce=int(nonce), + result=result, + to_address=(str(to_address).lower() if to_address else None), + success=bool(success), + error=(str(error)[:1024] if error else None), + ) + ) + self.session.flush() + + def get_evm_envelope(self, transaction_hash: str) -> EvmEnvelope | None: + return self.session.get(EvmEnvelope, transaction_hash) + + def lock_transaction_admission(self, transaction_hash: str) -> None: + """Serialize one raw transaction's admission across RPC workers. + + The lock is held by the request transaction until insert_transaction + commits. This keeps duplicate submissions from both authoring shadow + helper state before either worker can observe the canonical DB row. + """ + + bind = self.session.get_bind() + if bind.dialect.name != "postgresql": + return + self.session.execute( + text( + "SELECT pg_advisory_xact_lock(" + "hashtextextended(:transaction_hash, 0))" + ), + {"transaction_hash": transaction_hash}, + ) + + def lock_ghost_factory(self) -> None: + """Serialize Studio's virtual GhostFactory for this DB transaction.""" + + bind = self.session.get_bind() + if bind.dialect.name != "postgresql": + return + self.session.execute( + text( + "SELECT pg_advisory_xact_lock(" + "hashtextextended('studio-v0.6-ghost-factory', 0))" + ) + ) + + def lock_pending_recipients(self, addresses: Iterable[str]) -> None: + """Serialize child admission for every touched recipient queue. + + One Consensus EVM transaction processes a message batch serially. A + Studio worker can process unrelated parents concurrently, so acquire + the same recipient-scoped locks used by top-level RPC admission before + reading queue depth. Sorting the complete set keeps overlapping + multi-recipient batches deadlock-free. + """ + + bind = self.session.get_bind() + if bind.dialect.name != "postgresql": + return + for address in sorted( + { + str(address).lower() + for address in addresses + if isinstance(address, str) and address + } + ): + self.session.execute( + text("SELECT pg_advisory_xact_lock(" "hashtextextended(:lock_key, 0))"), + {"lock_key": f"pending-recipient:{address}"}, + ) + + def get_successful_ghost_creation_count(self) -> int: + """Return the virtual factory nonce offset. + + GhostFactory creates and registers the proxy during transaction + admission, before GenVM executes. A later failed or canceled deploy + therefore still consumed one successful CREATE/CREATE2 and remains a + registered ghost, so every admitted deployment row is counted. + """ + + return int( + self.session.query(Transactions) + .filter(Transactions.type == TransactionType.DEPLOY_CONTRACT.value) + .count() + ) + + def is_genvm_contract_address(self, address: str) -> bool: + """Whether Consensus would have registered ``address`` as a ghost.""" + + try: + address = to_checksum_address(address) + except (TypeError, ValueError): + return False + return ( + self.session.query(Transactions.hash) + .filter( + Transactions.type == TransactionType.DEPLOY_CONTRACT.value, + Transactions.to_address == address, + ) + .first() + is not None + ) def get_transactions_for_address( self, @@ -1526,7 +1734,11 @@ def get_transactions_for_address( ) ) - transactions = query.order_by(Transactions.created_at.desc()).all() + transactions = query.order_by( + Transactions.created_at.desc(), + Transactions.nonce.desc().nullslast(), + Transactions.hash.desc(), + ).all() return [ self._parse_transaction_data(transaction) for transaction in transactions @@ -1849,10 +2061,10 @@ def get_newer_transactions(self, transaction_hash: str): self.session.query(Transactions) .options(selectinload(Transactions.triggered_transactions)) .filter( - Transactions.created_at > transaction.created_at, + _transaction_order_tuple() > _transaction_order_value(transaction), Transactions.to_address == transaction.to_address, ) - .order_by(Transactions.created_at) + .order_by(Transactions.queue_order) .all() ) return [ @@ -2012,7 +2224,10 @@ def transactions_in_process_by_contract(self) -> list[dict]: ), ) .distinct(Transactions.to_address) - .order_by(Transactions.to_address, Transactions.created_at.asc()) + .order_by( + Transactions.to_address, + Transactions.queue_order.asc(), + ) .all() ) @@ -2036,7 +2251,7 @@ def get_previous_transaction( return None filters = [ - Transactions.created_at < transaction.created_at, + _transaction_order_tuple() < _transaction_order_value(transaction), Transactions.to_address == transaction.to_address, ] if status is not None: @@ -2067,7 +2282,7 @@ def get_previous_transaction( closest_transaction = ( self.session.query(Transactions) .filter(*filters) - .order_by(desc(Transactions.created_at)) + .order_by(Transactions.queue_order.desc()) .first() ) @@ -2154,7 +2369,10 @@ def set_transaction_appeal_validators_timeout( def get_pending_transaction_count_for_address(self, address: str) -> int: """ - Get the count of pending transactions for a given recipient address. + Get Consensus pending-queue membership for a recipient. + + The activated head remains in Queues.pending until it reaches a + decision, so all pre-decision processing states consume one slot. Args: address: The recipient address to count pending transactions for @@ -2164,8 +2382,8 @@ def get_pending_transaction_count_for_address(self, address: str) -> int: """ try: # Normalize address to checksum format - checksum_address = self.web3.to_checksum_address(address) - except ValueError: + checksum_address = to_checksum_address(address) + except (TypeError, ValueError): # If address normalization fails, use as-is checksum_address = address @@ -2173,7 +2391,15 @@ def get_pending_transaction_count_for_address(self, address: str) -> int: self.session.query(Transactions) .filter( Transactions.to_address == checksum_address, - Transactions.status == TransactionStatus.PENDING, + Transactions.status.in_( + [ + TransactionStatus.PENDING, + TransactionStatus.ACTIVATED, + TransactionStatus.PROPOSING, + TransactionStatus.COMMITTING, + TransactionStatus.REVEALING, + ] + ), ) .count() ) @@ -2204,7 +2430,7 @@ def is_transaction_finalization_head(self, transaction_hash: str) -> bool: not self.session.query(Transactions.hash) .filter( Transactions.to_address == transaction.to_address, - Transactions.created_at < transaction.created_at, + _transaction_order_tuple() < _transaction_order_value(transaction), Transactions.hash != transaction.hash, Transactions.status.notin_( [TransactionStatus.FINALIZED, TransactionStatus.CANCELED] @@ -2267,7 +2493,7 @@ def get_oldest_pending_for_contract(self, contract_address: str) -> dict | None: Transactions.to_address == contract_address, Transactions.status == TransactionStatus.PENDING, ) - .order_by(Transactions.created_at) + .order_by(Transactions.queue_order) .first() ) diff --git a/backend/node/genvm/base.py b/backend/node/genvm/base.py index 7282e7d2e..0b53d683a 100644 --- a/backend/node/genvm/base.py +++ b/backend/node/genvm/base.py @@ -364,12 +364,12 @@ def _emission_external_fee_params(emission: dict) -> bytes: return _bytes_from_emission_value(value) -def _emission_allocation_subtree(emission: dict) -> list[dict]: +def _emission_allocation_subtree(emission: dict) -> list[dict] | str: value = _emission_value(emission, "allocationSubtree") if isinstance(value, list): return value - subtree = _emission_value(emission, "subtree") + subtree = value if value is not None else _emission_value(emission, "subtree") if subtree is None: return [] @@ -380,7 +380,10 @@ def _emission_allocation_subtree(emission: dict) -> list[dict]: try: decoded = decode([MESSAGE_ALLOCATION_NODE_ABI_TYPE], raw)[0] except Exception: - return [] + # Consensus hashes the raw SubmittedMessage field even when later + # inheritance decoding is contained as a restricted child. Preserve + # those bytes instead of aliasing every malformed payload to empty. + return "0x" + raw.hex() allocation_subtree = [] for node in decoded: diff --git a/backend/node/types.py b/backend/node/types.py index 3e0e6cdc3..93936096a 100644 --- a/backend/node/types.py +++ b/backend/node/types.py @@ -179,7 +179,10 @@ class PendingTransaction: fee_params: bytes = b"" declared_budget: int = 0 call_key: str = "0x" + ("0" * 64) - allocation_subtree: list[dict] = field(default_factory=list) + # Decoded FlatArrays/HashCommitments nodes when well formed; otherwise the + # exact receipt bytes as a 0x string so descriptor hashing does not erase a + # malformed or Merkle-proof payload. + allocation_subtree: list[dict] | str = field(default_factory=list) gas_used: int = 0 use_balance: bool = False diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 12cdbbf9e..6bcc0b8c5 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -8,7 +8,7 @@ import logging from contextlib import asynccontextmanager from functools import partial, wraps -from typing import Any, Final, get_args +from typing import Any, Final, NoReturn, get_args from backend.protocol_rpc.exceptions import ( JSONRPCError, NotFoundError, @@ -67,6 +67,7 @@ studio_fee_config, validate_transaction_fee_deposit, ) +from backend.protocol_rpc.ghost_factory import GhostFactoryConfig from backend.consensus.history import ( actual_leader_rotations_by_round, completed_consensus_round_index, @@ -100,6 +101,7 @@ from backend.protocol_rpc.message_handler.types import LogEvent, EventType, EventScope from backend.protocol_rpc.types import ( DecodedRollupTransaction, + DecodedRollupTransactionData, DecodedTopUpFeesDataArgs, DecodedFinalizeTransactionDataArgs, DecodedsubmitAppealDataArgs, @@ -115,6 +117,27 @@ _GENVM_CONCURRENCY = int(os.environ.get("GENVM_MAX_CONCURRENT", "8")) _genvm_admission_semaphore = asyncio.Semaphore(_GENVM_CONCURRENCY) + +class _EvmExecutionReverted(Exception): + """A valid signed protocol envelope was mined but its call reverted.""" + + def __init__( + self, + *, + transaction_hash: str, + from_address: str, + to_address: str | None, + nonce: int, + reason: Exception, + ) -> None: + super().__init__(str(reason)) + self.transaction_hash = transaction_hash + self.from_address = from_address + self.to_address = to_address + self.nonce = int(nonce) + self.reason = reason + + # --------------------------------------------------------------------------- # Per-address rate limiting for gen_call / sim_call # Prevents a single contract from monopolising all GenVM execution slots. @@ -277,14 +300,10 @@ async def _run_singleflight_gen_call( # --------------------------------------------------------------------------- # Admission control on PENDING queue depth (eth_sendRawTransaction path). # -# Studio Prod is a shared sandbox. Without this, a single user can submit -# thousands of txs to one contract and starve everyone else's contracts -# behind a 5-day backlog. We cap PENDING per (to_address) and per -# (from_address) at submission time and return a structured error pointing -# users at non-shared deployments for production-volume workloads. -# -# Both caps default to unset (unlimited) — meaningful only when explicitly -# configured (the public studio deployment sets them; self-hosted does not). +# Consensus Queues initializes maxPendingTxsPerRecipient to 20. Studio uses the +# same per-recipient default; deployments may lower/raise it explicitly when +# they apply the matching governance setting on Consensus. The Studio-only +# sender cap remains optional. # --------------------------------------------------------------------------- def _parse_optional_positive_int(env_name: str) -> int | None: raw = os.environ.get(env_name) @@ -297,18 +316,14 @@ def _parse_optional_positive_int(env_name: str) -> int | None: return parsed if parsed > 0 else None -_MAX_PENDING_PER_CONTRACT = _parse_optional_positive_int( - "MAX_PENDING_PER_CONTRACT_DEFAULT" +_MAX_PENDING_PER_CONTRACT = ( + _parse_optional_positive_int("MAX_PENDING_PER_CONTRACT_DEFAULT") or 20 ) _MAX_PENDING_PER_SENDER = _parse_optional_positive_int("MAX_PENDING_PER_SENDER_DEFAULT") # Generic guidance text in the error response — directs heavy users to # non-shared deployments rather than retrying against the same instance. -_QUEUE_DEPTH_HELP = ( - "The public Studio is a shared sandbox with per-contract and " - "per-sender PENDING transaction caps. For production-volume " - "workloads, run a self-hosted instance." -) +_QUEUE_DEPTH_HELP = "The protocol limits each recipient's pending queue depth." def _enforce_pending_queue_caps( @@ -318,17 +333,29 @@ def _enforce_pending_queue_caps( ) -> None: """Raise QueueDepthExceeded if the per-contract or per-sender cap is hit. - Both checks are best-effort and racy by design — two concurrent - submissions can both pass the COUNT(*) check before either commits, - so the actual depth can transiently exceed the cap by a few. That's - fine; the goal is to prevent unbounded growth, not to enforce an - exact bound. + Postgres admissions take deterministic advisory locks before counting so + two concurrent submissions cannot both claim the final queue slot. This + mirrors the serialized Queues insertion performed by Consensus. """ + session = transactions_processor.session + bind = session.get_bind() + if bind.dialect.name == "postgresql": + lock_keys = [] + if _MAX_PENDING_PER_CONTRACT is not None and to_address is not None: + lock_keys.append(f"pending-recipient:{str(to_address).lower()}") + if _MAX_PENDING_PER_SENDER is not None and from_address is not None: + lock_keys.append(f"pending-sender:{str(from_address).lower()}") + for lock_key in sorted(lock_keys): + session.execute( + text("SELECT pg_advisory_xact_lock(" "hashtextextended(:lock_key, 0))"), + {"lock_key": lock_key}, + ) if _MAX_PENDING_PER_CONTRACT is not None and to_address is not None: - contract_pending = transactions_processor.session.execute( + contract_pending = session.execute( text( "SELECT COUNT(*) FROM transactions " - "WHERE to_address = :addr AND status = 'PENDING'" + "WHERE to_address = :addr AND status IN " + "('PENDING', 'ACTIVATED', 'PROPOSING', 'COMMITTING', 'REVEALING')" ), {"addr": to_address}, ).scalar() @@ -351,7 +378,7 @@ def _enforce_pending_queue_caps( ) if _MAX_PENDING_PER_SENDER is not None and from_address is not None: - sender_pending = transactions_processor.session.execute( + sender_pending = session.execute( text( "SELECT COUNT(*) FROM transactions " "WHERE from_address = :addr AND status = 'PENDING'" @@ -374,6 +401,22 @@ def _enforce_pending_queue_caps( ) +def _allocate_top_level_ghost_address( + transactions_processor: TransactionsProcessor, + salt_nonce: int, +) -> str: + """Reserve the next virtual GhostFactory address until admission commits.""" + + transactions_processor.lock_ghost_factory() + deployment_count = transactions_processor.get_successful_ghost_creation_count() + address = GhostFactoryConfig.from_env().address_for(salt_nonce, deployment_count) + if transactions_processor.is_genvm_contract_address(address): + # GhostFactory rejects a reused CREATE2 address and the enclosing + # Consensus submission reverts. The factory nonce is unchanged. + raise InvalidTransactionError("GhostAlreadyDeployed") + return address + + def get_studio_fee_config() -> dict[str, Any]: return studio_fee_config(StudioFeePolicy.from_env()) @@ -2356,7 +2399,15 @@ def _validate_fee_envelope( return args = decoded_rollup_transaction.data.args + policy = StudioFeePolicy.from_env() + if ( + decoded_rollup_transaction.data.function_name == "deploySalted" + and int(args.salt_nonce or 0) == 0 + ): + raise InvalidTransactionError("InvalidDeploymentWithSalt") if args.fees_distribution is None: + if policy.fee_accounting_enabled(): + raise InvalidTransactionError("FeesDistributionMissing") return try: @@ -2366,7 +2417,7 @@ def _validate_fee_envelope( num_of_validators=args.num_of_initial_validators, submitted_value=decoded_rollup_transaction.total_spend, user_value=int(args.user_value or 0), - policy=StudioFeePolicy.from_env(), + policy=policy, ) except FeeValidationError as exc: raise InvalidTransactionError(str(exc)) from exc @@ -2405,8 +2456,6 @@ def _handle_top_up_fees( decoded_rollup_transaction.from_address, decoded_rollup_transaction.total_spend, ) - if session is not None: - session.commit() except FeeValidationError as exc: if session is not None: session.rollback() @@ -2432,6 +2481,7 @@ def _handle_appeal_or_top_up_and_submit( transactions_processor: TransactionsProcessor, msg_handler: IMessageHandler, decoded_rollup_transaction: DecodedRollupTransaction, + emit_event: bool = True, ) -> str: assert isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs) tx_id = _tx_id_to_hex(decoded_rollup_transaction.data.tx_id) @@ -2582,8 +2632,6 @@ def prepare_fee_accounting(current_fee_accounting): decoded_rollup_transaction.from_address, surplus_refund, ) - if session is not None: - session.commit() except FeeValidationError as exc: if session is not None: session.rollback() @@ -2596,21 +2644,24 @@ def prepare_fee_accounting(current_fee_accounting): if session is not None: session.rollback() raise - msg_handler.send_message( - log_event=LogEvent( - "transaction_appeal_updated", - EventType.INFO, - EventScope.CONSENSUS, - "Set transaction appealed", - { - "hash": tx_id, - }, - ), - log_to_terminal=False, - ) + if emit_event: + msg_handler.send_message( + log_event=_transaction_appeal_updated_event(tx_id), + log_to_terminal=False, + ) return tx_id +def _transaction_appeal_updated_event(tx_id: str) -> LogEvent: + return LogEvent( + "transaction_appeal_updated", + EventType.INFO, + EventScope.CONSENSUS, + "Set transaction appealed", + {"hash": tx_id}, + ) + + def _handle_finalize_transaction( *, transactions_processor: TransactionsProcessor, @@ -2820,6 +2871,63 @@ def send_raw_transaction( consensus_service: ConsensusService, signed_rollup_transaction: str, sim_config: dict | None = None, +) -> str: + """Admit one signed envelope atomically with its protocol mutation.""" + + try: + return _send_raw_transaction_impl( + session, + msg_handler, + transactions_parser, + consensus_service, + signed_rollup_transaction, + sim_config, + ) + except _EvmExecutionReverted as reverted: + # Solidity execution reverts roll back the protocol mutation, but the + # execution-chain envelope is still mined and consumes the account + # nonce. Start a fresh DB transaction after the handler rollback and + # persist the status-0 receipt boundary. + rollback = getattr(session, "rollback", None) + if callable(rollback): + rollback() + transactions_processor = TransactionsProcessor(session) + try: + prior_result = transactions_processor.begin_evm_envelope( + reverted.transaction_hash, + reverted.from_address, + reverted.nonce, + ) + except ValueError as exc: + raise InvalidTransactionError(str(exc)) from exc + if prior_result is None: + transactions_processor.record_evm_envelope( + reverted.transaction_hash, + reverted.from_address, + reverted.nonce, + reverted.transaction_hash, + to_address=reverted.to_address, + success=False, + error=str(reverted.reason), + ) + commit = getattr(session, "commit", None) + if callable(commit): + commit() + return reverted.transaction_hash + except Exception: + rollback = getattr(session, "rollback", None) + if callable(rollback): + rollback() + raise + + +def _send_raw_transaction_impl( + session: Session, + msg_handler: IMessageHandler, + transactions_parser: TransactionParser, + consensus_service: ConsensusService, + signed_rollup_transaction: str, + sim_config: dict | None = None, ) -> str: """Persist a raw transaction using a request-scoped session.""" _validate_genvm_executor_selector(sim_config) @@ -2837,6 +2945,60 @@ def send_raw_transaction( if decoded_rollup_transaction is None: raise InvalidTransactionError("Invalid transaction data") + transaction_chain_id = getattr(decoded_rollup_transaction, "chain_id", None) + if ( + transaction_chain_id is not None + and int(transaction_chain_id) != get_simulator_chain_id() + ): + raise InvalidTransactionError("InvalidChainId") + + protocol_data_types = ( + DecodedRollupTransactionData, + DecodedsubmitAppealDataArgs, + DecodedTopUpFeesDataArgs, + DecodedFinalizeTransactionDataArgs, + ) + lifecycle_data_types = ( + DecodedsubmitAppealDataArgs, + DecodedTopUpFeesDataArgs, + DecodedFinalizeTransactionDataArgs, + ) + decoded_protocol_call = isinstance( + getattr(decoded_rollup_transaction, "data", None), protocol_data_types + ) + decoded_lifecycle_call = isinstance( + getattr(decoded_rollup_transaction, "data", None), lifecycle_data_types + ) + consensus_address = consensus_service.public_consensus_main_address() + envelope_destination = getattr(decoded_rollup_transaction, "to_address", None) + unknown_consensus_selector = False + if decoded_protocol_call: + # Calldata only has protocol meaning when the EVM envelope targets + # ConsensusMain. Previously Studio decoded the selector regardless of + # ``to``, so the same signed transaction that was a harmless transfer + # (or a revert) on Consensus could create/finalize/appeal in Studio. + if ( + not consensus_address + or not envelope_destination + or str(envelope_destination).lower() != str(consensus_address).lower() + ): + raise InvalidTransactionError("InvalidConsensusDestination") + elif ( + envelope_destination + and consensus_address + and str(envelope_destination).lower() == str(consensus_address).lower() + and getattr(decoded_rollup_transaction, "raw_data", None) + ): + # ConsensusMain has no catch-all protocol entry point. Do not reinterpret + # an unknown selector as a plain Studio value transfer. It remains a + # valid signed EVM envelope, though, so classify the failure after nonce + # admission as a mined execution revert. + unknown_consensus_selector = True + elif envelope_destination is None: + # Raw EVM bytecode deployment is outside Studio's transaction model; + # intelligent-contract deployment must use deploySalted/addTransaction. + raise InvalidTransactionError("UnsupportedEvmDeployment") + from_address = decoded_rollup_transaction.from_address value = decoded_rollup_transaction.value total_spend = getattr(decoded_rollup_transaction, "total_spend", value) @@ -2846,44 +3008,120 @@ def send_raw_transaction( from_address, f"Invalid address from_address: {from_address}" ) - # Ensure sender account exists - if accounts_manager.get_account(from_address) is None: - accounts_manager.create_new_account_with_address(from_address) - transaction_signature_valid = transactions_parser.transaction_has_valid_signature( signed_rollup_transaction, decoded_rollup_transaction ) if not transaction_signature_valid: raise InvalidTransactionError("Transaction signature verification failed") - if isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs): - _reject_genvm_executor_selector_unless_deploy(sim_config, is_deploy=False) - return _handle_appeal_or_top_up_and_submit( - accounts_manager=accounts_manager, - transactions_processor=transactions_processor, - msg_handler=msg_handler, - decoded_rollup_transaction=decoded_rollup_transaction, - ) - elif isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs): - _reject_genvm_executor_selector_unless_deploy(sim_config, is_deploy=False) - return _handle_top_up_fees( - accounts_manager=accounts_manager, - transactions_processor=transactions_processor, - decoded_rollup_transaction=decoded_rollup_transaction, - ) - elif isinstance( - decoded_rollup_transaction.data, DecodedFinalizeTransactionDataArgs - ): - _reject_genvm_executor_selector_unless_deploy(sim_config, is_deploy=False) - return _handle_finalize_transaction( - transactions_processor=transactions_processor, - decoded_rollup_transaction=decoded_rollup_transaction, + transaction_hash = consensus_service.generate_transaction_hash( + signed_rollup_transaction + ) + try: + prior_result = transactions_processor.begin_evm_envelope( + transaction_hash, + from_address, + decoded_rollup_transaction.nonce, ) + except ValueError as exc: + raise InvalidTransactionError(str(exc)) from exc + if prior_result is not None: + return prior_result + + def execution_revert(reason: Exception) -> NoReturn: + """Classify a post-admission protocol rejection as a mined revert.""" + raise _EvmExecutionReverted( + transaction_hash=transaction_hash, + from_address=from_address, + to_address=envelope_destination, + nonce=decoded_rollup_transaction.nonce, + reason=reason, + ) from reason + + if unknown_consensus_selector: + execution_revert(InvalidTransactionError("UnknownConsensusSelector")) + + # A rejected or invalid signed envelope must not leave a new account row. + if accounts_manager.get_account(from_address) is None: + accounts_manager.create_new_account_with_address(from_address, commit=False) + + def finish_envelope(result: str) -> str: + transactions_processor.record_evm_envelope( + transaction_hash, + from_address, + decoded_rollup_transaction.nonce, + result, + to_address=envelope_destination, + ) + commit = getattr(session, "commit", None) + if callable(commit): + commit() + return result + + if decoded_lifecycle_call: + post_commit_event = None + try: + _reject_genvm_executor_selector_unless_deploy(sim_config, is_deploy=False) + if isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs): + appealed_tx_id = _handle_appeal_or_top_up_and_submit( + accounts_manager=accounts_manager, + transactions_processor=transactions_processor, + msg_handler=msg_handler, + decoded_rollup_transaction=decoded_rollup_transaction, + emit_event=False, + ) + post_commit_event = _transaction_appeal_updated_event(appealed_tx_id) + elif isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs): + _handle_top_up_fees( + accounts_manager=accounts_manager, + transactions_processor=transactions_processor, + decoded_rollup_transaction=decoded_rollup_transaction, + ) + else: + assert isinstance( + decoded_rollup_transaction.data, + DecodedFinalizeTransactionDataArgs, + ) + _handle_finalize_transaction( + transactions_processor=transactions_processor, + decoded_rollup_transaction=decoded_rollup_transaction, + ) + except (InvalidTransactionError, InvalidAddressError, JSONRPCError) as exc: + raise _EvmExecutionReverted( + transaction_hash=transaction_hash, + from_address=from_address, + to_address=envelope_destination, + nonce=decoded_rollup_transaction.nonce, + reason=exc, + ) from exc + result = finish_envelope(transaction_hash) + if post_commit_event is not None: + try: + msg_handler.send_message( + log_event=post_commit_event, + log_to_terminal=False, + ) + except Exception: + # The protocol mutation and EVM envelope are already committed. + # A transient websocket/log transport failure must not turn a + # mined transaction into an RPC error or invite a duplicate. + logger.exception("Failed to publish committed appeal event") + return result else: - _validate_fee_envelope(decoded_rollup_transaction) - transaction_hash = consensus_service.generate_transaction_hash( - signed_rollup_transaction - ) + try: + _validate_fee_envelope(decoded_rollup_transaction) + except InvalidTransactionError as exc: + # The fee tuple, deployment salt, and submitted value are checked + # by ConsensusMain after the signed EVM envelope is admitted. A + # rejection therefore consumes the EVM nonce and has a status-0 + # receipt; it is not an eth_sendRawTransaction preflight error. + execution_revert(exc) + # Raw transaction submission is idempotent. Resolve duplicates before + # reserving a virtual-factory sequence slot or touching balances. + transactions_processor.lock_transaction_admission(transaction_hash) + is_duplicate = transactions_processor.get_transaction_by_hash(transaction_hash) + if is_duplicate is not None: + return finish_envelope(transaction_hash) to_address = decoded_rollup_transaction.to_address nonce = decoded_rollup_transaction.nonce value = decoded_rollup_transaction.value @@ -2891,6 +3129,11 @@ def send_raw_transaction( genlayer_transaction = transactions_parser.get_genlayer_transaction( decoded_rollup_transaction ) + # CreationPhase treats an ordinary direct caller as authoritative and + # ignores a spoofed sender embedded in calldata. Bind Studio's durable + # transaction owner to the recovered signed-envelope sender as well; + # otherwise value debits and later refunds can name different parties. + genlayer_transaction.from_address = from_address genlayer_transaction.max_rotations = _funded_max_rotations( decoded_rollup_transaction, genlayer_transaction.max_rotations, @@ -2900,45 +3143,80 @@ def send_raw_transaction( is_deploy=genlayer_transaction.type == TransactionType.DEPLOY_CONTRACT, ) + # Complete every discoverable admission check before reserving storage, + # a virtual-factory sequence slot, or sender funds. + storage_reservation = None + if genlayer_transaction.type == TransactionType.RUN_CONTRACT: + to_address = genlayer_transaction.to_address + if not accounts_manager.is_valid_address(to_address): + raise InvalidAddressError( + to_address, f"Invalid address to_address: {to_address}" + ) + + if not transactions_processor.is_genvm_contract_address(to_address): + execution_revert(InvalidTransactionError("NonGenVMContract")) + + # Size-only lookup: do not hydrate current_state.data (can be + # tens of MB) just to test existence. + if live_state_column_size(session, to_address) is None: + raise NotFoundError( + message="Contract not found", + data={"address": to_address}, + ) + + try: + _enforce_pending_queue_caps( + transactions_processor=transactions_processor, + to_address=to_address, + from_address=from_address, + ) + except QueueDepthExceeded as exc: + execution_revert(exc) + storage_reservation = enforce_contract_storage_quota( + session, to_address, transaction_hash + ) + elif genlayer_transaction.type == TransactionType.DEPLOY_CONTRACT: + # A successful deployment always creates a fresh recipient (and a + # reused CREATE2 salt is rejected by the virtual factory), so only the + # sender cap can be non-zero before the authoritative address is + # known. + try: + _enforce_pending_queue_caps( + transactions_processor=transactions_processor, + to_address=None, + from_address=from_address, + ) + except QueueDepthExceeded as exc: + execution_revert(exc) + transaction_data = {} leader_only = False execution_mode = "NORMAL" - rollup_transaction_details = None if genlayer_transaction.type != TransactionType.SEND: leader_only = genlayer_transaction.data.leader_only execution_mode = genlayer_transaction.data.execution_mode - rollup_transaction_details = consensus_service.add_transaction( - signed_rollup_transaction, from_address - ) # because hardhat accounts are not funded - - if ( - consensus_service.web3.is_connected() - and rollup_transaction_details is None - ): - # raise JSONRPCError( - # code=-32000, - # message="Failed to add transaction to consensus layer", - # data={}, - # ) - logger.warning( - "Failed to add transaction to consensus layer", - extra={ - "from_address": from_address, - "transaction_type": genlayer_transaction.type.name, - "leader_only": leader_only, - }, - ) if genlayer_transaction.type == TransactionType.DEPLOY_CONTRACT: - if ( - rollup_transaction_details is None - or not "recipient" in rollup_transaction_details - ): - new_account = accounts_manager.create_new_account() - new_contract_address = new_account.address - else: - new_contract_address = rollup_transaction_details["recipient"] - accounts_manager.create_new_account_with_address(new_contract_address) + try: + new_contract_address = _allocate_top_level_ghost_address( + transactions_processor, + int(decoded_rollup_transaction.data.args.salt_nonce or 0), + ) + # Keep the address row in the transaction admission unit. A + # premature commit here would release the duplicate-admission + # and virtual-factory advisory locks before the raw tx exists. + accounts_manager.create_new_account_with_address( + new_contract_address, + commit=False, + ) + except InvalidTransactionError as exc: + if storage_reservation is not None: + storage_reservation.release() + execution_revert(exc) + except Exception: + if storage_reservation is not None: + storage_reservation.release() + raise transaction_data = { "contract_address": new_contract_address, @@ -2951,56 +3229,17 @@ def send_raw_transaction( elif genlayer_transaction.type == TransactionType.RUN_CONTRACT: # Contract Call to_address = genlayer_transaction.to_address - if not accounts_manager.is_valid_address(to_address): - raise InvalidAddressError( - to_address, f"Invalid address to_address: {to_address}" - ) - - # Size-only lookup: do not hydrate current_state.data (can be - # tens of MB) just to test existence. - if live_state_column_size(session, to_address) is None: - raise NotFoundError( - message="Contract not found", - data={"address": to_address}, - ) - transaction_data = {"calldata": genlayer_transaction.data.calldata} if fee_metadata := _fee_metadata(decoded_rollup_transaction): transaction_data.update(fee_metadata) - # Check for duplicate before debit+insert to avoid TOCTOU races - is_duplicate = transactions_processor.get_transaction_by_hash(transaction_hash) - - # Queue-depth admission control: refuse new submissions when a - # contract or a sender already has too many txs queued. Studio Prod - # is a shared sandbox; without this, one heavy user (e.g. an - # external oracle backend running batch verifications) can pile up - # thousands of PENDING txs on a single contract and starve the - # network for everyone else. - # - # Skip duplicates (resubmission of an already-known hash is benign) - # and SEND txs (faucet/transfer; not subject to per-contract pile-up - # because to_address is a user account, not a contract). - storage_reservation = None - if is_duplicate is None and genlayer_transaction.type != TransactionType.SEND: - _enforce_pending_queue_caps( - transactions_processor=transactions_processor, - to_address=to_address, - from_address=from_address, - ) - if genlayer_transaction.type == TransactionType.RUN_CONTRACT: - storage_reservation = enforce_contract_storage_quota( - session, to_address, transaction_hash - ) - try: # Debit sender BEFORE insert. Mint on demand if insufficient (Studio sandbox). - # Skip for SEND (execute_transfer handles it) and duplicates. + # Skip for SEND (execute_transfer handles it). if ( total_spend > 0 and from_address and genlayer_transaction.type != TransactionType.SEND - and is_duplicate is None ): _sandbox_debit_sender(accounts_manager, from_address, total_spend) @@ -3020,6 +3259,7 @@ def send_raw_transaction( sim_config, None, # triggered_on execution_mode, + commit=False, ) except Exception: if storage_reservation is not None: @@ -3059,7 +3299,7 @@ def send_raw_transaction( log_to_terminal=False, ) - return transaction_hash + return finish_envelope(transaction_hash) def get_transactions_for_address( @@ -3181,14 +3421,43 @@ def get_transaction_receipt( transaction = transactions_processor.get_transaction_by_hash( transaction_hash, include_contract_snapshot=False ) + envelope = transactions_processor.get_evm_envelope(transaction_hash) if not transaction: - return None + if envelope is None: + return None + return { + "transactionHash": transaction_hash, + "transactionIndex": hex(0), + "blockHash": transaction_hash, + "blockNumber": hex(0), + "from": envelope.from_address, + "to": envelope.to_address, + "cumulativeGasUsed": hex(0), + "gasUsed": hex(0), + "effectiveGasPrice": "0x0", + "type": "0x0", + "contractAddress": None, + "logs": [], + "logsBloom": "0x" + "00" * 256, + "status": hex(1 if envelope.success else 0), + } - event_signature = "NewTransaction(bytes32,address,address)" + # CreationPhase always emits CreatedTransaction from ConsensusMain. It only + # additionally emits NewTransaction when this transaction was the recipient + # queue head at admission. Studio does not persist that historical boolean, + # so expose the universal event instead of fabricating a NewTransaction for + # queued submissions. SDKs accept either event when extracting the GenLayer + # transaction id. + event_signature = "CreatedTransaction(bytes32,uint256)" event_signature_hash = eth_utils.keccak(text=event_signature).hex() - to_addr = transaction.get("to_address") + protocol_to_addr = envelope.to_address if envelope is not None else None + to_addr = protocol_to_addr or transaction.get("to_address") from_addr = transaction.get("from_address") + try: + tx_slot = int(transaction.get("tx_slot", 0)) + except (TypeError, ValueError): + tx_slot = 0 logs = [ { @@ -3196,18 +3465,8 @@ def get_transaction_receipt( "topics": [ f"0x{event_signature_hash}", transaction_hash, - ( - "0x000000000000000000000000" + to_addr.replace("0x", "") - if to_addr - else None - ), - ( - "0x000000000000000000000000" + from_addr.replace("0x", "") - if from_addr - else None - ), ], - "data": "0x", + "data": "0x" + tx_slot.to_bytes(32, "big").hex(), "blockNumber": 0, "transactionHash": transaction_hash, "transactionIndex": 0, @@ -3228,11 +3487,9 @@ def get_transaction_receipt( "gasUsed": hex(transaction.get("gas_used", 8000000)), "effectiveGasPrice": "0x0", "type": "0x0", - "contractAddress": ( - transaction.get("contract_address") - if transaction.get("contract_address") - else None - ), + # The signed EVM envelope calls ConsensusMain; deploySalted authors a + # GenVM ghost inside that call and is not EVM contract creation. + "contractAddress": None, "logs": logs, "logsBloom": "0x" + "00" * 256, "status": hex(1 if transaction.get("status", True) else 0), @@ -3303,7 +3560,11 @@ def get_contract(consensus_service: ConsensusService, contract_name: str) -> dic ) return { - "address": contract["address"], + "address": ( + consensus_service.public_consensus_main_address() + if contract_name == "ConsensusMain" + else contract["address"] + ), "abi": contract["abi"], "bytecode": contract["bytecode"], } diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index e41a7449c..5d1e2b96e 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -46,6 +46,7 @@ MAX_ALLOCATED_MESSAGES_CAP = 20 NONDET_OUTPUT_LENGTH_BYTES = 32 NODE_ROOT_SENTINEL = (1 << 256) - 1 +UINT256_MAX = (1 << 256) - 1 MAX_CONTRIBUTION_SEGMENTS = 64 EMPTY_CALL_KEY = "0x" + ("0" * 64) # Consensus reserves keccak256("") as the allocation wildcard. bytes32(0) @@ -101,14 +102,16 @@ DEFAULT_MIN_COMMIT_TIMEUNITS = 30 DEFAULT_MAX_COMMIT_TIMEUNITS = 600 GENVM_UNMETERED_DATA_FEE_BUCKET = (1 << 256) - 1 -SUCCESSFUL_APPEAL_REWARD_NUMERATOR = 5 -SUCCESSFUL_APPEAL_REWARD_DENOMINATOR = 2 class FeeValidationError(ValueError): pass +class ArithmeticOverflow(FeeValidationError): + pass + + class InvalidNumOfValidators(FeeValidationError): pass @@ -556,10 +559,38 @@ def _int_field(fees_distribution: dict[str, Any], field: str) -> int: return int(fees_distribution.get(field, 0) or 0) +def _require_uint256(value: int) -> int: + value = int(value) + if value < 0: + raise InvalidFeeParams("InvalidFeeParams") + if value > UINT256_MAX: + raise ArithmeticOverflow("ArithmeticOverflow") + return value + + +def _u256_add(*values: int) -> int: + total = 0 + for value in values: + total += _require_uint256(value) + if total > UINT256_MAX: + raise ArithmeticOverflow("ArithmeticOverflow") + return total + + +def _u256_mul(*values: int) -> int: + total = 1 + for value in values: + value = _require_uint256(value) + if value != 0 and total > UINT256_MAX // value: + raise ArithmeticOverflow("ArithmeticOverflow") + total *= value + return total + + def normalize_fees_distribution( fees_distribution: dict[str, Any], ) -> dict[str, int | list[int]]: - return { + normalized = { "leaderTimeunitsAllocation": _int_field( fees_distribution, "leaderTimeunitsAllocation" ), @@ -581,12 +612,25 @@ def normalize_fees_distribution( "storageFeeMaxGasPrice": _int_field(fees_distribution, "storageFeeMaxGasPrice"), "receiptFeeMaxGasPrice": _int_field(fees_distribution, "receiptFeeMaxGasPrice"), } + for field, value in normalized.items(): + if field == "rotations": + for rotation in value: + _require_uint256(rotation) + else: + _require_uint256(value) + return normalized def get_leader_rounds(fees_distribution: dict[str, Any]) -> int: fees = normalize_fees_distribution(fees_distribution) - return sum(rotation + 1 for rotation in fees["rotations"]) + int( - fees["appealRounds"] + rotations = fees["rotations"] + assert isinstance(rotations, list) + leader_attempts = 0 + for rotation in rotations: + leader_attempts = _u256_add(leader_attempts, _u256_add(rotation, 1)) + return _u256_add( + leader_attempts, + int(fees["appealRounds"]), ) @@ -606,10 +650,13 @@ def get_leader_rounds_through_round( rotations_index = 1 for offset in range(1, min(final_round, int(fees["appealRounds"]) * 2) + 1): if offset % 2 == 1: - total += 1 + total = _u256_add(total, 1) elif rotations_index < len(rotations): - total += _leader_slots_for_round( - rotations, rotations_index, actual_rotations, round_index=offset + total = _u256_add( + total, + _leader_slots_for_round( + rotations, rotations_index, actual_rotations, round_index=offset + ), ) rotations_index += 1 return total @@ -634,11 +681,11 @@ def calculate_time_unit_fees_through_round( if policy.gen_per_time_unit > 0: if max_price > 0 and policy.gen_per_time_unit > max_price: raise MaxPriceExceeded("MaxPriceExceeded") - leader_timeunits = ( - int(fees["leaderTimeunitsAllocation"]) * policy.gen_per_time_unit + leader_timeunits = _u256_mul( + int(fees["leaderTimeunitsAllocation"]), policy.gen_per_time_unit ) - validator_timeunits = ( - int(fees["validatorTimeunitsAllocation"]) * policy.gen_per_time_unit + validator_timeunits = _u256_mul( + int(fees["validatorTimeunitsAllocation"]), policy.gen_per_time_unit ) else: leader_timeunits = int(fees["leaderTimeunitsAllocation"]) @@ -664,12 +711,15 @@ def calculate_time_unit_fees_through_round( rotations_index += 1 else: rotations_this_round = 1 - total += _calculate_fee_for_round( - _validators_per_round_safe(offset), - rotations_this_round, - leader_timeunits, - validator_timeunits, - leader_multiplier=_round_leader_multiplier(round_outcomes.get(offset)), + total = _u256_add( + total, + _calculate_fee_for_round( + _validators_per_round_safe(offset), + rotations_this_round, + leader_timeunits, + validator_timeunits, + leader_multiplier=_round_leader_multiplier(round_outcomes.get(offset)), + ), ) return total @@ -767,11 +817,11 @@ def _calculate_settled_time_unit_fees( } } if policy.gen_per_time_unit > 0: - leader_timeunits = ( - int(fees["leaderTimeunitsAllocation"]) * policy.gen_per_time_unit + leader_timeunits = _u256_mul( + int(fees["leaderTimeunitsAllocation"]), policy.gen_per_time_unit ) - validator_timeunits = ( - int(fees["validatorTimeunitsAllocation"]) * policy.gen_per_time_unit + validator_timeunits = _u256_mul( + int(fees["validatorTimeunitsAllocation"]), policy.gen_per_time_unit ) else: leader_timeunits = int(fees["leaderTimeunitsAllocation"]) @@ -801,26 +851,29 @@ def _calculate_settled_time_unit_fees( prior_attempts = attempts[:rotations] current_attempt = attempts[-1] if attempts else None prior_rotation_fee = 0 - if prior_attempts and all( - _entry_validator_receipts(attempt) for attempt in prior_attempts - ): - for attempt in prior_attempts: - aligned = _aligned_validator_count( - attempt, - exclude_leader=True, - electorate_size=round_electorate_size, - ) - rotated_leader_fee = ( - 0 - if _fee_alignment_result( - attempt, electorate_size=round_electorate_size - ) - == "deterministic_violation" - else leader_timeunits // 2 - ) - prior_rotation_fee += aligned * validator_timeunits + rotated_leader_fee - else: - prior_rotation_fee = rotations * _calculate_fee_for_round( + # FeesRecorder keeps a proposal-timeout rotation with an empty + # validator array. FeesProcessor therefore pays that rotated-out + # leader one half allocation but pays zero validator seats. Treat an + # explicitly recorded empty array as material evidence, not as missing + # history that should be expanded to a full committee. + for attempt in prior_attempts: + aligned = _aligned_validator_count( + attempt, + exclude_leader=True, + electorate_size=round_electorate_size, + ) + rotated_leader_fee = ( + 0 + if _fee_alignment_result(attempt, electorate_size=round_electorate_size) + == "deterministic_violation" + else leader_timeunits // 2 + ) + prior_rotation_fee += aligned * validator_timeunits + rotated_leader_fee + # Legacy/synthetic histories can expose only a rotation count. Preserve + # the configured upper-bound fallback for slots with no attempt record. + missing_rotation_attempts = max(0, rotations - len(prior_attempts)) + if missing_rotation_attempts: + prior_rotation_fee += missing_rotation_attempts * _calculate_fee_for_round( max(0, validators - 1), 1, leader_timeunits, @@ -1166,6 +1219,7 @@ def _settlement_storage_recipient_count( fees_distribution: dict[str, Any], policy: StudioFeePolicy, execution_mode: str, + bond_settlements: list[dict[str, Any]] | None = None, terminal_electorate_size: int | None = None, ) -> int: """Count the unique fee-ledger recipients that share persistent storage. @@ -1204,6 +1258,14 @@ def _settlement_storage_recipient_count( leader_only = str(execution_mode).upper() == "LEADER_ONLY" recipients: set[str] = set() + def bond_distribution_for_round(round_index: int, key: str) -> int: + return sum( + max(0, int(item.get(key, 0) or 0)) + for item in (bond_settlements or []) + if item.get("outcomeRound") is not None + and int(item["outcomeRound"]) == int(round_index) + ) + def add_leader( attempt: dict[str, Any], *, @@ -1262,6 +1324,7 @@ def validator_addresses( for round_index, attempts in attempts_by_round.items(): settlement = settlement_by_round.get(round_index) or {} rule = str(settlement.get("rule") or "") + outcome = outcomes.get(round_index, "") if rule in non_material_rules: continue @@ -1316,9 +1379,25 @@ def validator_addresses( electorate_size=round_electorate_size, ) ) + elif outcome == ConsensusRound.LEADER_APPEAL_FAILED.value: + # A failed leader appeal redistributes its forfeited bond to + # aligned replay voters. Even when validator time-unit work is + # explicitly zero, recipients enter FeesProcessor's index only + # when integer division actually gives them nonzero principal. + if bond_distribution_for_round(round_index, "bondDistributed") > 0: + recipients.update( + validator_addresses( + current_attempt, + aligned_only=True, + forced_result=( + str(alignment_result) if alignment_result else None + ), + electorate_size=round_electorate_size, + ) + ) elif rule in leader_timeout_rules: payable_value = ( - 1 + bond_distribution_for_round(round_index, "leaderPayout") if rule == "post_timeout_appeal_repeated_timeout" else leader_value // 2 ) @@ -1330,12 +1409,16 @@ def validator_addresses( # With a nonzero validator allocation every revealer is either # paid or penalized. If it is zero, only aligned recipients of the # forfeited bond enter the distribution index. - recipients.update( - validator_addresses( - current_attempt, - aligned_only=validator_value == 0, + if ( + validator_value > 0 + or bond_distribution_for_round(round_index, "bondDistributed") > 0 + ): + recipients.update( + validator_addresses( + current_attempt, + aligned_only=validator_value == 0, + ) ) - ) # A successful validator appeal also tracks each vindicated voter from the # skipped original round, even when that address is not on the appeal jury. @@ -1398,10 +1481,10 @@ def calculate_round_fees( policy, enforce_cap=enforce_gen_price_cap, ) - total = ( - priced_work - + _time_unit_overlay(priced_work, policy.time_unit_overlay_bps) - + appeal_profit_reserve + total = _u256_add( + priced_work, + _time_unit_overlay(priced_work, policy.time_unit_overlay_bps), + appeal_profit_reserve, ) _enforce_gas_price_cap( policy.storage_unit_price, int(fees["storageFeeMaxGasPrice"]) @@ -1409,7 +1492,10 @@ def calculate_round_fees( _enforce_gas_price_cap(policy.receipt_gas_price, int(fees["receiptFeeMaxGasPrice"])) if round == 0: - total += int(fees["executionBudgetPerRound"]) * get_leader_rounds(fees) + total = _u256_add( + total, + _u256_mul(int(fees["executionBudgetPerRound"]), get_leader_rounds(fees)), + ) return total @@ -1420,8 +1506,9 @@ def required_fee_deposit( policy: StudioFeePolicy | None = None, ) -> int: fees = normalize_fees_distribution(fees_distribution) - return calculate_round_fees(fees, num_of_validators, 0, policy) + int( - fees["totalMessageFees"] + return _u256_add( + calculate_round_fees(fees, num_of_validators, 0, policy), + int(fees["totalMessageFees"]), ) @@ -1728,7 +1815,10 @@ def create_child_fee_accounting( ) -> tuple[dict[str, Any], dict[str, Any]]: policy = policy or StudioFeePolicy() declared_budget = int(message.get("declaredBudget", 0) or 0) - if declared_budget <= 0: + # Consensus permits a zero-budget child when its complete calculated + # primary obligation and allocation subtree are also zero. Negative + # values are impossible in the ABI and remain invalid locally. + if declared_budget < 0: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") fee_params = decode_internal_message_fee_params(message.get("feeParams", b"")) @@ -2058,7 +2148,16 @@ def record_appeal_bond( bond = int(charge["bond"]) funding = int(charge["funding"]) total_required = bond + funding - if bond <= 0 or amount < bond: + if ( + str(status).upper() == "LEADER_TIMEOUT" + and leader_timeout_live_seats is not None + and int(leader_timeout_live_seats) <= 1 + ): + raise InvalidAppealBond("InvalidAppealBond") + # Zero is a valid economic bond when an internal child explicitly opts + # out of both phase-time allocations. Consensus still admits that appeal + # (and records zero-valued custody); only an underpayment is invalid. + if bond < 0 or amount < bond: raise InvalidAppealBond("InvalidAppealBond") if amount < total_required: raise InsufficientFees("InsufficientFees") @@ -2247,28 +2346,32 @@ def calculate_appeal_charge( ), ) if pre_funded: - time_units = max(0, replacement_count - scheduled_replacement) * int( - fees["validatorTimeunitsAllocation"] + time_units = _u256_mul( + max(0, replacement_count - scheduled_replacement), + int(fees["validatorTimeunitsAllocation"]), ) else: - time_units = ( - jury_count * int(fees["validatorTimeunitsAllocation"]) - + replacement_count * int(fees["validatorTimeunitsAllocation"]) - + int(fees["leaderTimeunitsAllocation"]) + time_units = _u256_add( + _u256_mul(jury_count, int(fees["validatorTimeunitsAllocation"])), + _u256_mul( + replacement_count, + int(fees["validatorTimeunitsAllocation"]), + ), + int(fees["leaderTimeunitsAllocation"]), ) taxable_work = ( - time_units * policy.gen_per_time_unit + _u256_mul(time_units, policy.gen_per_time_unit) if policy.gen_per_time_unit > 0 - else time_units + else _require_uint256(time_units) ) appellant_profit = 0 if pre_funded else successful_appeal_profit(bond) execution_slots = 0 if not pre_funded: - execution_slots = replacement_rotations + 2 if leader_appeal else 2 - execution_backing = execution_slots * int(fees["executionBudgetPerRound"]) - overlay = _time_unit_overlay(taxable_work, policy.time_unit_overlay_bps) - funding = taxable_work + overlay + appellant_profit + execution_backing + execution_slots = _u256_add(replacement_rotations, 2) if leader_appeal else 2 + execution_backing = _u256_mul(execution_slots, int(fees["executionBudgetPerRound"])) + overlay = _time_unit_overlay_muldiv(taxable_work, policy.time_unit_overlay_bps) + funding = _u256_add(taxable_work, overlay, appellant_profit, execution_backing) return { "bond": bond, "funding": funding, @@ -2315,12 +2418,14 @@ def calculate_min_appeal_bond( ) total = _calculate_fee_for_round( _validators_per_round_safe(current_round), - rotations_left + 1, + _u256_add(rotations_left, 1), int(fees["leaderTimeunitsAllocation"]), int(fees["validatorTimeunitsAllocation"]), ) return ( - total * policy.gen_per_time_unit if policy.gen_per_time_unit > 0 else total + _u256_mul(total, policy.gen_per_time_unit) + if policy.gen_per_time_unit > 0 + else total ) if status_value == "UNDETERMINED": @@ -2333,12 +2438,14 @@ def calculate_min_appeal_bond( ) total = _calculate_fee_for_round( _validators_per_round_safe(target_round), - rotations + 1, + _u256_add(rotations, 1), int(fees["leaderTimeunitsAllocation"]), int(fees["validatorTimeunitsAllocation"]), ) return ( - total * policy.gen_per_time_unit if policy.gen_per_time_unit > 0 else total + _u256_mul(total, policy.gen_per_time_unit) + if policy.gen_per_time_unit > 0 + else total ) if status_value in {"VALIDATORS_TIMEOUT", "ACCEPTED"}: @@ -2347,7 +2454,9 @@ def calculate_min_appeal_bond( fees["validatorTimeunitsAllocation"] ) return ( - total * policy.gen_per_time_unit if policy.gen_per_time_unit > 0 else total + _u256_mul(total, policy.gen_per_time_unit) + if policy.gen_per_time_unit > 0 + else total ) return 0 @@ -2429,12 +2538,14 @@ def fill_message_fee_payload_from_allocation( updated.get("callKey", allocation["callKey"]) ) expected_subtree = _allocation_subtree(allocations, index) - if not updated.get("allocationSubtree"): - updated["allocationSubtree"] = expected_subtree - elif ( - _canonical_allocation_subtree(updated["allocationSubtree"]) != expected_subtree - ): - raise AllocationSubtreeMismatch("AllocationSubtreeMismatch") + # The deployed/default Consensus storage mode is FlatArrays. In that mode + # allocationSubtree is receipt data only: MessagePayments ignores it and + # inherits the canonical subtree from the parent's stored allocation tree. + # Keep the leader/GenVM bytes untouched for the SubmittedMessage descriptor, + # while carrying Studio's local FlatArrays resolution out-of-band for child + # materialization. In particular, a missing or malformed supplied subtree + # must not invalidate the reveal or skip a child. + updated["_studioResolvedAllocationSubtree"] = expected_subtree updated["messageFeeMode"] = "mode2" return updated @@ -3002,12 +3113,18 @@ def settle_fee_accounting( list(updated.get("execution_fee_consumed_buckets") or []), 1, ) + bond_settlements, bond_payout = _settle_appeal_bonds( + updated, + consensus_history=consensus_history, + cancel=False, + ) storage_recipients = _settlement_storage_recipient_count( consensus_history, settlement_rounds if actual_final_round is not None else [], fees_distribution, locked_policy, execution_mode, + bond_settlements=bond_settlements, terminal_electorate_size=( int(updated["selection_pool_count"]) if updated.get("selection_pool_count") is not None @@ -3027,11 +3144,6 @@ def settle_fee_accounting( else capped_storage_fee ) execution_spent = max(0, execution_spent - storage_dust) - bond_settlements, bond_payout = _settle_appeal_bonds( - updated, - consensus_history=consensus_history, - cancel=False, - ) appeal_profit_requested = sum( max(0, int(item.get("payout", 0)) - int(item.get("amount", 0))) for item in bond_settlements @@ -3601,7 +3713,7 @@ def _calculate_appeal_round_total( ) return _calculate_fee_for_round( _validators_per_round_safe(round), - rotations + 1, + _u256_add(rotations, 1), int(fees["leaderTimeunitsAllocation"]), int(fees["validatorTimeunitsAllocation"]), ) @@ -3624,13 +3736,13 @@ def _calculate_appeal_profit_reserve( ) next_normal_bond = _calculate_fee_for_round( _validators_per_round_safe((appeal_ordinal + 1) * 2), - funded_rotations + 1, + _u256_add(funded_rotations, 1), int(fees["leaderTimeunitsAllocation"]), int(fees["validatorTimeunitsAllocation"]), ) if gen_per_time_unit > 0: - next_normal_bond *= gen_per_time_unit - reserve += successful_appeal_profit(next_normal_bond) + next_normal_bond = _u256_mul(next_normal_bond, gen_per_time_unit) + reserve = _u256_add(reserve, successful_appeal_profit(next_normal_bond)) return reserve @@ -3645,7 +3757,7 @@ def _apply_time_unit_price( raise MaxPriceExceeded("MaxPriceExceeded") # Consensus reserves at the caller's ceiling. The live price is checked # against that ceiling and locked separately for settlement. - return total * max_price if max_price > 0 else total + return _u256_mul(total, max_price) if max_price > 0 else _require_uint256(total) def _time_unit_overlay(time_unit_work: int, split_bps: int) -> int: @@ -3654,7 +3766,18 @@ def _time_unit_overlay(time_unit_work: int, split_bps: int) -> int: return 0 if split_bps >= 10_000: raise FeeValidationError("InvalidTimeUnitOverlayBps") - return time_unit_work * split_bps // (10_000 - split_bps) + return _u256_mul(time_unit_work, split_bps) // (10_000 - split_bps) + + +def _time_unit_overlay_muldiv(time_unit_work: int, split_bps: int) -> int: + """Mirror AppealEconomics' full-precision Math.mulDiv overlay quote.""" + time_unit_work = _require_uint256(time_unit_work) + split_bps = int(split_bps) + if time_unit_work <= 0 or split_bps <= 0: + return 0 + if split_bps >= 10_000: + raise FeeValidationError("InvalidTimeUnitOverlayBps") + return _require_uint256(time_unit_work * split_bps // (10_000 - split_bps)) def _carve_time_unit_overlay_reserve( @@ -3684,16 +3807,17 @@ def _carve_time_unit_overlay_reserve( def successful_appeal_reward(appeal_bond: int) -> int: - appeal_bond = int(appeal_bond) - return ( - appeal_bond - * SUCCESSFUL_APPEAL_REWARD_NUMERATOR - // SUCCESSFUL_APPEAL_REWARD_DENOMINATOR + appeal_bond = _require_uint256(appeal_bond) + # AppealEconomics deliberately arranges floor(5*bond/2) this way so an + # otherwise representable result does not overflow in bond * 5. + return _u256_add( + _u256_mul(appeal_bond, 2), + appeal_bond // 2, ) def successful_appeal_profit(appeal_bond: int) -> int: - return successful_appeal_reward(appeal_bond) - int(appeal_bond) + return successful_appeal_reward(appeal_bond) - _require_uint256(appeal_bond) def _validators_per_round_safe(round: int) -> int: @@ -3728,7 +3852,7 @@ def _calculate_fees( validator_timeunits = int(fees_distribution["validatorTimeunitsAllocation"]) calculated_fees = _calculate_fee_for_round( VALIDATORS_PER_ROUND[validator_index], - int(rotations[0]) + 1, + _u256_add(int(rotations[0]), 1), leader_timeunits, validator_timeunits, ) @@ -3742,16 +3866,19 @@ def _calculate_fees( # ladder saturate at its final size. round_validators = _validators_per_round_safe(offset) if offset % 2 == 0 and rotations_index < len(rotations): - rotations_this_round = int(rotations[rotations_index]) + 1 + rotations_this_round = _u256_add(int(rotations[rotations_index]), 1) rotations_index += 1 elif offset % 2 == 1: rotations_this_round = 1 - calculated_fees += _calculate_fee_for_round( - round_validators, - rotations_this_round, - leader_timeunits, - validator_timeunits, + calculated_fees = _u256_add( + calculated_fees, + _calculate_fee_for_round( + round_validators, + rotations_this_round, + leader_timeunits, + validator_timeunits, + ), ) return calculated_fees @@ -3765,10 +3892,14 @@ def _calculate_fee_for_round( leader_multiplier: tuple[int, int] = (1, 1), ) -> int: leader_num, leader_den = leader_multiplier - leader_total = rotations * leader_timeunits_allocation - leader_fee = leader_total * leader_num // leader_den - validator_fee = rotations * (num_of_validators * validator_timeunits_allocation) - return leader_fee + validator_fee + leader_total = _u256_mul(rotations, leader_timeunits_allocation) + leader_fee = _u256_mul(leader_total, leader_num) // leader_den + validator_fee = _u256_mul( + rotations, + num_of_validators, + validator_timeunits_allocation, + ) + return _u256_add(leader_fee, validator_fee) def _leader_slots_for_round( @@ -3778,11 +3909,11 @@ def _leader_slots_for_round( *, round_index: int | None = None, ) -> int: - funded_slots = int(funded_rotations[funded_index]) + 1 + funded_slots = _u256_add(int(funded_rotations[funded_index]), 1) if not actual_rotations: return funded_slots actual_round_index = funded_index if round_index is None else round_index - actual_slots = int(actual_rotations.get(actual_round_index, 0)) + 1 + actual_slots = _u256_add(int(actual_rotations.get(actual_round_index, 0)), 1) return min(funded_slots, actual_slots) diff --git a/backend/protocol_rpc/ghost_factory.py b/backend/protocol_rpc/ghost_factory.py new file mode 100644 index 000000000..bfb982977 --- /dev/null +++ b/backend/protocol_rpc/ghost_factory.py @@ -0,0 +1,123 @@ +"""Deterministic Studio analogue of Consensus' GhostFactory deployment rules. + +Studio does not execute user submissions on an EVM. Its former fallback for +an unavailable shadow Hardhat node created a random account, which made salted +deployments non-deterministic and let RPC replicas disagree. This module keeps +the address calculation identical to GhostFactory's CREATE/CREATE2 mechanics +while giving Studio its own configurable virtual protocol deployment. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import os + +import rlp +from eth_utils import is_address, keccak, to_bytes, to_checksum_address + + +UINT256_MAX = (1 << 256) - 1 + +# These identify Studio's virtual v0.6 protocol deployment. Operators that +# intentionally mirror a concrete EVM deployment can override all three values +# with that deployment's GhostFactory, CreationPhase, and bytecodeEVM values. +DEFAULT_GHOST_FACTORY_ADDRESS = "0x130e09c996462963A6398CA04e1011e6ef9d68a6" +DEFAULT_CREATION_PHASE_ADDRESS = "0x6AF00387f985684f159d673b15265E651B9E29f8" +DEFAULT_GHOST_BYTECODE_HASH = ( + "0x4249189346902cb6f0afc196d5719e1b4cbc861a700ae3b0c6dcbe51e5e566de" +) +DEFAULT_GHOST_FACTORY_INITIAL_NONCE = 2 + + +class InvalidGhostFactoryConfiguration(ValueError): + pass + + +@dataclass(frozen=True) +class GhostFactoryConfig: + factory_address: str + creation_phase_address: str + bytecode_hash: bytes + initial_nonce: int + + @classmethod + def from_env(cls) -> "GhostFactoryConfig": + factory_address = _configured_address( + "GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS", + DEFAULT_GHOST_FACTORY_ADDRESS, + ) + creation_phase_address = _configured_address( + "GENLAYER_STUDIO_CREATION_PHASE_ADDRESS", + DEFAULT_CREATION_PHASE_ADDRESS, + ) + bytecode_hash = _configured_bytes32( + "GENLAYER_STUDIO_GHOST_BYTECODE_HASH", + DEFAULT_GHOST_BYTECODE_HASH, + ) + initial_nonce = _configured_uint256( + "GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE", + DEFAULT_GHOST_FACTORY_INITIAL_NONCE, + ) + return cls( + factory_address=factory_address, + creation_phase_address=creation_phase_address, + bytecode_hash=bytecode_hash, + initial_nonce=initial_nonce, + ) + + def address_for(self, salt_nonce: int, successful_deployments: int) -> str: + """Return the next GhostFactory address without mutating state. + + ``successful_deployments`` is the number of ghosts already created by + this virtual factory. Every successful CREATE and CREATE2 increments + the EVM account nonce, while a reverted collision does not. + """ + + salt_nonce = _coerce_uint256("salt_nonce", salt_nonce) + successful_deployments = _coerce_uint256( + "successful_deployments", successful_deployments + ) + factory = to_bytes(hexstr=self.factory_address) + if salt_nonce == 0: + factory_nonce = self.initial_nonce + successful_deployments + if factory_nonce > UINT256_MAX: + raise OverflowError("GhostFactoryNonceOverflow") + digest = keccak(rlp.encode([factory, factory_nonce])) + else: + creation_phase = to_bytes(hexstr=self.creation_phase_address) + salt = keccak(creation_phase + salt_nonce.to_bytes(32, "big")) + digest = keccak(b"\xff" + factory + salt + self.bytecode_hash) + return to_checksum_address("0x" + digest[-20:].hex()) + + +def _configured_address(name: str, default: str) -> str: + value = os.getenv(name, default) + if not is_address(value): + raise InvalidGhostFactoryConfiguration(f"{name} must be an EVM address") + return to_checksum_address(value) + + +def _configured_bytes32(name: str, default: str) -> bytes: + value = os.getenv(name, default) + try: + raw = to_bytes(hexstr=value) + except (TypeError, ValueError) as exc: + raise InvalidGhostFactoryConfiguration(f"{name} must be bytes32") from exc + if len(raw) != 32: + raise InvalidGhostFactoryConfiguration(f"{name} must be bytes32") + return raw + + +def _configured_uint256(name: str, default: int) -> int: + value = os.getenv(name, str(default)) + try: + return _coerce_uint256(name, int(value, 10)) + except (TypeError, ValueError) as exc: + raise InvalidGhostFactoryConfiguration(f"{name} must be uint256") from exc + + +def _coerce_uint256(name: str, value: int) -> int: + value = int(value) + if value < 0 or value > UINT256_MAX: + raise InvalidGhostFactoryConfiguration(f"{name} must be uint256") + return value diff --git a/backend/protocol_rpc/transactions_parser.py b/backend/protocol_rpc/transactions_parser.py index 6142cbb82..493c5959e 100644 --- a/backend/protocol_rpc/transactions_parser.py +++ b/backend/protocol_rpc/transactions_parser.py @@ -5,6 +5,7 @@ from rlp.exceptions import DeserializationError, SerializationError from eth_account import Account from eth_account._utils.legacy_transactions import Transaction +from eth_account._utils.signing import extract_chain_id import eth_utils from eth_utils import to_checksum_address from hexbytes import HexBytes @@ -391,6 +392,12 @@ def _to_int(value: bytes) -> int: else: to_address = None nonce = signed_transaction_as_dict["nonce"] + chain_id = signed_transaction_as_dict.get("chainId") + if chain_id is None and signed_transaction_as_dict.get("v") is not None: + raw_v = signed_transaction_as_dict["v"] + if isinstance(raw_v, (bytes, bytearray, HexBytes)): + raw_v = int.from_bytes(raw_v, byteorder="big") + chain_id, _ = extract_chain_id(int(raw_v)) value = signed_transaction_as_dict["value"] submitted_value = int(value) fee_value = 0 @@ -408,6 +415,7 @@ def _to_int(value: bytes) -> int: data = input_raw else: data = None + raw_data = f"0x{data.removeprefix('0x')}" if data else None decoded_data = None contract_abi = self._get_contract_abi() if data and contract_abi: @@ -510,6 +518,8 @@ def _to_int(value: bytes) -> int: value=value, fee_value=fee_value, submitted_value=submitted_value, + raw_data=raw_data, + chain_id=int(chain_id) if chain_id is not None else None, ) except Exception as e: @@ -582,7 +592,15 @@ def get_genlayer_transaction( ) sender = rollup_transaction.data.args.sender - recipient = rollup_transaction.data.args.recipient + # The v0.6 deploySalted selector is authoritative: Consensus ignores + # the tuple recipient and always passes address(0) into CreationPhase. + # Inferring only from the user-controlled recipient let a salted deploy + # be misclassified as a normal contract call in Studio. + recipient = ( + ZERO_ADDRESS + if rollup_transaction.data.function_name == "deploySalted" + else rollup_transaction.data.args.recipient + ) max_rotations = rollup_transaction.data.args.max_rotations type = self._get_genlayer_transaction_type(recipient) data = self._get_genlayer_transaction_data(type, rollup_transaction.data.args) diff --git a/backend/protocol_rpc/types.py b/backend/protocol_rpc/types.py index c917981ac..190763e28 100644 --- a/backend/protocol_rpc/types.py +++ b/backend/protocol_rpc/types.py @@ -84,6 +84,8 @@ class DecodedRollupTransaction: value: int fee_value: int = 0 submitted_value: int | None = None + raw_data: str | None = None + chain_id: int | None = None @property def total_spend(self) -> int: diff --git a/backend/rollup/consensus_service.py b/backend/rollup/consensus_service.py index aa68644cb..cf351a638 100644 --- a/backend/rollup/consensus_service.py +++ b/backend/rollup/consensus_service.py @@ -10,6 +10,14 @@ ) from backend.rollup.web3_pool import Web3ConnectionPool +FEE_AWARE_SHADOW_SELECTORS = { + bytes.fromhex("35a251fb"), + bytes.fromhex("98863702"), +} +LEGACY_ADD_TRANSACTION_SELECTOR = keccak( + text="addTransaction(address,address,uint256,uint256,bytes)" +)[:4] + class ConsensusService: def __init__(self): @@ -19,6 +27,17 @@ def __init__(self): # Use singleton Web3 connection pool self.web3 = Web3ConnectionPool.get() + @staticmethod + def public_consensus_main_address() -> str: + """Return Studio's user-facing virtual ConsensusMain address. + + A connected Hardhat helper may be deployed at a different ephemeral + address. That helper is an internal shadow only; signed client + envelopes continue to target the stable address exported by the SDK. + """ + + return str(get_default_consensus_main_contract()["address"]) + def _get_contract(self, contract_name: str): """ Get a contract instance @@ -152,6 +171,81 @@ def forward_transaction(self, transaction: str | HexBytes) -> dict: receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) return receipt + @staticmethod + def _bind_shadow_sender_calldata( + calldata: str | HexBytes, + authoritative_sender: str | None, + ) -> str | HexBytes: + """Replace the user-controlled sender word with the recovered signer. + + Consensus' CreationPhase ignores a spoofed ``params.sender`` on an + ordinary EOA submission and attributes the transaction to the direct + caller. Studio relays through a funded Hardhat account, so the shadow + helper cannot derive that caller itself and must receive the already- + verified signer from the RPC boundary. + """ + + if authoritative_sender is None: + return calldata + payload = bytes(HexBytes(calldata)) + if len(payload) < 4: + return calldata + selector = payload[:4] + if selector in FEE_AWARE_SHADOW_SELECTORS: + sender_offset = 4 + 32 # selector + dynamic tuple offset word + elif selector == LEGACY_ADD_TRANSACTION_SELECTOR: + sender_offset = 4 + else: + return calldata + if len(payload) < sender_offset + 32: + raise RuntimeError("InvalidShadowTransactionEncoding") + sender = to_bytes(hexstr=authoritative_sender) + if len(sender) != 20: + raise RuntimeError("InvalidShadowTransactionSender") + bound = ( + payload[:sender_offset] + + (b"\x00" * 12) + + sender + + payload[sender_offset + 32 :] + ) + return to_hex(bound) + + def forward_shadow_transaction( + self, + calldata: str | HexBytes, + authoritative_sender: str | None = None, + ) -> dict: + """Submit verified user calldata through Hardhat's funded system account. + + Studio account balances and nonces live in Postgres, not in the helper + EVM. Replaying the user's raw signed envelope against Hardhat therefore + fails for ordinary Studio accounts and previously caused deployments + to fall back to an unrelated random address. The RPC layer has already + recovered the signer and validated the complete fee envelope; this + shadow call exists only to let the protocol helper author the child id + and CREATE/CREATE2 recipient. + """ + + consensus_main = self._get_contract("ConsensusMain") + if consensus_main is None: + raise RuntimeError("ConsensusMainUnavailable") + accounts = self.web3.eth.accounts + if not accounts: + raise RuntimeError("ConsensusShadowAccountUnavailable") + bound_calldata = self._bind_shadow_sender_calldata( + calldata, + authoritative_sender, + ) + tx_hash = self.web3.eth.send_transaction( + { + "from": accounts[0], + "to": consensus_main.address, + "data": bound_calldata, + "value": 0, + } + ) + return self.web3.eth.wait_for_transaction_receipt(tx_hash) + def wait_new_transaction_event(self, receipt: dict) -> dict: """ Wait for NewTransaction event from receipt @@ -184,20 +278,31 @@ def wait_new_transaction_event(self, receipt: dict) -> dict: return receipt def add_transaction( - self, transaction: dict, from_address: str, retry: bool = True + self, + transaction: dict, + from_address: str, + retry: bool = True, + calldata: str | HexBytes | None = None, ) -> str: """ Forward a transaction to the consensus rollup and wait for NewTransaction event """ - if not self.web3.is_connected(): + if self.web3 is None or not self.web3.is_connected(): # print( # "[CONSENSUS_SERVICE]: Not connected to Hardhat node, skipping transaction forwarding" # ) return None try: - receipt = self.forward_transaction(transaction) - return self.wait_new_transaction_event(receipt) + receipt = ( + self.forward_shadow_transaction(calldata, from_address) + if calldata is not None + else self.forward_transaction(transaction) + ) + details = self.wait_new_transaction_event(receipt) + if not isinstance(details, dict) or "tx_id" not in details: + raise RuntimeError("NewTransactionEventMissing") + return details except Exception as e: error_str = str(e) @@ -224,7 +329,10 @@ def add_transaction( if retry: return self.add_transaction( - transaction, from_address, retry=False + transaction, + from_address, + retry=False, + calldata=calldata, ) else: print( @@ -243,7 +351,7 @@ def transaction_forwarding_skipped(self, account: dict) -> bool: emit_transaction_event returns None *by design*, which callers must not confuse with a forwarding failure (which also returns None). """ - if not self.web3.is_connected(): + if self.web3 is None or not self.web3.is_connected(): return True return account.get("private_key") is None @@ -257,7 +365,7 @@ def emit_transaction_event(self, event_name: str, account: dict, *args): *args: Arguments to pass to the event function """ if self.transaction_forwarding_skipped(account): - if self.web3.is_connected(): + if self.web3 is not None and self.web3.is_connected(): print( f"[CONSENSUS_SERVICE]: Error emitting {event_name}: Account object must contain private_key" ) diff --git a/backend/rollup/default_contracts/consensus_main.py b/backend/rollup/default_contracts/consensus_main.py index cc2a89f52..aba4346bd 100644 --- a/backend/rollup/default_contracts/consensus_main.py +++ b/backend/rollup/default_contracts/consensus_main.py @@ -2,7 +2,7 @@ import json DEFAULT_CONSENSUS_MAIN_ADDRESS = os.environ.get( - "DEFAULT_CONSENSUS_MAIN_ADDRESS", "0x0000000000000000000000000000000000000000" + "DEFAULT_CONSENSUS_MAIN_ADDRESS", "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" ) DEFAULT_CONSENSUS_MAIN_ABI = """[ { @@ -1468,6 +1468,28 @@ }, ] +_STUDIO_TRAIN_EVENT_ABI = [ + { + "anonymous": False, + "inputs": [ + { + "indexed": True, + "internalType": "bytes32", + "name": "txId", + "type": "bytes32", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "txSlot", + "type": "uint256", + }, + ], + "name": "CreatedTransaction", + "type": "event", + } +] + def _abi_function_signature(entry: dict) -> str: """Canonical ``name(type,...)`` signature, i.e. the 4-byte selector identity.""" @@ -1531,6 +1553,7 @@ def get_default_consensus_main_contract(): or entry.get("name") not in served_train_names ] abi.extend(served_train_abi) + abi.extend(_STUDIO_TRAIN_EVENT_ABI) return { "address": DEFAULT_CONSENSUS_MAIN_ADDRESS, "abi": abi, diff --git a/docker-compose.yml b/docker-compose.yml index 88a28382a..c7e62e0c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -149,10 +149,13 @@ services: - GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS=${GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS:-210000} - GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS=${GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS:-100000} - GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES=${GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES:-1024} - # Per-contract / per-sender PENDING tx caps (admission control on - # eth_sendRawTransaction). Empty/unset = no cap. Set in shared - # deployments to keep one heavy user from filling the queue. - - MAX_PENDING_PER_CONTRACT_DEFAULT=${MAX_PENDING_PER_CONTRACT_DEFAULT:-} + - GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS=${GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS:-0x130e09c996462963A6398CA04e1011e6ef9d68a6} + - GENLAYER_STUDIO_CREATION_PHASE_ADDRESS=${GENLAYER_STUDIO_CREATION_PHASE_ADDRESS:-0x6AF00387f985684f159d673b15265E651B9E29f8} + - GENLAYER_STUDIO_GHOST_BYTECODE_HASH=${GENLAYER_STUDIO_GHOST_BYTECODE_HASH:-0x4249189346902cb6f0afc196d5719e1b4cbc861a700ae3b0c6dcbe51e5e566de} + - GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE=${GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE:-2} + # Per-recipient defaults to Consensus Queues.maxPendingTxsPerRecipient + # (20). The Studio-only per-sender guard remains optional. + - MAX_PENDING_PER_CONTRACT_DEFAULT=${MAX_PENDING_PER_CONTRACT_DEFAULT:-20} - MAX_PENDING_PER_SENDER_DEFAULT=${MAX_PENDING_PER_SENDER_DEFAULT:-} - MAX_CONTRACT_SNAPSHOT_BYTES_PER_DAY=${MAX_CONTRACT_SNAPSHOT_BYTES_PER_DAY:-} ports: diff --git a/hardhat/contracts/v2_contracts/ConsensusData.sol b/hardhat/contracts/v2_contracts/ConsensusData.sol index 56d77885e..a9ac2128b 100644 --- a/hardhat/contracts/v2_contracts/ConsensusData.sol +++ b/hardhat/contracts/v2_contracts/ConsensusData.sol @@ -277,10 +277,10 @@ contract ConsensusData is /** * @notice Retrieves a paginated list of latest accepted transactions for a specific recipient * @param recipient The address of the recipient contract - * @param startIndex The starting index for pagination (0-based) - * @param pageSize The maximum number of transactions to return - * @return TransactionData[] Array of transaction data objects, ordered by acceptance time (newest first) - * @dev Returns an empty array if startIndex is out of bounds + * @param startIndex The raw accepted-queue slot cursor (0-based) + * @param pageSize The maximum number of raw slots to inspect + * @return TransactionData[] Non-hole entries in the requested slot window + * @dev Raw cursors stay stable and a page may be shorter than pageSize when its window contains holes. */ function getLatestAcceptedTransactions( address recipient, @@ -293,15 +293,23 @@ contract ConsensusData is if (startIndex >= totalAccepted) { return new TransactionData[](0); } - uint256 endIndex = startIndex + pageSize; - if (endIndex > totalAccepted) { - endIndex = totalAccepted; + uint256 remaining = totalAccepted - startIndex; + if (pageSize > remaining) { + pageSize = remaining; } + uint256 endIndex = startIndex + pageSize; uint256 count = endIndex - startIndex; TransactionData[] memory results = new TransactionData[](count); + uint256 kept; for (uint256 i = startIndex; i < endIndex; i++) { bytes32 txId = queues.getAcceptedTxId(recipient, i); - results[i - startIndex] = _getTransactionData(txId); + if (txId == bytes32(0)) { + continue; + } + results[kept++] = _getTransactionData(txId); + } + assembly ("memory-safe") { + mstore(results, kept) } return results; } @@ -309,10 +317,10 @@ contract ConsensusData is /** * @notice Retrieves a paginated list of latest finalized transactions for a specific recipient * @param recipient The address of the recipient contract - * @param startIndex The starting index for pagination (0-based) - * @param pageSize The maximum number of transactions to return - * @return TransactionData[] Array of transaction data objects, ordered by finalization time (newest first) - * @dev Returns an empty array if startIndex is out of bounds + * @param startIndex The raw issued-order terminal slot cursor (0-based) + * @param pageSize The maximum number of raw terminal slots to inspect + * @return TransactionData[] Actually-finalized entries in the requested window + * @dev finalizedCount includes canceled slots, which are compacted out without shifting the raw cursor. */ function getLatestFinalizedTransactions( address recipient, @@ -323,15 +331,27 @@ contract ConsensusData is if (startIndex >= totalFinalized) { return new TransactionData[](0); } - uint256 endIndex = startIndex + pageSize; - if (endIndex > totalFinalized) { - endIndex = totalFinalized; + uint256 remaining = totalFinalized - startIndex; + if (pageSize > remaining) { + pageSize = remaining; } + uint256 endIndex = startIndex + pageSize; uint256 count = endIndex - startIndex; TransactionData[] memory results = new TransactionData[](count); + uint256 kept; for (uint256 i = startIndex; i < endIndex; i++) { bytes32 txId = queues.getFinalizedTxId(recipient, i); - results[i - startIndex] = _getTransactionData(txId); + if ( + txId == bytes32(0) || + transactions.getTransaction(txId).status != + ITransactions.TransactionStatus.Finalized + ) { + continue; + } + results[kept++] = _getTransactionData(txId); + } + assembly ("memory-safe") { + mstore(results, kept) } return results; } @@ -433,4 +453,4 @@ contract ConsensusData is // ) external pure returns (ITransactions.IdleTransactionInfo[] memory) { // // TODO: // } -} \ No newline at end of file +} diff --git a/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js b/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js index 403c78bc8..8e9bae133 100644 --- a/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js +++ b/hardhat/test/consensus/ConsensusMain.InternalMessages.test.js @@ -5,6 +5,7 @@ describe("ConsensusMain internal message bridge", function () { let consensusMain; let ghostFactory; let queues; + let consensusData; let transactions; let owner; let recipient; @@ -25,6 +26,10 @@ describe("ConsensusMain internal message bridge", function () { "Queues", (await deployments.get("Queues")).address, ); + consensusData = await ethers.getContractAt( + "ConsensusData", + (await deployments.get("ConsensusData")).address, + ); transactions = await ethers.getContractAt( "Transactions", (await deployments.get("Transactions")).address, @@ -296,6 +301,9 @@ describe("ConsensusMain internal message bridge", function () { expect(await queues.getFinalizedTxId(recipient, 0)).to.equal( deploymentTxId, ); + expect( + await consensusData.getLatestFinalizedTransactions(recipient, 0, 1), + ).to.deep.equal([]); }); it("skips an unregistered internal recipient without stranding valid siblings", async function () { diff --git a/tests/db-sqlalchemy/test_finalization_ordering.py b/tests/db-sqlalchemy/test_finalization_ordering.py index b9bf1eba6..103eda05d 100644 --- a/tests/db-sqlalchemy/test_finalization_ordering.py +++ b/tests/db-sqlalchemy/test_finalization_ordering.py @@ -159,6 +159,39 @@ async def test_ordering_blocks_younger_when_older_still_in_consensus( ) +@pytest.mark.asyncio +async def test_ordering_uses_issued_slot_for_same_timestamp_siblings( + worker: ConsensusWorker, session: Session +): + """One parent message batch gives every child the same DB timestamp. + + The durable issued slot is the recipient-queue authority, so child N+1 + cannot finalize while child N is still in consensus. + """ + + created_at = datetime.now(timezone.utc) - timedelta(minutes=10) + _insert_tx( + session, + tx_hash="0x" + "12" * 32, + status="COMMITTING", + nonce=7, + created_at=created_at, + ) + _insert_tx( + session, + tx_hash="0x" + "13" * 32, + status="ACCEPTED", + nonce=8, + created_at=created_at, + timestamp_awaiting_finalization=int(time.time()) - 600, + ) + + with worker.get_session() as claimed_session: + result = await worker.claim_next_finalization(claimed_session) + + assert result is None + + @pytest.mark.asyncio async def test_ordering_allows_younger_after_older_finalized( worker: ConsensusWorker, session: Session diff --git a/tests/db-sqlalchemy/test_queue_depth_admission.py b/tests/db-sqlalchemy/test_queue_depth_admission.py index 7a6fb082e..e0aea765e 100644 --- a/tests/db-sqlalchemy/test_queue_depth_admission.py +++ b/tests/db-sqlalchemy/test_queue_depth_admission.py @@ -6,8 +6,7 @@ one contract (observed in Studio Prod: one oracle backend backlogged ~2000 verifications on a single contract for 5 days, starving other contracts behind it). The cap rejects new submissions at admission time -with a structured QueueDepthExceeded error, pointing heavy users toward -non-shared deployments. +with a structured QueueDepthExceeded error. These tests exercise `_enforce_pending_queue_caps` directly against a real Postgres session so the COUNT(*) query and the raise paths are @@ -15,6 +14,8 @@ """ import importlib +import threading +from concurrent.futures import ThreadPoolExecutor import pytest from sqlalchemy import Engine, text from sqlalchemy.orm import sessionmaker @@ -58,6 +59,33 @@ def _seed_pending(session, n: int, *, to_address: str, from_address: str) -> Non session.commit() +def _insert_pending(session, *, tx_hash: str, nonce: int) -> None: + session.execute( + text( + """ + INSERT INTO transactions ( + hash, status, from_address, to_address, data, value, type, + nonce, leader_only, execution_mode, appealed, appeal_failed, + appeal_undetermined, appeal_leader_timeout, + appeal_validators_timeout, appeal_processing_time, + recovery_count, value_credited + ) VALUES ( + :hash, CAST('PENDING' AS transaction_status), + :from_addr, :to_addr, CAST('{}' AS jsonb), 0, 2, + :nonce, false, 'NORMAL', false, 0, + false, false, false, 0, 0, false + ) + """ + ), + { + "hash": tx_hash, + "from_addr": SENDER, + "to_addr": CONTRACT, + "nonce": nonce, + }, + ) + + def _reload_endpoints_module(monkeypatch, **env_overrides): """Re-import endpoints with patched env so module-level cap vars are re-read. The caps are parsed at import time, so each test that wants @@ -72,8 +100,8 @@ def _reload_endpoints_module(monkeypatch, **env_overrides): return importlib.reload(endpoints) -def test_no_caps_set_allows_unlimited(engine: Engine, monkeypatch): - """Self-hosted default: both caps unset → never raises.""" +def test_unset_recipient_cap_uses_consensus_default_20(engine: Engine, monkeypatch): + """Studio defaults to Queues.maxPendingTxsPerRecipient.""" Session_ = sessionmaker(bind=engine, expire_on_commit=False) with Session_() as session: _seed_pending(session, 100, to_address=CONTRACT, from_address=SENDER) @@ -83,12 +111,13 @@ def test_no_caps_set_allows_unlimited(engine: Engine, monkeypatch): MAX_PENDING_PER_SENDER_DEFAULT=None, ) tp = TransactionsProcessor(session) - # Should not raise even with 100 PENDING already. - endpoints._enforce_pending_queue_caps( - transactions_processor=tp, - to_address=CONTRACT, - from_address=SENDER, - ) + with pytest.raises(QueueDepthExceeded) as exc_info: + endpoints._enforce_pending_queue_caps( + transactions_processor=tp, + to_address=CONTRACT, + from_address=SENDER, + ) + assert exc_info.value.data["limit"] == 20 def test_per_contract_cap_rejects_when_at_limit(engine: Engine, monkeypatch): @@ -110,8 +139,7 @@ def test_per_contract_cap_rejects_when_at_limit(engine: Engine, monkeypatch): assert exc_info.value.data["scope"] == "contract" assert exc_info.value.data["limit"] == 50 assert exc_info.value.data["pending"] == 50 - # Error message should point users at alternatives. - assert "self-hosted" in exc_info.value.message.lower() + assert "protocol limits" in exc_info.value.message.lower() def test_per_contract_cap_allows_under_limit(engine: Engine, monkeypatch): @@ -132,6 +160,85 @@ def test_per_contract_cap_allows_under_limit(engine: Engine, monkeypatch): ) +def test_active_head_still_occupies_consensus_pending_queue_slot( + engine: Engine, monkeypatch +): + Session_ = sessionmaker(bind=engine, expire_on_commit=False) + with Session_() as session: + _seed_pending(session, 20, to_address=CONTRACT, from_address=SENDER) + session.execute( + text( + "UPDATE transactions SET status = CAST('COMMITTING' AS transaction_status) " + "WHERE hash = :hash" + ), + {"hash": f"0x{0:064x}"}, + ) + session.commit() + endpoints = _reload_endpoints_module( + monkeypatch, + MAX_PENDING_PER_CONTRACT_DEFAULT="20", + MAX_PENDING_PER_SENDER_DEFAULT=None, + ) + + with pytest.raises(QueueDepthExceeded) as exc_info: + endpoints._enforce_pending_queue_caps( + transactions_processor=TransactionsProcessor(session), + to_address=CONTRACT, + from_address="0x" + "ee" * 20, + ) + + assert exc_info.value.data["pending"] == 20 + + +def test_concurrent_admissions_cannot_both_claim_last_recipient_slot( + engine: Engine, monkeypatch +): + Session_ = sessionmaker(bind=engine, expire_on_commit=False) + with Session_() as session: + _seed_pending(session, 19, to_address=CONTRACT, from_address=SENDER) + endpoints = _reload_endpoints_module( + monkeypatch, + MAX_PENDING_PER_CONTRACT_DEFAULT="20", + MAX_PENDING_PER_SENDER_DEFAULT=None, + ) + barrier = threading.Barrier(2) + + def admit(index: int) -> str: + with Session_() as session: + tp = TransactionsProcessor(session) + barrier.wait() + try: + endpoints._enforce_pending_queue_caps( + transactions_processor=tp, + to_address=CONTRACT, + from_address="0x" + f"{index + 1:02x}" * 20, + ) + except QueueDepthExceeded: + session.rollback() + return "rejected" + _insert_pending( + session, + tx_hash=f"0x{100 + index:064x}", + nonce=100 + index, + ) + session.commit() + return "accepted" + + with ThreadPoolExecutor(max_workers=2) as pool: + outcomes = list(pool.map(admit, range(2))) + + assert sorted(outcomes) == ["accepted", "rejected"] + with Session_() as session: + count = session.execute( + text( + "SELECT COUNT(*) FROM transactions " + "WHERE to_address = :addr AND status = 'PENDING'" + ), + {"addr": CONTRACT}, + ).scalar_one() + assert count == 20 + + def test_per_sender_cap_rejects_when_at_limit(engine: Engine, monkeypatch): Session_ = sessionmaker(bind=engine, expire_on_commit=False) with Session_() as session: @@ -154,8 +261,10 @@ def test_per_sender_cap_rejects_when_at_limit(engine: Engine, monkeypatch): assert exc_info.value.data["pending"] == 20 -def test_invalid_cap_env_value_falls_back_to_unlimited(engine: Engine, monkeypatch): - """Garbage values in env shouldn't break submissions — fall back to no cap.""" +def test_invalid_recipient_cap_env_falls_back_to_consensus_default( + engine: Engine, monkeypatch +): + """Garbage values do not silently disable the protocol recipient cap.""" Session_ = sessionmaker(bind=engine, expire_on_commit=False) with Session_() as session: _seed_pending(session, 100, to_address=CONTRACT, from_address=SENDER) @@ -165,11 +274,13 @@ def test_invalid_cap_env_value_falls_back_to_unlimited(engine: Engine, monkeypat MAX_PENDING_PER_SENDER_DEFAULT="-5", # negative → ignored ) tp = TransactionsProcessor(session) - endpoints._enforce_pending_queue_caps( - transactions_processor=tp, - to_address=CONTRACT, - from_address=SENDER, - ) + with pytest.raises(QueueDepthExceeded) as exc_info: + endpoints._enforce_pending_queue_caps( + transactions_processor=tp, + to_address=CONTRACT, + from_address=SENDER, + ) + assert exc_info.value.data["limit"] == 20 def test_null_to_address_is_skipped(engine: Engine, monkeypatch): diff --git a/tests/db-sqlalchemy/transactions_processor_test.py b/tests/db-sqlalchemy/transactions_processor_test.py index 7057754c6..3525a059c 100644 --- a/tests/db-sqlalchemy/transactions_processor_test.py +++ b/tests/db-sqlalchemy/transactions_processor_test.py @@ -1,9 +1,12 @@ from sqlalchemy import text +from sqlalchemy.orm import sessionmaker import pytest from unittest.mock import patch, MagicMock import os import math +import threading from datetime import datetime +from types import SimpleNamespace from web3 import Web3 from web3.providers import BaseProvider @@ -12,7 +15,10 @@ TransactionsProcessor, TransactionAddressFilter, ) +from backend.database_handler.models import EvmEnvelope, Transactions from backend.consensus.types import ConsensusRound +from backend.consensus.base import _external_message_pending_freeze_total +from backend.node.types import ExecutionResultStatus _tx_counter = 0 @@ -315,6 +321,307 @@ def test_insert_transaction_duplicate_hash_returns_existing( assert tx["data"] == data # Original data, not the second attempt's data +def test_transaction_admission_lock_serializes_duplicate_workers(engine): + session_factory = sessionmaker(bind=engine) + first_session = session_factory() + second_session = session_factory() + first = TransactionsProcessor(first_session) + second = TransactionsProcessor(second_session) + transaction_hash = "0x" + "ab" * 32 + entered = threading.Event() + acquired = threading.Event() + + first.lock_transaction_admission(transaction_hash) + + def acquire_second(): + entered.set() + second.lock_transaction_admission(transaction_hash) + acquired.set() + + worker = threading.Thread(target=acquire_second, daemon=True) + worker.start() + assert entered.wait(1) + assert not acquired.wait(0.1) + + first_session.commit() + assert acquired.wait(2) + + second_session.rollback() + worker.join(timeout=2) + first_session.close() + second_session.close() + + +def test_pending_recipient_lock_serializes_child_admission_workers(engine): + session_factory = sessionmaker(bind=engine) + first_session = session_factory() + second_session = session_factory() + first = TransactionsProcessor(first_session) + second = TransactionsProcessor(second_session) + recipient = "0xAcec3A6d871C25F591aBd4fC24054e524BBbF794" + entered = threading.Event() + acquired = threading.Event() + + first.lock_pending_recipients([recipient]) + + def acquire_second(): + entered.set() + second.lock_pending_recipients([recipient.lower()]) + acquired.set() + + worker = threading.Thread(target=acquire_second, daemon=True) + worker.start() + assert entered.wait(1) + assert not acquired.wait(0.1) + + first_session.commit() + assert acquired.wait(2) + + second_session.rollback() + worker.join(timeout=2) + first_session.close() + second_session.close() + + +def test_ghost_factory_state_is_transactional_and_counts_all_admitted_deploys(engine): + session_factory = sessionmaker(bind=engine) + first_session = session_factory() + observer_session = session_factory() + first = TransactionsProcessor(first_session) + observer = TransactionsProcessor(observer_session) + ghost = "0x0aD72A9a303bDF888d3bf7d76e3568248a353199" + + first.lock_ghost_factory() + assert first.get_successful_ghost_creation_count() == 0 + first.insert_transaction( + from_address="0x9F0e84243496AcFB3Cd99D02eA59673c05901501", + to_address=ghost, + data={"contract_address": ghost}, + value=0, + type=1, + nonce=0, + leader_only=False, + config_rotation_rounds=0, + transaction_hash="0x" + ("cd" * 32), + commit=False, + ) + + assert first.is_genvm_contract_address(ghost) is True + assert first.get_successful_ghost_creation_count() == 1 + assert observer.is_genvm_contract_address(ghost) is False + assert observer.get_successful_ghost_creation_count() == 0 + + first_session.commit() + observer_session.expire_all() + assert observer.is_genvm_contract_address(ghost) is True + assert observer.get_successful_ghost_creation_count() == 1 + + observer_session.rollback() + first_session.close() + observer_session.close() + + +def test_evm_envelope_ledger_enforces_nonce_and_replays_prior_result(engine): + session_factory = sessionmaker(bind=engine) + session = session_factory() + processor = TransactionsProcessor(session) + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + first_hash = "0x" + ("e1" * 32) + first_result = "0x" + ("a1" * 32) + + assert processor.begin_evm_envelope(first_hash, sender, 0) is None + processor.record_evm_envelope(first_hash, sender, 0, first_result) + session.commit() + + assert processor.get_transaction_count(sender) == 1 + assert processor.get_transaction_count(sender.lower()) == 1 + assert session.get(EvmEnvelope, first_hash).from_address == sender.lower() + assert processor.begin_evm_envelope(first_hash, sender, 0) == first_result + session.rollback() + + with pytest.raises(ValueError, match=r"NonceTooLow\(expected=1,actual=0\)"): + processor.begin_evm_envelope("0x" + ("e2" * 32), sender, 0) + session.rollback() + with pytest.raises(ValueError, match=r"NonceTooHigh\(expected=1,actual=2\)"): + processor.begin_evm_envelope("0x" + ("e3" * 32), sender, 2) + session.rollback() + + second_hash = "0x" + ("e4" * 32) + assert processor.begin_evm_envelope(second_hash, sender, 1) is None + processor.record_evm_envelope(second_hash, sender, 1, second_hash) + session.commit() + assert processor.get_transaction_count(sender) == 2 + session.close() + + +def test_reverted_evm_envelope_is_durable_and_consumes_nonce(engine): + session_factory = sessionmaker(bind=engine) + with session_factory() as session: + processor = TransactionsProcessor(session) + sender = "0x8F0e84243496AcFB3Cd99D02eA59673c05901501" + destination = "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" + envelope_hash = "0x" + ("ea" * 32) + + assert processor.begin_evm_envelope(envelope_hash, sender, 0) is None + processor.record_evm_envelope( + envelope_hash, + sender, + 0, + envelope_hash, + to_address=destination, + success=False, + error="NonPayableCall", + ) + session.commit() + + envelope = processor.get_evm_envelope(envelope_hash) + assert envelope is not None + assert envelope.success is False + assert envelope.to_address == destination.lower() + assert envelope.error == "NonPayableCall" + assert processor.get_transaction_count(sender) == 1 + assert processor.begin_evm_envelope(envelope_hash, sender, 0) == envelope_hash + + +def test_concurrent_identical_evm_envelope_is_recorded_once(engine): + from concurrent.futures import ThreadPoolExecutor + + session_factory = sessionmaker(bind=engine) + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + envelope_hash = "0x" + ("e5" * 32) + result_hash = "0x" + ("a5" * 32) + barrier = threading.Barrier(2) + + def submit() -> str: + with session_factory() as session: + processor = TransactionsProcessor(session) + barrier.wait() + prior = processor.begin_evm_envelope(envelope_hash, sender, 0) + if prior is not None: + session.rollback() + return prior + processor.record_evm_envelope( + envelope_hash, + sender, + 0, + result_hash, + ) + session.commit() + return result_hash + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _index: submit(), range(2))) + + assert results == [result_hash, result_hash] + with session_factory() as session: + assert session.query(EvmEnvelope).count() == 1 + assert TransactionsProcessor(session).get_transaction_count(sender) == 1 + + +def test_same_timestamp_child_issued_slot_selects_previous_transaction(engine): + session_factory = sessionmaker(bind=engine) + with session_factory() as session: + processor = TransactionsProcessor(session) + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + recipient = "0xAcec3A6d871C25F591aBd4fC24054e524BBbF794" + first_hash = "0x" + ("e6" * 32) + second_hash = "0x" + ("e7" * 32) + for tx_hash, nonce in ((first_hash, 3), (second_hash, 4)): + processor.insert_transaction( + from_address=sender, + to_address=recipient, + data={"calldata": "0x"}, + value=0, + type=2, + nonce=nonce, + leader_only=False, + config_rotation_rounds=0, + transaction_hash=tx_hash, + commit=False, + ) + session.commit() + + first = session.get(Transactions, first_hash) + second = session.get(Transactions, second_hash) + assert first.created_at == second.created_at + previous = processor.get_previous_transaction(second_hash) + assert previous is not None + assert previous["hash"] == first_hash + + +def test_same_timestamp_child_issued_slot_orders_prior_external_freeze(engine): + session_factory = sessionmaker(bind=engine) + with session_factory() as session: + processor = TransactionsProcessor(session) + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + recipient = "0xAcec3A6d871C25F591aBd4fC24054e524BBbF794" + first_hash = "0x" + ("e8" * 32) + second_hash = "0x" + ("e9" * 32) + for tx_hash, nonce in ((first_hash, 3), (second_hash, 4)): + processor.insert_transaction( + from_address=sender, + to_address=recipient, + data={"calldata": "0x"}, + value=0, + type=2, + nonce=nonce, + leader_only=False, + config_rotation_rounds=0, + transaction_hash=tx_hash, + commit=False, + ) + first = session.get(Transactions, first_hash) + second = session.get(Transactions, second_hash) + first.status = TransactionStatus.ACCEPTED + second.status = TransactionStatus.ACCEPTED + first.consensus_data = { + "leader_receipt": [ + { + "execution_result": ExecutionResultStatus.SUCCESS.value, + "pending_transactions": [ + { + "is_eth_send": True, + "on": "finalized", + "value": 6, + } + ], + } + ] + } + second.consensus_data = {"leader_receipt": []} + session.commit() + assert first.created_at == second.created_at + + def context(tx_hash): + return SimpleNamespace( + transaction=SimpleNamespace(hash=tx_hash, to_address=recipient), + transactions_processor=processor, + ) + + assert _external_message_pending_freeze_total(context(second_hash)) == 6 + assert _external_message_pending_freeze_total(context(first_hash)) == 0 + + +def test_pending_queue_count_includes_active_head_but_not_decided_rows( + transactions_processor: TransactionsProcessor, +): + recipient = "0xAcec3A6d871C25F591aBd4fC24054e524BBbF794" + pending_hash = _make_tx(transactions_processor, to_address=recipient, nonce=10) + active_hash = _make_tx(transactions_processor, to_address=recipient, nonce=11) + decided_hash = _make_tx(transactions_processor, to_address=recipient, nonce=12) + transactions_processor.update_transaction_status( + active_hash, TransactionStatus.COMMITTING + ) + transactions_processor.update_transaction_status( + decided_hash, TransactionStatus.ACCEPTED + ) + + assert ( + transactions_processor.get_pending_transaction_count_for_address(recipient) == 2 + ) + assert pending_hash is not None + + def test_get_transaction_by_hash_exposes_value_credited( transactions_processor: TransactionsProcessor, session ): diff --git a/tests/unit/consensus/test_payable_balance_flow.py b/tests/unit/consensus/test_payable_balance_flow.py index a76bed3fc..5523a18f1 100644 --- a/tests/unit/consensus/test_payable_balance_flow.py +++ b/tests/unit/consensus/test_payable_balance_flow.py @@ -8,8 +8,17 @@ - Cumulative accepted message debits across appeal rounds """ -from unittest.mock import Mock -from backend.consensus.base import TransactionContext +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from backend.consensus import base as consensus_base +from backend.consensus.base import ( + PendingState, + TransactionContext, + _pending_valid_until_expired, +) from backend.domain.types import ( Transaction, TransactionType, @@ -17,6 +26,7 @@ TransactionExecutionMode, ) from backend.database_handler.contract_snapshot import ContractSnapshot +from backend.protocol_rpc.fees import FEE_ACCOUNTING_KEY def _make_snapshot(states=None, balance=0): @@ -141,6 +151,73 @@ def test_credit_tx_value_once_is_idempotent(self): assert am.credit_tx_value_once("0xhash", "0xcontract", 500) is False +@pytest.mark.asyncio +async def test_expired_pending_transaction_cancels_before_activation(monkeypatch): + transaction_hash = "0x" + "ab" * 32 + sender = "0x1111111111111111111111111111111111111111" + transaction = { + "hash": transaction_hash, + "status": TransactionStatus.PENDING.value, + "type": TransactionType.RUN_CONTRACT.value, + "from_address": sender, + "to_address": "0x2222222222222222222222222222222222222222", + "value": 17, + "data": { + "valid_until": 99, + FEE_ACCOUNTING_KEY: {"sender": sender}, + }, + } + transactions_processor = Mock() + transactions_processor.get_transaction_by_hash.return_value = transaction + accounts_manager = Mock() + dispatch = AsyncMock() + monkeypatch.setattr(consensus_base.time, "time", lambda: 100) + monkeypatch.setattr( + consensus_base.ConsensusAlgorithm, + "dispatch_transaction_status_update", + dispatch, + ) + context = SimpleNamespace( + transaction=SimpleNamespace(hash=transaction_hash), + transactions_processor=transactions_processor, + accounts_manager=accounts_manager, + msg_handler=Mock(), + ) + + result = await PendingState().handle(context) + + assert result is None + accounts_manager.refund_tx_value.assert_called_once_with(transaction_hash, sender) + accounts_manager.cancel_tx_fee_accounting_once.assert_called_once_with( + transaction_hash, + sender, + "valid_until_expired", + ) + dispatch.assert_awaited_once_with( + transactions_processor, + transaction_hash, + TransactionStatus.CANCELED, + context.msg_handler, + ) + + +def test_valid_until_equality_remains_activatable_for_full_evm_second(): + transaction = SimpleNamespace(data={"valid_until": 100}, consensus_history={}) + + assert _pending_valid_until_expired(transaction, now=100.999) is False + assert _pending_valid_until_expired(transaction, now=101.0) is True + + +def test_valid_until_does_not_cancel_appeal_recomputation(): + transaction = SimpleNamespace( + data={"valid_until": 100}, + consensus_history={"current_status_changes": ["ACCEPTED"]}, + appealed=True, + ) + + assert _pending_valid_until_expired(transaction, now=10_000) is False + + class TestAppealReentry: """On appeal re-entry, value_credited flag prevents double credit.""" diff --git a/tests/unit/test_consensus_contract_soft_not_found.py b/tests/unit/test_consensus_contract_soft_not_found.py index d7cc8f3c3..5860b704a 100644 --- a/tests/unit/test_consensus_contract_soft_not_found.py +++ b/tests/unit/test_consensus_contract_soft_not_found.py @@ -39,6 +39,22 @@ def test_get_contract_raises_NotFoundError_when_load_contract_returns_None(): consensus_service.load_contract.assert_called_once_with("Queues") +def test_get_contract_keeps_shadow_consensus_main_address_private(): + consensus_service = MagicMock() + consensus_service.load_contract.return_value = { + "address": "0x" + "3" * 40, + "abi": [], + "bytecode": "0x", + } + consensus_service.public_consensus_main_address.return_value = ( + "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" + ) + + result = endpoints.get_contract(consensus_service, "ConsensusMain") + + assert result["address"] == "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" + + def test_consensus_service_load_contract_returns_None_when_deployment_missing( monkeypatch, ): diff --git a/tests/unit/test_consensus_service_message_replay.py b/tests/unit/test_consensus_service_message_replay.py index c6ade2a34..ca8d9805e 100644 --- a/tests/unit/test_consensus_service_message_replay.py +++ b/tests/unit/test_consensus_service_message_replay.py @@ -17,6 +17,43 @@ def _service_with_contract(contract): return service +def test_fee_aware_submission_uses_funded_shadow_account_and_requires_event(): + contract = MagicMock() + contract.address = "0x2222222222222222222222222222222222222222" + service = ConsensusService.__new__(ConsensusService) + service.web3 = MagicMock() + service.web3.is_connected.return_value = True + service.web3.eth.accounts = ["0x1111111111111111111111111111111111111111"] + service.web3.eth.send_transaction.return_value = b"shadow-hash" + service.web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + service._get_contract = MagicMock(return_value=contract) + service.wait_new_transaction_event = MagicMock( + return_value={"tx_id": b"child", "recipient": contract.address} + ) + service.forward_transaction = MagicMock() + + calldata = "0x35a251fb" + f"{32:064x}" + ("00" * 32) + authoritative_sender = "0x3333333333333333333333333333333333333333" + result = service.add_transaction( + "0xraw", + authoritative_sender, + calldata=calldata, + ) + + assert result["recipient"] == contract.address + service.web3.eth.send_transaction.assert_called_once_with( + { + "from": "0x1111111111111111111111111111111111111111", + "to": contract.address, + "data": ( + "0x35a251fb" + f"{32:064x}" + ("00" * 12) + authoritative_sender[2:] + ), + "value": 0, + } + ) + service.forward_transaction.assert_not_called() + + def test_retried_message_phase_recovers_stored_child_ids_without_new_events(): contract = MagicMock() contract.functions.emitTransactionAccepted.return_value.build_transaction.return_value = ( diff --git a/tests/unit/test_ghost_factory.py b/tests/unit/test_ghost_factory.py new file mode 100644 index 000000000..8454aa79a --- /dev/null +++ b/tests/unit/test_ghost_factory.py @@ -0,0 +1,50 @@ +import pytest + +from backend.protocol_rpc.ghost_factory import ( + GhostFactoryConfig, + InvalidGhostFactoryConfiguration, +) + + +def test_default_virtual_factory_matches_frozen_create_vectors(monkeypatch): + for name in ( + "GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS", + "GENLAYER_STUDIO_CREATION_PHASE_ADDRESS", + "GENLAYER_STUDIO_GHOST_BYTECODE_HASH", + "GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE", + ): + monkeypatch.delenv(name, raising=False) + + factory = GhostFactoryConfig.from_env() + assert factory.address_for(0, 0) == "0x0aD72A9a303bDF888d3bf7d76e3568248a353199" + assert factory.address_for(0, 1) == "0xC6a967E0b0D12c109CD6367F245B422Ae05565A4" + assert factory.address_for(42, 0) == "0x4104e3b744E60be5E612b909f04D1547Fc87094a" + + +def test_create2_address_ignores_factory_nonce_but_create_does_not(): + factory = GhostFactoryConfig.from_env() + assert factory.address_for(42, 0) == factory.address_for(42, 99) + assert factory.address_for(0, 0) != factory.address_for(0, 1) + + +@pytest.mark.parametrize( + ("name", "value"), + [ + ("GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS", "not-an-address"), + ("GENLAYER_STUDIO_CREATION_PHASE_ADDRESS", "0x12"), + ("GENLAYER_STUDIO_GHOST_BYTECODE_HASH", "0x1234"), + ("GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE", "-1"), + ], +) +def test_virtual_factory_rejects_invalid_configuration(monkeypatch, name, value): + monkeypatch.setenv(name, value) + with pytest.raises(InvalidGhostFactoryConfiguration): + GhostFactoryConfig.from_env() + + +def test_virtual_factory_rejects_non_uint256_salt(): + factory = GhostFactoryConfig.from_env() + with pytest.raises(InvalidGhostFactoryConfiguration): + factory.address_for(-1, 0) + with pytest.raises(InvalidGhostFactoryConfiguration): + factory.address_for(1 << 256, 0) diff --git a/tests/unit/test_node_state_proxy_metrics.py b/tests/unit/test_node_state_proxy_metrics.py index a46a89eb4..03287545d 100644 --- a/tests/unit/test_node_state_proxy_metrics.py +++ b/tests/unit/test_node_state_proxy_metrics.py @@ -8,7 +8,12 @@ from backend.database_handler.contract_snapshot import ContractSnapshot from backend.domain.types import LLMProvider, Validator from backend.node.base import Node, _SnapshotView -from backend.node.genvm.base import Context, ExecutionResult, ExecutionReturn +from backend.node.genvm.base import ( + Context, + ExecutionResult, + ExecutionReturn, + _emission_allocation_subtree, +) from backend.node.genvm.base import Host as GenVMHost import backend.node.genvm.origin.calldata as gvm_calldata from backend.node.genvm.origin.base_host import RunHostAndProgramRes @@ -233,6 +238,10 @@ def test_host_provide_result_preserves_fee_metadata_from_genvm_emissions(): assert execution.data_fees_remaining == [100, 90, 80] +def test_malformed_allocation_subtree_preserves_exact_receipt_bytes(): + assert _emission_allocation_subtree({"subtree": b"\x01\x02\xff"}) == "0x0102ff" + + @pytest.mark.asyncio async def test_state_proxy_metrics_use_executed_proxy_instance(): node = _make_node() diff --git a/tests/unit/test_rpc_methods.py b/tests/unit/test_rpc_methods.py index a4dd18b5e..b7e1b44a5 100644 --- a/tests/unit/test_rpc_methods.py +++ b/tests/unit/test_rpc_methods.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -120,21 +121,53 @@ def test_eth_get_transaction_by_hash_can_show_private_keys_for_local_debug( def test_eth_transaction_receipt_requests_no_contract_snapshot(): transactions_processor = MagicMock() + consensus_main = "0xb7278a61aa25c888815afc32ad3cc52ff24fe575" transactions_processor.get_transaction_by_hash.return_value = { "hash": "0xabc", "from_address": "0x1111111111111111111111111111111111111111", "to_address": "0x2222222222222222222222222222222222222222", + "tx_slot": "7", + "contract_address": "0x3333333333333333333333333333333333333333", "status": "FINALIZED", } + transactions_processor.get_evm_envelope.return_value = SimpleNamespace( + to_address=consensus_main, + ) receipt = endpoints.get_transaction_receipt(transactions_processor, "0xabc") assert receipt["transactionHash"] == "0xabc" + assert receipt["to"] == consensus_main + assert receipt["contractAddress"] is None + assert receipt["logs"][0]["address"] == consensus_main + assert ( + receipt["logs"][0]["topics"][0] + == "0x" + + endpoints.eth_utils.keccak(text="CreatedTransaction(bytes32,uint256)").hex() + ) + assert receipt["logs"][0]["topics"][1] == "0xabc" + assert receipt["logs"][0]["data"] == "0x" + (7).to_bytes(32, "big").hex() transactions_processor.get_transaction_by_hash.assert_called_once_with( "0xabc", include_contract_snapshot=False ) +def test_eth_transaction_receipt_reports_reverted_lifecycle_envelope(): + transactions_processor = MagicMock() + transactions_processor.get_transaction_by_hash.return_value = None + transactions_processor.get_evm_envelope.return_value = SimpleNamespace( + from_address="0x1111111111111111111111111111111111111111", + to_address="0x2222222222222222222222222222222222222222", + success=False, + ) + + receipt = endpoints.get_transaction_receipt(transactions_processor, "0xabc") + + assert receipt["transactionHash"] == "0xabc" + assert receipt["status"] == "0x0" + assert receipt["logs"] == [] + + def test_eth_get_block_by_number_requests_no_contract_snapshot_for_full_tx(): transactions_processor = MagicMock() transactions_processor.get_transactions_for_block.return_value = { diff --git a/tests/unit/test_simulator_sessions.py b/tests/unit/test_simulator_sessions.py index cae1d6203..b668d8a2d 100644 --- a/tests/unit/test_simulator_sessions.py +++ b/tests/unit/test_simulator_sessions.py @@ -7,6 +7,36 @@ from backend.errors.errors import InvalidAddressError from backend.domain.types import LLMProvider, TransactionType from backend.database_handler.models import TransactionStatus +from backend.protocol_rpc.types import ( + DecodedRollupTransaction, + DecodedRollupTransactionData, + DecodedRollupTransactionDataArgs, + DecodedFinalizeTransactionDataArgs, + DecodedsubmitAppealDataArgs, + DecodedTopUpFeesDataArgs, +) + + +def test_top_level_deploy_uses_transactional_virtual_ghost_factory(): + processor = MagicMock() + processor.get_successful_ghost_creation_count.return_value = 0 + processor.is_genvm_contract_address.return_value = False + + address = endpoints._allocate_top_level_ghost_address(processor, 42) + + assert address == "0x4104e3b744E60be5E612b909f04D1547Fc87094a" + processor.lock_ghost_factory.assert_called_once_with() + processor.get_successful_ghost_creation_count.assert_called_once_with() + processor.is_genvm_contract_address.assert_called_once_with(address) + + +def test_top_level_deploy_rejects_reused_create2_address(): + processor = MagicMock() + processor.get_successful_ghost_creation_count.return_value = 4 + processor.is_genvm_contract_address.return_value = True + + with pytest.raises(endpoints.InvalidTransactionError, match="GhostAlreadyDeployed"): + endpoints._allocate_top_level_ghost_address(processor, 42) def test_fund_account_uses_request_scoped_session(monkeypatch): @@ -371,7 +401,9 @@ def test_send_raw_transaction_uses_request_session(monkeypatch): accounts_manager = MagicMock() accounts_manager.is_valid_address.return_value = True transactions_processor = MagicMock() + transactions_processor.get_transaction_by_hash.return_value = None transactions_processor.insert_transaction.return_value = None + transactions_processor.begin_evm_envelope.return_value = None constructed = [] @@ -428,6 +460,495 @@ def transactions_processor_factory(s): consensus_service.generate_transaction_hash.assert_called_once_with("0xdead") +def test_send_raw_transaction_rejects_foreign_chain_signature(monkeypatch): + accounts_manager = MagicMock() + decoded = SimpleNamespace( + chain_id=1, + from_address="0x" + "1" * 40, + value=0, + ) + transactions_parser = MagicMock() + transactions_parser.decode_signed_transaction.return_value = decoded + monkeypatch.setattr(endpoints, "AccountsManager", lambda _session: accounts_manager) + monkeypatch.setattr( + endpoints, "TransactionsProcessor", lambda _session: MagicMock() + ) + monkeypatch.setattr(endpoints, "get_simulator_chain_id", lambda: 61127) + + with pytest.raises(endpoints.InvalidTransactionError, match="InvalidChainId"): + endpoints.send_raw_transaction( + object(), + MagicMock(), + transactions_parser, + MagicMock(), + signed_rollup_transaction="0xdead", + ) + + transactions_parser.transaction_has_valid_signature.assert_not_called() + + +def test_send_raw_transaction_rejects_protocol_calldata_to_wrong_destination( + monkeypatch, +): + main = "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" + decoded = DecodedRollupTransaction( + from_address="0x" + "1" * 40, + to_address="0x" + "9" * 40, + data=DecodedRollupTransactionData( + function_name="addTransaction", + args=DecodedRollupTransactionDataArgs( + sender="0x" + "1" * 40, + recipient="0x" + "2" * 40, + num_of_initial_validators=5, + max_rotations=0, + data="0x", + ), + ), + type="2", + nonce=0, + value=0, + raw_data="0x35a251fb", + ) + parser = MagicMock() + parser.decode_signed_transaction.return_value = decoded + consensus_service = MagicMock() + consensus_service.public_consensus_main_address.return_value = main + monkeypatch.setattr(endpoints, "AccountsManager", lambda _session: MagicMock()) + monkeypatch.setattr( + endpoints, "TransactionsProcessor", lambda _session: MagicMock() + ) + + with pytest.raises( + endpoints.InvalidTransactionError, match="InvalidConsensusDestination" + ): + endpoints.send_raw_transaction( + object(), + MagicMock(), + parser, + consensus_service, + signed_rollup_transaction="0xdead", + ) + + +def test_send_raw_transaction_mines_unknown_consensus_selector_as_revert(monkeypatch): + main = "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" + decoded = DecodedRollupTransaction( + from_address="0x" + "1" * 40, + to_address=main, + data=None, + type="2", + nonce=0, + value=0, + raw_data="0xdeadbeef", + ) + parser = MagicMock() + parser.decode_signed_transaction.return_value = decoded + parser.transaction_has_valid_signature.return_value = True + consensus_service = MagicMock() + consensus_service.public_consensus_main_address.return_value = main + consensus_service.generate_transaction_hash.return_value = "0x" + "ab" * 32 + accounts_manager = MagicMock() + accounts_manager.is_valid_address.return_value = True + processor = MagicMock() + processor.begin_evm_envelope.side_effect = [None, None] + session = MagicMock() + monkeypatch.setattr(endpoints, "AccountsManager", lambda _session: accounts_manager) + monkeypatch.setattr(endpoints, "TransactionsProcessor", lambda _session: processor) + + result = endpoints.send_raw_transaction( + session, + MagicMock(), + parser, + consensus_service, + signed_rollup_transaction="0xdead", + ) + + assert result == "0x" + "ab" * 32 + processor.record_evm_envelope.assert_called_once_with( + result, + decoded.from_address, + 0, + result, + to_address=main, + success=False, + error="UnknownConsensusSelector", + ) + + +def test_send_raw_transaction_mines_fee_rejection_as_revert(monkeypatch): + main = "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" + sender = "0x" + "1" * 40 + envelope_hash = "0x" + "ab" * 32 + decoded = DecodedRollupTransaction( + from_address=sender, + to_address=main, + data=DecodedRollupTransactionData( + function_name="addTransaction", + args=DecodedRollupTransactionDataArgs( + sender=sender, + recipient="0x" + "2" * 40, + num_of_initial_validators=5, + max_rotations=0, + data="0x", + fees_distribution={}, + ), + ), + type="2", + nonce=0, + value=0, + raw_data="0x35a251fb", + ) + parser = MagicMock() + parser.decode_signed_transaction.return_value = decoded + parser.transaction_has_valid_signature.return_value = True + consensus_service = MagicMock() + consensus_service.public_consensus_main_address.return_value = main + consensus_service.generate_transaction_hash.return_value = envelope_hash + accounts_manager = MagicMock() + accounts_manager.is_valid_address.return_value = True + accounts_manager.get_account.return_value = object() + processor = MagicMock() + processor.begin_evm_envelope.side_effect = [None, None] + session = MagicMock() + monkeypatch.setattr(endpoints, "AccountsManager", lambda _session: accounts_manager) + monkeypatch.setattr(endpoints, "TransactionsProcessor", lambda _session: processor) + + def reject_fee_envelope(_decoded): + raise endpoints.InvalidTransactionError("InsufficientFees") + + monkeypatch.setattr( + endpoints, + "_validate_fee_envelope", + reject_fee_envelope, + ) + + result = endpoints.send_raw_transaction( + session, + MagicMock(), + parser, + consensus_service, + signed_rollup_transaction="0xdead", + ) + + assert result == envelope_hash + parser.get_genlayer_transaction.assert_not_called() + assert processor.begin_evm_envelope.call_count == 2 + processor.record_evm_envelope.assert_called_once_with( + envelope_hash, + sender, + 0, + envelope_hash, + to_address=main, + success=False, + error="InsufficientFees", + ) + session.rollback.assert_called_once() + session.commit.assert_called_once() + + +def test_send_raw_lifecycle_call_returns_signed_envelope_hash(monkeypatch): + main = "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" + sender = "0x" + "1" * 40 + envelope_hash = "0x" + "ab" * 32 + target_tx_id = "0x" + "cd" * 32 + decoded = DecodedRollupTransaction( + from_address=sender, + to_address=main, + data=DecodedTopUpFeesDataArgs( + tx_id=target_tx_id, + fees_distribution={}, + ), + type="2", + nonce=0, + value=1, + fee_value=1, + raw_data="0xdeadbeef", + ) + session = MagicMock() + accounts_manager = MagicMock() + accounts_manager.get_account.return_value = object() + transactions_processor = MagicMock() + transactions_processor.begin_evm_envelope.return_value = None + parser = MagicMock() + parser.decode_signed_transaction.return_value = decoded + parser.transaction_has_valid_signature.return_value = True + consensus_service = MagicMock() + consensus_service.public_consensus_main_address.return_value = main + consensus_service.generate_transaction_hash.return_value = envelope_hash + monkeypatch.setattr(endpoints, "AccountsManager", lambda _session: accounts_manager) + monkeypatch.setattr( + endpoints, "TransactionsProcessor", lambda _session: transactions_processor + ) + handler = MagicMock(return_value=target_tx_id) + monkeypatch.setattr(endpoints, "_handle_top_up_fees", handler) + + result = endpoints.send_raw_transaction( + session, + MagicMock(), + parser, + consensus_service, + signed_rollup_transaction="0xdead", + ) + + assert result == envelope_hash + handler.assert_called_once() + transactions_processor.record_evm_envelope.assert_called_once_with( + envelope_hash, + sender, + 0, + envelope_hash, + to_address=main, + ) + session.commit.assert_called_once() + + +def test_send_raw_appeal_publishes_event_only_after_envelope_commit(monkeypatch): + main = "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" + sender = "0x" + "1" * 40 + envelope_hash = "0x" + "ab" * 32 + target_tx_id = "0x" + "cd" * 32 + decoded = DecodedRollupTransaction( + from_address=sender, + to_address=main, + data=DecodedsubmitAppealDataArgs( + tx_id=target_tx_id, + expected_decision_id=1, + ), + type="2", + nonce=0, + value=1, + raw_data="0xdeadbeef", + ) + session = MagicMock() + accounts_manager = MagicMock() + accounts_manager.get_account.return_value = object() + transactions_processor = MagicMock() + transactions_processor.begin_evm_envelope.return_value = None + parser = MagicMock() + parser.decode_signed_transaction.return_value = decoded + parser.transaction_has_valid_signature.return_value = True + consensus_service = MagicMock() + consensus_service.public_consensus_main_address.return_value = main + consensus_service.generate_transaction_hash.return_value = envelope_hash + monkeypatch.setattr(endpoints, "AccountsManager", lambda _session: accounts_manager) + monkeypatch.setattr( + endpoints, "TransactionsProcessor", lambda _session: transactions_processor + ) + handler = MagicMock(return_value=target_tx_id) + monkeypatch.setattr(endpoints, "_handle_appeal_or_top_up_and_submit", handler) + publish_commit_states = [] + msg_handler = MagicMock() + msg_handler.send_message.side_effect = ( + lambda **_kwargs: publish_commit_states.append(session.commit.called) + ) + + result = endpoints.send_raw_transaction( + session, + msg_handler, + parser, + consensus_service, + signed_rollup_transaction="0xdead", + ) + + assert result == envelope_hash + assert handler.call_args.kwargs["emit_event"] is False + assert publish_commit_states == [True] + session.commit.assert_called_once() + + +def test_reverted_raw_lifecycle_call_consumes_nonce_and_returns_envelope_hash( + monkeypatch, +): + main = "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" + sender = "0x" + "1" * 40 + envelope_hash = "0x" + "ab" * 32 + decoded = DecodedRollupTransaction( + from_address=sender, + to_address=main, + data=DecodedFinalizeTransactionDataArgs( + tx_id="0x" + "cd" * 32, + expected_decision_id=1, + ), + type="2", + nonce=0, + value=1, + raw_data="0xdeadbeef", + ) + session = MagicMock() + accounts_manager = MagicMock() + accounts_manager.is_valid_address.return_value = True + accounts_manager.get_account.return_value = object() + transactions_processor = MagicMock() + transactions_processor.begin_evm_envelope.side_effect = [None, None] + parser = MagicMock() + parser.decode_signed_transaction.return_value = decoded + parser.transaction_has_valid_signature.return_value = True + consensus_service = MagicMock() + consensus_service.public_consensus_main_address.return_value = main + consensus_service.generate_transaction_hash.return_value = envelope_hash + monkeypatch.setattr(endpoints, "AccountsManager", lambda _session: accounts_manager) + monkeypatch.setattr( + endpoints, "TransactionsProcessor", lambda _session: transactions_processor + ) + + result = endpoints.send_raw_transaction( + session, + MagicMock(), + parser, + consensus_service, + signed_rollup_transaction="0xdead", + ) + + assert result == envelope_hash + assert transactions_processor.begin_evm_envelope.call_count == 2 + transactions_processor.record_evm_envelope.assert_called_once_with( + envelope_hash, + sender, + 0, + envelope_hash, + to_address=main, + success=False, + error="NonPayableCall", + ) + session.rollback.assert_called_once() + session.commit.assert_called_once() + + +def test_send_raw_transaction_rejects_missing_contract_before_shadow_mutation( + monkeypatch, +): + session = object() + sender = "0x" + "1" * 40 + recipient = "0x" + "2" * 40 + accounts_manager = MagicMock() + accounts_manager.is_valid_address.return_value = True + accounts_manager.get_account.return_value = object() + transactions_processor = MagicMock() + transactions_processor.get_transaction_by_hash.return_value = None + transactions_processor.begin_evm_envelope.return_value = None + + monkeypatch.setattr(endpoints, "AccountsManager", lambda _session: accounts_manager) + monkeypatch.setattr( + endpoints, + "TransactionsProcessor", + lambda _session: transactions_processor, + ) + monkeypatch.setattr(endpoints, "live_state_column_size", lambda *_args: None) + + decoded = SimpleNamespace( + from_address=sender, + value=0, + total_spend=0, + data=object(), + raw_data="0x35a251fb", + to_address=recipient, + nonce=1, + ) + genlayer_tx = SimpleNamespace( + type=TransactionType.RUN_CONTRACT, + from_address=sender, + to_address=recipient, + max_rotations=0, + num_of_initial_validators=5, + data=SimpleNamespace( + leader_only=False, + execution_mode="NORMAL", + calldata=b"payload", + ), + ) + transactions_parser = MagicMock() + transactions_parser.decode_signed_transaction.return_value = decoded + transactions_parser.transaction_has_valid_signature.return_value = True + transactions_parser.get_genlayer_transaction.return_value = genlayer_tx + consensus_service = MagicMock() + consensus_service.generate_transaction_hash.return_value = "0xhash" + + with pytest.raises(endpoints.NotFoundError, match="Contract not found"): + endpoints.send_raw_transaction( + session, + MagicMock(), + transactions_parser, + consensus_service, + signed_rollup_transaction="0xdead", + ) + + consensus_service.add_transaction.assert_not_called() + transactions_processor.insert_transaction.assert_not_called() + + +def test_send_raw_transaction_uses_recovered_signer_without_shadow_mutation( + monkeypatch, +): + session = object() + signer = "0x" + "1" * 40 + claimed_sender = "0x" + "9" * 40 + recipient = "0x" + "2" * 40 + accounts_manager = MagicMock() + accounts_manager.is_valid_address.return_value = True + accounts_manager.get_account.return_value = object() + transactions_processor = MagicMock() + transactions_processor.get_transaction_by_hash.return_value = None + transactions_processor.get_transaction_status.return_value = "PENDING" + transactions_processor.begin_evm_envelope.return_value = None + + monkeypatch.setattr(endpoints, "AccountsManager", lambda _session: accounts_manager) + monkeypatch.setattr( + endpoints, + "TransactionsProcessor", + lambda _session: transactions_processor, + ) + monkeypatch.setattr(endpoints, "live_state_column_size", lambda *_args: 1) + monkeypatch.setattr( + endpoints, "_enforce_pending_queue_caps", lambda **_kwargs: None + ) + monkeypatch.setattr( + endpoints, + "enforce_contract_storage_quota", + lambda *_args: None, + ) + + decoded = SimpleNamespace( + from_address=signer, + value=0, + total_spend=0, + data=object(), + raw_data="0x35a251fb", + to_address=recipient, + nonce=1, + ) + genlayer_tx = SimpleNamespace( + type=TransactionType.RUN_CONTRACT, + from_address=claimed_sender, + to_address=recipient, + max_rotations=0, + num_of_initial_validators=5, + data=SimpleNamespace( + leader_only=False, + execution_mode="NORMAL", + calldata=b"payload", + ), + ) + transactions_parser = MagicMock() + transactions_parser.decode_signed_transaction.return_value = decoded + transactions_parser.transaction_has_valid_signature.return_value = True + transactions_parser.get_genlayer_transaction.return_value = genlayer_tx + consensus_service = MagicMock() + consensus_service.generate_transaction_hash.return_value = "0xhash" + + result = endpoints.send_raw_transaction( + session, + MagicMock(), + transactions_parser, + consensus_service, + signed_rollup_transaction="0xdead", + ) + + assert result == "0xhash" + assert genlayer_tx.from_address == signer + consensus_service.add_transaction.assert_not_called() + assert transactions_processor.insert_transaction.call_args.args[0] == signer + + def test_update_transaction_status_uses_request_session(monkeypatch): session = object() transactions_processor = MagicMock() diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 7dad87d0a..a593ca7eb 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -10,6 +10,7 @@ from backend.consensus.base import ( ConsensusAlgorithm, + _author_message_phase_locally, _apply_external_message_freeze_check, _apply_message_value_withdrawals_for_phase, _child_config_rotation_rounds, @@ -17,6 +18,7 @@ _emit_messages, _get_messages_data, _runtime_rotation_limit, + _studio_child_transaction_id, _validators_in_frozen_selection_pool, ) from backend.consensus.history import ( @@ -54,9 +56,9 @@ sim_min_message_primary_fees, ) from backend.protocol_rpc.fees import ( + ArithmeticOverflow, AllocationDuplicateKey, AllocationLifecycleBudgetInsufficient, - AllocationSubtreeMismatch, AllocationTreeBudgetInconsistent, AllocationTreeMalformed, AllocationTreeTooDeep, @@ -131,6 +133,7 @@ refund_failed_external_message_fee, required_fee_deposit, settle_fee_accounting, + _settlement_storage_recipient_count, stamp_receipt_execution_policy, studio_fee_config, successful_appeal_profit, @@ -443,6 +446,37 @@ def test_calculate_round_fees_rejects_invalid_appeal_rounds(): calculate_round_fees(fees_distribution, 5) +def test_calculate_round_fees_rejects_uint256_work_overflow(): + fees_distribution = _fees_distribution( + leader_timeunits=2**256 - 1, + validator_timeunits=1, + ) + + with pytest.raises(ArithmeticOverflow): + calculate_round_fees(fees_distribution, 5) + + +def test_calculate_round_fees_rejects_uint256_execution_budget_overflow(): + fees_distribution = _fees_distribution( + execution_budget_per_round=2**256 - 1, + ) + + with pytest.raises(ArithmeticOverflow): + calculate_round_fees(fees_distribution, 5) + + +def test_successful_appeal_reward_uses_consensus_overflow_avoiding_arrangement(): + # bond * 5 would overflow here, but AppealEconomics computes bond * 2 + + # bond / 2 and the result is representable. + bond = (2**256 - 1) // 4 + assert successful_appeal_profit(bond) == bond * 3 // 2 + + +def test_successful_appeal_reward_rejects_unrepresentable_result(): + with pytest.raises(ArithmeticOverflow): + successful_appeal_profit((2**256 - 1) // 2) + + def test_calculate_round_fees_applies_gen_per_time_unit_multiplier(): fees_distribution = _fees_distribution(max_price_gen_per_time_unit=12) policy = StudioFeePolicy(gen_per_time_unit=10) @@ -1180,6 +1214,67 @@ def test_endpoint_fee_envelope_accepts_exact_fee_deposit(): _validate_fee_envelope(decoded) +def test_fee_enabled_endpoint_rejects_legacy_submission_without_distribution( + monkeypatch, +): + monkeypatch.setattr( + StudioFeePolicy, + "from_env", + classmethod(lambda cls: StudioFeePolicy(gen_per_time_unit=1)), + ) + decoded = DecodedRollupTransaction( + from_address="0x1111111111111111111111111111111111111111", + to_address="0x0000000000000000000000000000000000000000", + data=DecodedRollupTransactionData( + function_name="addTransaction", + args=DecodedRollupTransactionDataArgs( + sender="0x1111111111111111111111111111111111111111", + recipient="0x2222222222222222222222222222222222222222", + num_of_initial_validators=5, + max_rotations=0, + data="0x", + fees_distribution=None, + ), + ), + type="2", + nonce=0, + value=0, + ) + + with pytest.raises(InvalidTransactionError, match="FeesDistributionMissing"): + _validate_fee_envelope(decoded) + + +def test_endpoint_rejects_zero_salt_for_deploy_salted(monkeypatch): + monkeypatch.setattr( + StudioFeePolicy, + "from_env", + classmethod(lambda cls: StudioFeePolicy()), + ) + decoded = DecodedRollupTransaction( + from_address="0x1111111111111111111111111111111111111111", + to_address="0x0000000000000000000000000000000000000000", + data=DecodedRollupTransactionData( + function_name="deploySalted", + args=DecodedRollupTransactionDataArgs( + sender="0x1111111111111111111111111111111111111111", + recipient="0x2222222222222222222222222222222222222222", + num_of_initial_validators=5, + max_rotations=0, + salt_nonce=0, + data="0x", + fees_distribution=None, + ), + ), + type="2", + nonce=0, + value=0, + ) + + with pytest.raises(InvalidTransactionError, match="InvalidDeploymentWithSalt"): + _validate_fee_envelope(decoded) + + def test_submission_preserves_largest_funded_round_as_global_rotation_cap(): fees_distribution = _env_fees_distribution( appeals=2, @@ -1359,7 +1454,7 @@ def insert_transaction(self, *args, **kwargs): ) -def test_internal_deployment_binds_local_child_to_helper_created_recipient(): +def test_internal_deployment_binds_local_child_to_authoritative_recipient(): calls = [] created_accounts = [] @@ -1368,8 +1463,8 @@ def insert_transaction(self, *args, **kwargs): calls.append((args, kwargs)) class _Accounts: - def create_new_account_with_address(self, address): - created_accounts.append(address) + def create_new_account_with_address(self, address, *, commit=True): + created_accounts.append((address, commit)) context = SimpleNamespace( transaction=SimpleNamespace( @@ -1408,7 +1503,7 @@ def create_new_account_with_address(self, address): "accepted", ) - assert created_accounts == [Web3.to_checksum_address(helper_recipient)] + assert created_accounts == [(Web3.to_checksum_address(helper_recipient), False)] assert calls[0][0][1] == Web3.to_checksum_address(helper_recipient) assert child_data["contract_address"] == Web3.to_checksum_address(helper_recipient) @@ -1557,8 +1652,9 @@ class _Processor: def insert_transaction(self, *args, **kwargs): insert_calls.append((args, kwargs)) - def mutate_transaction_fee_accounting(self, tx_hash, mutator): + def mutate_transaction_fee_accounting(self, tx_hash, mutator, *, commit=True): assert tx_hash == parent_hash + assert commit is False updated = mutator(self.accounting) self.accounting = updated return updated @@ -1614,6 +1710,165 @@ def credit_account_balance(self, address, amount): assert record["skippedRefundAmount"] == expected_account_refund +def test_local_message_authority_applies_ghost_factory_and_per_child_failures(): + registered = "0x2222222222222222222222222222222222222222" + missing = "0x3333333333333333333333333333333333333333" + parent_hash = "0x" + ("11" * 32) + + class _Processor: + def __init__(self): + self.ghosts = {registered.lower()} + self.locked = False + + def lock_ghost_factory(self): + self.locked = True + + def lock_pending_recipients(self, addresses): + self.locked_recipients = list(addresses) + + def get_successful_ghost_creation_count(self): + return 0 + + def is_genvm_contract_address(self, address): + return str(address).lower() in self.ghosts + + def get_pending_transaction_count_for_address(self, _address): + return 0 + + processor = _Processor() + context = SimpleNamespace( + transaction=SimpleNamespace(hash=parent_hash), + transactions_processor=processor, + ) + + def child(recipient, tx_type, occurrence, salt=0): + return [ + recipient, + {}, + tx_type, + 0, + 0, + occurrence, + {"saltNonce": salt, "data": b"\x01"}, + ] + + first_occurrence = "0x" + ("01" * 32) + missing_occurrence = "0x" + ("02" * 32) + deploy_occurrence = "0x" + ("03" * 32) + duplicate_occurrence = "0x" + ("04" * 32) + receipt = _author_message_phase_locally( + context, + [ + child(registered, TransactionType.RUN_CONTRACT.value, first_occurrence), + child(missing, TransactionType.RUN_CONTRACT.value, missing_occurrence), + child( + "0x0000000000000000000000000000000000000000", + TransactionType.DEPLOY_CONTRACT.value, + deploy_occurrence, + 42, + ), + child( + "0x0000000000000000000000000000000000000000", + TransactionType.DEPLOY_CONTRACT.value, + duplicate_occurrence, + 42, + ), + ], + "accepted", + ) + + zero = "0x" + ("00" * 32) + assert processor.locked is True + assert {address.lower() for address in processor.locked_recipients} == { + registered.lower(), + "0x4104e3b744e60be5e612b909f04d1547fc87094a", + } + assert receipt["tx_ids_hex"] == [ + _studio_child_transaction_id(parent_hash, "accepted", first_occurrence), + zero, + _studio_child_transaction_id(parent_hash, "accepted", deploy_occurrence), + zero, + ] + assert receipt["recipients"][0] == Web3.to_checksum_address(registered) + assert receipt["recipients"][1] == Web3.to_checksum_address(missing) + assert receipt["recipients"][2] == "0x4104e3b744E60be5E612b909f04D1547Fc87094a" + assert receipt["recipients"][3] == Web3.to_checksum_address( + "0x0000000000000000000000000000000000000000" + ) + + +def test_local_message_authority_enforces_recipient_queue_capacity(monkeypatch): + monkeypatch.setenv("MAX_PENDING_PER_CONTRACT_DEFAULT", "20") + recipient = "0x2222222222222222222222222222222222222222" + + processor = SimpleNamespace( + lock_ghost_factory=lambda: None, + lock_pending_recipients=lambda _addresses: None, + get_successful_ghost_creation_count=lambda: 0, + is_genvm_contract_address=lambda address: address.lower() == recipient.lower(), + get_pending_transaction_count_for_address=lambda _address: 20, + ) + occurrence = "0x" + ("05" * 32) + receipt = _author_message_phase_locally( + SimpleNamespace( + transaction=SimpleNamespace(hash="0x" + ("22" * 32)), + transactions_processor=processor, + ), + [ + [ + recipient, + {}, + TransactionType.RUN_CONTRACT.value, + 0, + 0, + occurrence, + {"saltNonce": 0, "data": b"\x01"}, + ] + ], + "finalized", + ) + + assert receipt == { + "tx_ids_hex": ["0x" + ("00" * 32)], + "recipients": [Web3.to_checksum_address(recipient)], + } + + +def test_local_message_authority_skips_empty_payload_before_deployment(): + processor = SimpleNamespace( + lock_ghost_factory=lambda: None, + lock_pending_recipients=lambda _addresses: None, + get_successful_ghost_creation_count=lambda: 0, + is_genvm_contract_address=lambda _address: False, + get_pending_transaction_count_for_address=lambda _address: 0, + ) + receipt = _author_message_phase_locally( + SimpleNamespace( + transaction=SimpleNamespace(hash="0x" + ("23" * 32)), + transactions_processor=processor, + ), + [ + [ + "0x0000000000000000000000000000000000000000", + {}, + TransactionType.DEPLOY_CONTRACT.value, + 0, + 0, + "0x" + ("06" * 32), + {"saltNonce": 0, "data": b""}, + ] + ], + "accepted", + ) + + assert receipt == { + "tx_ids_hex": ["0x" + ("00" * 32)], + "recipients": [ + Web3.to_checksum_address("0x0000000000000000000000000000000000000000") + ], + } + + def test_simulation_fee_accounting_accepts_sdk_style_fee_options(): policy = StudioFeePolicy.from_env() fees_distribution = _env_fees_distribution( @@ -3254,23 +3509,6 @@ def test_simulation_fee_consumption_fills_mode2_payload_from_allocation(): StudioFeePolicy(), ) message = recorded["execution_fee_report"]["messageReveal"]["messages"][0] - expected_subtree = encode( - [MESSAGE_ALLOCATION_NODE_ABI_TYPE], - [ - [ - ( - 1, - True, - NODE_ROOT_SENTINEL, - recipient, - bytes.fromhex(CALL_KEY_WILDCARD[2:]), - 75, - fee_params, - ) - ] - ], - ) - assert recorded["execution_fee_consumed"] == 80 assert recorded["execution_fee_consumed_buckets"] == [80, 0] assert recorded["genvm_fee_consumed_buckets"] == [80, 0, 55] @@ -3289,8 +3527,8 @@ def test_simulation_fee_consumption_fills_mode2_payload_from_allocation(): assert message["messageFeeMode"] == "mode2" assert message["feeParams"] == "0x" + fee_params.hex() assert message["declaredBudget"] == 75 - assert message["allocationSubtree"] == "0x" + expected_subtree.hex() - assert message["allocationSubtreeBytes"] == len(expected_subtree) + assert message["allocationSubtree"] == "0x" + assert message["allocationSubtreeBytes"] == 0 assert recorded_again["message_fee_consumed"] == 75 assert recorded_again["allocation_consumed"] == {"0": 75} @@ -3494,7 +3732,7 @@ def test_simulation_fee_report_labels_prepopulated_allocation_messages_as_mode2( assert message["messageFeeMode"] == "mode2" -def test_simulation_fee_report_fills_missing_subtree_for_prepopulated_mode2_message(): +def test_flat_array_simulation_fee_report_preserves_missing_receipt_subtree(): fee_params = _encode_internal_fee_params() child_fee_params = _encode_internal_fee_params() recipient = "0x2222222222222222222222222222222222222222" @@ -3539,37 +3777,11 @@ def test_simulation_fee_report_fills_missing_subtree_for_prepopulated_mode2_mess recorded = record_execution_fee_consumption(accounting, receipt, StudioFeePolicy()) message = recorded["execution_fee_report"]["messageReveal"]["messages"][0] - expected_subtree = encode( - [MESSAGE_ALLOCATION_NODE_ABI_TYPE], - [ - [ - ( - 1, - True, - NODE_ROOT_SENTINEL, - recipient, - bytes.fromhex("12" * 32), - 110, - fee_params, - ), - ( - 1, - True, - 0, - child_recipient, - bytes.fromhex("34" * 32), - 55, - child_fee_params, - ), - ] - ], - ) - assert recorded["message_fee_consumed"] == 110 assert recorded["allocation_consumed"] == {"0": 110} assert message["messageFeeMode"] == "mode2" - assert message["allocationSubtree"] == "0x" + expected_subtree.hex() - assert message["allocationSubtreeBytes"] == len(expected_subtree) + assert message["allocationSubtree"] == "0x" + assert message["allocationSubtreeBytes"] == 0 def test_simulation_fee_consumption_records_external_allocation_reservation(): @@ -5480,6 +5692,203 @@ def test_failed_leader_appeal_dv_replay_withholds_leader_and_refunds_bond_dust() assert settled["appeal_bond_settlements"][0]["bondDistributed"] == 2_298 +def test_failed_leader_appeal_bond_recipients_share_storage_with_zero_validator_work(): + leader = f"0x{20:040x}" + replay_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote="agree" if index < 23 else "disagree", + ) + for index in range(20, 25) + ] + history = { + "consensus_results": [ + {"consensus_round": "Undetermined"}, + { + "consensus_round": "Leader Appeal Failed", + "leader_result": [ + _history_receipt(mode="leader", address=leader), + replay_validators[0], + ], + "validator_results": replay_validators[1:], + }, + ] + } + + recipients = _settlement_storage_recipient_count( + history, + [ + { + "round": 2, + "outcome": "Leader Appeal Failed", + "rule": "leader_appeal_replay", + "rotations": 0, + "timeUnitAmount": 100, + } + ], + _fees_distribution(leader_timeunits=100, validator_timeunits=0), + StudioFeePolicy(), + "NORMAL", + bond_settlements=[ + { + "outcomeRound": 2, + "bondDistributed": 2_298, + } + ], + ) + + # Three aligned replay voters receive the bond; the leader overlaps the + # first of those seats, so the unique on-chain distribution index has 3. + assert recipients == 3 + + +def test_zero_failed_leader_appeal_bond_does_not_create_storage_recipients(): + leader = f"0x{20:040x}" + replay_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote="agree" if index < 23 else "disagree", + ) + for index in range(20, 25) + ] + history = { + "consensus_results": [ + {"consensus_round": "Undetermined"}, + { + "consensus_round": "Leader Appeal Failed", + "leader_result": [ + _history_receipt(mode="leader", address=leader), + replay_validators[0], + ], + "validator_results": replay_validators[1:], + }, + ] + } + + recipients = _settlement_storage_recipient_count( + history, + [ + { + "round": 2, + "outcome": "Leader Appeal Failed", + "rule": "leader_appeal_replay", + "rotations": 0, + "timeUnitAmount": 0, + } + ], + _fees_distribution(leader_timeunits=0, validator_timeunits=0), + StudioFeePolicy(), + "NORMAL", + bond_settlements=[ + { + "outcomeRound": 2, + "bondDistributed": 0, + } + ], + ) + + assert recipients == 0 + + +def test_zero_failed_timeout_leader_share_does_not_create_storage_recipient(): + leader = "0x2222222222222222222222222222222222222222" + history = { + "consensus_results": [ + {"consensus_round": "Leader Timeout"}, + { + "consensus_round": "Leader Timeout Appeal Failed", + "leader_result": [ + _history_receipt(mode="leader", address=leader, timeout=True) + ], + "validator_results": [], + }, + ] + } + settlement_rounds = [ + { + "round": 2, + "outcome": "Leader Timeout Appeal Failed", + "rule": "post_timeout_appeal_repeated_timeout", + "rotations": 0, + "timeUnitAmount": 0, + } + ] + fees = _fees_distribution(leader_timeunits=0, validator_timeunits=0) + + no_payout = _settlement_storage_recipient_count( + history, + settlement_rounds, + fees, + StudioFeePolicy(), + "NORMAL", + bond_settlements=[{"outcomeRound": 2, "leaderPayout": 0}], + ) + paid = _settlement_storage_recipient_count( + history, + settlement_rounds, + fees, + StudioFeePolicy(), + "NORMAL", + bond_settlements=[{"outcomeRound": 2, "leaderPayout": 1}], + ) + + assert no_payout == 0 + assert paid == 1 + + +def test_zero_failed_validator_appeal_bond_does_not_create_storage_recipients(): + validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote="agree" if index < 3 else "disagree", + ) + for index in range(1, 6) + ] + history = { + "consensus_results": [ + {"consensus_round": "Accepted"}, + { + "consensus_round": "Validator Appeal Failed", + "leader_result": validators[0], + "validator_results": validators[1:], + }, + ] + } + settlement_rounds = [ + { + "round": 1, + "outcome": "Validator Appeal Failed", + "rule": "validator_appeal_failed_redistribution", + "rotations": 0, + "timeUnitAmount": 0, + } + ] + fees = _fees_distribution(leader_timeunits=0, validator_timeunits=0) + + no_payout = _settlement_storage_recipient_count( + history, + settlement_rounds, + fees, + StudioFeePolicy(), + "NORMAL", + bond_settlements=[{"outcomeRound": 1, "bondDistributed": 0}], + ) + paid = _settlement_storage_recipient_count( + history, + settlement_rounds, + fees, + StudioFeePolicy(), + "NORMAL", + bond_settlements=[{"outcomeRound": 1, "bondDistributed": 9}], + ) + + assert no_payout == 0 + assert paid == 3 + + def test_failed_leader_timeout_appeal_refunds_sender_half_of_bond(): fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) accounting = create_fee_accounting( @@ -5719,6 +6128,57 @@ def attempt(leader_index: int, eq_outputs_length: int) -> tuple[dict, list[dict] assert len(settled["historical_execution_attempts"]) == 2 +def test_settlement_does_not_fabricate_validator_pay_for_timeout_rotation(): + fees_distribution = _fees_distribution(rotations=[1]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + rotated_out = _history_receipt( + mode="leader", + address="0x1111111111111111111111111111111111111111", + ) + final_leader = _history_receipt( + mode="leader", + address="0x2222222222222222222222222222222222222222", + ) + final_validators = [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + vote="agree", + ) + for index in range(20, 25) + ] + history = { + "consensus_results": [ + { + "consensus_round": "Leader Rotation", + "leader_result": [rotated_out], + "validator_results": [], + }, + { + "consensus_round": "Accepted", + "leader_result": [final_leader, final_validators[0]], + "validator_results": final_validators[1:], + }, + ] + } + + settled, _ = settle_fee_accounting( + accounting, + actual_final_round=0, + num_of_validators=5, + consensus_history=history, + ) + + # Consensus pays the timed-out, rotated leader 50%, then pays the final + # normal attempt. The empty prior validator array is not five hidden votes. + assert settled["settlement_rounds"][0]["timeUnitAmount"] == 50 + 100 + 5 * 200 + + def test_settlement_preserves_each_attempts_live_metering_snapshot(): submission_policy = StudioFeePolicy(receipt_gas_price=2) fees_distribution = _fees_distribution( @@ -6211,14 +6671,16 @@ def test_fill_message_fee_payload_from_allocation_uses_matching_policy_and_subtr assert message["declaredBudget"] == 111 assert message["feeParams"].startswith("0x") - assert len(message["allocationSubtree"]) == 2 - assert message["allocationSubtree"][0]["parentIndex"] == NODE_ROOT_SENTINEL - assert message["allocationSubtree"][0]["budget"] == 111 - assert message["allocationSubtree"][1]["parentIndex"] == 0 - assert message["allocationSubtree"][1]["budget"] == 56 + assert "allocationSubtree" not in message + resolved = message["_studioResolvedAllocationSubtree"] + assert len(resolved) == 2 + assert resolved[0]["parentIndex"] == NODE_ROOT_SENTINEL + assert resolved[0]["budget"] == 111 + assert resolved[1]["parentIndex"] == 0 + assert resolved[1]["budget"] == 56 -def test_fill_message_fee_payload_rejects_mismatched_prepopulated_subtree(): +def test_flat_array_message_fee_payload_ignores_mismatched_receipt_subtree(): fee_params = _encode_internal_fee_params() child_fee_params = _encode_internal_fee_params(leader_timeunits=6) recipient = "0x2222222222222222222222222222222222222222" @@ -6242,25 +6704,28 @@ def test_fill_message_fee_payload_rejects_mismatched_prepopulated_subtree(): user_value=0, ) - with pytest.raises(AllocationSubtreeMismatch): - fill_message_fee_payload_from_allocation( - accounting, - { - "messageType": 1, - "recipient": recipient, - "onAcceptance": True, - "declaredBudget": 111, - "feeParams": fee_params, - "callKey": "0x" + "0" * 64, - "allocationSubtree": [ - _allocation( - recipient=recipient, - budget=111, - fee_params=fee_params, - ) - ], - }, + supplied = [ + _allocation( + recipient=recipient, + budget=111, + fee_params=fee_params, ) + ] + message = fill_message_fee_payload_from_allocation( + accounting, + { + "messageType": 1, + "recipient": recipient, + "onAcceptance": True, + "declaredBudget": 111, + "feeParams": fee_params, + "callKey": "0x" + "0" * 64, + "allocationSubtree": supplied, + }, + ) + + assert message["allocationSubtree"] == supplied + assert len(message["_studioResolvedAllocationSubtree"]) == 2 def test_mode2_message_fees_reject_missing_allocation_and_phase_mismatch(): @@ -8066,6 +8531,66 @@ def test_create_child_fee_accounting_prices_primary_at_child_owned_caps(): assert child_accounting["user_value"] == 7 +def test_zero_work_internal_child_and_zero_principal_appeal_match_consensus(): + fee_params = _encode_internal_fee_params( + leader_timeunits=0, + validator_timeunits=0, + appeals=1, + rotations=[0, 0], + execution_budget_per_round=0, + ) + child_fees, child_accounting = create_child_fee_accounting( + message={ + "messageType": 1, + "recipient": "0x3333333333333333333333333333333333333333", + "value": 0, + "onAcceptance": True, + "feeParams": fee_params, + "declaredBudget": 0, + "callKey": "0x" + "0" * 64, + }, + parent_fees_distribution=_fees_distribution(), + sender="0x1111111111111111111111111111111111111111", + ) + + assert child_fees["leaderTimeunitsAllocation"] == 0 + assert child_fees["validatorTimeunitsAllocation"] == 0 + assert child_accounting["paid_fee_value"] == 0 + assert child_accounting["primary_fee_required"] == 0 + + appealed = record_appeal_bond( + child_accounting, + amount=0, + appealer="0x1111111111111111111111111111111111111111", + current_round=0, + status="ACCEPTED", + available_appeal_validators=7, + ) + assert appealed["appeal_bonds"][0]["amount"] == 0 + assert appealed["appeal_bonds"][0]["requiredCharge"] == 0 + + +def test_zero_bond_does_not_make_one_seat_leader_timeout_appealable(): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(), + num_of_validators=5, + submitted_value=1100, + user_value=0, + ) + accounting["fees_distribution"]["leaderTimeunitsAllocation"] = 0 + accounting["fees_distribution"]["validatorTimeunitsAllocation"] = 0 + + with pytest.raises(InvalidAppealBond): + record_appeal_bond( + accounting, + amount=0, + appealer="0x1111111111111111111111111111111111111111", + current_round=0, + status="LEADER_TIMEOUT", + leader_timeout_live_seats=1, + ) + + def test_create_child_fee_accounting_rejects_budget_below_child_primary_fee(): with pytest.raises(MessageDeclaredBudgetInsufficient): create_child_fee_accounting( @@ -8207,7 +8732,7 @@ def __init__(self, fee_accounting): self.updated_hash = None self.fee_accounting = fee_accounting - def get_transaction_count(self, address): + def get_genlayer_transaction_count(self, address): return 3 def update_transaction_fee_accounting(self, tx_hash, accounting): @@ -8215,7 +8740,8 @@ def update_transaction_fee_accounting(self, tx_hash, accounting): self.updated_fee_accounting = accounting self.fee_accounting = accounting - def mutate_transaction_fee_accounting(self, tx_hash, mutator): + def mutate_transaction_fee_accounting(self, tx_hash, mutator, *, commit=True): + assert commit is False updated = mutator(self.fee_accounting) self.update_transaction_fee_accounting(tx_hash, updated) return updated @@ -8265,36 +8791,37 @@ class _MessageValueReservationProcessor: def __init__(self, fee_accounting): self.fee_accounting = fee_accounting - def mutate_transaction_fee_accounting(self, tx_hash, mutator): + def mutate_transaction_fee_accounting(self, tx_hash, mutator, *, commit=True): + assert commit is False self.fee_accounting = mutator(self.fee_accounting) return self.fee_accounting class _ExternalFreezeQuery: - def __init__(self, *, scalar_value=None, rows=None): - self.scalar_value = scalar_value + def __init__(self, *, one_value=None, rows=None): + self.one_value = one_value self.rows = rows or [] def filter(self, *args): return self def scalar(self): - return self.scalar_value + return self.one_value def all(self): return self.rows class _ExternalFreezeSession: - def __init__(self, *, current_created_at=None, accepted_rows=None): - self.current_created_at = current_created_at + def __init__(self, *, current_order=None, accepted_rows=None): + self.current_order = current_order self.accepted_rows = accepted_rows or [] self.query_count = 0 def query(self, *args): self.query_count += 1 if self.query_count == 1: - return _ExternalFreezeQuery(scalar_value=self.current_created_at) + return _ExternalFreezeQuery(one_value=self.current_order) return _ExternalFreezeQuery(rows=self.accepted_rows) @@ -8741,7 +9268,7 @@ def test_internal_deployment_descriptor_stays_zero_address_and_carries_salt(): assert pending.address == "0x" -def test_message_dispatch_fills_mode2_child_fee_accounting_from_allocation_subtree( +def test_flat_array_message_dispatch_ignores_bad_receipt_subtree_and_inherits_parent( monkeypatch, ): monkeypatch.setenv("GENLAYER_STUDIO_GEN_PER_TIME_UNIT", "1") @@ -8793,6 +9320,14 @@ def test_message_dispatch_fills_mode2_child_fee_accounting_from_allocation_subtr fee_params=b"", declared_budget=0, call_key=root_call_key, + allocation_subtree=[ + _allocation( + recipient=recipient, + call_key=root_call_key, + budget=root_budget, + fee_params=root_fee_params, + ) + ], ) _, inserts = _get_messages_data(context, [pending], "accepted") @@ -11482,7 +12017,7 @@ def test_transaction_payload_fees_null_when_fee_accounting_disabled(): assert parsed["txExecutionResultName"] == "NOT_VOTED" -def _acceptance_dispatch_context(calls, *, forwarding_skipped): +def _acceptance_dispatch_context(calls): """A parent owing an acceptance-phase emission that carries no children.""" accounting = { @@ -11496,10 +12031,19 @@ class _Processor: def insert_transaction(self, *args, **kwargs): calls.append((args, kwargs)) - def get_transaction_count(self, _address): + def get_genlayer_transaction_count(self, _address): return 0 - def mutate_transaction_fee_accounting(self, _hash, mutate): + def lock_ghost_factory(self): + return None + + def get_successful_ghost_creation_count(self): + return 0 + + def lock_pending_recipients(self, _recipients): + return None + + def mutate_transaction_fee_accounting(self, _hash, mutate, *, commit=True): transaction.data[FEE_ACCOUNTING_KEY] = mutate( transaction.data[FEE_ACCOUNTING_KEY] ) @@ -11514,14 +12058,9 @@ def mutate_transaction_fee_accounting(self, _hash, mutate): origin_address=None, data={FEE_ACCOUNTING_KEY: accounting}, ) - consensus_service = SimpleNamespace( - emit_transaction_event=lambda *args, **kwargs: None, - transaction_forwarding_skipped=lambda _account: forwarding_skipped, - ) return SimpleNamespace( transaction=transaction, transactions_processor=_Processor(), - consensus_service=consensus_service, ) @@ -11533,16 +12072,10 @@ def _empty_success_receipt(): ) -def test_acceptance_dispatch_completes_without_a_rollup(): - """Regression: this looped forever instead of committing. - - A parent owing an acceptance emission but carrying no children still - reaches _emit_messages. With no rollup attached the receipt is None by - design, which the strict check read as a lost emission and retried - forever, so acceptanceDispatched was never set. - """ +def test_acceptance_dispatch_completes_without_a_helper_rollup(): + """An empty acceptance phase commits through Studio's local authority.""" calls = [] - context = _acceptance_dispatch_context(calls, forwarding_skipped=True) + context = _acceptance_dispatch_context(calls) assert ( _dispatch_messages_for_phase(context, _empty_success_receipt(), "accepted") @@ -11555,14 +12088,6 @@ def test_acceptance_dispatch_completes_without_a_rollup(): assert generation["acceptanceDispatched"] is True -def test_acceptance_dispatch_still_fails_loudly_when_a_rollup_is_attached(): - calls = [] - context = _acceptance_dispatch_context(calls, forwarding_skipped=False) - - with pytest.raises(RuntimeError, match="InternalMessageEmissionFailed"): - _dispatch_messages_for_phase(context, _empty_success_receipt(), "accepted") - - def test_child_rotation_clamp_tolerates_a_null_parent_schedule(): """transactions.config_rotation_rounds is nullable. @@ -11602,3 +12127,25 @@ def test_triggered_child_insert_survives_a_parent_without_a_rotation_schedule(): assert len(calls) == 1 assert isinstance(calls[0][1]["config_rotation_rounds"], int) + + +def test_transaction_payload_exposes_only_consensus_child_transactions(): + transaction = _processor_transaction() + transaction.triggered_transactions = [ + SimpleNamespace(hash="0x" + "01" * 32, type=TransactionType.SEND.value), + SimpleNamespace( + hash="0x" + "02" * 32, + type=TransactionType.RUN_CONTRACT.value, + ), + SimpleNamespace( + hash="0x" + "03" * 32, + type=TransactionType.DEPLOY_CONTRACT.value, + ), + ] + + parsed = TransactionsProcessor._parse_transaction_data(transaction) + + assert parsed["triggered_transactions"] == [ + "0x" + "02" * 32, + "0x" + "03" * 32, + ] diff --git a/tests/unit/test_transactions_parser.py b/tests/unit/test_transactions_parser.py index 17246ada8..0cac0e83e 100644 --- a/tests/unit/test_transactions_parser.py +++ b/tests/unit/test_transactions_parser.py @@ -7,6 +7,13 @@ DecodedMethodSendData, DecodedDeploymentData, ) +from backend.protocol_rpc.types import ( + DecodedRollupTransaction, + DecodedRollupTransactionData, + DecodedRollupTransactionDataArgs, + ZERO_ADDRESS, +) +from backend.domain.types import TransactionType import re from typing import Optional, List, Any from rlp import encode @@ -30,7 +37,9 @@ def transaction_parser(): def test_default_consensus_abi_exports_exact_decision_guarded_actions(): - abi = get_default_consensus_main_contract()["abi"] + default_contract = get_default_consensus_main_contract() + assert default_contract["address"] == "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" + abi = default_contract["abi"] functions = { entry["name"]: entry for entry in abi if entry.get("type") == "function" } @@ -98,6 +107,15 @@ def test_default_consensus_abi_exports_exact_decision_guarded_actions(): "uint256", "bytes", ] + created = next( + entry + for entry in abi + if entry.get("type") == "event" and entry.get("name") == "CreatedTransaction" + ) + assert [(item["type"], item["indexed"]) for item in created["inputs"]] == [ + ("bytes32", True), + ("uint256", False), + ] def test_transaction_rpc_payload_stringifies_unsafe_fee_integers(): @@ -510,11 +528,12 @@ def test_decode_signed_transaction_fee_aware_v06(function_name, monkeypatch): message_allocations, ) + fee_aware_data = _fee_aware_call_data(parser, function_name, params) raw = _build_eip1559_raw( nonce=8, to=bytes.fromhex("0000000000000000000000000000000000000000"), value=70, - data=_fee_aware_call_data(parser, function_name, params), + data=fee_aware_data, ) decoded = parser.decode_signed_transaction(raw) @@ -524,6 +543,8 @@ def test_decode_signed_transaction_fee_aware_v06(function_name, monkeypatch): assert decoded.fee_value == 58 assert decoded.submitted_value == 70 assert decoded.total_spend == 70 + assert decoded.raw_data == "0x" + fee_aware_data.hex() + assert decoded.chain_id == 1 assert decoded.data.function_name == function_name assert decoded.data.args.sender == "0x3333333333333333333333333333333333333333" assert decoded.data.args.recipient == Web3.to_checksum_address(recipient) @@ -559,6 +580,35 @@ def test_decode_signed_transaction_fee_aware_v06(function_name, monkeypatch): } +def test_deploy_salted_selector_ignores_spoofed_nonzero_recipient( + transaction_parser, +): + deployment_payload = encode([b"class Contract: pass", b"init"]) + decoded = DecodedRollupTransaction( + from_address="0x1111111111111111111111111111111111111111", + to_address=ZERO_ADDRESS, + data=DecodedRollupTransactionData( + function_name="deploySalted", + args=DecodedRollupTransactionDataArgs( + sender="0x1111111111111111111111111111111111111111", + recipient="0x2222222222222222222222222222222222222222", + num_of_initial_validators=5, + max_rotations=0, + salt_nonce=7, + data="0x" + deployment_payload.hex(), + ), + ), + type="2", + nonce=0, + value=0, + ) + + parsed = transaction_parser.get_genlayer_transaction(decoded) + + assert parsed.type == TransactionType.DEPLOY_CONTRACT + assert parsed.to_address == ZERO_ADDRESS + + @pytest.mark.parametrize( "function_name,top_up_and_submit", [ diff --git a/tests/unit/test_transactions_processor_improvements.py b/tests/unit/test_transactions_processor_improvements.py index 1643e3076..b305b54ae 100644 --- a/tests/unit/test_transactions_processor_improvements.py +++ b/tests/unit/test_transactions_processor_improvements.py @@ -2,7 +2,8 @@ from unittest.mock import Mock, patch from backend.database_handler.transactions_processor import TransactionsProcessor -from backend.database_handler.models import TransactionStatus, Transactions +from backend.database_handler.models import EvmEnvelope, TransactionStatus, Transactions +from sqlalchemy import func from sqlalchemy.orm import Session @@ -151,7 +152,7 @@ def test_get_transaction_count_with_checksum_address(self): # Mock database query mock_query = Mock() mock_query.filter.return_value = mock_query - mock_query.count.return_value = 5 + mock_query.scalar.return_value = 4 self.mock_session.query.return_value = mock_query # Execute @@ -159,12 +160,13 @@ def test_get_transaction_count_with_checksum_address(self): # Verify self.mock_web3.to_checksum_address.assert_called_once_with(test_address) - self.mock_session.query.assert_called_once_with(Transactions) + query_expression = self.mock_session.query.call_args.args[0] + assert str(query_expression) == str(func.max(EvmEnvelope.nonce)) mock_query.filter.assert_called_once() - # Verify the filter checks from_address with checksum address + # EVM address identity is case-insensitive in the durable nonce ledger. filter_call = mock_query.filter.call_args[0][0] - assert str(filter_call.left) == "transactions.from_address" - assert str(filter_call.right.value) == checksum_address + assert str(filter_call.left) == "evm_envelopes.from_address" + assert str(filter_call.right.value) == checksum_address.lower() assert result == 5 def test_get_transaction_count_with_invalid_address(self): @@ -177,7 +179,7 @@ def test_get_transaction_count_with_invalid_address(self): # Mock database query with original address mock_query = Mock() mock_query.filter.return_value = mock_query - mock_query.count.return_value = 3 + mock_query.scalar.return_value = 2 self.mock_session.query.return_value = mock_query # Execute @@ -185,11 +187,12 @@ def test_get_transaction_count_with_invalid_address(self): # Verify - should use original address after checksum fails self.mock_web3.to_checksum_address.assert_called_once_with(test_address) - self.mock_session.query.assert_called_once_with(Transactions) + query_expression = self.mock_session.query.call_args.args[0] + assert str(query_expression) == str(func.max(EvmEnvelope.nonce)) mock_query.filter.assert_called_once() # Verify the filter uses the original address since checksum failed filter_call = mock_query.filter.call_args[0][0] - assert str(filter_call.left) == "transactions.from_address" + assert str(filter_call.left) == "evm_envelopes.from_address" assert str(filter_call.right.value) == test_address assert result == 3 @@ -203,7 +206,7 @@ def test_get_transaction_count_returns_zero_when_no_transactions(self): # Mock database query returning 0 mock_query = Mock() mock_query.filter.return_value = mock_query - mock_query.count.return_value = 0 + mock_query.scalar.return_value = None self.mock_session.query.return_value = mock_query # Execute @@ -211,9 +214,10 @@ def test_get_transaction_count_returns_zero_when_no_transactions(self): # Verify assert result == 0 - self.mock_session.query.assert_called_once_with(Transactions) + query_expression = self.mock_session.query.call_args.args[0] + assert str(query_expression) == str(func.max(EvmEnvelope.nonce)) mock_query.filter.assert_called_once() - mock_query.count.assert_called_once() + mock_query.scalar.assert_called_once() def test_get_transaction_count_with_multiple_transactions(self): """Test get_transaction_count correctly counts multiple transactions""" @@ -225,7 +229,7 @@ def test_get_transaction_count_with_multiple_transactions(self): # Mock database query returning count of 7 mock_query = Mock() mock_query.filter.return_value = mock_query - mock_query.count.return_value = 7 + mock_query.scalar.return_value = 6 self.mock_session.query.return_value = mock_query # Execute @@ -233,9 +237,10 @@ def test_get_transaction_count_with_multiple_transactions(self): # Verify database is queried and correct count returned assert result == 7 - self.mock_session.query.assert_called_once_with(Transactions) + query_expression = self.mock_session.query.call_args.args[0] + assert str(query_expression) == str(func.max(EvmEnvelope.nonce)) mock_query.filter.assert_called_once() - mock_query.count.assert_called_once() + mock_query.scalar.assert_called_once() def test_get_transaction_count_database_query_structure(self): """Test that get_transaction_count queries database with correct structure""" @@ -248,19 +253,43 @@ def test_get_transaction_count_database_query_structure(self): mock_query = Mock() mock_filter = Mock() mock_query.filter.return_value = mock_filter - mock_filter.count.return_value = 8 + mock_filter.scalar.return_value = 7 self.mock_session.query.return_value = mock_query # Execute result = self.processor.get_transaction_count(test_address) # Verify correct database query structure - self.mock_session.query.assert_called_once_with(Transactions) + query_expression = self.mock_session.query.call_args.args[0] + assert str(query_expression) == str(func.max(EvmEnvelope.nonce)) mock_query.filter.assert_called_once() - mock_filter.count.assert_called_once() + mock_filter.scalar.assert_called_once() assert result == 8 +def test_get_genlayer_transaction_count_keeps_child_sequence_separate_from_evm_nonce(): + session = Mock(spec=Session) + query = Mock() + query.filter.return_value = query + query.count.return_value = 4 + session.query.return_value = query + processor = TransactionsProcessor(session) + processor.web3 = Mock() + processor.web3.to_checksum_address.return_value = ( + "0xABcdEF1234567890aBcDef1234567890AbCdEf12" + ) + + assert ( + processor.get_genlayer_transaction_count( + "0xabcdef1234567890abcdef1234567890abcdef12" + ) + == 4 + ) + session.query.assert_called_once_with(Transactions) + filtered = query.filter.call_args.args[0] + assert str(filtered.left) == "transactions.from_address" + + class TestSetTransactionAppealProcessingTime: """Test the improved set_transaction_appeal_processing_time method""" From 6a8592cac5dbb50c65029961f06702a8e3f9759b Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 10:47:10 +0100 Subject: [PATCH 25/55] fix(consensus): report exact creation slots --- .../transactions_processor.py | 28 ++++++++- backend/protocol_rpc/endpoints.py | 63 ++++++++++--------- .../transactions_processor_test.py | 15 +++++ tests/unit/test_rpc_methods.py | 20 ++++++ 4 files changed, 95 insertions(+), 31 deletions(-) diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index 55af5e690..b062a8c35 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -833,7 +833,6 @@ def _prepare_basic_transaction_data(self, transaction_data: dict) -> dict: transaction_data["current_timestamp"] = str(round(time.time())) transaction_data["sender"] = transaction_data["from_address"] transaction_data["recipient"] = transaction_data["to_address"] - transaction_data["tx_slot"] = "0" transaction_data["created_timestamp"] = str( int(datetime.fromisoformat(transaction_data["created_at"]).timestamp()) ) @@ -885,6 +884,32 @@ def _prepare_basic_transaction_data(self, transaction_data: dict) -> dict: transaction_data["last_leader"] = "" return transaction_data + def _transaction_issued_slot(self, transaction: Transactions) -> int: + """Return Consensus' recipient-scoped, zero-based issuance slot. + + ``Queues.enqueueNewPending`` assigns ``txSlot`` from the recipient's + monotonically increasing ``issuedTxCount``. Studio's durable + ``queue_order`` gives us the same serialization boundary. Local SEND + rows are execution effects rather than Consensus transactions, so they + must not advance the protocol slot. + """ + + if transaction.type == TransactionType.SEND.value: + return 0 + if transaction.to_address is None: + return 0 + return int( + self.session.query(func.count(Transactions.hash)) + .filter( + func.lower(Transactions.to_address) + == str(transaction.to_address).lower(), + Transactions.type != TransactionType.SEND.value, + Transactions.queue_order < transaction.queue_order, + ) + .scalar() + or 0 + ) + def _encode_transaction_data(self, transaction_data: dict) -> dict: to_encode = [] if transaction_data["data"] is not None: @@ -1137,6 +1162,7 @@ def get_transaction_by_hash( # Process for testnet transaction_data = self._prepare_basic_transaction_data(transaction_data) + transaction_data["tx_slot"] = str(self._transaction_issued_slot(transaction)) transaction_data = self._process_result(transaction_data) transaction_data = self._encode_transaction_data(transaction_data) transaction_data = self._process_execution_hash(transaction_data) diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 6bcc0b8c5..f3c737af8 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -3442,39 +3442,42 @@ def get_transaction_receipt( "status": hex(1 if envelope.success else 0), } - # CreationPhase always emits CreatedTransaction from ConsensusMain. It only - # additionally emits NewTransaction when this transaction was the recipient - # queue head at admission. Studio does not persist that historical boolean, - # so expose the universal event instead of fabricating a NewTransaction for - # queued submissions. SDKs accept either event when extracting the GenLayer - # transaction id. - event_signature = "CreatedTransaction(bytes32,uint256)" - event_signature_hash = eth_utils.keccak(text=event_signature).hex() - protocol_to_addr = envelope.to_address if envelope is not None else None to_addr = protocol_to_addr or transaction.get("to_address") from_addr = transaction.get("from_address") - try: - tx_slot = int(transaction.get("tx_slot", 0)) - except (TypeError, ValueError): - tx_slot = 0 - - logs = [ - { - "address": to_addr, - "topics": [ - f"0x{event_signature_hash}", - transaction_hash, - ], - "data": "0x" + tx_slot.to_bytes(32, "big").hex(), - "blockNumber": 0, - "transactionHash": transaction_hash, - "transactionIndex": 0, - "blockHash": transaction_hash, - "logIndex": 0, - "removed": False, - } - ] + logs = [] + if int(transaction.get("type", TransactionType.SEND.value)) != int( + TransactionType.SEND.value + ): + # CreationPhase always emits CreatedTransaction for a Consensus + # transaction. It only additionally emits NewTransaction when this was + # the recipient queue head at admission. Studio does not persist that + # historical boolean, so expose the universal event instead of + # fabricating NewTransaction. Plain EVM value transfers are not created + # through CreationPhase and therefore emit neither event. + event_signature = "CreatedTransaction(bytes32,uint256)" + event_signature_hash = eth_utils.keccak(text=event_signature).hex() + try: + tx_slot = int(transaction.get("tx_slot", 0)) + except (TypeError, ValueError): + tx_slot = 0 + + logs.append( + { + "address": to_addr, + "topics": [ + f"0x{event_signature_hash}", + transaction_hash, + ], + "data": "0x" + tx_slot.to_bytes(32, "big").hex(), + "blockNumber": 0, + "transactionHash": transaction_hash, + "transactionIndex": 0, + "blockHash": transaction_hash, + "logIndex": 0, + "removed": False, + } + ) receipt = { "transactionHash": transaction_hash, diff --git a/tests/db-sqlalchemy/transactions_processor_test.py b/tests/db-sqlalchemy/transactions_processor_test.py index 3525a059c..c45aaf424 100644 --- a/tests/db-sqlalchemy/transactions_processor_test.py +++ b/tests/db-sqlalchemy/transactions_processor_test.py @@ -18,6 +18,7 @@ from backend.database_handler.models import EvmEnvelope, Transactions from backend.consensus.types import ConsensusRound from backend.consensus.base import _external_message_pending_freeze_total +from backend.domain.types import TransactionType from backend.node.types import ExecutionResultStatus @@ -942,6 +943,20 @@ def test_get_transaction_by_hash_not_found(self, tp): result = tp.get_transaction_by_hash("0x" + "ff" * 32) assert result is None + def test_transaction_slot_is_recipient_scoped_and_excludes_send_rows(self, tp): + recipient = "0xAcec3A6d871C25F591aBd4fC24054e524BBbF794" + other_recipient = "0x1111111111111111111111111111111111111111" + first = _make_tx(tp, to_address=recipient, type=TransactionType.RUN_CONTRACT) + _make_tx(tp, to_address=recipient, type=TransactionType.SEND) + other = _make_tx( + tp, to_address=other_recipient, type=TransactionType.RUN_CONTRACT + ) + second = _make_tx(tp, to_address=recipient, type=TransactionType.RUN_CONTRACT) + + assert tp.get_transaction_by_hash(first)["tx_slot"] == "0" + assert tp.get_transaction_by_hash(other)["tx_slot"] == "0" + assert tp.get_transaction_by_hash(second)["tx_slot"] == "1" + def test_get_transactions_for_address(self, tp): addr_a = "0xAcec3A6d871C25F591aBd4fC24054e524BBbF794" addr_b = "0x1111111111111111111111111111111111111111" diff --git a/tests/unit/test_rpc_methods.py b/tests/unit/test_rpc_methods.py index b7e1b44a5..a244e6122 100644 --- a/tests/unit/test_rpc_methods.py +++ b/tests/unit/test_rpc_methods.py @@ -126,6 +126,7 @@ def test_eth_transaction_receipt_requests_no_contract_snapshot(): "hash": "0xabc", "from_address": "0x1111111111111111111111111111111111111111", "to_address": "0x2222222222222222222222222222222222222222", + "type": 2, "tx_slot": "7", "contract_address": "0x3333333333333333333333333333333333333333", "status": "FINALIZED", @@ -152,6 +153,25 @@ def test_eth_transaction_receipt_requests_no_contract_snapshot(): ) +def test_eth_transaction_receipt_does_not_fabricate_creation_event_for_send(): + transactions_processor = MagicMock() + transactions_processor.get_transaction_by_hash.return_value = { + "hash": "0xabc", + "from_address": "0x1111111111111111111111111111111111111111", + "to_address": "0x2222222222222222222222222222222222222222", + "type": 0, + "tx_slot": "0", + "status": "FINALIZED", + } + transactions_processor.get_evm_envelope.return_value = SimpleNamespace( + to_address="0x2222222222222222222222222222222222222222", + ) + + receipt = endpoints.get_transaction_receipt(transactions_processor, "0xabc") + + assert receipt["logs"] == [] + + def test_eth_transaction_receipt_reports_reverted_lifecycle_envelope(): transactions_processor = MagicMock() transactions_processor.get_transaction_by_hash.return_value = None From 4321d06e0b4b2d12bc0178fea973b2d18ba2ce88 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 12:12:51 +0100 Subject: [PATCH 26/55] fix(fees): close remaining consensus handoff gaps Pin the complete activation policy and funding overlay, preserve pool-aware contribution and external remainder ownership, reject invalid allocation and contribution states, and refund activated child value to its actual funder. Honor exact initial committees and fee-plan-sized leader replays, including receipt-less Undetermined finalization when capacity is unavailable. Add the shared simulator quote oracle plus focused lifecycle, settlement, and PostgreSQL atomicity coverage. --- backend/consensus/base.py | 142 ++- backend/consensus/decisions.py | 4 +- backend/database_handler/accounts_manager.py | 55 +- backend/protocol_rpc/endpoints.py | 11 +- backend/protocol_rpc/fees.py | 518 ++++++++--- .../test_activated_value_refund.py | 178 ++++ .../fixtures/fee_simulator_appeal_quotes.json | 37 + .../unit/consensus/test_decisions_accepted.py | 12 +- .../consensus/test_payable_balance_flow.py | 163 ++++ tests/unit/test_node_state_proxy_metrics.py | 13 +- tests/unit/test_studio_fees.py | 871 ++++++++++++++++-- 11 files changed, 1763 insertions(+), 241 deletions(-) create mode 100644 tests/db-sqlalchemy/test_activated_value_refund.py create mode 100644 tests/fixtures/fee_simulator_appeal_quotes.json diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 364ccbe1e..71a366045 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -65,6 +65,7 @@ create_child_fee_accounting, derive_external_message_call_key, discard_active_message_generation, + execution_policy_for_accounting, fill_message_fee_payload_from_allocation, mark_message_effects_delivered, message_effect_identities, @@ -1543,6 +1544,42 @@ def get_extra_validators( return current_validators, extra_validators + @staticmethod + def get_leader_replay_validators( + all_validators: List[dict], + consensus_history: dict, + consensus_data: ConsensusData, + target_committee_size: int, + ) -> list[dict]: + """Build an exact fee-plan-sized leader replay committee. + + The initial committee is caller-selectable, while later raw rounds use + the protocol's absolute committee ladder. Reusing every survivor and + then adding a fixed ladder increment over-seats a replay whenever the + initial committee was not five. Retain at most the target number of + survivors, then fill only the remaining seats with fresh validators. + """ + + current_validators, _ = ConsensusAlgorithm.get_validators_from_consensus_data( + all_validators, + consensus_data, + False, + ) + target = max(0, int(target_committee_size)) + retained = current_validators[:target] + required_fresh = target - len(retained) + if required_fresh == 0: + return retained + + _, fresh = ConsensusAlgorithm.get_extra_validators( + all_validators, + consensus_history, + consensus_data, + 0, + required_extra_validators=required_fresh, + ) + return retained + fresh + @staticmethod def get_validators_from_consensus_data( all_validators: List[dict], consensus_data: ConsensusData, include_leader: bool @@ -2258,9 +2295,8 @@ def _adjust_unbacked_message_values( and pending_transaction.use_balance and not use_balance_funding_backed ): - unfunded = _pending_transaction_with_value(pending_transaction, 0) - unfunded.declared_budget = 0 - adjusted_pending_transactions.append(unfunded) + # MessagePayments must not turn a failed contract-funded debit + # into an executable zero-fee child transaction. continue value = int(pending_transaction.value or 0) if pending_transaction.on == on and value > 0: @@ -2320,8 +2356,7 @@ def _message_value_effect_record( adjusted_budget = int(pending_transaction.declared_budget or 0) if pending_transaction.use_balance and not use_balance_funding_backed: - adjusted_value = 0 - adjusted_budget = 0 + include = False elif value > 0 and pending_transaction.is_eth_send and not external_value_backed: include = False elif ( @@ -2558,10 +2593,10 @@ async def handle(self, context): ) await EffectExecutor(context).execute(pre_effects) - # Consensus reserves the time-unit pool at the user's cap during - # submission, then locks live execution prices only when the queued - # transaction first activates. A live GEN price above the cap cancels - # with a full value/fee refund; storage and receipt prices simply lock. + # Consensus reserves time-unit work at the submitted ceiling, then + # validates all three signed price caps and the live execution floor + # when the queued transaction activates. The complete activation-time + # execution policy is then immutable for this transaction. fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) if fee_accounting: activation_result: dict[str, bool] = {} @@ -2610,7 +2645,10 @@ def activate_latest(current_fee_accounting): context.accounts_manager.cancel_tx_fee_accounting_once( context.transaction.hash, fee_sender, - "activation_gen_price_cap_exceeded", + str( + activated_accounting.get("activation_cancel_reason") + or "activation_fee_policy_rejected" + ), ) await ConsensusAlgorithm.dispatch_transaction_status_update( context.transactions_processor, @@ -2670,6 +2708,41 @@ def activate_latest(current_fee_accounting): fee_accounting, ) + is_initial_activation = not ( + bool(context.transaction.consensus_data) + or bool(context.transaction.consensus_history) + or bool(context.transaction.appealed) + or bool(context.transaction.appeal_undetermined) + or bool(context.transaction.appeal_leader_timeout) + or bool(context.transaction.appeal_validators_timeout) + ) + requested_initial_validators = int( + context.transaction.num_of_initial_validators or DEFAULT_VALIDATORS_COUNT + ) + if ( + is_initial_activation + and len(all_validators or []) < requested_initial_validators + ): + # Consensus admission prices the exact caller-selected committee. + # Activation must not silently execute a cheaper/smaller round when + # that many frozen-pool validators are unavailable. Its canonical + # no-capacity outcome is Undetermined (Pending -> Undetermined). + context.msg_handler.send_message( + LogEvent( + "consensus_event", + EventType.WARNING, + EventScope.CONSENSUS, + "Requested initial validator committee is unavailable", + { + "transaction_hash": context.transaction.hash, + "requested_validators": requested_initial_validators, + "available_validators": len(all_validators or []), + }, + transaction_hash=context.transaction.hash, + ) + ) + return UndeterminedState() + if not all_validators: context.msg_handler.send_message( LogEvent( @@ -2719,21 +2792,19 @@ def activate_latest(current_fee_accounting): all_validators, context.transaction.num_of_initial_validators ) else: - current_validators, extra_validators = ( - ConsensusAlgorithm.get_extra_validators( + context.involved_validators = ( + ConsensusAlgorithm.get_leader_replay_validators( all_validators, context.transaction.consensus_history, context.transaction.consensus_data, - 0, - required_extra_validators=VALIDATORS_PER_ROUND[ + VALIDATORS_PER_ROUND[ min( - max(0, context.active_fee_round - 1), + max(0, context.active_fee_round), len(VALIDATORS_PER_ROUND) - 1, ) ], ) ) - context.involved_validators = current_validators + extra_validators context.consensus_service.emit_transaction_event( "emitAppealStarted", @@ -3774,12 +3845,19 @@ class FinalizingState(TransactionState): """ async def handle(self, context): - leader_receipt = context.transaction.consensus_data.leader_receipt[0] + consensus_data = context.transaction.consensus_data + leader_receipts = ( + consensus_data.leader_receipt + if consensus_data is not None and consensus_data.leader_receipt + else [] + ) + leader_receipt = leader_receipts[0] if leader_receipts else None # Acceptance is a separate message lifecycle phase. If the helper EVM # or worker died after the agreed decision was committed, repair that # phase before allowing finalization to overtake it. - _dispatch_messages_for_phase(context, leader_receipt, "accepted") + if leader_receipt is not None: + _dispatch_messages_for_phase(context, leader_receipt, "accepted") pre_effects, post_effects, should_finalize_contract = decide_finalizing( tx_hash=context.transaction.hash, @@ -3787,14 +3865,33 @@ async def handle(self, context): context.transaction.status == TransactionStatus.ACCEPTED ), execution_result_success=( - leader_receipt.execution_result == ExecutionResultStatus.SUCCESS + leader_receipt is not None + and leader_receipt.execution_result == ExecutionResultStatus.SUCCESS + ), + leader_node_config=( + leader_receipt.node_config if leader_receipt is not None else None ), - leader_node_config=leader_receipt.node_config, ) executor = EffectExecutor(context) await executor.execute(pre_effects) + if not should_finalize_contract and int(context.transaction.value or 0) > 0: + value_beneficiary = context.transaction.from_address + if value_beneficiary: + refunded_activated = ( + context.accounts_manager.refund_activated_tx_value_once( + context.transaction.hash, + context.transaction.to_address, + value_beneficiary, + ) + ) + if not refunded_activated: + context.accounts_manager.refund_tx_value( + context.transaction.hash, + value_beneficiary, + ) + # Impure: contract finalization + triggered transactions (needs DB reads) if should_finalize_contract: snapshot = context.contract_snapshot_factory(context.transaction.to_address) @@ -4023,7 +4120,10 @@ def _attach_child_fee_accounting( else context.transaction.origin_address or context.transaction.from_address ), - policy=StudioFeePolicy.from_env(), + policy=execution_policy_for_accounting( + parent_fee_accounting, + StudioFeePolicy.from_env(), + ), ) except FeeValidationError as exc: raise RuntimeError(str(exc)) from exc diff --git a/backend/consensus/decisions.py b/backend/consensus/decisions.py index b592a20cc..4cac5c283 100644 --- a/backend/consensus/decisions.py +++ b/backend/consensus/decisions.py @@ -483,7 +483,7 @@ def decide_finalizing( tx_hash: str, tx_status_accepted: bool, execution_result_success: bool, - leader_node_config: dict, + leader_node_config: dict | None, ) -> tuple[list[Effect], list[Effect], bool]: """Decide effects for FinalizingState. @@ -506,7 +506,7 @@ def decide_finalizing( should_finalize_contract = tx_status_accepted and execution_result_success - if not should_finalize_contract: + if not should_finalize_contract and leader_node_config: # Emit rollup event with empty messages pre_effects.append( EmitRollupEventEffect( diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index 2cd287d42..6a010b338 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -185,16 +185,61 @@ def refund_tx_value(self, tx_hash: str, sender_address: str) -> bool: Returns True if refund was applied.""" result = self.session.execute( text( - "SELECT value, value_credited FROM transactions " "WHERE hash = :hash" + "UPDATE transactions " + "SET data = jsonb_set(COALESCE(data, '{}'::jsonb), " + "'{uncreditedValueRefunded}', 'true'::jsonb, true) " + "WHERE hash = :hash AND value_credited = false AND value > 0 " + "AND COALESCE(data ->> 'uncreditedValueRefunded', 'false') <> 'true' " + "RETURNING value" ), {"hash": tx_hash}, ) row = result.first() - if row is None or row.value is None or row.value <= 0: + if row is None: return False - if row.value_credited: - return False # target already received funds, can't refund self.credit_account_balance(sender_address, row.value) + self.session.expire_all() + return True + + def refund_activated_tx_value_once( + self, + tx_hash: str, + target_address: str, + beneficiary_address: str, + ) -> bool: + """Return an activated transaction's value to its principal owner once. + + Activation credits the destination before consensus has a terminal + result. A no-result/error finalization must reverse that provisional + credit. For an internal child the owner is ``from_address`` (the + emitting contract), never the outer transaction origin. + """ + + result = self.session.execute( + text( + "UPDATE transactions " + "SET data = jsonb_set(COALESCE(data, '{}'::jsonb), " + "'{activatedValueRefunded}', 'true'::jsonb, true) " + "WHERE hash = :hash AND value_credited = true AND value > 0 " + "AND COALESCE(data ->> 'activatedValueRefunded', 'false') <> 'true' " + "RETURNING value" + ), + {"hash": tx_hash}, + ) + row = result.first() + if row is None: + return False + + amount = int(row.value) + if not self.debit_account_balance(target_address, amount): + # Raising keeps the status update, marker, debit, and refund in one + # database rollback domain. A retry can therefore repair the + # lifecycle instead of silently minting or misdirecting value. + raise RuntimeError( + f"ActivatedValueRefundInsufficientBalance({tx_hash},{target_address},{amount})" + ) + self.credit_account_balance(beneficiary_address, amount) + self.session.expire_all() return True def cancel_tx_fee_accounting_once( @@ -215,6 +260,7 @@ def cancel_tx_fee_accounting_once( accounting = data.get(FEE_ACCOUNTING_KEY) if not accounting: return 0 + sender_address = accounting.get("sender") or sender_address was_terminal = accounting.get("status") in {"settled", "canceled"} updated, refund = cancel_fee_accounting(accounting, reason=reason) data[FEE_ACCOUNTING_KEY] = updated @@ -292,6 +338,7 @@ def settle_tx_fee_accounting_once( accounting = data.get(FEE_ACCOUNTING_KEY) if not accounting: return 0 + sender_address = accounting.get("sender") or sender_address was_terminal = accounting.get("status") in {"settled", "canceled"} updated, refund = settle_fee_accounting( accounting, diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index f3c737af8..78722e655 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -58,6 +58,7 @@ calculate_round_fees, decode_internal_message_fee_params, create_fee_accounting, + funding_policy_for_accounting, get_leader_rounds, normalize_fees_distribution, min_message_primary_fees, @@ -2124,7 +2125,10 @@ def estimate_latest_appeal_charge( ), available_appeal_validators=available_appeal_validators, leader_timeout_live_seats=live_seats, - policy=StudioFeePolicy.from_env(), + policy=funding_policy_for_accounting( + fee_accounting, + StudioFeePolicy.from_env(), + ), ) decision_id = _transaction_decision_id(transaction) return { @@ -2599,7 +2603,10 @@ def prepare_fee_accounting(current_fee_accounting): available_appeal_validators=available_appeal_validators, replacement_rotations=replacement_rotations, leader_timeout_live_seats=leader_timeout_live_seats, - time_unit_overlay_bps=StudioFeePolicy.from_env().time_unit_overlay_bps, + policy=funding_policy_for_accounting( + current_fee_accounting, + StudioFeePolicy.from_env(), + ), ) return updated, int(updated["appeal_bonds"][-1]["surplusRefund"]) diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 5d1e2b96e..3d9a0fe17 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -200,6 +200,14 @@ class MessageBudgetExceeded(FeeValidationError): pass +class MessageAllocationsRestricted(FeeValidationError): + pass + + +class ContributionSegmentsFull(FeeValidationError): + pass + + def _with_cap_headroom( value: int, headroom_bps: int = DEFAULT_PRICE_CAP_HEADROOM_BPS ) -> int: @@ -277,6 +285,11 @@ def __post_init__(self) -> None: f"{self.max_allocated_messages} (protocol cap " f"{MAX_ALLOCATED_MESSAGES_CAP})" ) + if not 0 <= int(self.time_unit_overlay_bps) < 10_000: + raise ValueError( + "invalid time-unit overlay bps: " + f"{self.time_unit_overlay_bps} (expected 0..9999)" + ) for minimum, maximum, label in ( ( self.min_propose_timeunits, @@ -494,18 +507,16 @@ def _live_policy_for_accounting( accounting: dict[str, Any] | None, override: StudioFeePolicy | None = None, ) -> StudioFeePolicy: - """Return the governance policy that applies to work performed now. + """Return the policy applicable to this transaction's next operation. - Before activation, the submission snapshot is also the best available - representation of the live configuration (and keeps pure callers/tests - independent of process environment). Once prices are locked, Consensus - continues reading every non-price metering parameter and admission cap - live from FeeManager/Messages. + Before activation an explicit/live policy is authoritative. Activation + commits the complete receipt formula, limits, and prices so later config + changes cannot reprice or invalidate work already admitted under escrow. """ + if accounting and accounting.get("activation_prices_locked"): + return _accounting_policy(accounting) if override is not None: return override - if accounting and accounting.get("activation_prices_locked"): - return StudioFeePolicy.from_env() return _accounting_policy(accounting) @@ -513,17 +524,24 @@ def execution_policy_for_accounting( accounting: dict[str, Any] | None, live_policy: StudioFeePolicy | None = None, ) -> StudioFeePolicy: - """Combine live metering configuration with activation-locked prices.""" - live_policy = _live_policy_for_accounting(accounting, live_policy) - if not accounting or not accounting.get("activation_prices_locked"): - return live_policy + """Return the activation-committed execution policy when available.""" + return _live_policy_for_accounting(accounting, live_policy) - locked_policy = _accounting_policy(accounting) + +def funding_policy_for_accounting( + accounting: dict[str, Any] | None, + live_policy: StudioFeePolicy | None = None, +) -> StudioFeePolicy: + """Return execution prices/config plus the transaction's funding-time split.""" + policy = _live_policy_for_accounting(accounting, live_policy) return replace( - live_policy, - gen_per_time_unit=int(locked_policy.gen_per_time_unit), - storage_unit_price=int(locked_policy.storage_unit_price), - receipt_gas_price=int(locked_policy.receipt_gas_price), + policy, + time_unit_overlay_bps=int( + (accounting or {}).get( + "funding_overlay_bps", + policy.time_unit_overlay_bps, + ) + ), ) @@ -997,6 +1015,18 @@ def _calculate_settled_time_unit_fees( elif outcome in leader_appeal_outcomes: round_fee = 0 rule = "leader_appeal" + elif ( + outcome == ConsensusRound.UNDETERMINED.value + and current_attempt is not None + and not _entry_leader_address(current_attempt) + and not _entry_validator_receipts(current_attempt) + ): + # An activation-time committee shortfall materializes an explicit + # Undetermined round without selecting a leader or validators. + # Empty participant evidence is authoritative: charge no invented + # seats, while retaining any real prior-rotation work. + round_fee = prior_rotation_fee + rule = "committee_unavailable" else: current_result = ( _fee_alignment_result( @@ -1750,14 +1780,7 @@ def activate_fee_accounting( selection_pool_count: int | None = None, selection_pool_addresses: list[str] | None = None, ) -> tuple[dict[str, Any], bool]: - """Lock v0.6 execution prices on first activation. - - Submission reserves time-unit work at the user's GEN ceiling. Consensus - checks the live GEN price only once the pending transaction activates; an - over-cap transaction is canceled with its escrow untouched. Storage and - receipt prices are also locked at activation, but do not cause a second - cap check after the submission-time quote. - """ + """Validate ceilings and commit the complete v0.6 fee policy at activation.""" updated = copy.deepcopy(accounting) if updated.get("activation_prices_locked"): return updated, False @@ -1780,20 +1803,52 @@ def activate_fee_accounting( live_policy = policy or StudioFeePolicy.from_env() fees = normalize_fees_distribution(updated.get("fees_distribution") or {}) - max_gen_price = int(fees["maxPriceGenPerTimeUnit"]) - if max_gen_price > 0 and live_policy.gen_per_time_unit > max_gen_price: - updated["activation_price_cap_exceeded"] = { - "actual": int(live_policy.gen_per_time_unit), - "maximum": max_gen_price, + cap_checks = ( + ( + "genPerTimeUnit", + int(live_policy.gen_per_time_unit), + int(fees["maxPriceGenPerTimeUnit"]), + ), + ( + "storageUnitPrice", + int(live_policy.storage_unit_price), + int(fees["storageFeeMaxGasPrice"]), + ), + ( + "receiptGasPrice", + int(live_policy.receipt_gas_price), + int(fees["receiptFeeMaxGasPrice"]), + ), + ) + for cap_type, actual, maximum in cap_checks: + if maximum > 0 and actual > maximum: + updated["activation_price_cap_exceeded"] = { + "actual": actual, + "maximum": maximum, + } + updated["activation_price_cap_type"] = cap_type + updated["activation_cancel_reason"] = ( + "activation_price_cap_exceeded:" + cap_type + ) + return updated, True + + execution_budget_per_round = int(fees["executionBudgetPerRound"]) + activation_floor = int(live_policy.message_fee_params_budget_floor()) + if execution_budget_per_round > 0 and execution_budget_per_round < activation_floor: + updated["activation_budget_floor_not_met"] = { + "actual": execution_budget_per_round, + "minimum": activation_floor, } + updated["activation_cancel_reason"] = "activation_budget_floor_not_met" return updated, True - submission_policy = _accounting_policy(updated) + # Receipt execution inputs are activation-pinned. The overlay split is a + # funding-ownership term and was already committed when escrow began. locked_policy = replace( - submission_policy, - gen_per_time_unit=int(live_policy.gen_per_time_unit), - storage_unit_price=int(live_policy.storage_unit_price), - receipt_gas_price=int(live_policy.receipt_gas_price), + live_policy, + time_unit_overlay_bps=int( + updated.get("funding_overlay_bps", live_policy.time_unit_overlay_bps) + ), ) updated["policy_snapshot"] = locked_policy.to_snapshot() updated["activation_prices_locked"] = True @@ -1980,8 +2035,6 @@ def genvm_message_fee_allocation( if 0 <= parent_index < len(genvm_nodes): genvm_nodes[parent_index]["children"].append(genvm_nodes[index]) - if roots: - roots.append(_genvm_external_legacy_fallback_message_fee_allocation()) return roots @@ -1995,10 +2048,9 @@ def apply_fee_top_up( perform_fee_checks: bool = True, policy: StudioFeePolicy | None = None, ) -> dict[str, Any]: - # Top-up validation uses the FeeManager configuration at the time of the - # top-up. Activation locks execution prices, not governance parameters or - # the gas-based minimum-budget floor. - policy = _live_policy_for_accounting(accounting, policy) + # After activation the complete fee formula and its budget floor are part + # of the transaction's committed economics. + policy = funding_policy_for_accounting(accounting, policy) amount = int(amount) if amount <= 0: raise InsufficientFees("InsufficientFees") @@ -2014,6 +2066,10 @@ def apply_fee_top_up( ): incoming[cap] = 0 incoming_message_fees = int(incoming["totalMessageFees"]) + if incoming_message_fees > 0 and bool( + accounting.get("message_allocations_restricted", False) + ): + raise MessageAllocationsRestricted("MessageAllocationsRestricted") if incoming_message_fees > amount: raise InsufficientFees("InsufficientFees") @@ -2075,7 +2131,9 @@ def apply_fee_top_up( _record_fee_contribution( updated, sender, - primary_amount - overlay_reserve_delta + incoming_message_fees, + primary=primary_amount - overlay_reserve_delta, + overlay=overlay_reserve_delta, + message=incoming_message_fees, ) updated.setdefault("top_ups", []).append( { @@ -2120,17 +2178,10 @@ def record_appeal_bond( "appeal_bonds_total", "contributions", "untracked_contributions", + "untracked_contribution_pools", ) } - policy = _accounting_policy(updated, policy) - if time_unit_overlay_bps is not None: - # Consensus prices the developer/DAO overlay at appeal admission; - # unlike the transaction's GEN multiplier, this split is not locked at - # initial activation. - policy = replace( - policy, - time_unit_overlay_bps=int(time_unit_overlay_bps), - ) + policy = funding_policy_for_accounting(updated, policy) amount = int(amount) if status is None: @@ -2188,7 +2239,8 @@ def record_appeal_bond( _record_fee_contribution( updated, appealer, - funding - int(charge["fundingBreakdown"]["overlay"]), + primary=funding - int(charge["fundingBreakdown"]["overlay"]), + overlay=int(charge["fundingBreakdown"]["overlay"]), ) updated["appeal_bonds_total"] = int(updated.get("appeal_bonds_total", 0)) + bond surplus = amount - total_required @@ -2574,18 +2626,11 @@ def consume_message_fees( updated.get("external_message_fee_remainder", 0) or 0 ) + # Attribute the independently consumed message pool deterministically: + # child escrows consume first, then external reservations in reveal order. + # This keeps contributor ownership independent from receipt message order. for message in messages: message_type = _message_type_value(message) - if message_type == MESSAGE_TYPE_EXTERNAL: - external_consumption_total += _consume_external_message_fee( - updated, - message, - execution_policy, - reimburse_external, - external_executor, - ) - continue - if message_type == MESSAGE_TYPE_INTERNAL: recalculated_total += _consume_internal_message_fee( updated, @@ -2593,6 +2638,25 @@ def consume_message_fees( live_policy, ) + funding_offset = _next_external_funding_offset( + updated, + int(updated.get("message_fee_consumed", 0)) + recalculated_total, + ) + external_reserved_total = 0 + for message in messages: + if _message_type_value(message) != MESSAGE_TYPE_EXTERNAL: + continue + consumed, reserved = _consume_external_message_fee( + updated, + message, + execution_policy, + reimburse_external, + external_executor, + funding_offset=funding_offset + external_reserved_total, + ) + external_consumption_total += consumed + external_reserved_total += reserved + if reported_total is not None and int(reported_total) < recalculated_total: raise MessageFeesReportMismatch("MessageFeesReportMismatch") @@ -2644,16 +2708,25 @@ def _consume_external_message_fee( policy: StudioFeePolicy, reimburse_external: bool, external_executor: str | None, -) -> int: + *, + funding_offset: int, +) -> tuple[int, int]: if int(message.get("declaredBudget", 0) or 0) != 0: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") - return _reserve_external_execution( + event_count = len(accounting.get("external_message_events") or []) + consumed = _reserve_external_execution( accounting, message, policy, reimburse=reimburse_external, executor=external_executor, + funding_offset=funding_offset, ) + events = accounting.get("external_message_events") or [] + if len(events) == event_count: + return consumed, 0 + event = events[-1] + return consumed, int(event.get("reservation", 0) or 0) def _consume_internal_message_fee( @@ -2719,7 +2792,7 @@ def record_external_message_execution_fees( executor: str | None = None, ) -> dict[str, Any]: updated = copy.deepcopy(accounting) - policy = _accounting_policy(updated, policy) + policy = execution_policy_for_accounting(updated, policy) consumed_total = 0 reimbursement_total = 0 remainder_total = 0 @@ -2914,6 +2987,7 @@ def unwind_reveal_message_fees( 0, int(updated.get("message_fee_consumed", 0)) - internal_refund, ) + _reindex_unexecuted_external_funding(updated) if ( internal_refund > 0 @@ -3152,21 +3226,26 @@ def settle_fee_accounting( appeal_bond_sender_refund = sum( int(item.get("senderRefund", 0) or 0) for item in bond_settlements ) - current_overlay_bps = ( - int(policy_override.time_unit_overlay_bps) - if policy_override is not None - else int(StudioFeePolicy.from_env().time_unit_overlay_bps) + current_overlay_bps = int( + updated.get("funding_overlay_bps", locked_policy.time_unit_overlay_bps) ) time_unit_overlay_requested = _time_unit_overlay( time_unit_budget, current_overlay_bps, ) - overlay_budget = int(updated.get("time_unit_overlay_budget", primary_budget) or 0) + overlay_budget = min( + primary_budget, + max(0, int(updated.get("time_unit_overlay_budget", 0) or 0)), + ) + primary_core_budget = max(0, primary_budget - overlay_budget) time_unit_overlay_spent = min(time_unit_overlay_requested, overlay_budget) - non_profit_spend = time_unit_budget + time_unit_overlay_spent + execution_spent + primary_core_non_profit_spend = time_unit_budget + execution_spent + if primary_core_non_profit_spend > primary_core_budget: + raise InsufficientFees("InsufficientFeesForRound") appeal_profit_spent = ( appeal_profit_requested - if non_profit_spend + appeal_profit_requested <= primary_budget + if primary_core_non_profit_spend + appeal_profit_requested + <= primary_core_budget else 0 ) if appeal_profit_requested > 0 and appeal_profit_spent == 0: @@ -3182,10 +3261,8 @@ def settle_fee_accounting( bond_payout -= max(0, payout - amount) item["payout"] = amount item["profitFunded"] = False - primary_spent = min( - primary_budget, - non_profit_spend + appeal_profit_spent, - ) + primary_core_spent = primary_core_non_profit_spend + appeal_profit_spent + primary_spent = primary_core_spent + time_unit_overlay_spent primary_refund = max( 0, primary_budget - primary_spent - int(updated.get("primary_fee_refunded", 0)) ) @@ -3221,7 +3298,18 @@ def settle_fee_accounting( int(updated.get("appeal_bonds_payout_total", 0)) + bond_payout ) updated["appeal_bond_settlements"] = bond_settlements - updated["fee_refund_settlements"] = _allocate_fee_refund(updated, refund) + primary_core_refund = max(0, primary_core_budget - primary_core_spent) + overlay_refund = max(0, overlay_budget - time_unit_overlay_spent) + updated["fee_refund_settlements"] = _allocate_fee_refund( + updated, + primary=primary_core_refund, + overlay=overlay_refund, + message=message_refund, + unattributed=( + appeal_bond_sender_refund + + max(0, primary_refund - primary_core_refund - overlay_refund) + ), + ) updated.setdefault("refunds", []).append( { "reason": reason, @@ -3411,7 +3499,26 @@ def cancel_fee_accounting( int(updated.get("appeal_bonds_payout_total", 0)) + bond_payout ) updated["appeal_bond_settlements"] = bond_settlements - updated["fee_refund_settlements"] = _allocate_fee_refund(updated, refund) + updated["canceled_message_allocations"] = copy.deepcopy( + updated.get("message_allocations") or [] + ) + updated["message_allocations"] = [] + updated["allocation_consumed"] = {} + updated["message_allocations_invalidated"] = True + overlay_budget = min( + primary_refund, + max( + 0, + int(updated.get("time_unit_overlay_budget", 0) or 0) + - int(updated.get("time_unit_overlay_spent", 0) or 0), + ), + ) + updated["fee_refund_settlements"] = _allocate_fee_refund( + updated, + primary=primary_refund - overlay_budget, + overlay=overlay_budget, + message=message_refund, + ) updated.setdefault("refunds", []).append( { "reason": reason, @@ -4119,15 +4226,26 @@ def _fee_params_bytes(fee_params: bytes | str) -> bytes: def _record_fee_contribution( accounting: dict[str, Any], depositor: str | None, - amount: int, + *, + primary: int = 0, + overlay: int = 0, + message: int = 0, ) -> None: - amount = max(0, int(amount)) + pools = { + "primary": max(0, int(primary)), + "overlay": max(0, int(overlay)), + "message": max(0, int(message)), + } + amount = sum(pools.values()) if amount == 0: return if not depositor: accounting["untracked_contributions"] = ( int(accounting.get("untracked_contributions", 0) or 0) + amount ) + untracked_pools = accounting.setdefault("untracked_contribution_pools", {}) + for pool, pool_amount in pools.items(): + untracked_pools[pool] = int(untracked_pools.get(pool, 0) or 0) + pool_amount return contributions = accounting.setdefault("contributions", []) @@ -4135,25 +4253,92 @@ def _record_fee_contribution( contributions and str(contributions[-1].get("depositor", "")).lower() == str(depositor).lower() + and all(pool in contributions[-1] for pool in pools) ): contributions[-1]["amount"] = int(contributions[-1].get("amount", 0)) + amount + for pool, pool_amount in pools.items(): + contributions[-1][pool] = ( + int(contributions[-1].get(pool, 0) or 0) + pool_amount + ) return if len(contributions) >= MAX_CONTRIBUTION_SEGMENTS: - accounting["untracked_contributions"] = ( - int(accounting.get("untracked_contributions", 0) or 0) + amount - ) - return - contributions.append({"depositor": depositor, "amount": amount}) + raise ContributionSegmentsFull("ContributionSegmentsFull") + contributions.append({"depositor": depositor, "amount": amount, **pools}) def _allocate_fee_refund( accounting: dict[str, Any], - refundable: int, + *, + primary: int = 0, + overlay: int = 0, + message: int = 0, + unattributed: int = 0, ) -> list[dict[str, Any]]: - refundable = max(0, int(refundable)) + refunds_by_pool = { + "primary": max(0, int(primary)), + "overlay": max(0, int(overlay)), + "message": max(0, int(message)), + } + unattributed = max(0, int(unattributed)) + refundable = sum(refunds_by_pool.values()) + unattributed if refundable == 0: return [] + raw_contributions = [ + item + for item in accounting.get("contributions") or [] + if isinstance(item, dict) and item.get("depositor") + ] + typed = bool(raw_contributions) and all( + all(pool in item for pool in refunds_by_pool) for item in raw_contributions + ) + if not typed: + return _allocate_legacy_fee_refund(accounting, refundable) + + settlements: list[dict[str, Any]] = [] + for pool, pool_refundable in refunds_by_pool.items(): + if pool_refundable <= 0: + continue + contributions = [ + { + "depositor": item.get("depositor"), + "amount": max(0, int(item.get(pool, 0) or 0)), + } + for item in raw_contributions + ] + tracked_total = sum(item["amount"] for item in contributions) + tracked_refundable = min(pool_refundable, tracked_total) + consumed = tracked_total - tracked_refundable + cumulative = 0 + for item in contributions: + end = cumulative + item["amount"] + amount = 0 + if cumulative >= consumed: + amount = item["amount"] + elif end > consumed: + amount = end - consumed + if amount > 0: + settlements.append( + { + "recipient": item["depositor"], + "amount": amount, + "source": f"{pool}-fifo", + } + ) + cumulative = end + remainder = refundable - sum(item["amount"] for item in settlements) + fallback = accounting.get("sender") + if remainder > 0 and fallback: + settlements.append( + {"recipient": fallback, "amount": remainder, "source": "remainder"} + ) + return settlements + + +def _allocate_legacy_fee_refund( + accounting: dict[str, Any], + refundable: int, +) -> list[dict[str, Any]]: contributions = [ { "depositor": item.get("depositor"), @@ -4183,15 +4368,9 @@ def _allocate_fee_refund( remainder = refundable - sum(item["amount"] for item in settlements) fallback = accounting.get("sender") if remainder > 0 and fallback: - for settlement in settlements: - if str(settlement["recipient"]).lower() == str(fallback).lower(): - settlement["amount"] += remainder - settlement["source"] = "fifo+remainder" - break - else: - settlements.append( - {"recipient": fallback, "amount": remainder, "source": "remainder"} - ) + settlements.append( + {"recipient": fallback, "amount": remainder, "source": "remainder"} + ) return settlements @@ -4225,9 +4404,8 @@ def _new_fee_accounting( incoming_primary=primary_budget, split_bps=policy.time_unit_overlay_bps, ) - tracked_contribution = primary_budget - overlay_budget + total_message_fees return { - "version": 1, + "version": 2, "source": source, "status": "active", "policy_snapshot": policy.to_snapshot(), @@ -4242,6 +4420,7 @@ def _new_fee_accounting( "primary_fee_refunded": 0, "execution_budget_total": execution_budget_total, "time_unit_overlay_budget": overlay_budget, + "funding_overlay_bps": int(policy.time_unit_overlay_bps), "execution_fee_consumed": 0, "execution_fee_consumed_buckets": [], "genvm_fee_consumed_buckets": [], @@ -4274,16 +4453,30 @@ def _new_fee_accounting( } ], "contributions": ( - [{"depositor": sender, "amount": tracked_contribution}] - if sender and tracked_contribution > 0 + [ + { + "depositor": sender, + "amount": primary_budget + total_message_fees, + "primary": primary_budget - overlay_budget, + "overlay": overlay_budget, + "message": total_message_fees, + } + ] + if sender and primary_budget + total_message_fees > 0 else [] ), "untracked_contributions": 0, + "untracked_contribution_pools": {}, "fees_distribution": fees, "message_allocations": [ _serializable_message_allocation(allocation) for allocation in message_allocations ], + # Studio resolves the canonical FlatArrays subtree locally, so an + # ordinary child with no descendants is open Mode 1, not Consensus' + # distinct sealed-child failure state. Keep the explicit bit for + # imported/recovered sealed state without conflating the two. + "message_allocations_restricted": False, "allocation_consumed": {}, "message_consumption_events": [], } @@ -4600,6 +4793,7 @@ def _reserve_external_execution( *, reimburse: bool = True, executor: str | None = None, + funding_offset: int = 0, ) -> int: if bool(message.get("onAcceptance", False)): raise ExternalAllocationInvalid("ExternalOnAcceptanceNotSupported") @@ -4610,7 +4804,7 @@ def _reserve_external_execution( candidates = list(_matching_root_allocations(allocations, message)) if not candidates: - return 0 + raise MessageNoMatchingAllocation("MessageNoMatchingAllocation") selected: tuple[int, dict[str, Any], int, int, int] | None = None for index, allocation in candidates: @@ -4624,6 +4818,8 @@ def _reserve_external_execution( if policy.receipt_gas_price > 0 else 0 ) + if locked_price <= 0: + raise ExternalAllocationInvalid("ExternalExecutionPriceUnavailable") reservation = gas_limit * locked_price key = str(index) consumed = int(accounting.setdefault("allocation_consumed", {}).get(key, 0)) @@ -4667,6 +4863,12 @@ def _reserve_external_execution( "reimbursement": reimbursement if reimburse else 0, "remainder": remainder if reimburse else 0, "executionRecorded": bool(reimburse), + "fundingOffset": max(0, int(funding_offset)), + "fundingOwners": _message_pool_owner_slices( + accounting, + max(0, int(funding_offset)), + reservation, + ), } accounting.setdefault("external_message_events", []).append(event) if reimburse: @@ -4684,8 +4886,8 @@ def _record_external_message_fee_payouts( ) -> None: reimbursement = int(event.get("reimbursement", 0) or 0) remainder = int(event.get("remainder", 0) or 0) - depositor = accounting.get("sender") - executor_recipient = executor or depositor + fallback_depositor = accounting.get("sender") + executor_recipient = executor or fallback_depositor payouts = accounting.setdefault("external_message_fee_payouts", []) if reimbursement > 0 and executor_recipient: payouts.append( @@ -4695,14 +4897,109 @@ def _record_external_message_fee_payouts( "source": "external-executor-reimbursement", } ) - if remainder > 0 and depositor: - payouts.append( - { - "recipient": depositor, - "amount": remainder, - "source": "external-execution-remainder", - } + if remainder > 0: + owner_refunds = _external_remainder_owner_slices(event, reimbursement) + if not owner_refunds and fallback_depositor: + owner_refunds = [{"recipient": fallback_depositor, "amount": remainder}] + for owner_refund in owner_refunds: + recipient = owner_refund.get("recipient") + amount = int(owner_refund.get("amount", 0) or 0) + if recipient and amount > 0: + payouts.append( + { + "recipient": recipient, + "amount": amount, + "source": "external-execution-remainder", + } + ) + + +def _message_pool_owner_slices( + accounting: dict[str, Any], + offset: int, + amount: int, +) -> list[dict[str, Any]]: + """Resolve an exact message-pool range to its ordered funders.""" + remaining_offset = max(0, int(offset)) + remaining = max(0, int(amount)) + owners: list[dict[str, Any]] = [] + for contribution in accounting.get("contributions") or []: + if not isinstance(contribution, dict): + continue + pool_amount = max(0, int(contribution.get("message", 0) or 0)) + if remaining_offset >= pool_amount: + remaining_offset -= pool_amount + continue + available = pool_amount - remaining_offset + owned = min(remaining, available) + recipient = contribution.get("depositor") + if owned > 0 and recipient: + owners.append({"recipient": recipient, "amount": owned}) + remaining -= owned + remaining_offset = 0 + if remaining == 0: + break + + if remaining > 0 and accounting.get("sender"): + owners.append({"recipient": accounting["sender"], "amount": remaining}) + return owners + + +def _external_remainder_owner_slices( + event: dict[str, Any], + reimbursement: int, +) -> list[dict[str, Any]]: + """The executor consumes the reservation prefix; its tail returns to funders.""" + skip = max(0, int(reimbursement)) + refunds: list[dict[str, Any]] = [] + for owner in event.get("fundingOwners") or []: + if not isinstance(owner, dict): + continue + owned = max(0, int(owner.get("amount", 0) or 0)) + if skip >= owned: + skip -= owned + continue + refundable = owned - skip + skip = 0 + recipient = owner.get("recipient") + if refundable > 0 and recipient: + refunds.append({"recipient": recipient, "amount": refundable}) + return refunds + + +def _next_external_funding_offset( + accounting: dict[str, Any], + baseline: int, +) -> int: + next_offset = max(0, int(baseline)) + for event in accounting.get("external_message_events") or []: + if not isinstance(event, dict): + continue + if event.get("executionRecorded") or event.get("unreserved"): + continue + next_offset = max( + next_offset, + int(event.get("fundingOffset", 0) or 0) + + int(event.get("reservation", 0) or 0), + ) + return next_offset + + +def _reindex_unexecuted_external_funding(accounting: dict[str, Any]) -> None: + cursor = max(0, int(accounting.get("message_fee_consumed", 0) or 0)) + for event in accounting.get("external_message_events") or []: + if not isinstance(event, dict): + continue + if event.get("executionRecorded") or event.get("unreserved"): + continue + reservation = max(0, int(event.get("reservation", 0) or 0)) + event["fundingOffset"] = cursor + event["fundingOwners"] = _message_pool_owner_slices( + accounting, + cursor, + reservation, ) + cursor += reservation def _find_unrefunded_external_message_event( @@ -4789,6 +5086,7 @@ def _unreserve_external_message_fee( int(accounting.get("external_message_fee_settled", 0)) - reservation, ) event["unreserved"] = True + _reindex_unexecuted_external_funding(accounting) return reservation, reimbursement, remainder diff --git a/tests/db-sqlalchemy/test_activated_value_refund.py b/tests/db-sqlalchemy/test_activated_value_refund.py new file mode 100644 index 000000000..f14921c77 --- /dev/null +++ b/tests/db-sqlalchemy/test_activated_value_refund.py @@ -0,0 +1,178 @@ +"""PostgreSQL coverage for activated payable-value terminal refunds.""" + +import pytest +from sqlalchemy import text +from sqlalchemy.orm import Session + +from backend.database_handler.accounts_manager import AccountsManager + + +def _insert_activated_value( + session: Session, + *, + tx_hash: str, + sender: str, + target: str, + value: int, + target_balance: int, +) -> None: + session.execute( + text( + """ + INSERT INTO current_state (id, data, balance) + VALUES + (:sender, CAST('{}' AS jsonb), 100), + (:target, CAST('{}' AS jsonb), :target_balance) + """ + ), + { + "sender": sender, + "target": target, + "target_balance": target_balance, + }, + ) + session.execute( + text( + """ + INSERT INTO transactions ( + hash, status, from_address, to_address, data, value, type, + nonce, leader_only, execution_mode, appealed, appeal_failed, + appeal_undetermined, appeal_leader_timeout, + appeal_validators_timeout, appeal_processing_time, + value_credited, consensus_history, num_of_initial_validators + ) VALUES ( + :hash, 'LEADER_TIMEOUT', :sender, :target, CAST('{}' AS jsonb), + :value, 2, 0, false, 'NORMAL', false, 0, false, false, false, + 0, true, CAST('{}' AS jsonb), 5 + ) + """ + ), + { + "hash": tx_hash, + "sender": sender, + "target": target, + "value": value, + }, + ) + session.commit() + + +def test_activated_value_refund_is_exact_and_idempotent(session: Session): + tx_hash = "0x" + "ad" * 32 + emitting_contract = "0x1111111111111111111111111111111111111111" + child = "0x2222222222222222222222222222222222222222" + _insert_activated_value( + session, + tx_hash=tx_hash, + sender=emitting_contract, + target=child, + value=500, + target_balance=700, + ) + + manager = AccountsManager(session) + assert manager.refund_activated_tx_value_once( + tx_hash, + child, + emitting_contract, + ) + session.commit() + assert not manager.refund_activated_tx_value_once( + tx_hash, + child, + emitting_contract, + ) + + balances = dict( + session.execute( + text( + "SELECT id, balance FROM current_state WHERE id IN (:sender, :target)" + ), + {"sender": emitting_contract, "target": child}, + ).all() + ) + marker = session.execute( + text( + "SELECT data ->> 'activatedValueRefunded' FROM transactions WHERE hash = :hash" + ), + {"hash": tx_hash}, + ).scalar_one() + assert balances == {emitting_contract: 600, child: 200} + assert marker == "true" + + +def test_activated_value_refund_fails_atomically_if_target_is_short(session: Session): + tx_hash = "0x" + "ae" * 32 + emitting_contract = "0x3333333333333333333333333333333333333333" + child = "0x4444444444444444444444444444444444444444" + _insert_activated_value( + session, + tx_hash=tx_hash, + sender=emitting_contract, + target=child, + value=500, + target_balance=499, + ) + + with pytest.raises(RuntimeError, match="ActivatedValueRefundInsufficientBalance"): + AccountsManager(session).refund_activated_tx_value_once( + tx_hash, + child, + emitting_contract, + ) + session.rollback() + + balances = dict( + session.execute( + text( + "SELECT id, balance FROM current_state WHERE id IN (:sender, :target)" + ), + {"sender": emitting_contract, "target": child}, + ).all() + ) + marker = session.execute( + text( + "SELECT data ->> 'activatedValueRefunded' FROM transactions WHERE hash = :hash" + ), + {"hash": tx_hash}, + ).scalar_one() + assert balances == {emitting_contract: 100, child: 499} + assert marker is None + + +def test_uncredited_value_refund_is_exact_and_idempotent(session: Session): + tx_hash = "0x" + "af" * 32 + sender = "0x5555555555555555555555555555555555555555" + target = "0x6666666666666666666666666666666666666666" + _insert_activated_value( + session, + tx_hash=tx_hash, + sender=sender, + target=target, + value=500, + target_balance=0, + ) + session.execute( + text("UPDATE transactions SET value_credited = false WHERE hash = :hash"), + {"hash": tx_hash}, + ) + session.commit() + + manager = AccountsManager(session) + assert manager.refund_tx_value(tx_hash, sender) + session.commit() + assert not manager.refund_tx_value(tx_hash, sender) + + balance = session.execute( + text("SELECT balance FROM current_state WHERE id = :sender"), + {"sender": sender}, + ).scalar_one() + marker = session.execute( + text( + "SELECT data ->> 'uncreditedValueRefunded' FROM transactions " + "WHERE hash = :hash" + ), + {"hash": tx_hash}, + ).scalar_one() + assert balance == 600 + assert marker == "true" diff --git a/tests/fixtures/fee_simulator_appeal_quotes.json b/tests/fixtures/fee_simulator_appeal_quotes.json new file mode 100644 index 000000000..0375d6ef8 --- /dev/null +++ b/tests/fixtures/fee_simulator_appeal_quotes.json @@ -0,0 +1,37 @@ +{ + "schema_version": 4, + "source": "genlayer-fee-distribution-simulator#30 / genlayer-consensus#1388", + "description": "Compact lossless projection of the shared appeal_quote_checkpoints.json campaign; one quote per covered admission signature.", + "quotes": [ + {"quote_round_index": 0, "source_status": "Accepted", "committee_basis": "configured_appeal_round", "committee_size": 7, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "1400"}, + {"quote_round_index": 1, "source_status": "Accepted", "committee_basis": "configured_appeal_round", "committee_size": 11, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "2200"}, + {"quote_round_index": 2, "source_status": "Accepted", "committee_basis": "configured_appeal_round", "committee_size": 13, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "2600"}, + {"quote_round_index": 3, "source_status": "Accepted", "committee_basis": "configured_appeal_round", "committee_size": 23, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "4600"}, + {"quote_round_index": 4, "source_status": "Accepted", "committee_basis": "configured_appeal_round", "committee_size": 25, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "5000"}, + {"quote_round_index": 5, "source_status": "Accepted", "committee_basis": "configured_appeal_round", "committee_size": 47, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "9400"}, + {"quote_round_index": 7, "source_status": "Accepted", "committee_basis": "configured_appeal_round", "committee_size": 95, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "19000"}, + + {"quote_round_index": 0, "source_status": "LeaderTimeout", "committee_basis": "configured_source_round", "committee_size": 5, "attempt_basis": "configured_next_normal_round", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "1100"}, + {"quote_round_index": 0, "source_status": "LeaderTimeout", "committee_basis": "configured_source_round", "committee_size": 5, "attempt_basis": "configured_next_normal_round", "rotations_value": 1, "attempts": 2, "leader_unit": "100", "validator_unit": "200", "expected_bond": "2200"}, + {"quote_round_index": 0, "source_status": "LeaderTimeout", "committee_basis": "configured_source_round", "committee_size": 5, "attempt_basis": "configured_next_normal_round", "rotations_value": 2, "attempts": 3, "leader_unit": "100", "validator_unit": "200", "expected_bond": "3300"}, + {"quote_round_index": 2, "source_status": "LeaderTimeout", "committee_basis": "configured_source_round", "committee_size": 11, "attempt_basis": "configured_next_normal_round", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "2300"}, + {"quote_round_index": 2, "source_status": "LeaderTimeout", "committee_basis": "configured_source_round", "committee_size": 11, "attempt_basis": "configured_next_normal_round", "rotations_value": 1, "attempts": 2, "leader_unit": "100", "validator_unit": "200", "expected_bond": "4600"}, + {"quote_round_index": 2, "source_status": "LeaderTimeout", "committee_basis": "configured_source_round", "committee_size": 11, "attempt_basis": "configured_next_normal_round", "rotations_value": 2, "attempts": 3, "leader_unit": "100", "validator_unit": "200", "expected_bond": "6900"}, + {"quote_round_index": 4, "source_status": "LeaderTimeout", "committee_basis": "configured_source_round", "committee_size": 23, "attempt_basis": "configured_next_normal_round", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "4700"}, + + {"quote_round_index": 0, "source_status": "Undetermined", "committee_basis": "configured_next_normal_round", "committee_size": 11, "attempt_basis": "configured_next_normal_round", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "2300"}, + {"quote_round_index": 0, "source_status": "Undetermined", "committee_basis": "configured_next_normal_round", "committee_size": 11, "attempt_basis": "configured_next_normal_round", "rotations_value": 1, "attempts": 2, "leader_unit": "100", "validator_unit": "200", "expected_bond": "4600"}, + {"quote_round_index": 0, "source_status": "Undetermined", "committee_basis": "configured_next_normal_round", "committee_size": 11, "attempt_basis": "configured_next_normal_round", "rotations_value": 2, "attempts": 3, "leader_unit": "100", "validator_unit": "200", "expected_bond": "6900"}, + {"quote_round_index": 2, "source_status": "Undetermined", "committee_basis": "configured_next_normal_round", "committee_size": 23, "attempt_basis": "configured_next_normal_round", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "4700"}, + {"quote_round_index": 2, "source_status": "Undetermined", "committee_basis": "configured_next_normal_round", "committee_size": 23, "attempt_basis": "configured_next_normal_round", "rotations_value": 1, "attempts": 2, "leader_unit": "100", "validator_unit": "200", "expected_bond": "9400"}, + {"quote_round_index": 2, "source_status": "Undetermined", "committee_basis": "configured_next_normal_round", "committee_size": 23, "attempt_basis": "configured_next_normal_round", "rotations_value": 2, "attempts": 3, "leader_unit": "100", "validator_unit": "200", "expected_bond": "14100"}, + + {"quote_round_index": 0, "source_status": "ValidatorsTimeout", "committee_basis": "configured_appeal_round", "committee_size": 7, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "1400"}, + {"quote_round_index": 1, "source_status": "ValidatorsTimeout", "committee_basis": "configured_appeal_round", "committee_size": 11, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "2200"}, + {"quote_round_index": 2, "source_status": "ValidatorsTimeout", "committee_basis": "configured_appeal_round", "committee_size": 13, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "2600"}, + {"quote_round_index": 3, "source_status": "ValidatorsTimeout", "committee_basis": "configured_appeal_round", "committee_size": 23, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "4600"}, + {"quote_round_index": 4, "source_status": "ValidatorsTimeout", "committee_basis": "configured_appeal_round", "committee_size": 25, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "5000"}, + {"quote_round_index": 5, "source_status": "ValidatorsTimeout", "committee_basis": "configured_appeal_round", "committee_size": 47, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "9400"}, + {"quote_round_index": 7, "source_status": "ValidatorsTimeout", "committee_basis": "configured_appeal_round", "committee_size": 95, "attempt_basis": "single_appeal_jury", "rotations_value": 0, "attempts": 1, "leader_unit": "100", "validator_unit": "200", "expected_bond": "19000"} + ] +} diff --git a/tests/unit/consensus/test_decisions_accepted.py b/tests/unit/consensus/test_decisions_accepted.py index 2d74603ab..04c930136 100644 --- a/tests/unit/consensus/test_decisions_accepted.py +++ b/tests/unit/consensus/test_decisions_accepted.py @@ -26,7 +26,6 @@ ) from backend.consensus.types import ConsensusRound - # ── Helpers ──────────────────────────────────────────────────────── @@ -466,6 +465,17 @@ def test_post_effects_always_have_status_update(self): assert e is not None assert e.new_status == "FINALIZED" + def test_receiptless_decision_skips_unattributable_rollup_event(self): + pre, _, should = decide_finalizing( + **_base_finalizing_kwargs( + tx_status_accepted=False, + leader_node_config=None, + ) + ) + + assert should is False + assert _find_effect(pre, EmitRollupEventEffect) is None + class TestDecideFinalizingNotAcceptedWithSuccess: """Not accepted but execution_result_success=True — still should not finalize.""" diff --git a/tests/unit/consensus/test_payable_balance_flow.py b/tests/unit/consensus/test_payable_balance_flow.py index 5523a18f1..64ba7fa97 100644 --- a/tests/unit/consensus/test_payable_balance_flow.py +++ b/tests/unit/consensus/test_payable_balance_flow.py @@ -15,8 +15,11 @@ from backend.consensus import base as consensus_base from backend.consensus.base import ( + FinalizingState, PendingState, + ProposingState, TransactionContext, + UndeterminedState, _pending_valid_until_expired, ) from backend.domain.types import ( @@ -26,6 +29,7 @@ TransactionExecutionMode, ) from backend.database_handler.contract_snapshot import ContractSnapshot +from backend.database_handler.types import ConsensusData from backend.protocol_rpc.fees import FEE_ACCOUNTING_KEY @@ -68,6 +72,7 @@ def _make_transaction( tx.appeal_leader_timeout = False tx.appeal_validators_timeout = False tx.triggered_by_hash = triggered_by_hash + tx.origin_address = None tx.status = status tx.consensus_history = consensus_history or {} tx.consensus_data = Mock() @@ -218,6 +223,76 @@ def test_valid_until_does_not_cancel_appeal_recomputation(): assert _pending_valid_until_expired(transaction, now=10_000) is False +def _pending_context_with_validator_capacity(*, requested, available): + transaction_hash = "0x" + "ab" * 32 + transaction = { + "hash": transaction_hash, + "status": TransactionStatus.PENDING.value, + "type": TransactionType.RUN_CONTRACT.value, + "from_address": "0x1111111111111111111111111111111111111111", + "to_address": "0x2222222222222222222222222222222222222222", + "value": 0, + "data": {"calldata": "AA=="}, + "consensus_data": None, + "consensus_history": {}, + "num_of_initial_validators": requested, + } + transactions_processor = Mock() + transactions_processor.get_transaction_by_hash.return_value = transaction + validator_nodes = [ + SimpleNamespace( + validator=SimpleNamespace( + to_dict=lambda index=index: { + "address": f"0x{index:040x}", + "stake": 1, + } + ) + ) + for index in range(1, available + 1) + ] + return SimpleNamespace( + transaction=SimpleNamespace(hash=transaction_hash), + transactions_processor=transactions_processor, + accounts_manager=Mock(), + msg_handler=SimpleNamespace(send_message=Mock()), + consensus_service=Mock(), + contract_processor=Mock(), + validators_snapshot=SimpleNamespace(nodes=validator_nodes), + involved_validators=[], + consensus_data=ConsensusData(votes={}, leader_receipt=None, validators=[]), + contract_snapshot=_make_snapshot(balance=0), + ) + + +@pytest.mark.asyncio +async def test_initial_committee_shortfall_becomes_undetermined_without_execution(): + context = _pending_context_with_validator_capacity(requested=7, available=5) + + result = await PendingState().handle(context) + + assert isinstance(result, UndeterminedState) + assert context.involved_validators == [] + context.accounts_manager.credit_tx_value_once.assert_not_called() + + await result.handle(context) + + context.transactions_processor.update_transaction_status.assert_called_once_with( + context.transaction.hash, + TransactionStatus.UNDETERMINED, + False, + ) + + +@pytest.mark.asyncio +async def test_initial_committee_uses_exact_requested_size_when_available(): + context = _pending_context_with_validator_capacity(requested=7, available=7) + + result = await PendingState().handle(context) + + assert isinstance(result, ProposingState) + assert len(context.involved_validators) == 7 + + class TestAppealReentry: """On appeal re-entry, value_credited flag prevents double credit.""" @@ -303,6 +378,94 @@ def test_zero_value_skips_credit(self): am.credit_tx_value_once.assert_not_called() +@pytest.mark.asyncio +async def test_terminal_no_result_returns_child_value_to_emitting_contract(monkeypatch): + """The outer origin must never receive value funded by a child sender.""" + + tx = _make_transaction( + value=500, + triggered_by_hash="0xparent", + status=TransactionStatus.LEADER_TIMEOUT, + ) + tx.from_address = "0xemitting-contract" + tx.origin_address = "0xouter-user" + tx.consensus_data.leader_receipt = [ + SimpleNamespace( + execution_result=SimpleNamespace(value="ERROR"), + node_config={"address": "0xleader"}, + pending_transactions=[], + ) + ] + accounts_manager = _make_accounts_manager() + context = _make_context(tx, accounts_manager=accounts_manager) + executor = Mock() + executor.execute = AsyncMock() + monkeypatch.setattr( + consensus_base, + "EffectExecutor", + Mock(return_value=executor), + ) + monkeypatch.setattr( + consensus_base, + "_dispatch_messages_for_phase", + Mock(return_value=False), + ) + + await FinalizingState().handle(context) + + accounts_manager.refund_activated_tx_value_once.assert_called_once_with( + tx.hash, + tx.to_address, + "0xemitting-contract", + ) + assert accounts_manager.refund_activated_tx_value_once.call_args.args[2] != ( + tx.origin_address + ) + + +@pytest.mark.asyncio +async def test_receiptless_undetermined_finalizes_and_refunds_uncredited_value( + monkeypatch, +): + tx = _make_transaction(value=500, status=TransactionStatus.UNDETERMINED) + tx.consensus_data = ConsensusData(votes={}, leader_receipt=None, validators=[]) + accounts_manager = _make_accounts_manager() + accounts_manager.refund_activated_tx_value_once.return_value = False + context = _make_context(tx, accounts_manager=accounts_manager) + executor = Mock() + executor.execute = AsyncMock() + monkeypatch.setattr( + consensus_base, + "EffectExecutor", + Mock(return_value=executor), + ) + dispatch_messages = Mock(return_value=False) + monkeypatch.setattr( + consensus_base, + "_dispatch_messages_for_phase", + dispatch_messages, + ) + + await FinalizingState().handle(context) + + dispatch_messages.assert_not_called() + accounts_manager.refund_activated_tx_value_once.assert_called_once_with( + tx.hash, + tx.to_address, + tx.from_address, + ) + accounts_manager.refund_tx_value.assert_called_once_with( + tx.hash, + tx.from_address, + ) + accounts_manager.settle_tx_fee_accounting_once.assert_called_once_with( + tx.hash, + tx.from_address, + receipt=None, + reason="finalized", + ) + + class TestMintOnDemand: """Studio sandbox mints shortfall for sender automatically.""" diff --git a/tests/unit/test_node_state_proxy_metrics.py b/tests/unit/test_node_state_proxy_metrics.py index 03287545d..42bff132a 100644 --- a/tests/unit/test_node_state_proxy_metrics.py +++ b/tests/unit/test_node_state_proxy_metrics.py @@ -419,7 +419,7 @@ async def test_run_genvm_passes_mode2_message_fee_allocations_to_genvm(): ) allocations = run_genvm_host.await_args.kwargs["fee_context"].message_fee_allocation - assert len(allocations) == 2 + assert len(allocations) == 1 allocation = allocations[0] assert allocation["recipient"].as_hex.lower() == recipient assert allocation["call_key"] == bytes.fromhex("34" * 32) @@ -439,17 +439,6 @@ async def test_run_genvm_passes_mode2_message_fee_allocations_to_genvm(): assert allocation["children"][0]["recipient"].as_hex.lower() == child_recipient assert allocation["children"][0]["call_key"] == bytes.fromhex("56" * 32) assert allocation["children"][0]["budget"] == 60 - fallback = allocations[1] - assert fallback["recipient"] is None - assert fallback["call_key"] is None - assert fallback["budget"] == 2**200 - assert fallback["on"] == "finalized" - assert fallback["fee_params"] == { - "External": { - "gas_limit": 2**200, - "max_gas_price": 0, - }, - } @pytest.mark.asyncio diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index a593ca7eb..c44dde7f1 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -1,6 +1,8 @@ import base64 +from dataclasses import replace from datetime import datetime import json +from pathlib import Path from types import SimpleNamespace import pytest @@ -63,6 +65,7 @@ AllocationTreeMalformed, AllocationTreeTooDeep, BudgetTooLow, + ContributionSegmentsFull, EqOutputsTooLarge, ExternalAllocationInvalid, FeeValueMustBeNonZero, @@ -78,6 +81,7 @@ MessageEffectDescriptorMismatch, MessageFeeParamsMismatch, MessageAllocationsNotEqualBudget, + MessageAllocationsRestricted, MessageFeesReportMismatch, MessageNoMatchingAllocation, Mode1MessageFeesRequireGenVMPerEmissionSupport, @@ -89,6 +93,7 @@ MESSAGE_ALLOCATION_NODE_ABI_TYPE, MIN_RECEIPT_BYTES, MESSAGE_REVEAL_LENGTH_SLOTS, + MAX_CONTRIBUTION_SEGMENTS, NODE_ROOT_SENTINEL, NONDET_OUTPUT_LENGTH_BYTES, FEE_ACCOUNTING_KEY, @@ -509,6 +514,15 @@ def test_calculate_later_round_fees_uses_normal_round_rotation_ordinal(): assert calculate_round_fees(fees_distribution, 23, round=4) == 3 * (100 + 23 * 200) +def test_nondefault_initial_committee_keeps_later_rounds_on_absolute_fee_ladder(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) + + # Round zero honors the admitted seven-seat committee. The appeal and + # replacement normal rounds remain the protocol's raw-round 7/11 seats; + # they do not shift to 11/13 merely because round zero started at seven. + assert calculate_round_fees(fees_distribution, 7) == 8_750 + + def test_sim_min_message_primary_fees_decodes_canonical_v06_abi(monkeypatch): monkeypatch.setenv("GENLAYER_STUDIO_GEN_PER_TIME_UNIT", "1") monkeypatch.setenv("GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS", "1500") @@ -559,7 +573,7 @@ def test_calculate_round_fees_prices_odd_bond_before_profit_rounding(): ) -def test_settlement_uses_current_overlay_split_capped_by_funded_reserve(): +def test_settlement_uses_funding_time_overlay_split_despite_live_drift(): submission_policy = StudioFeePolicy( gen_per_time_unit=1, time_unit_overlay_bps=1_000, @@ -590,10 +604,10 @@ def test_settlement_uses_current_overlay_split_capped_by_funded_reserve(): policy=StudioFeePolicy(gen_per_time_unit=1, time_unit_overlay_bps=2_000), ) - assert lower_split["time_unit_overlay_requested"] == 57 - assert lower_split["time_unit_overlay_spent"] == 57 - assert lower_refund == 65 - assert higher_split["time_unit_overlay_requested"] == 275 + assert lower_split["time_unit_overlay_requested"] == 122 + assert lower_split["time_unit_overlay_spent"] == 122 + assert lower_refund == 0 + assert higher_split["time_unit_overlay_requested"] == 122 assert higher_split["time_unit_overlay_spent"] == 122 assert higher_refund == 0 @@ -1011,6 +1025,12 @@ def test_v06_submission_enforces_deployed_phase_timeout_bounds(): ) +@pytest.mark.parametrize("overlay_bps", [-1, 10_000, 10_001]) +def test_fee_policy_rejects_non_live_overlay_split(overlay_bps): + with pytest.raises(ValueError, match="time-unit overlay bps"): + StudioFeePolicy(time_unit_overlay_bps=overlay_bps) + + def test_activation_checks_live_gen_cap_and_locks_all_live_prices(): submission_policy = StudioFeePolicy( gen_per_time_unit=2, @@ -1018,7 +1038,7 @@ def test_activation_checks_live_gen_cap_and_locks_all_live_prices(): receipt_gas_price=4, ) fees_distribution = _fees_distribution( - execution_budget_per_round=1, + execution_budget_per_round=10_000_000, max_price_gen_per_time_unit=5, storage_fee_max_gas_price=10, receipt_fee_max_gas_price=10, @@ -1078,7 +1098,84 @@ def test_activation_checks_live_gen_cap_and_locks_all_live_prices(): assert relocked["locked_prices"] == activated["locked_prices"] -def test_runtime_execution_policy_locks_only_activation_prices(): +@pytest.mark.parametrize( + ("live_policy", "cap_type", "actual", "maximum"), + [ + (StudioFeePolicy(storage_unit_price=11), "storageUnitPrice", 11, 10), + (StudioFeePolicy(receipt_gas_price=12), "receiptGasPrice", 12, 10), + ], +) +def test_activation_checks_all_signed_price_caps( + live_policy, cap_type, actual, maximum +): + fees_distribution = _fees_distribution( + max_price_gen_per_time_unit=10, + storage_fee_max_gas_price=10, + receipt_fee_max_gas_price=10, + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + + canceled, should_cancel = activate_fee_accounting(accounting, live_policy) + + assert should_cancel is True + assert canceled["activation_price_cap_type"] == cap_type + assert canceled["activation_price_cap_exceeded"] == { + "actual": actual, + "maximum": maximum, + } + assert canceled["activation_cancel_reason"] == ( + f"activation_price_cap_exceeded:{cap_type}" + ) + + +def test_activation_cancels_when_live_receipt_floor_outgrows_committed_budget(): + submission_policy = StudioFeePolicy( + receipt_gas_price=1, + intrinsic_gas=0, + bootloader_overhead=0, + gas_per_changed_slot=0, + calldata_gas_per_byte=0, + fixed_propose_receipt_gas=10, + ) + fees_distribution = _fees_distribution( + execution_budget_per_round=10, + receipt_fee_max_gas_price=2, + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit( + fees_distribution, + 5, + submission_policy, + ), + user_value=0, + policy=submission_policy, + ) + activation_policy = replace( + submission_policy, + receipt_gas_price=2, + ) + + canceled, should_cancel = activate_fee_accounting( + accounting, + activation_policy, + ) + + assert should_cancel is True + assert canceled["activation_budget_floor_not_met"] == { + "actual": 10, + "minimum": 20, + } + assert canceled["activation_cancel_reason"] == "activation_budget_floor_not_met" + + +def test_runtime_execution_policy_locks_complete_activation_formula(): submission_policy = StudioFeePolicy( gen_per_time_unit=2, storage_unit_price=3, @@ -1087,7 +1184,7 @@ def test_runtime_execution_policy_locks_only_activation_prices(): fixed_propose_receipt_gas=6, ) fees_distribution = _fees_distribution( - execution_budget_per_round=1, + execution_budget_per_round=10_000_000, max_price_gen_per_time_unit=10, storage_fee_max_gas_price=10, receipt_fee_max_gas_price=10, @@ -1110,6 +1207,8 @@ def test_runtime_execution_policy_locks_only_activation_prices(): gen_per_time_unit=7, storage_unit_price=8, receipt_gas_price=9, + calldata_gas_per_byte=17, + fixed_propose_receipt_gas=19, ), ) assert should_cancel is False @@ -1127,11 +1226,11 @@ def test_runtime_execution_policy_locks_only_activation_prices(): assert gas_data["genPerTimeUnit"] == "7" assert gas_data["storageUnitPrice"] == "8" - assert gas_data["receiptGasPerByte"] == str(9 * 11) - assert gas_data["fixedProposeReceiptGas"] == str(9 * 13) + assert gas_data["receiptGasPerByte"] == str(9 * 17) + assert gas_data["fixedProposeReceiptGas"] == str(9 * 19) -def test_activated_reveal_reads_live_message_count_cap(monkeypatch): +def test_activated_reveal_uses_committed_message_count_cap(monkeypatch): accounting = create_fee_accounting( fees_distribution=_fees_distribution(total_message_fees=120), num_of_validators=5, @@ -1153,8 +1252,9 @@ def test_activated_reveal_reads_live_message_count_cap(monkeypatch): "callKey": "0x" + "12" * 32, } - with pytest.raises(TooManyMessages): - record_reveal_message_fees(activated, [message, message]) + revealed = record_reveal_message_fees(activated, [message, message]) + + assert len(revealed["message_consumption_events"]) == 1 def test_endpoint_fee_envelope_rejects_insufficient_fee_deposit(): @@ -2926,19 +3026,7 @@ def test_genvm_message_fee_allocation_maps_studio_nodes(): }, "children": [], } - assert allocations[1] == { - "recipient": None, - "call_key": None, - "budget": 2**200, - "on": "finalized", - "fee_params": { - "External": { - "gas_limit": 2**200, - "max_gas_price": 0, - }, - }, - "children": [], - } + assert len(allocations) == 1 def test_genvm_message_fee_allocation_nests_descendants_under_roots(): @@ -2967,7 +3055,7 @@ def test_genvm_message_fee_allocation_nests_descendants_under_roots(): allocations = genvm_message_fee_allocation(accounting) - assert len(allocations) == 2 + assert len(allocations) == 1 assert allocations[0]["recipient"] == root["recipient"] assert allocations[0]["call_key"] == bytes.fromhex("12" * 32) assert allocations[0]["budget"] == 120 @@ -2991,12 +3079,9 @@ def test_genvm_message_fee_allocation_nests_descendants_under_roots(): "children": [], } ] - assert "External" in allocations[1]["fee_params"] - assert allocations[1]["recipient"] is None - assert allocations[1]["call_key"] is None -def test_genvm_message_fee_allocation_adds_external_legacy_fallback_after_roots(): +def test_genvm_message_fee_allocation_does_not_add_uncommitted_fallback(): external = _allocation( message_type=0, on_acceptance=False, @@ -3027,10 +3112,7 @@ def test_genvm_message_fee_allocation_adds_external_legacy_fallback_after_roots( "max_gas_price": 10, }, } - assert "External" in allocations[1]["fee_params"] - assert allocations[1]["recipient"] is None - assert allocations[1]["call_key"] is None - assert allocations[1]["budget"] == 2**200 + assert len(allocations) == 1 def test_genvm_message_fee_allocation_keeps_legacy_gasless_messages_unmetered(): @@ -3842,6 +3924,13 @@ def test_simulation_fee_consumption_records_external_allocation_reservation(): "reimbursement": 77, "remainder": 623, "executionRecorded": True, + "fundingOffset": 0, + "fundingOwners": [ + { + "recipient": "0x1111111111111111111111111111111111111111", + "amount": 700, + } + ], } ] assert recorded["execution_fee_report"]["messageFees"] == { @@ -4932,6 +5021,38 @@ def test_settle_fee_accounting_uses_actual_round_for_primary_refund(): assert refund == 21700 +def test_unavailable_initial_committee_charges_no_invented_validator_work(): + fees_distribution = _fees_distribution() + deposit = required_fee_deposit(fees_distribution, 7) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=7, + submitted_value=deposit, + user_value=0, + sender="0x1111111111111111111111111111111111111111", + ) + consensus_history = { + "consensus_results": [ + { + "consensus_round": "Undetermined", + "leader_result": None, + "validator_results": [], + } + ] + } + + settled, refund = settle_fee_accounting( + accounting, + actual_final_round=0, + num_of_validators=7, + consensus_history=consensus_history, + ) + + assert settled["primary_fee_spent"] == 0 + assert refund == deposit + assert settled["settlement_rounds"][0]["rule"] == "committee_unavailable" + + def test_leader_timeout_settlement_matches_fee_simulator_50_paid_1050_refunded(): accounting = create_fee_accounting( fees_distribution=_fees_distribution(), @@ -6179,7 +6300,7 @@ def test_settlement_does_not_fabricate_validator_pay_for_timeout_rotation(): assert settled["settlement_rounds"][0]["timeUnitAmount"] == 50 + 100 + 5 * 200 -def test_settlement_preserves_each_attempts_live_metering_snapshot(): +def test_settlement_preserves_activation_committed_metering_snapshot(): submission_policy = StudioFeePolicy(receipt_gas_price=2) fees_distribution = _fees_distribution( rotations=[1], @@ -6199,7 +6320,15 @@ def test_settlement_preserves_each_attempts_live_metering_snapshot(): ) activated, should_cancel = activate_fee_accounting( accounting, - StudioFeePolicy(receipt_gas_price=2), + StudioFeePolicy( + receipt_gas_price=2, + intrinsic_gas=0, + bootloader_overhead=0, + gas_per_changed_slot=0, + calldata_gas_per_byte=0, + fixed_propose_receipt_gas=11, + receipt_wrapper_bytes=0, + ), ) assert should_cancel is False @@ -6271,8 +6400,8 @@ def test_settlement_preserves_each_attempts_live_metering_snapshot(): assert [ attempt["receiptFee"] for attempt in settled["historical_execution_attempts"] - ] == [22, 34] - assert settled["execution_fee_consumed"] == 56 + ] == [22, 22] + assert settled["execution_fee_consumed"] == 44 def test_historical_error_attempt_does_not_charge_message_reveal(): @@ -6788,7 +6917,7 @@ def test_mode2_message_fees_reject_missing_allocation_and_phase_mismatch(): ) -def test_mode2_external_message_fees_use_legacy_fallback_without_matching_allocation(): +def test_mode2_external_message_fees_require_matching_committed_allocation(): accounting = create_fee_accounting( fees_distribution=_fees_distribution(total_message_fees=55), message_allocations=[ @@ -6803,30 +6932,21 @@ def test_mode2_external_message_fees_use_legacy_fallback_without_matching_alloca user_value=0, ) - updated = consume_message_fees( - accounting, - [ - { - "messageType": 0, - "recipient": "0x2222222222222222222222222222222222222222", - "onAcceptance": False, - "declaredBudget": 0, - "callKey": "0x" + "0" * 32, - "gasUsed": 1_000, - } - ], - policy=StudioFeePolicy(receipt_gas_price=1), - ) - - assert updated["message_fee_consumed"] == 0 - assert updated["allocation_consumed"] == {} - assert updated["external_message_events"] == [] - assert updated["message_consumption_events"][0] == { - "consumed": 0, - "internalConsumed": 0, - "externalReimbursed": 0, - "remaining": 55, - } + with pytest.raises(MessageNoMatchingAllocation): + consume_message_fees( + accounting, + [ + { + "messageType": 0, + "recipient": "0x2222222222222222222222222222222222222222", + "onAcceptance": False, + "declaredBudget": 0, + "callKey": "0x" + "0" * 32, + "gasUsed": 1_000, + } + ], + policy=StudioFeePolicy(receipt_gas_price=1), + ) def test_mode2_external_message_fees_reject_on_acceptance_execution_reservation(): @@ -6863,6 +6983,39 @@ def test_mode2_external_message_fees_reject_on_acceptance_execution_reservation( ) +def test_external_allocation_rejects_zero_live_execution_price(): + allocation = _allocation( + message_type=0, + on_acceptance=False, + budget=1_000, + fee_params=_encode_external_fee_params(gas_limit=100, max_gas_price=10), + ) + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=1_000), + message_allocations=[allocation], + num_of_validators=5, + submitted_value=2_100, + user_value=0, + ) + + with pytest.raises( + ExternalAllocationInvalid, match="ExternalExecutionPriceUnavailable" + ): + consume_message_fees( + accounting, + [ + { + "messageType": 0, + "recipient": allocation["recipient"], + "onAcceptance": False, + "declaredBudget": 0, + "callKey": allocation["callKey"], + } + ], + policy=StudioFeePolicy(receipt_gas_price=0), + ) + + def test_consume_external_message_fees_use_exact_then_wildcard_allocation_match(): recipient = "0x2222222222222222222222222222222222222222" exact_call_key = "0x" + "12" * 32 @@ -7199,6 +7352,88 @@ def test_reveal_external_message_reserves_then_execution_reimburses_once(): assert executed_again == executed +def test_external_retry_uses_fresh_reservation_after_reveal_unwind(): + allocation = _allocation( + message_type=0, + on_acceptance=False, + budget=1_000, + fee_params=_encode_external_fee_params(gas_limit=100, max_gas_price=10), + ) + message = { + "messageType": 0, + "recipient": allocation["recipient"], + "onAcceptance": False, + "declaredBudget": 0, + "callKey": allocation["callKey"], + "gasUsed": 60, + } + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=1_000), + message_allocations=[allocation], + num_of_validators=5, + submitted_value=2_100, + user_value=0, + ) + policy = StudioFeePolicy(receipt_gas_price=7) + + first = record_reveal_message_fees(accounting, [message], policy=policy) + unwound = unwind_reveal_message_fees(first, [message]) + retry = record_reveal_message_fees(unwound, [message], policy=policy) + + assert first["allocation_consumed"] == {"0": 700} + assert unwound["allocation_consumed"] == {} + assert unwound["external_message_events"][0]["unreserved"] is True + assert retry["allocation_consumed"] == {"0": 700} + assert retry["external_message_fee_reserved"] == 700 + assert len(retry["external_message_events"]) == 2 + assert retry["external_message_events"][1]["executionRecorded"] is False + assert not retry["external_message_events"][1].get("unreserved", False) + + +def test_external_execution_uses_activation_locked_receipt_price(): + allocation = _allocation( + message_type=0, + on_acceptance=False, + budget=1_000, + fee_params=_encode_external_fee_params(gas_limit=100, max_gas_price=10), + ) + message = { + "messageType": 0, + "recipient": allocation["recipient"], + "onAcceptance": False, + "declaredBudget": 0, + "callKey": allocation["callKey"], + "gasUsed": 60, + } + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=1_000), + message_allocations=[allocation], + num_of_validators=5, + submitted_value=2_100, + user_value=0, + ) + activated, should_cancel = activate_fee_accounting( + accounting, + StudioFeePolicy(receipt_gas_price=7), + ) + revealed = record_reveal_message_fees( + activated, + [message], + policy=StudioFeePolicy(receipt_gas_price=9), + ) + + executed = record_external_message_execution_fees( + revealed, + [message], + policy=StudioFeePolicy(receipt_gas_price=10), + ) + + assert should_cancel is False + assert revealed["external_message_events"][0]["lockedGasPrice"] == 7 + assert executed["external_message_fee_reimbursed"] == 420 + assert executed["external_message_fee_remainder"] == 280 + + def test_external_execution_routes_reimbursement_and_remainder_like_consensus(): depositor = "0x1111111111111111111111111111111111111111" executor = "0x9999999999999999999999999999999999999999" @@ -7963,13 +8198,22 @@ def test_settle_fee_accounting_pays_successful_appeal_bond_plus_profit(): ] -def test_settle_fee_accounting_clamps_unfunded_appeal_profit_to_principal(): - fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) +def test_successful_appeal_refunds_exact_separate_primary_and_overlay_pools(): + sender = "0x2222222222222222222222222222222222222222" + policy = StudioFeePolicy(gen_per_time_unit=1, time_unit_overlay_bps=1_500) + fees_distribution = _fees_distribution( + appeals=1, + rotations=[0, 0], + max_price_gen_per_time_unit=1, + ) + deposit = required_fee_deposit(fees_distribution, 5, policy) accounting = create_fee_accounting( fees_distribution=fees_distribution, num_of_validators=5, - submitted_value=required_fee_deposit(fees_distribution, 5), + submitted_value=deposit, user_value=0, + sender=sender, + policy=policy, ) recorded = record_appeal_bond( accounting, @@ -7978,13 +8222,62 @@ def test_settle_fee_accounting_clamps_unfunded_appeal_profit_to_principal(): current_round=0, status="ACCEPTED", ) - # Adversarial legacy state: less fee money remains than the complete - # 2,100-wei profit leg. Consensus promises no partial profit in this case. - recorded["primary_fee_budget"] = 2_000 - history = { - "consensus_results": [ - {"consensus_round": "Accepted"}, - { + + settled, refund = settle_fee_accounting( + recorded, + actual_final_round=1, + num_of_validators=5, + consensus_history={ + "consensus_results": [ + {"consensus_round": "Accepted"}, + {"consensus_round": "Validator Appeal Successful"}, + ] + }, + ) + + # The successful validator appeal spends 1,400 of time-unit work, its + # exact 2,100 profit reserve, and only the overlay on the 1,400 work leg. + assert settled["appeal_profit_spent"] == 2_100 + assert settled["time_unit_overlay_spent"] == 247 + assert settled["primary_fee_spent"] == 3_747 + assert refund == deposit - 3_747 + assert sum(item["amount"] for item in settled["fee_refund_settlements"]) == refund + assert settled["fee_refund_settlements"] == [ + { + "recipient": sender, + "amount": 4_332, + "source": "primary-fifo", + }, + { + "recipient": sender, + "amount": 1_135, + "source": "overlay-fifo", + }, + ] + + +def test_settle_fee_accounting_clamps_unfunded_appeal_profit_to_principal(): + fees_distribution = _fees_distribution(appeals=1, rotations=[0, 0]) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + recorded = record_appeal_bond( + accounting, + amount=1_400, + appealer="0x1111111111111111111111111111111111111111", + current_round=0, + status="ACCEPTED", + ) + # Adversarial legacy state: less fee money remains than the complete + # 2,100-wei profit leg. Consensus promises no partial profit in this case. + recorded["primary_fee_budget"] = 2_000 + history = { + "consensus_results": [ + {"consensus_round": "Accepted"}, + { "consensus_round": "Validator Appeal Successful", "validator_results": [ _history_receipt( @@ -8071,6 +8364,38 @@ def test_cancel_fee_accounting_returns_unadjudicated_appeal_bond(): assert canceled["appeal_bond_settlements"][0]["payout"] == 1400 +def test_cancel_fee_accounting_invalidates_committed_message_allocations(): + fee_params = _encode_internal_fee_params() + allocation = _allocation(budget=55, fee_params=fee_params) + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=55), + message_allocations=[allocation], + num_of_validators=5, + submitted_value=1_155, + user_value=0, + ) + consumed = consume_message_fees( + accounting, + [ + { + "messageType": 1, + "recipient": allocation["recipient"], + "onAcceptance": allocation["onAcceptance"], + "feeParams": fee_params, + "declaredBudget": 55, + "callKey": allocation["callKey"], + } + ], + ) + + canceled, _ = cancel_fee_accounting(consumed) + + assert canceled["canceled_message_allocations"] == accounting["message_allocations"] + assert canceled["message_allocations"] == [] + assert canceled["allocation_consumed"] == {} + assert canceled["message_allocations_invalidated"] is True + + def test_abort_unstarted_appeal_restores_accounting_and_returns_typed_charge(): fees_distribution = _fees_distribution(appeals=0, rotations=[0]) accounting = create_fee_accounting( @@ -8111,6 +8436,7 @@ def test_abort_unstarted_appeal_restores_accounting_and_returns_typed_charge(): "appeal_bonds_total", "contributions", "untracked_contributions", + "untracked_contribution_pools", ): assert restored[key] == accounting[key] assert restored["appeal_bonds"] == [] @@ -8194,6 +8520,44 @@ def test_leader_timeout_bond_matches_fee_simulator_configured_round_vector(): assert bond_with_two_rotations_left == 3 * (100 + 5 * 200) +def test_all_shared_simulator_consensus_appeal_quote_signatures(): + """Run Studio against the exact oracle campaign used by Consensus #1388.""" + + fixture = json.loads( + ( + Path(__file__).parents[1] / "fixtures" / "fee_simulator_appeal_quotes.json" + ).read_text() + ) + assert fixture["schema_version"] == 4 + assert len(fixture["quotes"]) == 27 + status_names = { + "Accepted": "ACCEPTED", + "LeaderTimeout": "LEADER_TIMEOUT", + "Undetermined": "UNDETERMINED", + "ValidatorsTimeout": "VALIDATORS_TIMEOUT", + } + + for quote in fixture["quotes"]: + current_round = int(quote["quote_round_index"]) + rotation_index = current_round // 2 + 1 + rotations = [0] * (rotation_index + 1) + rotations[rotation_index] = int(quote["rotations_value"]) + distribution = _fees_distribution( + leader_timeunits=int(quote["leader_unit"]), + validator_timeunits=int(quote["validator_unit"]), + appeals=len(rotations) - 1, + rotations=rotations, + ) + + actual = calculate_min_appeal_bond( + distribution, + current_round=current_round, + status=status_names[quote["source_status"]], + ) + + assert actual == int(quote["expected_bond"]), quote + + def test_leader_timeout_bond_uses_the_next_normal_round_schedule(): fees_distribution = _fees_distribution(appeals=1, rotations=[0, 2]) @@ -8358,7 +8722,7 @@ def test_top_up_and_submit_appeal_only_bumps_appeal_capacity_and_fee_pot(): assert updated["appeal_bonds"][0]["feesDistributionIgnored"] is True -def test_appeal_admission_uses_current_overlay_with_locked_gen_price(): +def test_appeal_admission_keeps_funding_time_overlay_with_locked_gen_price(): submission_policy = StudioFeePolicy( gen_per_time_unit=3, time_unit_overlay_bps=500, @@ -8375,15 +8739,11 @@ def test_appeal_admission_uses_current_overlay_with_locked_gen_price(): user_value=0, policy=submission_policy, ) - admission_policy = StudioFeePolicy( - gen_per_time_unit=3, - time_unit_overlay_bps=current_overlay_bps, - ) charge = calculate_appeal_charge( accounting["fees_distribution"], current_round=0, status="ACCEPTED", - policy=admission_policy, + policy=submission_policy, ) updated = record_appeal_bond( @@ -8397,7 +8757,7 @@ def test_appeal_admission_uses_current_overlay_with_locked_gen_price(): breakdown = updated["appeal_bonds"][0]["fundingBreakdown"] assert breakdown["taxableWork"] == 3_700 * 3 - assert breakdown["overlay"] == breakdown["taxableWork"] * 2_000 // 8_000 + assert breakdown["overlay"] == breakdown["taxableWork"] * 500 // 9_500 assert updated["time_unit_overlay_budget"] == ( accounting["time_unit_overlay_budget"] + breakdown["overlay"] ) @@ -8475,8 +8835,60 @@ def test_create_child_fee_accounting_seeds_mode1_bucket_without_child_allocation assert child_accounting["primary_fee_budget"] == 55 assert child_accounting["message_fee_budget"] == 15 assert child_accounting["message_allocations"] == [] + assert child_accounting["message_allocations_restricted"] is False assert child_accounting["user_value"] == 7 + sealed_child = dict(child_accounting) + sealed_child["message_allocations_restricted"] = True + with pytest.raises(MessageAllocationsRestricted): + apply_fee_top_up( + sealed_child, + fees_distribution=_top_up_distribution(total_message_fees=1), + amount=1, + perform_fee_checks=False, + ) + + +def test_committed_child_uses_parent_policy_then_checks_own_activation_floor(): + parent_policy = StudioFeePolicy( + receipt_gas_price=1, + intrinsic_gas=0, + bootloader_overhead=0, + gas_per_changed_slot=0, + calldata_gas_per_byte=0, + fixed_propose_receipt_gas=10, + ) + fee_params = _encode_internal_fee_params( + execution_budget_per_round=10, + receipt_fee_max_gas_price=2, + ) + + _, child_accounting = create_child_fee_accounting( + message={ + "messageType": 1, + "recipient": "0x3333333333333333333333333333333333333333", + "value": 0, + "onAcceptance": False, + "feeParams": fee_params, + "declaredBudget": 65, + "callKey": EMPTY_CALL_KEY, + }, + parent_fees_distribution=_fees_distribution(), + sender="0x1111111111111111111111111111111111111111", + policy=parent_policy, + ) + + canceled, should_cancel = activate_fee_accounting( + child_accounting, + replace(parent_policy, receipt_gas_price=2), + ) + + assert should_cancel is True + assert canceled["activation_budget_floor_not_met"] == { + "actual": 10, + "minimum": 20, + } + def test_create_child_fee_accounting_prices_primary_at_child_owned_caps(): policy = StudioFeePolicy( @@ -9157,7 +9569,7 @@ def test_message_value_withdrawal_debits_use_balance_fee_and_value_from_ghost(): ] -def test_message_value_withdrawal_keeps_short_use_balance_child_unfunded(): +def test_message_value_withdrawal_drops_short_use_balance_child(): context = _message_value_context(balance=9) use_balance_child = _pending_internal_value( 3, @@ -9171,10 +9583,7 @@ def test_message_value_withdrawal_keeps_short_use_balance_child_unfunded(): "accepted", ) - assert len(adjusted) == 1 - assert adjusted[0].value == 0 - assert adjusted[0].declared_budget == 0 - assert adjusted[0].use_balance is True + assert adjusted == [] assert context.accounts_manager.balance == 9 assert context.accounts_manager.debits == [] @@ -9526,14 +9935,173 @@ def test_settlement_refunds_top_up_contributors_fifo(): assert refund == 500 assert settled["contributions"] == [ - {"depositor": original, "amount": 1_100}, - {"depositor": rescuer, "amount": 500}, + { + "depositor": original, + "amount": 1_100, + "primary": 1_100, + "overlay": 0, + "message": 0, + }, + { + "depositor": rescuer, + "amount": 500, + "primary": 500, + "overlay": 0, + "message": 0, + }, ] assert settled["fee_refund_settlements"] == [ - {"recipient": rescuer, "amount": 500, "source": "fifo"} + {"recipient": rescuer, "amount": 500, "source": "primary-fifo"} ] +def test_legacy_contribution_segment_is_not_partially_retyped_by_top_up(): + sender = "0x1111111111111111111111111111111111111111" + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(), + num_of_validators=5, + submitted_value=1_100, + user_value=0, + sender=sender, + ) + accounting["version"] = 1 + accounting["contributions"] = [{"depositor": sender, "amount": 1_100}] + + topped_up = apply_fee_top_up( + accounting, + fees_distribution=_top_up_distribution(), + amount=500, + sender=sender, + perform_fee_checks=False, + ) + settled, refund = settle_fee_accounting( + topped_up, + actual_final_round=0, + num_of_validators=5, + policy=StudioFeePolicy(), + ) + + assert len(topped_up["contributions"]) == 2 + assert "primary" not in topped_up["contributions"][0] + assert refund == 500 + assert settled["fee_refund_settlements"] == [ + {"recipient": sender, "amount": 500, "source": "fifo"} + ] + + +def test_settlement_keeps_primary_and_message_contributor_pools_separate(): + primary_funder = "0x1111111111111111111111111111111111111111" + message_funder = "0x2222222222222222222222222222222222222222" + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(), + num_of_validators=5, + submitted_value=1_500, + user_value=0, + sender=primary_funder, + ) + topped_up = apply_fee_top_up( + accounting, + fees_distribution=_top_up_distribution(total_message_fees=500), + amount=500, + sender=message_funder, + perform_fee_checks=False, + ) + consumed = consume_message_fees( + topped_up, + [ + { + "messageType": 1, + "recipient": "0x3333333333333333333333333333333333333333", + "onAcceptance": False, + "feeParams": _encode_internal_fee_params(), + "declaredBudget": 500, + "callKey": EMPTY_CALL_KEY, + } + ], + ) + + settled, refund = settle_fee_accounting( + consumed, + actual_final_round=0, + num_of_validators=5, + policy=StudioFeePolicy(), + ) + + assert refund == 400 + assert settled["fee_refund_settlements"] == [ + { + "recipient": primary_funder, + "amount": 400, + "source": "primary-fifo", + } + ] + + +def test_cancellation_returns_overlay_and_primary_to_each_contributor(): + policy = StudioFeePolicy(gen_per_time_unit=1, time_unit_overlay_bps=1_000) + original = "0x1111111111111111111111111111111111111111" + rescuer = "0x2222222222222222222222222222222222222222" + fees_distribution = _fees_distribution(max_price_gen_per_time_unit=1) + initial = required_fee_deposit(fees_distribution, 5, policy) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=initial, + user_value=0, + sender=original, + policy=policy, + ) + topped_up = apply_fee_top_up( + accounting, + fees_distribution=_top_up_distribution(), + amount=1_000, + sender=rescuer, + perform_fee_checks=False, + policy=policy, + ) + + canceled, refund = cancel_fee_accounting(topped_up) + by_recipient: dict[str, int] = {} + for settlement in canceled["fee_refund_settlements"]: + by_recipient[settlement["recipient"]] = ( + by_recipient.get(settlement["recipient"], 0) + settlement["amount"] + ) + + assert refund == initial + 1_000 + assert by_recipient == {original: initial, rescuer: 1_000} + + +def test_contribution_segment_cap_rejects_unattributed_top_up_atomically(): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(), + num_of_validators=5, + submitted_value=1_100, + user_value=0, + sender="0x0000000000000000000000000000000000000001", + ) + for index in range(2, MAX_CONTRIBUTION_SEGMENTS + 1): + accounting = apply_fee_top_up( + accounting, + fees_distribution=_top_up_distribution(), + amount=1, + sender=f"0x{index:040x}", + perform_fee_checks=False, + ) + + before = accounting["paid_fee_value"] + with pytest.raises(ContributionSegmentsFull): + apply_fee_top_up( + accounting, + fees_distribution=_top_up_distribution(), + amount=1, + sender="0xffffffffffffffffffffffffffffffffffffffff", + perform_fee_checks=False, + ) + + assert len(accounting["contributions"]) == MAX_CONTRIBUTION_SEGMENTS + assert accounting["paid_fee_value"] == before + + def test_third_party_top_up_cannot_raise_original_depositor_price_caps(): owner = "0x1111111111111111111111111111111111111111" third_party = "0x2222222222222222222222222222222222222222" @@ -9718,6 +10286,50 @@ def test_apply_fee_top_up_checks_execution_budget_floor_after_merge(): ) +def test_post_activation_top_up_uses_locked_formula_and_floor(): + activation_policy = StudioFeePolicy( + receipt_gas_price=1, + intrinsic_gas=0, + bootloader_overhead=0, + gas_per_changed_slot=0, + calldata_gas_per_byte=0, + fixed_propose_receipt_gas=10, + ) + fees_distribution = _fees_distribution( + execution_budget_per_round=10, + receipt_fee_max_gas_price=2, + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit( + fees_distribution, + 5, + activation_policy, + ), + user_value=0, + policy=activation_policy, + ) + activated, should_cancel = activate_fee_accounting( + accounting, + activation_policy, + ) + assert should_cancel is False + + topped_up = apply_fee_top_up( + activated, + fees_distribution=_top_up_distribution(execution_budget_per_round=1), + amount=1, + policy=replace( + activation_policy, + receipt_gas_price=2, + fixed_propose_receipt_gas=100, + ), + ) + + assert topped_up["fees_distribution"]["executionBudgetPerRound"] == 11 + + def test_apply_fee_top_up_only_raises_existing_price_caps(): fees_distribution = _fees_distribution( max_price_gen_per_time_unit=100, @@ -11117,6 +11729,87 @@ def test_leader_appeal_requires_the_full_fresh_scheduled_set(): ) +def test_leader_replay_matches_fee_plan_after_seven_validator_initial_round(): + all_validators = [ + {"address": f"0x{index:040x}", "stake": 1} for index in range(1, 21) + ] + consensus_data = SimpleNamespace( + leader_receipt=[ + SimpleNamespace( + node_config={"address": "0x0000000000000000000000000000000000000001"} + ) + ], + validators=[ + SimpleNamespace(node_config={"address": f"0x{index:040x}"}) + for index in range(2, 8) + ], + ) + history = { + "consensus_results": [ + { + "consensus_round": "Undetermined", + "leader_result": [ + { + "node_config": { + "address": "0x0000000000000000000000000000000000000001" + } + } + ], + "validator_results": [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + ) + for index in range(2, 8) + ], + } + ] + } + + replay = ConsensusAlgorithm.get_leader_replay_validators( + all_validators, + history, + consensus_data, + target_committee_size=11, + ) + + assert len(replay) == 11 + assert {item["address"] for item in replay[:6]} == { + f"0x{index:040x}" for index in range(2, 8) + } + assert {item["address"] for item in replay[6:]}.isdisjoint( + {f"0x{index:040x}" for index in range(1, 8)} + ) + + +def test_leader_replay_downselects_survivors_when_initial_round_exceeds_target(): + all_validators = [ + {"address": f"0x{index:040x}", "stake": 1} for index in range(1, 31) + ] + consensus_data = SimpleNamespace( + leader_receipt=[ + SimpleNamespace( + node_config={"address": "0x0000000000000000000000000000000000000001"} + ) + ], + validators=[ + SimpleNamespace(node_config={"address": f"0x{index:040x}"}) + for index in range(2, 25) + ], + ) + + replay = ConsensusAlgorithm.get_leader_replay_validators( + all_validators, + {"consensus_results": []}, + consensus_data, + target_committee_size=11, + ) + + assert [item["address"] for item in replay] == [ + f"0x{index:040x}" for index in range(2, 13) + ] + + def test_appeal_selection_excludes_jurors_retained_only_in_history(): all_validators = [ {"address": f"0x{index:040x}", "stake": 1} for index in range(1, 11) From 601f4b9a005efceed763687c55cb1d19eb7d416b Mon Sep 17 00:00:00 2001 From: kirilaa Date: Fri, 28 Aug 2026 23:56:51 +0200 Subject: [PATCH 27/55] fix(rpc): decode submissions without a rollup connection --- .../transactions_processor.py | 4 +- backend/protocol_rpc/transactions_parser.py | 7 +- backend/rollup/web3_pool.py | 21 ++++++ tests/unit/test_transactions_parser.py | 66 +++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index b062a8c35..8b2f43b15 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -151,7 +151,9 @@ def __init__( self.snapshot_archive = SnapshotArchiveReader.from_environment() # Use singleton Web3 connection pool - self.web3 = Web3ConnectionPool.get() + # Only used for checksum normalisation here, which needs no rollup — + # and the rollup bridge is optional (HARDHAT_URL may be empty). + self.web3 = Web3ConnectionPool.get_for_utilities() @staticmethod def _select_receipt(receipts, index: int = 0) -> dict | None: diff --git a/backend/protocol_rpc/transactions_parser.py b/backend/protocol_rpc/transactions_parser.py index 493c5959e..039dff817 100644 --- a/backend/protocol_rpc/transactions_parser.py +++ b/backend/protocol_rpc/transactions_parser.py @@ -11,6 +11,7 @@ from hexbytes import HexBytes import os from backend.rollup.consensus_service import ConsensusService +from backend.rollup.web3_pool import Web3ConnectionPool from backend.domain.types import TransactionType from backend.protocol_rpc.types import ( @@ -301,7 +302,11 @@ def deserialize(self, serial): class TransactionParser: def __init__(self, consensus_service: ConsensusService): self.consensus_service = consensus_service - self.web3 = consensus_service.web3 + # Decoding a signed envelope is pure keccak + ABI codec work. The + # rollup bridge is optional (HARDHAT_URL may be empty), so the + # service's Web3 can be None; falling back keeps every submission + # decodable instead of failing the whole RPC boundary. + self.web3 = consensus_service.web3 or Web3ConnectionPool.get_for_utilities() def decode_signed_transaction( self, raw_transaction: str diff --git a/backend/rollup/web3_pool.py b/backend/rollup/web3_pool.py index 44312550c..56a9c427e 100644 --- a/backend/rollup/web3_pool.py +++ b/backend/rollup/web3_pool.py @@ -18,6 +18,27 @@ class Web3ConnectionPool: _web3: ClassVar[Web3 | None] = None _lock: ClassVar[threading.Lock] = threading.Lock() _session: ClassVar[requests.Session | None] = None + _offline: ClassVar[Web3 | None] = None + + @classmethod + def get_for_utilities(cls) -> Web3: + """A Web3 usable for pure helpers even with no rollup configured. + + HARDHAT_URL is optional — Studio runs without the legacy Hardhat + bridge — so ``get()`` legitimately returns None. Hashing, ABI codec + and checksum helpers still need a Web3 object and none of them touch + the network, so they fall back to a provider-less instance rather + than dereferencing None. Callers that actually reach the chain must + keep using ``get()`` and handle None themselves. + """ + web3 = cls.get() + if web3 is not None: + return web3 + if cls._offline is None: + with cls._lock: + if cls._offline is None: + cls._offline = Web3() + return cls._offline @classmethod def get(cls) -> Web3 | None: diff --git a/tests/unit/test_transactions_parser.py b/tests/unit/test_transactions_parser.py index 0cac0e83e..e2c618ba5 100644 --- a/tests/unit/test_transactions_parser.py +++ b/tests/unit/test_transactions_parser.py @@ -959,3 +959,69 @@ def test_pre_fee_deploy_decodes_when_deployment_artifacts_are_missing(monkeypatc assert genlayer_transaction.num_of_initial_validators == 5 assert genlayer_transaction.data.contract_code == contract_code assert genlayer_transaction.data.calldata == calldata + + +def test_utilities_web3_available_without_a_rollup(monkeypatch): + """HARDHAT_URL is optional, so the pool legitimately yields None.""" + from backend.rollup.web3_pool import Web3ConnectionPool + + monkeypatch.setattr(Web3ConnectionPool, "get", classmethod(lambda cls: None)) + assert isinstance(Web3ConnectionPool.get_for_utilities(), Web3) + + connected = Web3() + monkeypatch.setattr(Web3ConnectionPool, "get", classmethod(lambda cls: connected)) + assert Web3ConnectionPool.get_for_utilities() is connected + + +def test_pre_fee_submission_decodes_without_a_rollup_connection(monkeypatch): + """Regression: every submission was rejected as 'Invalid transaction data'. + + With HARDHAT_URL empty, ConsensusService.web3 is None. The parser used it + for keccak and the ABI codec, so decode_signed_transaction raised + AttributeError, returned None, and _send_raw_transaction_impl rejected + every transaction at the RPC boundary with -32602. + """ + sender = "0x715A17BA32a50bC11DADC257cb7c360FcaeE9dFA" + monkeypatch.setattr( + "backend.protocol_rpc.transactions_parser.Account.recover_transaction", + lambda raw: sender, + ) + + consensus_service = Mock() + consensus_service.web3 = None # HARDHAT_URL='' -> Web3ConnectionPool.get() is None + consensus_service.load_contract = Mock( + return_value=get_default_consensus_main_contract() + ) + parser = TransactionParser(consensus_service) + assert parser.web3 is not None + + contract_code = b"class Storage: pass" + calldata = b"\xc3\x01" + raw = _build_eip1559_raw( + nonce=0, + to=bytes.fromhex("b7278a61aa25c888815afc32ad3cc52ff24fe575"), + value=0, + data=_contract_call_data( + parser, + "addTransaction", + [ + sender, + "0x0000000000000000000000000000000000000000", + 5, + 3, + encode([contract_code, calldata]), + ], + input_count=5, + ), + ) + + decoded = parser.decode_signed_transaction(raw) + + # This is the exact gate _send_raw_transaction_impl applies before raising + # InvalidTransactionError("Invalid transaction data"). + assert decoded is not None + assert decoded.data is not None + + genlayer_transaction = parser.get_genlayer_transaction(decoded) + assert genlayer_transaction.type == TransactionType.DEPLOY_CONTRACT + assert genlayer_transaction.data.contract_code == contract_code From ce75ac3297b5c494e672b91d8c187d4a81edb30f Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 30 Aug 2026 20:07:41 +0100 Subject: [PATCH 28/55] fix(tooling): qualify the v0.6 fee train Align Studio lifecycle/status RPCs with the node v0.6 consumer interface, pin the exact fee-aware SDK and test-harness revisions, and make integration/load callers submit the canonical SDK fee estimate. Preserve legacy status-string compatibility for existing Studio consumers. --- .../scripts/install-python-dependencies.sh | 4 +- .github/workflows/load-test-oha.yml | 4 +- backend/protocol_rpc/endpoints.py | 62 +++++++++++++++---- backend/protocol_rpc/rpc_methods.py | 4 +- frontend/package-lock.json | 6 +- frontend/package.json | 2 +- requirements.test.txt | 5 +- tests/integration/conftest.py | 52 ++++++++++++++++ tests/load/deploy_contract/wizard_deploy.py | 9 ++- tests/load/test_state_integrity.py | 16 ++++- tests/unit/test_studio_fees.py | 47 +++++++++++++- 11 files changed, 184 insertions(+), 27 deletions(-) diff --git a/.github/scripts/install-python-dependencies.sh b/.github/scripts/install-python-dependencies.sh index efd0a1530..91e3e6823 100644 --- a/.github/scripts/install-python-dependencies.sh +++ b/.github/scripts/install-python-dependencies.sh @@ -2,5 +2,7 @@ set -euo pipefail python -m pip install --only-binary :all: -r requirements.txt -python -m pip install --only-binary :all: -r requirements.test.txt +# The prerelease qualification cut pins the SDK and test harness to immutable +# Git commits until their coordinated package versions are published. +python -m pip install -r requirements.test.txt python -m pip install --only-binary :all: -r backend/requirements.txt diff --git a/.github/workflows/load-test-oha.yml b/.github/workflows/load-test-oha.yml index 0d10a053c..17853618f 100644 --- a/.github/workflows/load-test-oha.yml +++ b/.github/workflows/load-test-oha.yml @@ -352,7 +352,7 @@ jobs: sleep 15 # Install required Python packages - pip install genlayer-py requests + pip install -r ../../requirements.test.txt # Deploy contracts one by one with delays echo "=== Contract Deployment Test ===" @@ -612,7 +612,7 @@ jobs: - name: Run State Integrity Test (lost update detection) run: | cd tests/load - pip install genlayer-py requests + pip install -r ../../requirements.test.txt python3 test_state_integrity.py http://localhost:4000/api --txs 20 - name: Dump Docker Compose logs diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 78722e655..8131783fc 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -1831,17 +1831,30 @@ def get_studio_transaction_by_hash( def get_transaction_status( - transactions_processor: TransactionsProcessor, transaction_hash: str -) -> str: + transactions_processor: TransactionsProcessor, transaction_hash: str | dict +) -> str | dict: + return_details = isinstance(transaction_hash, dict) + if return_details: + transaction_hash = transaction_hash.get("txId") or transaction_hash.get("tx_id") + if not isinstance(transaction_hash, str) or not transaction_hash: + raise JSONRPCError(code=-32602, message="txId is required", data={}) + elif not isinstance(transaction_hash, str) or not transaction_hash: + raise JSONRPCError( + code=-32602, + message="transaction hash must be a string or an object containing txId", + data={}, + ) + status = transactions_processor.get_transaction_status(transaction_hash) if status is None: raise NotFoundError( message=f"Transaction {transaction_hash} not found", data={"hash": transaction_hash}, ) - # Compatibility contract for deployed apps (Rally): this legacy RPC returns - # the status string only. Extensions belong in gen_getTransactionStatusDetails. - return status["status"] + # Node v0.6 uses an object request and returns the canonical status payload. + # Keep the original positional-string response for deployed Studio clients + # (notably Rally) until they migrate to the train interface. + return status if return_details else status["status"] def get_transaction_status_details( @@ -1938,6 +1951,32 @@ def _transaction_decision_id(transaction: dict) -> int: return history_current_decision_id(transaction.get("consensus_history")) +def _transaction_resolution_source_code( + transaction: dict, *, status: str, current_round: int +) -> int: + if status == TransactionStatus.LEADER_TIMEOUT.value: + return 3 # LeaderReceiptTimeout + + logical_entries = logical_fee_round_entries(transaction.get("consensus_history")) + if status == TransactionStatus.UNDETERMINED.value and len(logical_entries) == 1: + _, entry = logical_entries[0] + if ( + str(entry.get("consensus_round") or "") == "Undetermined" + and not entry.get("leader_result") + and not entry.get("validator_results") + ): + # Studio's exact-capacity activation path records the same + # receipt-less Pending -> Undetermined decision as Consensus's + # ActivationInsufficientValidators trigger. + return 1 + + appeal_attempt = current_round % 2 == 1 + # Studio materializes these outcomes only after its RevealingState has + # tallied the complete receipt set. A validator execution timeout is a + # VoteType.Timeout ballot, not a Consensus RevealDeadline trigger. + return 9 if appeal_attempt else 6 + + def get_transaction_lifecycle( transactions_processor: TransactionsProcessor, params: dict, @@ -1988,14 +2027,12 @@ def get_transaction_lifecycle( if not decision_active: resolution_source_code = 0 - elif status == TransactionStatus.LEADER_TIMEOUT.value: - resolution_source_code = 3 # LeaderReceiptTimeout else: - appeal_attempt = current_round % 2 == 1 - # Studio materializes these outcomes only after its RevealingState has - # tallied the complete receipt set. A validator execution timeout is a - # VoteType.Timeout ballot, not a Consensus RevealDeadline trigger. - resolution_source_code = 9 if appeal_attempt else 6 + resolution_source_code = _transaction_resolution_source_code( + transaction, + status=status, + current_round=current_round, + ) resolution_action_code = 0 deadline = _transaction_appeal_deadline(transaction) @@ -2021,7 +2058,6 @@ def get_transaction_lifecycle( "resolutionSourceCode": resolution_source_code, "decisionId": str(decision_id) if decision_id is not None else None, "decisionActive": decision_active, - "effectsPending": effects_pending, "evaluatedAt": evaluated_at, } diff --git a/backend/protocol_rpc/rpc_methods.py b/backend/protocol_rpc/rpc_methods.py index 7b63672c6..6bee280b9 100644 --- a/backend/protocol_rpc/rpc_methods.py +++ b/backend/protocol_rpc/rpc_methods.py @@ -528,9 +528,9 @@ def get_studio_transaction_by_hash( @rpc.method("gen_getTransactionStatus", log_policy=LogPolicy.debug()) def get_transaction_status( - transaction_hash: str, + transaction_hash: str | dict, transactions_processor: TransactionsProcessor = Depends(get_transactions_processor), -) -> str: +) -> str | dict: return impl.get_transaction_status( transactions_processor=transactions_processor, transaction_hash=transaction_hash, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 91d0cc7e7..d5168b882 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "^1.1.8", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#72be6986379e1aa144bf838c040954ac7e8eef47", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", @@ -11080,8 +11080,8 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-1.1.8.tgz", - "integrity": "sha512-qlqh8oqR9Ad7FVbIdqIrHfsMPLLJ24ZRHUZ2LGMpw6DX5ySjrEWdV1X93bVIHO44cu9CLGdx8m2ubkPv78/RLg==", + "resolved": "git+https://github.com/genlayerlabs/genlayer-js.git#72be6986379e1aa144bf838c040954ac7e8eef47", + "integrity": "sha512-W8vnalIYLzFrU+PKv2e0aEG19kmNXGX9hApDfScrXC90zI8ijAeKrt+FZrWC52f0hnJrsTkB7S5tdiYYQCZ2tw==", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", diff --git a/frontend/package.json b/frontend/package.json index e114bc534..d0b8510f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,7 +39,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "^1.1.8", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#72be6986379e1aa144bf838c040954ac7e8eef47", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", diff --git a/requirements.test.txt b/requirements.test.txt index 8fcdf29c2..18f17c82e 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -6,5 +6,8 @@ pytest==9.0.2 pytest-xdist==3.8.0 pytest-asyncio==1.3.0 pytest-timeout==2.4.0 -genlayer-test==0.24.0 +# Immutable resolution-kernel train consumers. Replace these with the released +# v0.19/v0.30 package versions when the coordinated prerelease is published. +genlayer-py @ git+https://github.com/genlayerlabs/genlayer-py.git@9ee438604c6a4d8e6783b23d61e360aa3405d893 +genlayer-test @ git+https://github.com/genlayerlabs/genlayer-testing-suite.git@478d939590cbdbc7c9179e0c2e0013e95059dc36 httpx==0.28.1 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d16f0cac5..c74574922 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,8 +1,10 @@ import json import os +from functools import wraps import pytest import requests +from genlayer_py.client.genlayer_client import GenLayerClient from tests.integration import gltest_compat @@ -10,6 +12,56 @@ gltest_compat.apply() +def _default_studio_fees(client: GenLayerClient) -> dict | None: + """Cache the canonical SDK estimate used by fee-enabled integration calls.""" + cache_key = "_studio_integration_default_fees" + if hasattr(client, cache_key): + return getattr(client, cache_key) + + estimate = client.estimate_transaction_fees() + fees = None + if estimate.get("policy", {}).get("enabled", True): + fees = { + "distribution": estimate["distribution"], + "feeValue": estimate["feeValue"], + } + setattr(client, cache_key, fees) + return fees + + +@pytest.fixture(scope="session", autouse=True) +def use_fee_aware_sdk_defaults(): + """Exercise fee-enabled Studio through the train SDK's canonical quote. + + Individual tests can still supply an explicit fee shape (including an + intentionally invalid one). Gasless CI keeps the legacy zero-fee call. + """ + original_deploy = GenLayerClient.deploy_contract + original_write = GenLayerClient.write_contract + + @wraps(original_deploy) + def deploy_with_default_fees(client, *args, **kwargs): + if kwargs.get("fees") is None: + fees = _default_studio_fees(client) + if fees is not None: + kwargs["fees"] = fees + return original_deploy(client, *args, **kwargs) + + @wraps(original_write) + def write_with_default_fees(client, *args, **kwargs): + if kwargs.get("fees") is None: + fees = _default_studio_fees(client) + if fees is not None: + kwargs["fees"] = fees + return original_write(client, *args, **kwargs) + + patch = pytest.MonkeyPatch() + patch.setattr(GenLayerClient, "deploy_contract", deploy_with_default_fees) + patch.setattr(GenLayerClient, "write_contract", write_with_default_fees) + yield + patch.undo() + + @pytest.fixture(scope="session", autouse=True) def ensure_rate_limiting_disabled(): """Fail fast if the backend has rate limiting enabled. diff --git a/tests/load/deploy_contract/wizard_deploy.py b/tests/load/deploy_contract/wizard_deploy.py index 348dc2569..0d8f3de87 100755 --- a/tests/load/deploy_contract/wizard_deploy.py +++ b/tests/load/deploy_contract/wizard_deploy.py @@ -39,7 +39,14 @@ def main(): # Deploy contract with initial state (have_coin=True) print("Deploying with have_coin=True...") - tx_hash = client.deploy_contract(code=contract_code, args=[True]) + estimate = client.estimate_transaction_fees() + fees = None + if estimate.get("policy", {}).get("enabled", True): + fees = { + "distribution": estimate["distribution"], + "feeValue": estimate["feeValue"], + } + tx_hash = client.deploy_contract(code=contract_code, args=[True], fees=fees) print(f"Deploy tx: {tx_hash}") # Wait for deployment diff --git a/tests/load/test_state_integrity.py b/tests/load/test_state_integrity.py index 8dc3b4147..088f9f127 100644 --- a/tests/load/test_state_integrity.py +++ b/tests/load/test_state_integrity.py @@ -57,6 +57,16 @@ def get_contract_address(api_url: str, tx_hash: str) -> str: raise RuntimeError(f"No contract address found in tx {tx_hash}") +def default_transaction_fees(client) -> dict | None: + estimate = client.estimate_transaction_fees() + if not estimate.get("policy", {}).get("enabled", True): + return None + return { + "distribution": estimate["distribution"], + "feeValue": estimate["feeValue"], + } + + def wait_for_tx_finalized(api_url: str, tx_hash: str, timeout: int = 300) -> bool: """Wait for a transaction to reach FINALIZED status via RPC polling.""" terminal_failures = { @@ -134,8 +144,11 @@ def main(): account = create_account() client.local_account = account print(f" Account: {account.address}") + transaction_fees = default_transaction_fees(client) - deploy_hash = client.deploy_contract(code=contract_code, args=[]) + deploy_hash = client.deploy_contract( + code=contract_code, args=[], fees=transaction_fees + ) print(f" Deploy tx: {deploy_hash}") # Wait for FINALIZED status — contract state is only committed to DB @@ -192,6 +205,7 @@ def main(): address=contract_address, function_name="increment", args=[], + fees=transaction_fees, ) tx_hashes.append(tx_hash) if (i + 1) % 5 == 0: diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index c44dde7f1..f50ca5da2 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -11214,6 +11214,24 @@ def get_transaction_status(self, transaction_hash): assert get_transaction_status(_Processor(), "0x1234") == "ACCEPTED" +def test_transaction_status_rpc_returns_node_v06_payload_for_object_request(): + class _Processor: + def get_transaction_status(self, transaction_hash): + assert transaction_hash == "0x1234" + return TransactionsProcessor._status_payload("ACCEPTED") + + assert get_transaction_status(_Processor(), {"txId": "0x1234"}) == { + "status": "ACCEPTED", + "statusCode": 5, + } + + +@pytest.mark.parametrize("params", [{}, {"txId": ""}, 123]) +def test_transaction_status_rpc_rejects_invalid_node_v06_request(params): + with pytest.raises(JSONRPCError, match="txId|transaction hash"): + get_transaction_status(SimpleNamespace(), params) + + def test_transaction_status_details_rpc_shape_includes_canonical_status_code(): class _Processor: def get_transaction_status(self, transaction_hash): @@ -11252,7 +11270,6 @@ def test_transaction_lifecycle_exposes_active_v06_decision_before_deadline( "resolutionSourceCode": 6, "decisionId": "1", "decisionActive": True, - "effectsPending": False, "evaluatedAt": 1_034, } @@ -11280,7 +11297,7 @@ def test_transaction_lifecycle_blocks_actions_while_acceptance_effects_are_pendi processor, {"txId": tx["hash"], "timestamp": 2_000} ) assert lifecycle["decisionActive"] is True - assert lifecycle["effectsPending"] is True + assert "effectsPending" not in lifecycle assert lifecycle["resolutionAction"] == "NoOp" with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): @@ -11320,6 +11337,32 @@ def test_transaction_lifecycle_projects_finalize_at_exact_deadline(monkeypatch): assert lifecycle["decisionActive"] is True +def test_transaction_lifecycle_reports_activation_capacity_shortfall_source(): + tx = _fee_accounted_tx(status="UNDETERMINED") + tx.update( + consensus_history={ + "consensus_results": [ + { + "consensus_round": "Undetermined", + "leader_result": None, + "validator_results": [], + } + ] + }, + timestamp_awaiting_finalization=1_000, + appealed=False, + execution_mode="NORMAL", + ) + + lifecycle = get_transaction_lifecycle( + _FakeTransactionsProcessor(tx), + {"txId": tx["hash"], "timestamp": 1_000}, + ) + + assert lifecycle["resolutionSourceCode"] == 1 + assert lifecycle["resolutionSource"] == "ActivationInsufficientValidators" + + def test_transaction_lifecycle_does_not_finalize_behind_older_transaction( monkeypatch, ): From 999b3023ee67bc6b1b5633712077b5e88717c027 Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 30 Aug 2026 21:52:16 +0100 Subject: [PATCH 29/55] fix(consensus): close latest v0.6 handoff gaps --- backend/consensus/base.py | 16 +++-- backend/consensus/history.py | 52 +++++++++++++++- backend/consensus/worker.py | 7 ++- .../transactions_processor.py | 57 +++++++++++++++++ backend/protocol_rpc/endpoints.py | 8 ++- backend/protocol_rpc/ghost_factory.py | 19 +++++- .../transactions_processor_test.py | 62 +++++++++++++++++++ .../consensus/test_appeal_state_restore.py | 15 +++++ tests/unit/test_ghost_factory.py | 34 +++++++++- tests/unit/test_simulator_sessions.py | 14 ++++- tests/unit/test_studio_fees.py | 54 ++++++++++++++-- 11 files changed, 315 insertions(+), 23 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 71a366045..4cc493316 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -1440,6 +1440,16 @@ async def rollback_transactions(self, context: TransactionContext): context.transaction.hash ) for future_transaction in future_transactions: + future_data = deepcopy(future_transaction.get("data") or {}) + future_fee_accounting = future_data.get(FEE_ACCOUNTING_KEY) + if isinstance(future_fee_accounting, dict): + future_data[FEE_ACCOUNTING_KEY] = discard_active_message_generation( + future_fee_accounting + ) + context.transactions_processor.reset_transaction_for_recomputation( + future_transaction["hash"], + future_data, + ) await ConsensusAlgorithm.dispatch_transaction_status_update( context.transactions_processor, future_transaction["hash"], @@ -1447,11 +1457,6 @@ async def rollback_transactions(self, context: TransactionContext): context.msg_handler, ) - # Reset the contract snapshot for the transaction - context.transactions_processor.set_transaction_contract_snapshot( - future_transaction["hash"], None - ) - @staticmethod def get_extra_validators( all_validators: List[dict], @@ -4524,6 +4529,7 @@ def _author_message_phase_locally( actual_recipient = factory.address_for( salt_nonce, successful_deployments, + namespace=context.transaction.to_address, ) normalized = actual_recipient.lower() if ( diff --git a/backend/consensus/history.py b/backend/consensus/history.py index 86ea1227a..d6d778dc5 100644 --- a/backend/consensus/history.py +++ b/backend/consensus/history.py @@ -51,6 +51,8 @@ # process failures can retry the paid appeal without resetting the original # decision or stranding its custody. APPEAL_RECOVERY_SNAPSHOT_KEY = "appealRecoverySnapshot" +VALIDATOR_APPEAL_CONTEXT = "validatorAppeal" +LEADER_APPEAL_REPLAY_CONTEXT = "leaderAppealReplay" def latest_decision_metadata( @@ -84,8 +86,9 @@ def prepare_appeal_decision_basis( submitted_at: int, appeal_deadline: int, retention_bps: int, + appeal_context: str | None = None, ) -> dict[str, Any]: - """Freeze the exact reduced window that Consensus freezes at submission.""" + """Freeze the appeal authority and validator-failure window at submission.""" updated = copy.deepcopy(consensus_history or {}) decision_id = current_decision_id(updated) @@ -94,12 +97,37 @@ def prepare_appeal_decision_basis( remaining = int(appeal_deadline) - int(submitted_at) if remaining <= 0: raise ValueError("CanNotAppeal") + if appeal_context is None: + decision = latest_decision_metadata(updated) or {} + decision_status = str(decision.get("status") or "").upper() + if not decision_status: + rounds = completed_consensus_rounds(updated) + if rounds: + decision_status = str(rounds[-1].get("consensus_round") or "").upper() + if decision_status in { + "ACCEPTED", + "VALIDATORS_TIMEOUT", + "VALIDATORS TIMEOUT", + }: + appeal_context = VALIDATOR_APPEAL_CONTEXT + elif decision_status in { + "UNDETERMINED", + "LEADER_TIMEOUT", + "LEADER TIMEOUT", + }: + appeal_context = LEADER_APPEAL_REPLAY_CONTEXT + if appeal_context not in { + VALIDATOR_APPEAL_CONTEXT, + LEADER_APPEAL_REPLAY_CONTEXT, + }: + raise ValueError("DecisionBasisMismatch") retention_bps = min(10_000, max(0, int(retention_bps))) next_window = (remaining * retention_bps) // 10_000 if next_window == 0: next_window = 1 updated[ACTIVE_APPEAL_BASIS_KEY] = { "decisionId": decision_id, + "context": appeal_context, "submittedAt": int(submitted_at), "nextAppealWindow": next_window, } @@ -127,6 +155,28 @@ def materialize_decision_metadata( raise ValueError("DecisionBasisMismatch") from exc if basis_decision_id != decision_id - 1 or appeal_window <= 0: raise ValueError("DecisionBasisMismatch") + context = basis.get("context") + if context == LEADER_APPEAL_REPLAY_CONTEXT: + # Leader appeals clear the reduced-window override at submission, + # regardless of whether the replay changes the application result. + appeal_window = max(1, int(default_appeal_window)) + elif context == VALIDATOR_APPEAL_CONTEXT: + # Only a failed validator appeal resumes the incumbent with the + # submission-frozen reduced remainder. A successful challenge + # creates a new decision and therefore gets a fresh full window. + latest_appeal_round = next( + ( + str(entry.get("consensus_round") or "") + for entry in reversed(completed_consensus_rounds(updated)) + if str(entry.get("consensus_round") or "") + in VALIDATOR_APPEAL_CONSENSUS_ROUNDS + ), + None, + ) + if latest_appeal_round in TERMINAL_VALIDATOR_APPEAL_ROUNDS: + appeal_window = max(1, int(default_appeal_window)) + elif context is not None: + raise ValueError("DecisionBasisMismatch") else: appeal_window = max(1, int(default_appeal_window)) diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index 0f43b0f72..342280e5b 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -375,7 +375,6 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: SELECT 1 FROM transactions t2 WHERE t2.to_address IS NOT DISTINCT FROM t.to_address AND t2.blocked_at IS NOT NULL - AND t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) AND t2.hash != t.hash ) AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) @@ -450,10 +449,12 @@ async def claim_next_appeal(self, session: Session) -> Optional[dict]: AND (t.blocked_at IS NULL OR t.blocked_at < NOW() - CAST(:timeout AS INTERVAL)) AND NOT EXISTS ( - -- Ensure no other appeal for same contract is being processed + -- An ancestor appeal can rewind every newer attempt on + -- the recipient. Never overlap it with any descendant + -- worker: otherwise that worker can publish ballots or + -- effects from the state generation being invalidated. SELECT 1 FROM transactions t2 WHERE t2.to_address IS NOT DISTINCT FROM t.to_address - AND t2.appealed = true AND t2.blocked_at IS NOT NULL AND t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) AND t2.hash != t.hash diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index 8b2f43b15..e88d725bf 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -26,6 +26,8 @@ ) from backend.consensus.history import ( APPEAL_RECOVERY_SNAPSHOT_KEY, + LEADER_APPEAL_REPLAY_CONTEXT, + VALIDATOR_APPEAL_CONTEXT, completed_consensus_round_index, completed_consensus_rounds, materialize_decision_metadata, @@ -1852,6 +1854,15 @@ def admit_transaction_appeal( submitted_at=admitted_at, appeal_deadline=appeal_deadline, retention_bps=retention_bps, + appeal_context=( + LEADER_APPEAL_REPLAY_CONTEXT + if row["status"] + in { + TransactionStatus.UNDETERMINED.value, + TransactionStatus.LEADER_TIMEOUT.value, + } + else VALIDATOR_APPEAL_CONTEXT + ), ) data = dict(row["data"] or {}) fee_accounting, surplus_refund = prepare_fee_accounting( @@ -2185,6 +2196,52 @@ def reset_consensus_history(self, transaction_hash: str): ) self.session.commit() + def reset_transaction_for_recomputation( + self, + transaction_hash: str, + data: dict | None, + ) -> None: + """Invalidate every attempt-local field before an ancestor rewind. + + The transaction identity, escrow, queue order, and credited user value + remain durable. Consensus evidence and worker ownership belong to the + invalidated descendant attempt and must not survive into recomputation. + """ + + result = self.session.execute( + text( + """ + UPDATE transactions + SET status = 'PENDING'::transaction_status, + data = CAST(:data AS jsonb), + consensus_data = NULL, + consensus_history = '{}'::jsonb, + contract_snapshot = NULL, + appealed = false, + appeal_failed = 0, + appeal_undetermined = false, + appeal_leader_timeout = false, + appeal_validators_timeout = false, + timestamp_appeal = NULL, + appeal_processing_time = 0, + timestamp_awaiting_finalization = NULL, + last_vote_timestamp = NULL, + rotation_count = 0, + leader_timeout_validators = NULL, + blocked_at = NULL, + worker_id = NULL + WHERE hash = :hash + """ + ), + { + "hash": transaction_hash, + "data": json.dumps(data) if data is not None else None, + }, + ) + if result.rowcount == 0: + raise ValueError("TransactionNotFound") + self.session.commit() + def set_transaction_timestamp_appeal( self, transaction: Transactions | str, timestamp_appeal: int | None ): diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 8131783fc..0c3497928 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -405,12 +405,17 @@ def _enforce_pending_queue_caps( def _allocate_top_level_ghost_address( transactions_processor: TransactionsProcessor, salt_nonce: int, + namespace: str, ) -> str: """Reserve the next virtual GhostFactory address until admission commits.""" transactions_processor.lock_ghost_factory() deployment_count = transactions_processor.get_successful_ghost_creation_count() - address = GhostFactoryConfig.from_env().address_for(salt_nonce, deployment_count) + address = GhostFactoryConfig.from_env().address_for( + salt_nonce, + deployment_count, + namespace=namespace, + ) if transactions_processor.is_genvm_contract_address(address): # GhostFactory rejects a reused CREATE2 address and the enclosing # Consensus submission reverts. The factory nonce is unchanged. @@ -3244,6 +3249,7 @@ def finish_envelope(result: str) -> str: new_contract_address = _allocate_top_level_ghost_address( transactions_processor, int(decoded_rollup_transaction.data.args.salt_nonce or 0), + from_address, ) # Keep the address row in the transaction admission unit. A # premature commit here would release the duplicate-admission diff --git a/backend/protocol_rpc/ghost_factory.py b/backend/protocol_rpc/ghost_factory.py index bfb982977..35a7db5a2 100644 --- a/backend/protocol_rpc/ghost_factory.py +++ b/backend/protocol_rpc/ghost_factory.py @@ -65,7 +65,13 @@ def from_env(cls) -> "GhostFactoryConfig": initial_nonce=initial_nonce, ) - def address_for(self, salt_nonce: int, successful_deployments: int) -> str: + def address_for( + self, + salt_nonce: int, + successful_deployments: int, + *, + namespace: str | None = None, + ) -> str: """Return the next GhostFactory address without mutating state. ``successful_deployments`` is the number of ghosts already created by @@ -84,8 +90,15 @@ def address_for(self, salt_nonce: int, successful_deployments: int) -> str: raise OverflowError("GhostFactoryNonceOverflow") digest = keccak(rlp.encode([factory, factory_nonce])) else: - creation_phase = to_bytes(hexstr=self.creation_phase_address) - salt = keccak(creation_phase + salt_nonce.to_bytes(32, "big")) + if namespace is None or not is_address(namespace): + raise InvalidGhostFactoryConfiguration( + "salted ghost deployment requires an EVM namespace" + ) + # The authenticated deployment sender owns the salt namespace. + # Using the shared CreationPhase address here reproduces the live + # cross-user collision defect and disagrees with public prediction. + namespace_bytes = to_bytes(hexstr=to_checksum_address(namespace)) + salt = keccak(namespace_bytes + salt_nonce.to_bytes(32, "big")) digest = keccak(b"\xff" + factory + salt + self.bytecode_hash) return to_checksum_address("0x" + digest[-20:].hex()) diff --git a/tests/db-sqlalchemy/transactions_processor_test.py b/tests/db-sqlalchemy/transactions_processor_test.py index c45aaf424..50c17416a 100644 --- a/tests/db-sqlalchemy/transactions_processor_test.py +++ b/tests/db-sqlalchemy/transactions_processor_test.py @@ -623,6 +623,68 @@ def test_pending_queue_count_includes_active_head_but_not_decided_rows( assert pending_hash is not None +def test_ancestor_rewind_clears_descendant_attempt_state( + transactions_processor: TransactionsProcessor, session +): + tx_hash = _make_tx(transactions_processor, nonce=13) + session.execute( + text( + """ + UPDATE transactions + SET status = CAST('ACCEPTED' AS transaction_status), + consensus_data = CAST('{"votes":{"0x1":"agree"}}' AS jsonb), + consensus_history = CAST('{"consensus_results":[{"consensus_round":"Accepted"}]}' AS jsonb), + contract_snapshot = CAST('{"states":{}}' AS jsonb), + appealed = true, + appeal_failed = 2, + appeal_undetermined = true, + appeal_leader_timeout = true, + appeal_validators_timeout = true, + timestamp_appeal = 123, + appeal_processing_time = 5, + timestamp_awaiting_finalization = 456, + last_vote_timestamp = 789, + rotation_count = 3, + leader_timeout_validators = CAST('[]' AS jsonb), + blocked_at = NOW(), + worker_id = 'stale-worker' + WHERE hash = :hash + """ + ), + {"hash": tx_hash}, + ) + session.commit() + + replacement_data = {"fee_accounting": {"message_fee_budget": 7}} + transactions_processor.reset_transaction_for_recomputation( + tx_hash, + replacement_data, + ) + reset = transactions_processor.get_transaction_by_hash(tx_hash) + + assert reset["status"] == TransactionStatus.PENDING.value + assert reset["data"] == replacement_data + assert reset["consensus_data"] is None + assert reset["consensus_history"] == {} + assert reset["contract_snapshot"] is None + assert reset["appealed"] is False + assert reset["appeal_failed"] == 0 + assert reset["appeal_undetermined"] is False + assert reset["appeal_leader_timeout"] is False + assert reset["appeal_validators_timeout"] is False + assert reset["timestamp_appeal"] is None + assert reset["appeal_processing_time"] == 0 + assert reset["timestamp_awaiting_finalization"] is None + # The legacy public parser stringifies this nullable timestamp. + assert reset["last_vote_timestamp"] == "None" + assert reset["rotation_count"] == 0 + assert reset["leader_timeout_validators"] is None + reset_row = session.get(Transactions, tx_hash) + assert reset_row is not None + assert reset_row.blocked_at is None + assert reset_row.worker_id is None + + def test_get_transaction_by_hash_exposes_value_credited( transactions_processor: TransactionsProcessor, session ): diff --git a/tests/unit/consensus/test_appeal_state_restore.py b/tests/unit/consensus/test_appeal_state_restore.py index d0f57753e..730c02da8 100644 --- a/tests/unit/consensus/test_appeal_state_restore.py +++ b/tests/unit/consensus/test_appeal_state_restore.py @@ -358,6 +358,21 @@ async def test_returning_clause_selects_snapshot_and_history_columns(self): assert "transactions.contract_snapshot" in returning_clause assert "transactions.consensus_history" in returning_clause + @pytest.mark.asyncio + async def test_claim_waits_for_any_inflight_descendant_on_same_contract(self): + session = Mock() + session.execute.return_value.first.return_value = None + + worker = _make_worker() + await worker.claim_next_appeal(session) + + executed_sql = str(session.execute.call_args[0][0]) + blocked_guard = executed_sql.split("An ancestor appeal", 1)[1].split( + "AND pg_try_advisory", 1 + )[0] + assert "t2.blocked_at IS NOT NULL" in blocked_guard + assert "t2.appealed = true" not in blocked_guard + @pytest.mark.asyncio async def test_claimed_appeal_hydrates_transaction_snapshot(self): """End-to-end over the claimed dict: Transaction.from_dict must produce diff --git a/tests/unit/test_ghost_factory.py b/tests/unit/test_ghost_factory.py index 8454aa79a..d4f4bc8ad 100644 --- a/tests/unit/test_ghost_factory.py +++ b/tests/unit/test_ghost_factory.py @@ -18,15 +18,43 @@ def test_default_virtual_factory_matches_frozen_create_vectors(monkeypatch): factory = GhostFactoryConfig.from_env() assert factory.address_for(0, 0) == "0x0aD72A9a303bDF888d3bf7d76e3568248a353199" assert factory.address_for(0, 1) == "0xC6a967E0b0D12c109CD6367F245B422Ae05565A4" - assert factory.address_for(42, 0) == "0x4104e3b744E60be5E612b909f04D1547Fc87094a" + assert ( + factory.address_for( + 42, + 0, + namespace="0x1111111111111111111111111111111111111111", + ) + == "0x25A58acd32f777db380EA378cCE191972aa62c5e" + ) def test_create2_address_ignores_factory_nonce_but_create_does_not(): factory = GhostFactoryConfig.from_env() - assert factory.address_for(42, 0) == factory.address_for(42, 99) + namespace = "0x1111111111111111111111111111111111111111" + assert factory.address_for(42, 0, namespace=namespace) == factory.address_for( + 42, + 99, + namespace=namespace, + ) assert factory.address_for(0, 0) != factory.address_for(0, 1) +def test_create2_salt_is_scoped_to_authenticated_sender(): + factory = GhostFactoryConfig.from_env() + first = factory.address_for( + 42, + 0, + namespace="0x1111111111111111111111111111111111111111", + ) + second = factory.address_for( + 42, + 0, + namespace="0x2222222222222222222222222222222222222222", + ) + + assert first != second + + @pytest.mark.parametrize( ("name", "value"), [ @@ -48,3 +76,5 @@ def test_virtual_factory_rejects_non_uint256_salt(): factory.address_for(-1, 0) with pytest.raises(InvalidGhostFactoryConfiguration): factory.address_for(1 << 256, 0) + with pytest.raises(InvalidGhostFactoryConfiguration): + factory.address_for(1, 0) diff --git a/tests/unit/test_simulator_sessions.py b/tests/unit/test_simulator_sessions.py index b668d8a2d..989dc52b9 100644 --- a/tests/unit/test_simulator_sessions.py +++ b/tests/unit/test_simulator_sessions.py @@ -22,9 +22,13 @@ def test_top_level_deploy_uses_transactional_virtual_ghost_factory(): processor.get_successful_ghost_creation_count.return_value = 0 processor.is_genvm_contract_address.return_value = False - address = endpoints._allocate_top_level_ghost_address(processor, 42) + address = endpoints._allocate_top_level_ghost_address( + processor, + 42, + "0x1111111111111111111111111111111111111111", + ) - assert address == "0x4104e3b744E60be5E612b909f04D1547Fc87094a" + assert address == "0x25A58acd32f777db380EA378cCE191972aa62c5e" processor.lock_ghost_factory.assert_called_once_with() processor.get_successful_ghost_creation_count.assert_called_once_with() processor.is_genvm_contract_address.assert_called_once_with(address) @@ -36,7 +40,11 @@ def test_top_level_deploy_rejects_reused_create2_address(): processor.is_genvm_contract_address.return_value = True with pytest.raises(endpoints.InvalidTransactionError, match="GhostAlreadyDeployed"): - endpoints._allocate_top_level_ghost_address(processor, 42) + endpoints._allocate_top_level_ghost_address( + processor, + 42, + "0x1111111111111111111111111111111111111111", + ) def test_fund_account_uses_request_scoped_session(monkeypatch): diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index f50ca5da2..e3fa42c07 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -1837,7 +1837,7 @@ def get_pending_transaction_count_for_address(self, _address): processor = _Processor() context = SimpleNamespace( - transaction=SimpleNamespace(hash=parent_hash), + transaction=SimpleNamespace(hash=parent_hash, to_address=registered), transactions_processor=processor, ) @@ -1881,7 +1881,7 @@ def child(recipient, tx_type, occurrence, salt=0): assert processor.locked is True assert {address.lower() for address in processor.locked_recipients} == { registered.lower(), - "0x4104e3b744e60be5e612b909f04d1547fc87094a", + "0x4e0065451873eaf51af1c7e00256a5db0f8a80ad", } assert receipt["tx_ids_hex"] == [ _studio_child_transaction_id(parent_hash, "accepted", first_occurrence), @@ -1891,7 +1891,7 @@ def child(recipient, tx_type, occurrence, salt=0): ] assert receipt["recipients"][0] == Web3.to_checksum_address(registered) assert receipt["recipients"][1] == Web3.to_checksum_address(missing) - assert receipt["recipients"][2] == "0x4104e3b744E60be5E612b909f04D1547Fc87094a" + assert receipt["recipients"][2] == "0x4E0065451873eaf51AF1C7E00256A5db0f8a80aD" assert receipt["recipients"][3] == Web3.to_checksum_address( "0x0000000000000000000000000000000000000000" ) @@ -10489,6 +10489,14 @@ def admit_transaction_appeal(self, tx_hash, *, prepare_fee_accounting, **kwargs) assert tx_hash == self.transaction["hash"] if self.transaction.get("appealed"): raise ValueError("CanNotAppeal") + kwargs.setdefault( + "appeal_context", + ( + "leaderAppealReplay" + if self.transaction.get("status") in {"UNDETERMINED", "LEADER_TIMEOUT"} + else "validatorAppeal" + ), + ) self.transaction["consensus_history"] = prepare_appeal_decision_basis( self.transaction.get("consensus_history"), **kwargs, @@ -10712,6 +10720,7 @@ def send_message(self, log_event, log_to_terminal=True): assert transactions.appeal_updates == [(tx["hash"], True)] assert tx["consensus_history"]["activeAppealBasis"] == { "decisionId": 1, + "context": "validatorAppeal", "submittedAt": 1_000, "nextAppealWindow": 24, } @@ -12152,7 +12161,7 @@ def test_failed_appeal_window_uses_remaining_time_at_submission_like_consensus() } -def test_successful_appeal_carries_frozen_window_to_terminal_decision(): +def test_successful_validator_appeal_starts_fresh_full_window(): initial_history = materialize_decision_metadata( {"consensus_results": [{"consensus_round": "Accepted"}]}, status="ACCEPTED", @@ -12186,10 +12195,45 @@ def test_successful_appeal_carries_frozen_window_to_terminal_decision(): "decisionId": 2, "status": "UNDETERMINED", "materializedAt": 1_025, - "appealDeadline": 1_041, + "appealDeadline": 1_055, } +@pytest.mark.parametrize( + "replay_round", + ["Leader Appeal Successful", "Leader Appeal Failed"], +) +def test_leader_appeal_replay_always_starts_fresh_full_window(replay_round): + initial_history = materialize_decision_metadata( + {"consensus_results": [{"consensus_round": "Undetermined"}]}, + status="UNDETERMINED", + materialized_at=1_000, + default_appeal_window=30, + ) + appealed_history = prepare_appeal_decision_basis( + initial_history, + expected_decision_id=1, + submitted_at=1_010, + appeal_deadline=1_030, + retention_bps=8_000, + ) + assert appealed_history["activeAppealBasis"] == { + "decisionId": 1, + "context": "leaderAppealReplay", + "submittedAt": 1_010, + "nextAppealWindow": 16, + } + appealed_history["consensus_results"].append({"consensus_round": replay_round}) + replayed_history = materialize_decision_metadata( + appealed_history, + status="UNDETERMINED", + materialized_at=1_025, + default_appeal_window=30, + ) + + assert replayed_history["latestDecision"]["appealDeadline"] == 1_055 + + def test_worker_prefers_persisted_remaining_time_deadline(monkeypatch): algorithm = ConsensusAlgorithm.__new__(ConsensusAlgorithm) algorithm.finality_window_time = 30 From 0ea9cd7e8bedc3b11f1acf7c507d3844fafc0766 Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 30 Aug 2026 22:00:24 +0100 Subject: [PATCH 30/55] fix(consensus): unwind descendant appeals on rewind --- backend/consensus/base.py | 22 ++++++- backend/consensus/worker.py | 1 - .../consensus/test_appeal_state_restore.py | 61 +++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 4cc493316..f3886b5ec 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -88,6 +88,7 @@ consensus_vote_type_code, ) from backend.consensus.history import ( + APPEAL_RECOVERY_SNAPSHOT_KEY, TERMINAL_VALIDATOR_APPEAL_ROUNDS, latest_decision_metadata, logical_fee_round_entries, @@ -1440,19 +1441,36 @@ async def rollback_transactions(self, context: TransactionContext): context.transaction.hash ) for future_transaction in future_transactions: + future_hash = future_transaction["hash"] future_data = deepcopy(future_transaction.get("data") or {}) + if bool(future_transaction.get("appealed")) or ( + APPEAL_RECOVERY_SNAPSHOT_KEY in future_data + ): + # The ancestor invalidates the descendant decision that this + # paid appeal targets. Refund its still-active admission before + # dropping the stale decision/worker state, then reload the + # accounting so the reset cannot resurrect the charged bond. + context.accounts_manager.abort_tx_appeal_admission_once( + future_hash, + "ancestor_rewind", + ) + refreshed = context.transactions_processor.get_transaction_by_hash( + future_hash + ) + future_data = deepcopy((refreshed or {}).get("data") or {}) + future_data.pop(APPEAL_RECOVERY_SNAPSHOT_KEY, None) future_fee_accounting = future_data.get(FEE_ACCOUNTING_KEY) if isinstance(future_fee_accounting, dict): future_data[FEE_ACCOUNTING_KEY] = discard_active_message_generation( future_fee_accounting ) context.transactions_processor.reset_transaction_for_recomputation( - future_transaction["hash"], + future_hash, future_data, ) await ConsensusAlgorithm.dispatch_transaction_status_update( context.transactions_processor, - future_transaction["hash"], + future_hash, TransactionStatus.PENDING, context.msg_handler, ) diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index 342280e5b..022dcc30c 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -456,7 +456,6 @@ async def claim_next_appeal(self, session: Session) -> Optional[dict]: SELECT 1 FROM transactions t2 WHERE t2.to_address IS NOT DISTINCT FROM t.to_address AND t2.blocked_at IS NOT NULL - AND t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) AND t2.hash != t.hash ) AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) diff --git a/tests/unit/consensus/test_appeal_state_restore.py b/tests/unit/consensus/test_appeal_state_restore.py index 730c02da8..19aec6412 100644 --- a/tests/unit/consensus/test_appeal_state_restore.py +++ b/tests/unit/consensus/test_appeal_state_restore.py @@ -22,6 +22,7 @@ import backend.consensus.base as consensus_base from backend.consensus.base import ConsensusAlgorithm +from backend.consensus.history import APPEAL_RECOVERY_SNAPSHOT_KEY from backend.consensus.types import ConsensusRound from backend.consensus.worker import ConsensusWorker from backend.database_handler.contract_snapshot import ContractSnapshot @@ -371,6 +372,7 @@ async def test_claim_waits_for_any_inflight_descendant_on_same_contract(self): "AND pg_try_advisory", 1 )[0] assert "t2.blocked_at IS NOT NULL" in blocked_guard + assert "t2.blocked_at >" not in blocked_guard assert "t2.appealed = true" not in blocked_guard @pytest.mark.asyncio @@ -393,3 +395,62 @@ async def test_claimed_appeal_hydrates_transaction_snapshot(self): assert transaction.contract_snapshot is not None assert transaction.contract_snapshot.states["accepted"] == stored_accepted assert transaction.consensus_history == stored_history + + +@pytest.mark.asyncio +async def test_ancestor_rewind_refunds_active_descendant_appeal_before_reset( + monkeypatch, +): + future_hash = "0xfuture" + stale_data = { + FEE_ACCOUNTING_KEY: {"active_message_generation": {"generation": 1}}, + APPEAL_RECOVERY_SNAPSHOT_KEY: {"status": "ACCEPTED"}, + } + refunded_data = { + FEE_ACCOUNTING_KEY: { + "aborted_appeals": [{"reason": "ancestor_rewind", "refund": 123}], + "active_message_generation": {"generation": 1}, + } + } + transactions_processor = Mock() + transactions_processor.get_newer_transactions.return_value = [ + {"hash": future_hash, "appealed": True, "data": stale_data} + ] + transactions_processor.get_transaction_by_hash.return_value = { + "hash": future_hash, + "data": refunded_data, + } + accounts_manager = Mock() + context = SimpleNamespace( + transaction=SimpleNamespace(hash=TX_HASH), + transactions_processor=transactions_processor, + accounts_manager=accounts_manager, + msg_handler=Mock(), + ) + status_update = AsyncMock() + monkeypatch.setattr( + ConsensusAlgorithm, + "dispatch_transaction_status_update", + staticmethod(status_update), + ) + + await ConsensusAlgorithm.__new__(ConsensusAlgorithm).rollback_transactions(context) + + accounts_manager.abort_tx_appeal_admission_once.assert_called_once_with( + future_hash, + "ancestor_rewind", + ) + reset_data = transactions_processor.reset_transaction_for_recomputation.call_args[ + 0 + ][1] + assert APPEAL_RECOVERY_SNAPSHOT_KEY not in reset_data + assert reset_data[FEE_ACCOUNTING_KEY]["aborted_appeals"] == [ + {"reason": "ancestor_rewind", "refund": 123} + ] + assert "active_message_generation" not in reset_data[FEE_ACCOUNTING_KEY] + status_update.assert_awaited_once_with( + transactions_processor, + future_hash, + TransactionStatus.PENDING, + context.msg_handler, + ) From 484beaaf15c241835ac015ac2129139dde86e3d4 Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 30 Aug 2026 22:08:31 +0100 Subject: [PATCH 31/55] fix(ghost): scope bundled factory salts by sender --- .../contracts/v2_contracts/ConsensusMain.sol | 2 +- .../v2_contracts/ConsensusMainWithFees.sol | 2 +- .../ghost_contracts/GhostFactory.sol | 3 +- .../v2_contracts/interfaces/IGhostFactory.sol | 6 +++- .../GhostFactory.SaltNamespace.test.js | 31 +++++++++++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 hardhat/test/consensus/GhostFactory.SaltNamespace.test.js diff --git a/hardhat/contracts/v2_contracts/ConsensusMain.sol b/hardhat/contracts/v2_contracts/ConsensusMain.sol index c42d84fa1..a6d832d2e 100644 --- a/hardhat/contracts/v2_contracts/ConsensusMain.sol +++ b/hardhat/contracts/v2_contracts/ConsensusMain.sol @@ -770,7 +770,7 @@ contract ConsensusMain is : contracts.genManager.recipientRandomSeed(_recipient); if (_recipient == address(0)) { // Contract deployment transaction - contracts.ghostFactory.createGhost(_saltNonce); + contracts.ghostFactory.createGhost(_sender, _saltNonce); address ghost = contracts.ghostFactory.latestGhost(); _storeGhost(ghost); _recipient = ghost; diff --git a/hardhat/contracts/v2_contracts/ConsensusMainWithFees.sol b/hardhat/contracts/v2_contracts/ConsensusMainWithFees.sol index 7c8481208..2e6191320 100644 --- a/hardhat/contracts/v2_contracts/ConsensusMainWithFees.sol +++ b/hardhat/contracts/v2_contracts/ConsensusMainWithFees.sol @@ -424,7 +424,7 @@ contract ConsensusMainWithFees is : genManager.recipientRandomSeed(_recipient); if (_recipient == address(0)) { // Contract deployment transaction - ghostFactory.createGhost(_saltNonce); + ghostFactory.createGhost(_sender, _saltNonce); address ghost = ghostFactory.latestGhost(); ghostContracts[ghost] = true; _recipient = ghost; diff --git a/hardhat/contracts/v2_contracts/ghost_contracts/GhostFactory.sol b/hardhat/contracts/v2_contracts/ghost_contracts/GhostFactory.sol index cf253f2bb..41db40c7e 100644 --- a/hardhat/contracts/v2_contracts/ghost_contracts/GhostFactory.sol +++ b/hardhat/contracts/v2_contracts/ghost_contracts/GhostFactory.sol @@ -33,6 +33,7 @@ contract GhostFactory is } function createGhost( + address _namespace, uint256 _saltNonce ) external onlyGenConsensus returns (address) { BeaconProxy beacon; @@ -46,7 +47,7 @@ contract GhostFactory is ); } else { bytes32 salt = keccak256( - abi.encodePacked(msg.sender, _saltNonce) + abi.encodePacked(_namespace, _saltNonce) ); beacon = new BeaconProxy{ salt: salt }( ghostBeaconProxy, diff --git a/hardhat/contracts/v2_contracts/interfaces/IGhostFactory.sol b/hardhat/contracts/v2_contracts/interfaces/IGhostFactory.sol index 65d498d82..1185e0ecd 100644 --- a/hardhat/contracts/v2_contracts/interfaces/IGhostFactory.sol +++ b/hardhat/contracts/v2_contracts/interfaces/IGhostFactory.sol @@ -5,9 +5,13 @@ pragma solidity ^0.8.20; /// @notice Interface for creating and managing ghost contracts in the GenVM system interface IGhostFactory { /// @notice Creates a new ghost contract using CREATE or CREATE2 + /// @param namespace Authenticated deployment identity that owns the salt namespace /// @param saltNonce Salt nonce; zero selects CREATE /// @return The address where the contract will be deployed - function createGhost(uint256 saltNonce) external returns (address); + function createGhost( + address namespace, + uint256 saltNonce + ) external returns (address); /// @notice Checks if an address is a ghost contract /// @param contractAddress The address to check diff --git a/hardhat/test/consensus/GhostFactory.SaltNamespace.test.js b/hardhat/test/consensus/GhostFactory.SaltNamespace.test.js new file mode 100644 index 000000000..f10a8352b --- /dev/null +++ b/hardhat/test/consensus/GhostFactory.SaltNamespace.test.js @@ -0,0 +1,31 @@ +const { expect } = require("chai"); +const { deployments, ethers } = require("hardhat"); + +describe("GhostFactory salted deployment namespace", function () { + let ghostFactory; + let owner; + let firstUser; + let secondUser; + + beforeEach(async function () { + await deployments.fixture(); + [owner, firstUser, secondUser] = await ethers.getSigners(); + ghostFactory = await ethers.getContractAt( + "GhostFactory", + (await deployments.get("GhostFactory")).address, + ); + await ghostFactory.connect(owner).setGenConsensus(owner.address); + }); + + it("lets unrelated authenticated senders reuse the same salt", async function () { + await ghostFactory.createGhost(firstUser.address, 42); + const firstGhost = await ghostFactory.latestGhost(); + + await ghostFactory.createGhost(secondUser.address, 42); + const secondGhost = await ghostFactory.latestGhost(); + + expect(secondGhost).to.not.equal(firstGhost); + expect(await ghostFactory.isGhost(firstGhost)).to.equal(true); + expect(await ghostFactory.isGhost(secondGhost)).to.equal(true); + }); +}); From ab5f9ea943de96424d4f26194aab2cdfb4d664f8 Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 30 Aug 2026 22:14:25 +0100 Subject: [PATCH 32/55] test(ghost): cover parent-scoped salt reuse --- .../GhostFactory.SaltNamespace.test.js | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/hardhat/test/consensus/GhostFactory.SaltNamespace.test.js b/hardhat/test/consensus/GhostFactory.SaltNamespace.test.js index f10a8352b..8ac26f477 100644 --- a/hardhat/test/consensus/GhostFactory.SaltNamespace.test.js +++ b/hardhat/test/consensus/GhostFactory.SaltNamespace.test.js @@ -3,6 +3,7 @@ const { deployments, ethers } = require("hardhat"); describe("GhostFactory salted deployment namespace", function () { let ghostFactory; + let consensusMain; let owner; let firstUser; let secondUser; @@ -14,10 +15,14 @@ describe("GhostFactory salted deployment namespace", function () { "GhostFactory", (await deployments.get("GhostFactory")).address, ); - await ghostFactory.connect(owner).setGenConsensus(owner.address); + consensusMain = await ethers.getContractAt( + "ConsensusMain", + (await deployments.get("ConsensusMain")).address, + ); }); it("lets unrelated authenticated senders reuse the same salt", async function () { + await ghostFactory.connect(owner).setGenConsensus(owner.address); await ghostFactory.createGhost(firstUser.address, 42); const firstGhost = await ghostFactory.latestGhost(); @@ -28,4 +33,35 @@ describe("GhostFactory salted deployment namespace", function () { expect(await ghostFactory.isGhost(firstGhost)).to.equal(true); expect(await ghostFactory.isGhost(secondGhost)).to.equal(true); }); + + it("threads each internal parent namespace through ConsensusMain", async function () { + const parentTxId = ethers.keccak256( + ethers.toUtf8Bytes("cross-parent-salt-namespace"), + ); + const messages = [firstUser, secondUser].map((user, index) => ({ + sender: user.address, + recipient: ethers.ZeroAddress, + saltNonce: 42, + data: index === 0 ? "0x0102" : "0x0304", + })); + + await consensusMain.emitTransactionAccepted(parentTxId, messages); + const childIds = await consensusMain.getInternalMessageTxIds( + parentTxId, + true, + messages, + ); + const recipients = await consensusMain.getInternalMessageRecipients( + parentTxId, + true, + messages, + ); + + expect(childIds).to.have.length(2); + expect(childIds[0]).to.not.equal(ethers.ZeroHash); + expect(childIds[1]).to.not.equal(ethers.ZeroHash); + expect(recipients[0]).to.not.equal(recipients[1]); + expect(await ghostFactory.isGhost(recipients[0])).to.equal(true); + expect(await ghostFactory.isGhost(recipients[1])).to.equal(true); + }); }); From 581cfb8efe38357dfe4471802d91cd530d6ee1b2 Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 31 Aug 2026 12:11:42 +0100 Subject: [PATCH 33/55] fix(fees): derive exact transaction message allocations --- .env.example | 3 + backend/node/base.py | 2 +- backend/protocol_rpc/endpoints.py | 110 ++++++--- backend/protocol_rpc/fees.py | 217 ++++++++++++++++++ docker-compose.yml | 2 + frontend/src/hooks/useContractQueries.ts | 186 ++++++++++++++- frontend/src/services/JsonRpcService.ts | 13 +- frontend/src/types/responses.ts | 6 +- .../useContractQueries.behavioral.test.ts | 162 +++++++++++-- .../test/unit/services/JsonRpcService.test.ts | 24 +- .../unit/test_contract_not_found_handling.py | 93 ++++++++ tests/unit/test_studio_fees.py | 115 ++++++++++ 12 files changed, 876 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index b216cf4e1..dbf5f6856 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,9 @@ GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS='30' GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS='600' GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS='30' GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS='600' +# Conservative gas cap used when transaction simulation discovers an external +# message whose exact downstream gas usage is unknowable at submission time. +GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT='500000' # Governance-controlled external allocation gas-limit floor; 0 disables it. GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT='0' # Messages has an independent hard protocol ceiling (20 maximum); the optional diff --git a/backend/node/base.py b/backend/node/base.py index dc343d4fb..545bd47d3 100644 --- a/backend/node/base.py +++ b/backend/node/base.py @@ -812,7 +812,7 @@ async def deploy_contract( transaction_datetime = self._date_from_str(transaction_created_at) if transaction_datetime is None: - transaction_datetime = datetime.datetime.now() + transaction_datetime = datetime.datetime.now(datetime.UTC) return await self._run_genvm( from_address, diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 0c3497928..03574a7aa 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -58,6 +58,7 @@ calculate_round_fees, decode_internal_message_fee_params, create_fee_accounting, + fee_accounting_with_discovered_messages, funding_policy_for_accounting, get_leader_rounds, normalize_fees_distribution, @@ -1550,6 +1551,7 @@ async def sim_estimate_transaction_fees( estimate_params = { **estimate_params, "_allow_low_execution_budget_for_estimate": True, + "_discover_message_allocations_for_estimate": True, } receipt = await sim_call( session=session, @@ -1646,6 +1648,18 @@ async def _gen_call_with_validator( if not accounts_manager.is_valid_address(from_address): raise InvalidAddressError(from_address) + if type == "deploy": + deployment_count = ( + TransactionsProcessor(session).get_successful_ghost_creation_count() + if session is not None + else 0 + ) + to_address = GhostFactoryConfig.from_env().address_for( + int(params.get("salt_nonce", params.get("saltNonce", 0)) or 0), + deployment_count, + namespace=from_address, + ) + if not accounts_manager.is_valid_address(to_address): raise InvalidAddressError(to_address) @@ -1663,34 +1677,46 @@ async def _gen_call_with_validator( message="No validators available to execute the gen_call", ) - # Create validator node - try: - contract_snapshot = ContractSnapshot(to_address, session) - except ContractNotFoundError: - raise NotFoundError( - message=f"Contract {to_address} not found", - data={"contract_address": to_address}, - ) - if type in {"write", "deploy"}: - _stage_simulated_call_value(contract_snapshot, call_value) - node = Node( - contract_snapshot=contract_snapshot, - contract_snapshot_factory=partial(ContractSnapshot, session=session), - validator_mode=ExecutionMode.LEADER, - validator=validator, - leader_receipt=None, - msg_handler=msg_handler.with_client_session(get_client_session_id()), - validators_snapshot=validators_snapshot, - manager=genvm_manager, - ) - sc_raw = params.get("sim_config") + _validate_genvm_executor_selector(sc_raw) + _reject_genvm_executor_selector_unless_deploy( + sc_raw, + is_deploy=type == "deploy", + ) sim_config = SimConfig.from_dict(sc_raw) if sc_raw else None override_transaction_datetime: bool = ( sim_config is not None and sim_config.genvm_datetime is not None ) - try: + def create_node() -> Node: + # A fee estimate can execute twice: first to discover exact message + # keys and then to meter them. Each pass must start from the same DB + # snapshot; _SnapshotView writes through to its ContractSnapshot. + if type == "deploy": + contract_snapshot = ContractSnapshot(None, session) + contract_snapshot.contract_address = to_address + contract_snapshot.balance = 0 + contract_snapshot.states = {"accepted": {}, "finalized": {}} + contract_snapshot.genvm_executor_selector = ( + sim_config.genvm_executor_selector if sim_config else None + ) + else: + contract_snapshot = ContractSnapshot(to_address, session) + if type in {"write", "deploy"}: + _stage_simulated_call_value(contract_snapshot, call_value) + return Node( + contract_snapshot=contract_snapshot, + contract_snapshot_factory=partial(ContractSnapshot, session=session), + validator_mode=ExecutionMode.LEADER, + validator=validator, + leader_receipt=None, + msg_handler=msg_handler.with_client_session(get_client_session_id()), + validators_snapshot=validators_snapshot, + manager=genvm_manager, + ) + + async def execute(active_fee_accounting): + node = create_node() if type == "read": # Pre-parse timestamp override and map errors txn_dt = None @@ -1704,7 +1730,7 @@ async def _gen_call_with_validator( data={}, ) from e decoded_data = transactions_parser.decode_method_call_data(data) - receipt = await node.get_contract_data( + return await node.get_contract_data( from_address=from_address, calldata=decoded_data.calldata, state_status=state_status, @@ -1724,13 +1750,13 @@ async def _gen_call_with_validator( data={}, ) from e decoded_data = transactions_parser.decode_method_send_data(data) - receipt = await node.run_contract( + return await node.run_contract( from_address=from_address, calldata=decoded_data.calldata, transaction_created_at=txn_created_at, value=call_value, origin_address=origin_address, - fee_accounting=genvm_fee_accounting, + fee_accounting=active_fee_accounting, ) elif type == "deploy": txn_created_at = None @@ -1745,20 +1771,50 @@ async def _gen_call_with_validator( data={}, ) from e decoded_data = transactions_parser.decode_deployment_data(data) - receipt = await node.deploy_contract( + return await node.deploy_contract( from_address=from_address, code_to_deploy=decoded_data.contract_code, calldata=decoded_data.calldata, transaction_created_at=txn_created_at, value=call_value, origin_address=origin_address, - fee_accounting=genvm_fee_accounting, + fee_accounting=active_fee_accounting, ) else: raise JSONRPCError( code=-32602, message=f"Invalid type '{type}': must be 'read', 'write', or 'deploy'", ) + + policy = StudioFeePolicy.from_env() + discover_message_allocations = bool( + params.get("_discover_message_allocations_for_estimate") + and type in {"write", "deploy"} + and simulation_fee_accounting is not None + and not simulation_fee_accounting.get("message_allocations") + and policy.fee_accounting_enabled() + ) + + try: + # The first pass reveals concrete recipient/call-key pairs without + # making the transaction envelope permissive. The second pass meters + # the same execution under the exact discovered allocation roots. + receipt = await execute( + None if discover_message_allocations else genvm_fee_accounting + ) + if ( + discover_message_allocations + and receipt.execution_result == ExecutionResultStatus.SUCCESS + ): + simulation_fee_accounting = fee_accounting_with_discovered_messages( + simulation_fee_accounting, + receipt, + policy, + ) + genvm_fee_accounting = _effective_simulation_fee_accounting_for_genvm( + simulation_fee_accounting + ) + receipt = await execute(genvm_fee_accounting) except ContractNotFoundError as e: raise NotFoundError( message=f"Contract {e.address} not found", diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 3d9a0fe17..65686b979 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -96,6 +96,10 @@ DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION = 200 DEFAULT_PRICE_CAP_HEADROOM_BPS = 12_000 DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM = 10_000 +# Conservative off-chain quote for an allocated EVM message. Consensus refunds +# unused reservation, while 21k only covers a plain value transfer and would +# make common contract calls (for example ERC-20 transfer) deterministically OOG. +DEFAULT_EXTERNAL_MESSAGE_GAS_LIMIT = 500_000 DEFAULT_TIME_UNIT_OVERLAY_BPS = 1_500 DEFAULT_MIN_PROPOSE_TIMEUNITS = 30 DEFAULT_MAX_PROPOSE_TIMEUNITS = 600 @@ -267,6 +271,7 @@ class StudioFeePolicy: max_allocated_messages: int = MAX_ALLOCATED_MESSAGES_CAP max_messages_per_tx: int = 0 min_external_gas_limit: int = 0 + default_external_gas_limit: int = DEFAULT_EXTERNAL_MESSAGE_GAS_LIMIT max_eq_outputs_bytes: int = 0 max_submitted_messages_bytes: int = 0 time_unit_overlay_bps: int = 0 @@ -290,6 +295,11 @@ def __post_init__(self) -> None: "invalid time-unit overlay bps: " f"{self.time_unit_overlay_bps} (expected 0..9999)" ) + if int(self.default_external_gas_limit) <= 0: + raise ValueError( + "invalid default external gas limit: " + f"{self.default_external_gas_limit} (expected > 0)" + ) for minimum, maximum, label in ( ( self.min_propose_timeunits, @@ -346,6 +356,10 @@ def from_env(cls) -> "StudioFeePolicy": min_external_gas_limit=_env_int( "GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT", 0 ), + default_external_gas_limit=_env_int( + "GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT", + DEFAULT_EXTERNAL_MESSAGE_GAS_LIMIT, + ), max_eq_outputs_bytes=_env_int("GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES", 0), max_submitted_messages_bytes=_env_int( "GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES", 0 @@ -1612,6 +1626,7 @@ def studio_fee_config(policy: StudioFeePolicy | None = None) -> dict[str, Any]: "maxAllocatedMessages": str(policy.max_allocated_messages), "maxMessagesPerTx": str(policy.max_messages_per_tx), "minExternalGasLimit": str(policy.min_external_gas_limit), + "defaultExternalGasLimit": str(policy.default_external_gas_limit), "maxEqOutputsBytes": str(policy.max_eq_outputs_bytes), "maxSubmittedMessagesBytes": str(policy.max_submitted_messages_bytes), "timeUnitOverlayBps": str(policy.time_unit_overlay_bps), @@ -5256,6 +5271,208 @@ def _receipt_pending_transaction_fee_payload(raw: Any) -> dict[str, Any]: } +def discovered_message_fee_allocations( + receipt: Any, + fees_distribution: dict[str, Any], + policy: StudioFeePolicy | None = None, +) -> list[dict[str, Any]]: + """Build an exact root allocation tree from an unmetered simulation. + + GenVM must receive an allocation before it can emit a fee-aware message, + which otherwise makes a transaction-specific estimate circular. Studio's + estimate endpoint first executes without fee metering, then converts the + observed recipient/call-key pairs into the same FlatArrays roots accepted + by Consensus and reruns the transaction under those exact roots. + + This intentionally does not create a wildcard recipient or call key. The + returned preset therefore funds only the messages the representative + execution actually emitted. + """ + policy = policy or StudioFeePolicy() + fees = normalize_fees_distribution(fees_distribution) + observed_messages = [ + _receipt_pending_transaction_fee_payload(raw) + for raw in _receipt_pending_transactions(receipt) + ] + if not observed_messages: + return [] + if len(observed_messages) > int( + policy.max_allocated_messages or MAX_ALLOCATED_MESSAGES_CAP + ): + raise TooManyMessages("TooManyMessages") + # Balance-funded internal messages reserve their declared budget from the + # emitting contract. They are deliberately outside the sender-funded + # message bucket and must not gain a root allocation during discovery. + messages = [ + message + for message in observed_messages + if not ( + int(message["messageType"]) == MESSAGE_TYPE_INTERNAL + and bool(message.get("useBalance", False)) + ) + ] + if not messages: + return [] + + leader_timeunits = int(fees["leaderTimeunitsAllocation"]) + validator_timeunits = int(fees["validatorTimeunitsAllocation"]) + if not ( + int(policy.min_propose_timeunits) + <= leader_timeunits + <= int(policy.max_propose_timeunits) + ): + leader_timeunits = DEFAULT_LEADER_TIMEUNITS_ALLOCATION + if not ( + int(policy.min_commit_timeunits) + <= validator_timeunits + <= int(policy.max_commit_timeunits) + ): + validator_timeunits = DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION + + rotations = [int((fees.get("rotations") or [0])[0])] + execution_budget = max( + int(fees["executionBudgetPerRound"]), + int(policy.message_fee_params_budget_floor()), + int(policy.genvm_start_budget_floor()), + ) + max_gen_price = max( + int(fees["maxPriceGenPerTimeUnit"]), + _with_cap_headroom(int(policy.gen_per_time_unit)), + ) + storage_price_cap = max( + int(fees["storageFeeMaxGasPrice"]), + _with_cap_headroom(int(policy.storage_unit_price)), + ) + receipt_price_cap = max( + int(fees["receiptFeeMaxGasPrice"]), + _with_cap_headroom(int(policy.receipt_gas_price)), + ) + + grouped: dict[tuple[int, str, str], dict[str, Any]] = {} + for message in messages: + message_type = int(message["messageType"]) + recipient = str(message["recipient"]).lower() + call_key = _normalize_call_key(message["callKey"]) + on_acceptance = bool(message["onAcceptance"]) + key = (message_type, recipient, call_key) + existing = grouped.get(key) + if existing is not None: + if bool(existing["onAcceptance"]) != on_acceptance: + raise MessageEmissionPhaseMismatch("MessageEmissionPhaseMismatch") + existing["count"] += 1 + continue + grouped[key] = { + "messageType": message_type, + "recipient": recipient, + "callKey": call_key, + "onAcceptance": on_acceptance, + "count": 1, + } + + allocations: list[dict[str, Any]] = [] + for item in grouped.values(): + count = int(item.pop("count")) + if int(item["messageType"]) == MESSAGE_TYPE_EXTERNAL: + gas_limit = max( + int(policy.default_external_gas_limit), + int(policy.min_external_gas_limit), + ) + max_gas_price = receipt_price_cap + if max_gas_price <= 0: + raise ExternalAllocationInvalid("ExternalExecutionPriceUnavailable") + fee_params = encode( + [EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], + [(gas_limit, max_gas_price)], + ) + budget = gas_limit * max_gas_price * count + on_acceptance = False + else: + internal_params = { + "leaderTimeunitsAllocation": leader_timeunits, + "validatorTimeunitsAllocation": validator_timeunits, + "appealRounds": 0, + "executionBudgetPerRound": execution_budget, + "rotations": rotations, + "maxPriceGenPerTimeUnit": max_gen_price, + "storageFeeMaxGasPrice": storage_price_cap, + "receiptFeeMaxGasPrice": receipt_price_cap, + } + fee_params = encode( + [INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], + [ + ( + internal_params["leaderTimeunitsAllocation"], + internal_params["validatorTimeunitsAllocation"], + internal_params["appealRounds"], + internal_params["executionBudgetPerRound"], + internal_params["rotations"], + internal_params["maxPriceGenPerTimeUnit"], + internal_params["storageFeeMaxGasPrice"], + internal_params["receiptFeeMaxGasPrice"], + ) + ], + ) + per_message_budget = min_message_primary_fees(internal_params, policy) + budget = per_message_budget * count + on_acceptance = bool(item["onAcceptance"]) + + allocations.append( + { + "messageType": int(item["messageType"]), + "onAcceptance": on_acceptance, + "parentIndex": NODE_ROOT_SENTINEL, + "recipient": item["recipient"], + "callKey": item["callKey"], + "budget": budget, + "feeParams": fee_params, + } + ) + + return allocations + + +def fee_accounting_with_discovered_messages( + accounting: dict[str, Any], + receipt: Any, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + """Rebuild simulation accounting around exact discovered allocations.""" + policy = _accounting_policy(accounting, policy) + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + allocations = discovered_message_fee_allocations(receipt, fees, policy) + if not allocations: + return accounting + + fees["totalMessageFees"] = sum( + int(allocation["budget"]) + for allocation in allocations + if int(allocation["parentIndex"]) == NODE_ROOT_SENTINEL + ) + fees["executionBudgetPerRound"] = max( + int(fees["executionBudgetPerRound"]), + int(policy.message_fee_params_budget_floor()) + + int(policy.receipt_gas_price) * DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM, + int(policy.genvm_start_budget_floor()), + ) + fee_value = required_fee_deposit( + fees, + int(accounting.get("num_of_initial_validators") or VALIDATORS_PER_ROUND[0]), + policy, + ) + return create_fee_accounting( + fees_distribution=fees, + message_allocations=allocations, + num_of_validators=int( + accounting.get("num_of_initial_validators") or VALIDATORS_PER_ROUND[0] + ), + submitted_value=int(accounting.get("user_value", 0) or 0) + fee_value, + user_value=int(accounting.get("user_value", 0) or 0), + sender=accounting.get("sender"), + policy=policy, + allow_low_execution_budget=True, + ) + + def _execution_fee_buckets(consumed: list[int]) -> list[int]: if len(consumed) <= 2: return consumed diff --git a/docker-compose.yml b/docker-compose.yml index c7e62e0c0..513ddc64e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -142,6 +142,7 @@ services: - GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS:-30} - GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS:-600} - GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT=${GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT:-0} + - GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT=${GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT:-500000} - GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES=${GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES:-20} - GENLAYER_STUDIO_MAX_MESSAGES_PER_TX=${GENLAYER_STUDIO_MAX_MESSAGES_PER_TX:-0} - GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES=${GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES:-0} @@ -346,6 +347,7 @@ services: - GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS:-30} - GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS:-600} - GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT=${GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT:-0} + - GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT=${GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT:-500000} - GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES=${GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES:-20} - GENLAYER_STUDIO_MAX_MESSAGES_PER_TX=${GENLAYER_STUDIO_MAX_MESSAGES_PER_TX:-0} - GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES=${GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES:-0} diff --git a/frontend/src/hooks/useContractQueries.ts b/frontend/src/hooks/useContractQueries.ts index 3e009bee4..dce306e24 100644 --- a/frontend/src/hooks/useContractQueries.ts +++ b/frontend/src/hooks/useContractQueries.ts @@ -26,9 +26,11 @@ import type { Address, TransactionHash, CalldataEncodable, + MessageFeeAllocationInput, TransactionHashVariant, } from 'genlayer-js/types'; import { TransactionStatus } from 'genlayer-js/types'; +import { JsonRpcServiceError } from '@/services/JsonRpcService'; const schema = ref(); @@ -38,6 +40,7 @@ type TrustedFeeEstimate = { }; distribution: StudioFeeConfig['defaultFees']['distribution']; feeValue: bigint | number | string; + messageAllocations?: MessageFeeAllocationInput[]; }; function feeNumberToRpc(value: bigint | number | string) { @@ -47,6 +50,7 @@ function feeNumberToRpc(value: bigint | number | string) { function feesToRpcParams(fees: { distribution: StudioFeeConfig['defaultFees']['distribution']; feeValue: bigint | number | string; + messageAllocations?: MessageFeeAllocationInput[]; }) { return { distribution: Object.fromEntries( @@ -58,20 +62,58 @@ function feesToRpcParams(fees: { ]), ), feeValue: feeNumberToRpc(fees.feeValue), + ...(fees.messageAllocations !== undefined + ? { messageAllocations: fees.messageAllocations } + : {}), }; } +function feesFromStudioEstimate( + estimate: StudioFeeEstimateResult, +): TrustedFeeEstimate { + const preset = estimate.recommendedPreset; + if (!preset?.distribution || preset.feeValue === undefined) { + throw new Error('Studio fee estimator returned no recommended preset'); + } + return { + distribution: preset.distribution, + feeValue: preset.feeValue, + ...(preset.messageAllocations !== undefined + ? { messageAllocations: preset.messageAllocations } + : {}), + }; +} + +function isExpectedSimulationExecutionFailure(error: unknown) { + return ( + error instanceof JsonRpcServiceError && + error.code === -32000 && + error.message === 'execution failed' + ); +} + async function encodeWriteMethodCalldata( method: string, args: CalldataEncodable[], + kwargs: { [key: string]: CalldataEncodable }, + leaderOnly = false, ) { const { abi } = await import('genlayer-js'); - const { toRlp, toHex } = await import('viem'); - const calldataObj = abi.calldata.makeCalldataObject(method, args, undefined); + const calldataObj = abi.calldata.makeCalldataObject(method, args, kwargs); const encoded = abi.calldata.encode(calldataObj); - return toRlp( - [toHex(encoded), toHex(false)].map((item) => item as `0x${string}`), - ); + return abi.transactions.serialize([encoded, leaderOnly]); +} + +async function encodeDeployCalldata( + code: Uint8Array, + args: CalldataEncodable[], + kwargs: { [key: string]: CalldataEncodable }, + leaderOnly: boolean, +) { + const { abi } = await import('genlayer-js'); + const calldataObj = abi.calldata.makeCalldataObject(undefined, args, kwargs); + const encoded = abi.calldata.encode(calldataObj); + return abi.transactions.serialize([code, encoded, leaderOnly]); } /** @@ -165,6 +207,9 @@ export function useContractQueries() { return { distribution: estimate.distribution, feeValue: BigInt(estimate.feeValue), + ...(estimate.messageAllocations !== undefined + ? { messageAllocations: estimate.messageAllocations } + : {}), }; } @@ -182,6 +227,77 @@ export function useContractQueries() { }; } + async function getStudioWriteFees({ + method, + args, + kwargs, + value, + leaderOnly, + }: { + method: string; + args: CalldataEncodable[]; + kwargs: { [key: string]: CalldataEncodable }; + value: bigint; + leaderOnly: boolean; + }): Promise { + try { + const serialized = await encodeWriteMethodCalldata( + method, + args, + kwargs, + leaderOnly, + ); + const estimate = await rpcClient.estimateTransactionFees({ + scenarioName: method, + type: 'write', + to: address.value, + from: accountsStore.selectedAccount?.address, + data: serialized, + value: '0x' + value.toString(16), + transaction_hash_variant: 'latest-nonfinal', + }); + return feesFromStudioEstimate(estimate); + } catch (error) { + // Preserve the ability to submit transactions whose execution is + // intentionally expected to fail; they still need a valid base fee + // envelope so Consensus can materialize the error receipt. + if (!isExpectedSimulationExecutionFailure(error)) throw error; + return getDefaultStudioFees(); + } + } + + async function getStudioDeployFees({ + code, + args, + kwargs, + leaderOnly, + }: { + code: Uint8Array; + args: CalldataEncodable[]; + kwargs: { [key: string]: CalldataEncodable }; + leaderOnly: boolean; + }): Promise { + try { + const serialized = await encodeDeployCalldata( + code, + args, + kwargs, + leaderOnly, + ); + const estimate = await rpcClient.estimateTransactionFees({ + scenarioName: 'deploy', + type: 'deploy', + to: '0x0000000000000000000000000000000000000000', + from: accountsStore.selectedAccount?.address, + data: serialized, + }); + return feesFromStudioEstimate(estimate); + } catch (error) { + if (!isExpectedSimulationExecutionFailure(error)) throw error; + return getDefaultStudioFees(); + } + } + const contractSchemaQuery = useQuery({ queryKey: ['schema', () => contract.value?.id], queryFn: fetchContractSchemaDebounced, @@ -237,11 +353,19 @@ export function useContractQueries() { const code = contract.value?.content ?? ''; const code_bytes = new TextEncoder().encode(code); - const fees = await getDefaultStudioFees(); + const fees = networkStore.isStudio + ? await getStudioDeployFees({ + code: code_bytes, + args: args.args, + kwargs: args.kwargs, + leaderOnly, + }) + : await getDefaultStudioFees(); const result = await genlayerClient.value?.deployContract({ code: code_bytes as any as string, // FIXME: code should accept both bytes and string in genlayer-js args: args.args, + kwargs: args.kwargs, leaderOnly, consensusMaxRotations, ...(fees ? { fees } : {}), @@ -278,6 +402,9 @@ export function useContractQueries() { title: 'Error deploying contract', }); console.error('Error Deploying the contract', error); + if (error instanceof Error) { + throw error; + } throw new Error('Error Deploying the contract'); } } @@ -328,6 +455,7 @@ export function useContractQueries() { address: address.value as Address, functionName: method, args: args.args, + kwargs: args.kwargs, transactionHashVariant, }); @@ -363,12 +491,21 @@ export function useContractQueries() { } await ensureCorrectChain(); - const fees = await getDefaultStudioFees(); + const fees = networkStore.isStudio + ? await getStudioWriteFees({ + method, + args: args.args, + kwargs: args.kwargs, + value, + leaderOnly, + }) + : await getDefaultStudioFees(); const result = await genlayerClient.value?.writeContract({ address: address.value as Address, functionName: method, args: args.args, + kwargs: args.kwargs, value, leaderOnly, consensusMaxRotations, @@ -390,6 +527,9 @@ export function useContractQueries() { return true; } catch (error) { console.error(error); + if (error instanceof Error) { + throw error; + } throw new Error('Error writing to contract'); } } @@ -407,13 +547,25 @@ export function useContractQueries() { value?: bigint; }) { try { - const fees = await getDefaultStudioFees(); + const fees = networkStore.isStudio + ? await getStudioWriteFees({ + method, + args: args.args, + kwargs: args.kwargs, + value: value ?? 0n, + leaderOnly: false, + }) + : await getDefaultStudioFees(); const from = accountsStore.selectedAccount?.address || '0x0000000000000000000000000000000000000000'; if (networkStore.isStudio) { - const serialized = await encodeWriteMethodCalldata(method, args.args); + const serialized = await encodeWriteMethodCalldata( + method, + args.args, + args.kwargs, + ); return await rpcClient.simulateCall({ type: 'write', to: address.value, @@ -428,7 +580,11 @@ export function useContractQueries() { // Use simulateWriteContract for non-payable, but for payable we need // to call gen_call directly since the SDK doesn't support value param. if (value && value > 0n) { - const serialized = await encodeWriteMethodCalldata(method, args.args); + const serialized = await encodeWriteMethodCalldata( + method, + args.args, + args.kwargs, + ); const result = await (genlayerClient.value as any).request({ method: 'gen_call', params: [ @@ -458,6 +614,7 @@ export function useContractQueries() { address: address.value as Address, functionName: method, args: args.args, + kwargs: args.kwargs, value, ...(fees ? { fees } : {}), }); @@ -473,6 +630,7 @@ export function useContractQueries() { method, args, value, + leaderOnly = false, }: { method: string; args: { @@ -480,6 +638,7 @@ export function useContractQueries() { kwargs: { [key: string]: CalldataEncodable }; }; value?: bigint; + leaderOnly?: boolean; }): Promise { if (!networkStore.isStudio) { throw new Error('Fee estimation is only available in Studio'); @@ -489,7 +648,12 @@ export function useContractQueries() { const from = accountsStore.selectedAccount?.address || '0x0000000000000000000000000000000000000000'; - const serialized = await encodeWriteMethodCalldata(method, args.args); + const serialized = await encodeWriteMethodCalldata( + method, + args.args, + args.kwargs, + leaderOnly, + ); return await rpcClient.estimateTransactionFees({ scenarioName: method, type: 'write', diff --git a/frontend/src/services/JsonRpcService.ts b/frontend/src/services/JsonRpcService.ts index 93a40dead..4a8306afa 100644 --- a/frontend/src/services/JsonRpcService.ts +++ b/frontend/src/services/JsonRpcService.ts @@ -16,6 +16,17 @@ import type { StudioFeeEstimateResult, } from '@/types'; +export class JsonRpcServiceError extends Error { + constructor( + message: string, + public readonly code: number, + public readonly data?: unknown, + ) { + super(message); + this.name = 'JsonRpcServiceError'; + } +} + export class JsonRpcService implements IJsonRpcService { constructor(protected rpcClient: IRpcClient) {} @@ -32,7 +43,7 @@ export class JsonRpcService implements IJsonRpcService { console.error(error.message, error.code); // Preserve the server's error message instead of a generic fallback const detail = error.message || errorMessage; - throw new Error(detail); + throw new JsonRpcServiceError(detail, error.code, error.data); } return result; } diff --git a/frontend/src/types/responses.ts b/frontend/src/types/responses.ts index acfde027d..ae9e38cd7 100644 --- a/frontend/src/types/responses.ts +++ b/frontend/src/types/responses.ts @@ -1,3 +1,5 @@ +import type { MessageFeeAllocationInput } from 'genlayer-js/types'; + export interface JsonRPCResponse { id: string; jsonrpc: string; @@ -130,7 +132,7 @@ export interface StudioRecommendedFeePreset { numOfInitialValidators?: string | number; distribution?: StudioFeesDistribution; feeValue?: string | number; - messageAllocations?: unknown[]; + messageAllocations?: MessageFeeAllocationInput[]; messageBudgetMode?: | 'current' | 'observed' @@ -173,7 +175,7 @@ export interface StudioFeeAccounting { appeal_bonds_total?: string | number; total_refunded?: string | number; fees_distribution?: StudioFeesDistribution; - message_allocations?: unknown[]; + message_allocations?: MessageFeeAllocationInput[]; allocation_consumed?: Record; message_consumption_events?: unknown[]; refunds?: unknown[]; diff --git a/frontend/test/unit/hooks/useContractQueries.behavioral.test.ts b/frontend/test/unit/hooks/useContractQueries.behavioral.test.ts index d2e8bd715..ca4dcb686 100644 --- a/frontend/test/unit/hooks/useContractQueries.behavioral.test.ts +++ b/frontend/test/unit/hooks/useContractQueries.behavioral.test.ts @@ -6,6 +6,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ref } from 'vue'; +import { JsonRpcServiceError } from '@/services/JsonRpcService'; const mockDeployContract = vi.fn(() => Promise.resolve('0xDeployHash')); const mockWriteContract = vi.fn(() => Promise.resolve('0xWriteHash')); @@ -47,12 +48,26 @@ const mockGenlayerClient = { }; const mockEnsureCorrectChain = vi.fn(); +const mockMessageAllocations = [ + { + messageType: 1, + onAcceptance: true, + parentIndex: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + recipient: '0x0000000000000000000000000000000000000001', + callKey: + '0x7469700000000000000000000000000000000000000000000000000000000000', + budget: '22000000000000000', + feeParams: '0x1234', + }, +]; const mockRpcEstimateTransactionFees = vi.fn(() => Promise.resolve({ scenario: 'tip', feeReport: { totalEstimatedFee: '120000000000000000' }, recommendedPreset: { feeValue: '132000000000000000', + messageAllocations: mockMessageAllocations, distribution: { leaderTimeunitsAllocation: '110', validatorTimeunitsAllocation: '55', @@ -292,49 +307,104 @@ describe('useContractQueries — behavioral contract', () => { }); describe('Studio fee defaults', () => { - it('should attach trusted Studio default fees to deployments', async () => { + it('should estimate and attach transaction-specific Studio fees to deployments', async () => { mockNetworkStore.isStudio = true; + const { abi } = await import('genlayer-js'); + const constructorArgs = { + args: ['arg1'], + kwargs: { owner: 'alice' }, + }; + const expectedData = abi.transactions.serialize([ + new TextEncoder().encode(mockContractsStore.currentContract.content), + abi.calldata.encode( + abi.calldata.makeCalldataObject( + undefined, + constructorArgs.args, + constructorArgs.kwargs, + ), + ), + false, + ]); const { useContractQueries } = await import('@/hooks/useContractQueries'); const { deployContract } = useContractQueries(); - await deployContract({ args: [], kwargs: {} }, 'NORMAL' as any, 3); + await deployContract(constructorArgs, 'NORMAL' as any, 3); - expect(mockEstimateTransactionFees).toHaveBeenCalled(); + expect(mockRpcEstimateTransactionFees).toHaveBeenCalledWith( + expect.objectContaining({ + scenarioName: 'deploy', + type: 'deploy', + to: '0x0000000000000000000000000000000000000000', + from: '0xUser', + data: expectedData, + }), + ); + expect(mockEstimateTransactionFees).not.toHaveBeenCalled(); expect(mockDeployContract).toHaveBeenCalledWith( expect.objectContaining({ fees: expect.objectContaining({ - feeValue: 120000000000000000n, + feeValue: '132000000000000000', + messageAllocations: mockMessageAllocations, distribution: expect.objectContaining({ - leaderTimeunitsAllocation: '100', - totalMessageFees: '20000000000000000', + leaderTimeunitsAllocation: '110', + totalMessageFees: '22000000000000000', maxPriceGenPerTimeUnit: '1000000000000000', }), }), + kwargs: constructorArgs.kwargs, }), ); }); - it('should attach trusted Studio default fees to writes', async () => { + it('should estimate and attach transaction-specific Studio fees to writes', async () => { mockNetworkStore.isStudio = true; + const { abi } = await import('genlayer-js'); + const writeArgs = { + args: ['hello'], + kwargs: { recipient: 'alice' }, + }; + const expectedData = abi.transactions.serialize([ + abi.calldata.encode( + abi.calldata.makeCalldataObject( + 'tip', + writeArgs.args, + writeArgs.kwargs, + ), + ), + false, + ]); const { useContractQueries } = await import('@/hooks/useContractQueries'); const { callWriteMethod } = useContractQueries(); await callWriteMethod({ method: 'tip', - args: { args: ['hello'], kwargs: {} }, + args: writeArgs, executionMode: 'NORMAL' as any, value: 5n, }); - expect(mockEstimateTransactionFees).toHaveBeenCalled(); + expect(mockRpcEstimateTransactionFees).toHaveBeenCalledWith( + expect.objectContaining({ + scenarioName: 'tip', + type: 'write', + to: '0xContract', + from: '0xUser', + value: '0x5', + transaction_hash_variant: 'latest-nonfinal', + data: expectedData, + }), + ); + expect(mockEstimateTransactionFees).not.toHaveBeenCalled(); expect(mockWriteContract).toHaveBeenCalledWith( expect.objectContaining({ functionName: 'tip', + kwargs: writeArgs.kwargs, value: 5n, fees: expect.objectContaining({ - feeValue: 120000000000000000n, + feeValue: '132000000000000000', + messageAllocations: mockMessageAllocations, distribution: expect.objectContaining({ - validatorTimeunitsAllocation: '50', + validatorTimeunitsAllocation: '55', receiptFeeMaxGasPrice: '1', }), }), @@ -362,10 +432,11 @@ describe('useContractQueries — behavioral contract', () => { transaction_hash_variant: 'latest-nonfinal', data: expect.stringMatching(/^0x/), fees: expect.objectContaining({ - feeValue: '120000000000000000', + feeValue: '132000000000000000', + messageAllocations: mockMessageAllocations, distribution: expect.objectContaining({ - leaderTimeunitsAllocation: '100', - totalMessageFees: '20000000000000000', + leaderTimeunitsAllocation: '110', + totalMessageFees: '22000000000000000', }), }), }), @@ -375,6 +446,9 @@ describe('useContractQueries — behavioral contract', () => { it('should fall back to sim_getFeeConfig when SDK fee defaults are unavailable', async () => { mockNetworkStore.isStudio = true; + mockRpcEstimateTransactionFees.mockRejectedValueOnce( + new JsonRpcServiceError('execution failed', -32000), + ); mockGenlayerClient.estimateTransactionFees = undefined; const { useContractQueries } = await import('@/hooks/useContractQueries'); const { callWriteMethod } = useContractQueries(); @@ -402,6 +476,9 @@ describe('useContractQueries — behavioral contract', () => { it('should omit Studio fees when fee config explicitly reports gasless mode', async () => { mockNetworkStore.isStudio = true; + mockRpcEstimateTransactionFees.mockRejectedValueOnce( + new JsonRpcServiceError('execution failed', -32000), + ); mockGenlayerClient.estimateTransactionFees = undefined; mockRpcGetFeeConfig.mockResolvedValue({ enabled: false, @@ -426,6 +503,63 @@ describe('useContractQueries — behavioral contract', () => { }), ); }); + + it('should not submit with generic fees when transaction-specific estimation is unavailable', async () => { + mockNetworkStore.isStudio = true; + mockRpcEstimateTransactionFees.mockRejectedValueOnce( + new JsonRpcServiceError('manager unavailable', -32002), + ); + const { useContractQueries } = await import('@/hooks/useContractQueries'); + const { callWriteMethod } = useContractQueries(); + + await expect( + callWriteMethod({ + method: 'tip', + args: { args: ['hello'], kwargs: {} }, + executionMode: 'NORMAL' as any, + }), + ).rejects.toThrow('manager unavailable'); + + expect(mockWriteContract).not.toHaveBeenCalled(); + expect(mockEstimateTransactionFees).not.toHaveBeenCalled(); + }); + + it('should not deploy with generic fees when transaction-specific estimation is unavailable', async () => { + mockNetworkStore.isStudio = true; + mockRpcEstimateTransactionFees.mockRejectedValueOnce( + new JsonRpcServiceError('manager unavailable', -32002), + ); + const { useContractQueries } = await import('@/hooks/useContractQueries'); + const { deployContract } = useContractQueries(); + + await expect( + deployContract({ args: [], kwargs: {} }, 'NORMAL' as any, 3), + ).rejects.toThrow('manager unavailable'); + + expect(mockDeployContract).not.toHaveBeenCalled(); + expect(mockEstimateTransactionFees).not.toHaveBeenCalled(); + }); + + it('should not submit when the estimator omits its recommended preset', async () => { + mockNetworkStore.isStudio = true; + mockRpcEstimateTransactionFees.mockResolvedValueOnce({ + scenario: 'tip', + feeReport: {}, + } as any); + const { useContractQueries } = await import('@/hooks/useContractQueries'); + const { callWriteMethod } = useContractQueries(); + + await expect( + callWriteMethod({ + method: 'tip', + args: { args: ['hello'], kwargs: {} }, + executionMode: 'NORMAL' as any, + }), + ).rejects.toThrow('Studio fee estimator returned no recommended preset'); + + expect(mockWriteContract).not.toHaveBeenCalled(); + expect(mockEstimateTransactionFees).not.toHaveBeenCalled(); + }); }); describe('Studio fee estimation', () => { diff --git a/frontend/test/unit/services/JsonRpcService.test.ts b/frontend/test/unit/services/JsonRpcService.test.ts index a2cebdb25..00d22afff 100644 --- a/frontend/test/unit/services/JsonRpcService.test.ts +++ b/frontend/test/unit/services/JsonRpcService.test.ts @@ -1,4 +1,4 @@ -import { JsonRpcService } from '@/services/JsonRpcService'; +import { JsonRpcService, JsonRpcServiceError } from '@/services/JsonRpcService'; import type { IJsonRpcService } from '@/services/IJsonRpcService'; import type { IRpcClient } from '@/clients/rpc'; import type { GetContractStateResult, JsonRPCResponse } from '@/types'; @@ -112,5 +112,27 @@ describe('JsonRprService', () => { 'Cannot cancel', ); }); + + it('should preserve the JSON-RPC code and data on errors', async () => { + vi.spyOn(rpcClient, 'call').mockImplementationOnce(() => + Promise.resolve({ + result: null, + error: { + code: -32000, + message: 'execution failed', + data: { receipt: { status: 'ERROR' } }, + }, + }), + ); + + const error = await jsonRpcService + .cancelTransaction(txHash) + .catch((value) => value); + expect(error).toBeInstanceOf(JsonRpcServiceError); + expect(error).toMatchObject({ + code: -32000, + data: { receipt: { status: 'ERROR' } }, + }); + }); }); }); diff --git a/tests/unit/test_contract_not_found_handling.py b/tests/unit/test_contract_not_found_handling.py index 3f66006d2..a7484fce2 100644 --- a/tests/unit/test_contract_not_found_handling.py +++ b/tests/unit/test_contract_not_found_handling.py @@ -287,6 +287,99 @@ async def test_gen_call_with_validator_catches_contract_not_found_during_executi assert contract_address in str(exc_info.value) + @pytest.mark.asyncio + async def test_fee_estimate_discovery_restarts_from_a_fresh_snapshot(self): + """The discovery pass must not leak simulated writes into metering.""" + from backend.node.types import ExecutionResultStatus + from backend.protocol_rpc.endpoints import _gen_call_with_validator + from backend.protocol_rpc.fees import StudioFeePolicy + + mock_session = MagicMock(spec=Session) + mock_accounts_manager = MagicMock() + mock_accounts_manager.is_valid_address.return_value = True + mock_genvm_manager = MagicMock() + mock_msg_handler = MagicMock() + mock_msg_handler.with_client_session.return_value = mock_msg_handler + mock_transactions_parser = MagicMock() + mock_decoded = MagicMock(calldata=b"write") + mock_transactions_parser.decode_method_send_data.return_value = mock_decoded + + validators_snapshot = MagicMock() + validators_snapshot.nodes = [MagicMock(validator=MagicMock())] + + discovery_receipt = MagicMock( + execution_result=ExecutionResultStatus.SUCCESS, + genvm_result={}, + ) + metered_receipt = MagicMock( + execution_result=ExecutionResultStatus.SUCCESS, + genvm_result={}, + ) + discovery_node = MagicMock() + discovery_node.run_contract = AsyncMock(return_value=discovery_receipt) + metered_node = MagicMock() + metered_node.run_contract = AsyncMock(return_value=metered_receipt) + + initial_accounting = {"message_allocations": []} + rebuilt_accounting = {"message_allocations": [{"budget": 1}]} + exact_genvm_accounting = {"exact": True} + params = { + "type": "write", + "data": "0x1234", + "to": "0x" + "ab" * 20, + "from": "0x" + "cd" * 20, + "_discover_message_allocations_for_estimate": True, + } + + with patch( + "backend.protocol_rpc.endpoints.ContractSnapshot", + side_effect=[MagicMock(), MagicMock()], + ) as snapshot_cls, patch( + "backend.protocol_rpc.endpoints.Node", + side_effect=[discovery_node, metered_node], + ) as node_cls, patch( + "backend.protocol_rpc.endpoints._check_rate_limit" + ), patch( + "backend.protocol_rpc.endpoints._simulation_fee_accounting", + return_value=initial_accounting, + ), patch( + "backend.protocol_rpc.endpoints._effective_simulation_fee_accounting_for_genvm", + side_effect=[{"initial": True}, exact_genvm_accounting], + ), patch( + "backend.protocol_rpc.endpoints.fee_accounting_with_discovered_messages", + return_value=rebuilt_accounting, + ), patch( + "backend.protocol_rpc.endpoints.record_execution_fee_consumption", + return_value={"recorded": True}, + ), patch( + "backend.protocol_rpc.endpoints.StudioFeePolicy.from_env", + return_value=StudioFeePolicy( + gen_per_time_unit=1, + storage_unit_price=1, + receipt_gas_price=1, + ), + ): + receipt = await _gen_call_with_validator( + mock_session, + mock_accounts_manager, + mock_genvm_manager, + mock_msg_handler, + mock_transactions_parser, + validators_snapshot, + params, + ) + + assert receipt is metered_receipt + assert snapshot_cls.call_count == 2 + first_snapshot = node_cls.call_args_list[0].kwargs["contract_snapshot"] + second_snapshot = node_cls.call_args_list[1].kwargs["contract_snapshot"] + assert first_snapshot is not second_snapshot + assert discovery_node.run_contract.await_args.kwargs["fee_accounting"] is None + assert ( + metered_node.run_contract.await_args.kwargs["fee_accounting"] + == exact_genvm_accounting + ) + class TestEthCallContractNotFoundHandling: """Test that eth_call properly handles ContractNotFoundError from execution""" diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index e3fa42c07..d8066f57a 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -86,6 +86,7 @@ MessageNoMatchingAllocation, Mode1MessageFeesRequireGenVMPerEmissionSupport, message_effect_identities, + min_message_primary_fees, PhaseTimeoutOutOfBounds, SubmittedMessagesTooLarge, CALL_KEY_WILDCARD, @@ -101,6 +102,7 @@ PROPOSE_RECEIPT_SLOTS, SUBMITTED_MESSAGE_ABI_TYPE, DEFAULT_GEN_PER_TIME_UNIT, + DEFAULT_EXTERNAL_MESSAGE_GAS_LIMIT, DEFAULT_PRICE_CAP_HEADROOM_BPS, DEFAULT_TIME_UNIT_OVERLAY_BPS, DEFAULT_RECEIPT_GAS_PRICE, @@ -120,9 +122,13 @@ consume_message_fees, create_child_fee_accounting, create_fee_accounting, + decode_external_message_fee_params, + decode_internal_message_fee_params, cancel_fee_accounting, default_transaction_fees_for_policy, derive_external_message_call_key, + discovered_message_fee_allocations, + fee_accounting_with_discovered_messages, fill_message_fee_payload_from_allocation, mark_message_effects_delivered, message_novelty_mask, @@ -737,12 +743,14 @@ def test_studio_fee_policy_env_defaults_to_fee_enabled(monkeypatch): monkeypatch.delenv("GENLAYER_STUDIO_GEN_PER_TIME_UNIT", raising=False) monkeypatch.delenv("GENLAYER_STUDIO_STORAGE_UNIT_PRICE", raising=False) monkeypatch.delenv("GENLAYER_STUDIO_RECEIPT_GAS_PRICE", raising=False) + monkeypatch.delenv("GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT", raising=False) policy = StudioFeePolicy.from_env() assert policy.gen_per_time_unit == DEFAULT_GEN_PER_TIME_UNIT assert policy.storage_unit_price == DEFAULT_STORAGE_UNIT_PRICE assert policy.receipt_gas_price == DEFAULT_RECEIPT_GAS_PRICE + assert policy.default_external_gas_limit == DEFAULT_EXTERNAL_MESSAGE_GAS_LIMIT assert policy.time_unit_overlay_bps == DEFAULT_TIME_UNIT_OVERLAY_BPS assert policy.fee_accounting_enabled() is True @@ -2061,6 +2069,111 @@ def test_simulation_fee_accounting_defaults_to_required_deposit(): assert accounting["message_fee_budget"] == 55 +def test_discovered_message_allocations_are_exact_and_consensus_valid(): + policy = StudioFeePolicy.from_env() + fees_distribution = _env_fees_distribution() + internal_recipient = "0x2222222222222222222222222222222222222222" + external_recipient = "0x3333333333333333333333333333333333333333" + internal_call_key = "0x" + "12" * 32 + external_calldata = bytes.fromhex("aabbccdd01") + receipt = { + "pending_transactions": [ + { + "messageType": "Internal", + "address": internal_recipient, + "on": "accepted", + "call_key": internal_call_key, + }, + { + "messageType": "Internal", + "address": internal_recipient, + "on": "accepted", + "call_key": internal_call_key, + }, + { + "messageType": "External", + "address": external_recipient, + "on": "finalized", + "calldata": external_calldata, + "call_key": EMPTY_CALL_KEY, + }, + { + "messageType": "Internal", + "address": "0x4444444444444444444444444444444444444444", + "on": "finalized", + "call_key": "0x" + "56" * 32, + "use_balance": True, + }, + ] + } + + allocations = discovered_message_fee_allocations( + receipt, + fees_distribution, + policy, + ) + + assert len(allocations) == 2 + internal, external = allocations + assert internal["recipient"] == internal_recipient + assert internal["callKey"] == internal_call_key + assert internal["onAcceptance"] is True + internal_params = decode_internal_message_fee_params(internal["feeParams"]) + assert internal["budget"] == 2 * min_message_primary_fees( + internal_params, + policy, + ) + assert external["recipient"] == external_recipient + assert external["callKey"] == "0x" + "aabbccdd" + "00" * 28 + assert external["onAcceptance"] is False + external_params = decode_external_message_fee_params(external["feeParams"]) + assert external_params["gasLimit"] == policy.default_external_gas_limit + assert external["budget"] == ( + external_params["gasLimit"] * external_params["maxGasPrice"] + ) + + total_message_fees = sum(allocation["budget"] for allocation in allocations) + validate_message_allocations( + allocations, + total_message_fees=total_message_fees, + policy=policy, + ) + + +def test_discovered_messages_rebuild_simulation_accounting_and_deposit(): + policy = StudioFeePolicy.from_env() + fees_distribution = _env_fees_distribution() + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5, policy), + user_value=0, + sender="0x1111111111111111111111111111111111111111", + policy=policy, + ) + receipt = { + "pending_transactions": [ + { + "messageType": "Internal", + "address": "0x2222222222222222222222222222222222222222", + "on": "finalized", + "call_key": "0x" + "34" * 32, + } + ] + } + + rebuilt = fee_accounting_with_discovered_messages(accounting, receipt, policy) + + assert len(rebuilt["message_allocations"]) == 1 + assert rebuilt["message_fee_budget"] == rebuilt["message_allocations"][0]["budget"] + assert rebuilt["required_fee_value"] == required_fee_deposit( + rebuilt["fees_distribution"], + 5, + policy, + ) + assert rebuilt["paid_fee_value"] == rebuilt["required_fee_value"] + + def test_simulation_fee_accounting_rejects_insufficient_sdk_fee_value(): fees_distribution = _env_fees_distribution(total_message_fees=55) with pytest.raises(JSONRPCError, match="InsufficientFees"): @@ -2172,7 +2285,9 @@ async def fake_sim_call(**kwargs): assert seen["params"]["fees"] == fees assert seen["params"]["_allow_low_execution_budget_for_estimate"] is True + assert seen["params"]["_discover_message_allocations_for_estimate"] is True assert "_allow_low_execution_budget_for_estimate" not in params + assert "_discover_message_allocations_for_estimate" not in params assert result["scenario"] == "mode-2-message" assert result["feeReport"] == {"messageFees": {"budget": 55}} assert result["recommendedPreset"] == { From c5fbef18c6779db0efb2b7e8e3870564dcb22cf9 Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 31 Aug 2026 12:12:19 +0100 Subject: [PATCH 34/55] test(integration): submit canonical fee envelopes --- tests/integration/accounts/test_accounts.py | 3 +- tests/integration/conftest.py | 128 ++++++++++++++++-- .../tests/test_call_to_errored_deploy.py | 2 +- tests/integration/test_deploy_reroute_to.py | 16 ++- 4 files changed, 134 insertions(+), 15 deletions(-) diff --git a/tests/integration/accounts/test_accounts.py b/tests/integration/accounts/test_accounts.py index 767ea3377..697a81559 100644 --- a/tests/integration/accounts/test_accounts.py +++ b/tests/integration/accounts/test_accounts.py @@ -1,6 +1,7 @@ # tests/e2e/test_storage.py from tests.common.request import ( + ZERO_ADDRESS, payload, post_request_localhost, wait_for_transaction, @@ -109,7 +110,7 @@ def test_accounts_burn(): # Test burn burn_amount = 200 - transaction_response_call_1 = send_transaction(account_1, None, burn_amount) + transaction_response_call_1 = send_transaction(account_1, ZERO_ADDRESS, burn_amount) assert has_success_status(transaction_response_call_1) # Verify balance after transfer diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c74574922..3dfaa75e7 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,10 +1,15 @@ +import inspect import json import os from functools import wraps import pytest import requests +from genlayer_py.abi import calldata +from genlayer_py.abi.transactions import serialize from genlayer_py.client.genlayer_client import GenLayerClient +from genlayer_py.contracts.utils import make_calldata_object +from web3.constants import ADDRESS_ZERO from tests.integration import gltest_compat @@ -29,6 +34,65 @@ def _default_studio_fees(client: GenLayerClient) -> dict | None: return fees +def _transaction_fees_from_estimate(estimate: dict) -> dict: + distribution = estimate.get("distribution") + fee_value = estimate.get("feeValue", estimate.get("fee_value")) + if not isinstance(distribution, dict) or fee_value is None: + raise RuntimeError("fee estimator returned no recommended preset") + fees = { + "distribution": distribution, + "feeValue": fee_value, + } + allocations = estimate.get( + "messageAllocations", estimate.get("message_allocations") + ) + if allocations is not None: + fees["messageAllocations"] = allocations + return fees + + +def _is_expected_simulation_execution_failure(error: Exception) -> bool: + return "execution failed" in str(error).lower() + + +def _estimate_deploy_fees( + client: GenLayerClient, + *, + code, + account=None, + args=None, + kwargs=None, + leader_only=False, + sim_config=None, +) -> dict: + sender = account or client.local_account + data = serialize( + [ + code, + calldata.encode( + make_calldata_object(method=None, args=args, kwargs=kwargs) + ), + leader_only, + ] + ) + request = { + "type": "deploy", + "to": ADDRESS_ZERO, + "from": sender.address, + "data": data, + } + if sim_config is not None: + request["sim_config"] = sim_config + response = client.provider.make_request( + method="sim_estimateTransactionFees", + params=[request], + ) + if response.get("error"): + raise RuntimeError(response["error"].get("message", "fee estimate failed")) + preset = (response.get("result") or {}).get("recommendedPreset") or {} + return _transaction_fees_from_estimate(preset) + + @pytest.fixture(scope="session", autouse=True) def use_fee_aware_sdk_defaults(): """Exercise fee-enabled Studio through the train SDK's canonical quote. @@ -40,20 +104,64 @@ def use_fee_aware_sdk_defaults(): original_write = GenLayerClient.write_contract @wraps(original_deploy) - def deploy_with_default_fees(client, *args, **kwargs): - if kwargs.get("fees") is None: - fees = _default_studio_fees(client) + def deploy_with_default_fees(client, *args, **call_kwargs): + if call_kwargs.get("fees") is None: + fees = None + default_fees = _default_studio_fees(client) + if default_fees is not None: + try: + bound = inspect.signature(original_deploy).bind( + client, *args, **call_kwargs + ) + bound.apply_defaults() + fees = _estimate_deploy_fees( + client, + code=bound.arguments["code"], + account=bound.arguments["account"], + args=bound.arguments["args"], + kwargs=bound.arguments["kwargs"], + leader_only=bound.arguments["leader_only"], + sim_config=bound.arguments["sim_config"], + ) + except Exception as error: + # Error-path tests still need to submit the transaction so + # they can assert its finalized execution failure. + if not _is_expected_simulation_execution_failure(error): + raise + fees = default_fees if fees is not None: - kwargs["fees"] = fees - return original_deploy(client, *args, **kwargs) + call_kwargs["fees"] = fees + return original_deploy(client, *args, **call_kwargs) @wraps(original_write) - def write_with_default_fees(client, *args, **kwargs): - if kwargs.get("fees") is None: - fees = _default_studio_fees(client) + def write_with_default_fees(client, *args, **call_kwargs): + if call_kwargs.get("fees") is None: + fees = None + default_fees = _default_studio_fees(client) + if default_fees is not None: + try: + bound = inspect.signature(original_write).bind( + client, *args, **call_kwargs + ) + bound.apply_defaults() + estimate = client.estimate_transaction_fees_for_write( + address=bound.arguments["address"], + function_name=bound.arguments["function_name"], + account=bound.arguments["account"], + args=bound.arguments["args"], + kwargs=bound.arguments["kwargs"], + value=bound.arguments["value"], + leader_only=bound.arguments["leader_only"], + sim_config=bound.arguments["sim_config"], + ) + fees = _transaction_fees_from_estimate(estimate) + except Exception as error: + if not _is_expected_simulation_execution_failure(error): + raise + fees = default_fees if fees is not None: - kwargs["fees"] = fees - return original_write(client, *args, **kwargs) + call_kwargs["fees"] = fees + return original_write(client, *args, **call_kwargs) patch = pytest.MonkeyPatch() patch.setattr(GenLayerClient, "deploy_contract", deploy_with_default_fees) diff --git a/tests/integration/icontracts/tests/test_call_to_errored_deploy.py b/tests/integration/icontracts/tests/test_call_to_errored_deploy.py index 2d62d2ae6..c0e1294de 100644 --- a/tests/integration/icontracts/tests/test_call_to_errored_deploy.py +++ b/tests/integration/icontracts/tests/test_call_to_errored_deploy.py @@ -15,8 +15,8 @@ from gltest import get_contract_factory from gltest.assertions import tx_execution_failed from gltest.clients import get_gl_client +from gltest.types import TransactionStatus from gltest.utils import extract_contract_address -from genlayer_py.types import TransactionStatus from backend.node.types import ExecutionResultStatus import pytest diff --git a/tests/integration/test_deploy_reroute_to.py b/tests/integration/test_deploy_reroute_to.py index bf0268cfb..1b0c322f7 100644 --- a/tests/integration/test_deploy_reroute_to.py +++ b/tests/integration/test_deploy_reroute_to.py @@ -36,10 +36,11 @@ _prepare_transaction, ) from genlayer_py.contracts.utils import make_calldata_object +from genlayer_py.transactions.fees import normalize_transaction_fees from genlayer_py.types.transactions import TransactionHashVariant from web3.constants import ADDRESS_ZERO -RPC_URL = "http://localhost:4000/api" +RPC_URL = os.environ.get("TEST_JSONRPC_URL", "http://localhost:4000/api") # Executor version directory contract B is pinned to. Must exist under the # GenVM manager's executors path in the image under test. @@ -159,7 +160,7 @@ def read_peer_storage(self) -> str: CLIENT.local_account = ACCOUNT -def rpc_call(method: str, params): +def rpc_call(method: str, params, *, allow_not_found: bool = False): response = requests.post( RPC_URL, json={"jsonrpc": "2.0", "method": method, "params": params, "id": 1}, @@ -167,6 +168,12 @@ def rpc_call(method: str, params): ) result = response.json() if "error" in result: + error = result["error"] + if allow_not_found and ( + error.get("code") == -32001 + or "not found" in str(error.get("message", "")).lower() + ): + return None raise AssertionError(f"RPC error on {method}: {result['error']}") return result.get("result") @@ -188,7 +195,7 @@ def _wait_for_tx(tx_hash: str, timeout: int = 240) -> dict: start = time.time() last = None while time.time() - start < timeout: - tx = rpc_call("eth_getTransactionByHash", [tx_hash]) + tx = rpc_call("eth_getTransactionByHash", [tx_hash], allow_not_found=True) last = tx and tx.get("status") if last == "FINALIZED": return tx @@ -214,18 +221,21 @@ def _deploy(code: str, args: list, reroute_to: str | None = None) -> str: False, # leader_only ] ) + transaction_fees = normalize_transaction_fees(CLIENT.estimate_transaction_fees()) encoded_data = _encode_add_transaction_data( self=CLIENT, sender_account=ACCOUNT, recipient=ADDRESS_ZERO, consensus_max_rotations=CLIENT.chain.default_consensus_max_rotations, data=data, + transaction_fees=transaction_fees, ) transaction = _prepare_transaction( self=CLIENT, sender=ACCOUNT.address, recipient=CLIENT.chain.consensus_main_contract["address"], data=encoded_data, + value=transaction_fees["fee_value"] or 0, ) signed = ACCOUNT.sign_transaction(transaction) From c754fff17dea600806ddcdeb3e0d2c23d2558daa Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 31 Aug 2026 13:21:18 +0100 Subject: [PATCH 35/55] fix(studio): close v0.123 correctness gaps --- backend/consensus/base.py | 35 ++++++--------- backend/consensus/constants.py | 1 + backend/consensus/errors.py | 2 + backend/consensus/vrf.py | 15 +++++-- backend/database_handler/accounts_manager.py | 8 ++++ backend/protocol_rpc/transactions_parser.py | 18 +++++--- ...cute_transfer_undetermined_finalization.py | 19 +++++++- tests/unit/test_transactions_parser.py | 45 +++++++++++++++++++ tests/unit/test_vrf.py | 30 ++++++++++++- 9 files changed, 139 insertions(+), 34 deletions(-) create mode 100644 backend/consensus/constants.py create mode 100644 backend/consensus/errors.py diff --git a/backend/consensus/base.py b/backend/consensus/base.py index f3886b5ec..8403c18a8 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -1,6 +1,5 @@ # backend/consensus/base.py -DEFAULT_VALIDATORS_COUNT = 5 ACTIVATED_TRANSACTION_TIMEOUT = 900 MAX_IDLE_REPLACEMENTS = 5 DEFAULT_EXEC_TIMEOUT_SECONDS = 600 @@ -20,6 +19,8 @@ from eth_utils import is_address, keccak, to_bytes, to_checksum_address from sqlalchemy import text from sqlalchemy.orm import Session +from backend.consensus.constants import DEFAULT_VALIDATORS_COUNT +from backend.consensus.errors import NoValidatorsAvailableError from backend.consensus.vrf import get_validators_for_transaction from backend.database_handler.chain_snapshot import ChainSnapshot from backend.database_handler.contract_snapshot import ContractSnapshot @@ -162,10 +163,6 @@ def _validators_in_frozen_selection_pool( ] -class NoValidatorsAvailableError(Exception): - """Raised when no validators are available to process a transaction.""" - - class InternalMessageEmissionError(RuntimeError): """Raised when the helper EVM did not durably accept a message phase.""" @@ -782,13 +779,14 @@ async def execute_transfer( is_triggered = transaction.triggered_by_hash is not None if not is_triggered and transaction.from_address is not None: - # Get the balance of the sender account - from_balance = accounts_manager.get_account_balance( - transaction.from_address + # The finalization workers use independent database sessions. A + # read/check/write balance update lets concurrent transfers spend + # the same stale balance. Keep the sufficiency check and debit in + # one conditional SQL update instead. + debited = accounts_manager.debit_account_balance( + transaction.from_address, transaction.value ) - - # Check if the sender has enough balance - if from_balance < transaction.value: + if not debited: # UNDETERMINED is finalization-eligible: claim_next_finalization # filters on timestamp_awaiting_finalization IS NOT NULL. # Without this stamp the row strands forever (16 such rows @@ -805,18 +803,11 @@ async def execute_transfer( return - # Update the balance of the sender account - accounts_manager.update_account_balance( - transaction.from_address, from_balance - transaction.value - ) - if transaction.to_address is not None: - # Get the balance of the recipient account - to_balance = accounts_manager.get_account_balance(transaction.to_address) - - # Update the balance of the recipient account - accounts_manager.update_account_balance( - transaction.to_address, to_balance + transaction.value + # Atomic increment prevents concurrent credits from overwriting + # each other when they target the same recipient. + accounts_manager.credit_account_balance( + transaction.to_address, transaction.value ) # Mark the tx as credited so a later retry (or duplicate sync path) diff --git a/backend/consensus/constants.py b/backend/consensus/constants.py new file mode 100644 index 000000000..b2ae6410d --- /dev/null +++ b/backend/consensus/constants.py @@ -0,0 +1 @@ +DEFAULT_VALIDATORS_COUNT = 5 diff --git a/backend/consensus/errors.py b/backend/consensus/errors.py new file mode 100644 index 000000000..4597cafc5 --- /dev/null +++ b/backend/consensus/errors.py @@ -0,0 +1,2 @@ +class NoValidatorsAvailableError(Exception): + """Raised when no validators are available to process a transaction.""" diff --git a/backend/consensus/vrf.py b/backend/consensus/vrf.py index 55c953600..96ecf942f 100644 --- a/backend/consensus/vrf.py +++ b/backend/consensus/vrf.py @@ -1,23 +1,32 @@ -from datetime import datetime import numpy as np -from backend.consensus.base import DEFAULT_VALIDATORS_COUNT +from numpy.random import Generator as RNG + +from backend.consensus.constants import DEFAULT_VALIDATORS_COUNT +from backend.consensus.errors import NoValidatorsAvailableError def get_validators_for_transaction( nodes: list[dict], num_validators: int | None = None, - rng=np.random.default_rng(seed=int(datetime.now().timestamp())), + rng: RNG | None = None, ) -> list[dict]: """ Returns subset of validators for a transaction. The selelction and order is given by a random sampling based on the stake of the validators. """ + if rng is None: + rng = np.random.default_rng() + if num_validators is None: num_validators = DEFAULT_VALIDATORS_COUNT num_validators = min(num_validators, len(nodes)) total_stake = sum(validator["stake"] for validator in nodes) + if total_stake <= 0: + raise NoValidatorsAvailableError( + "Cannot select validators: total validator stake must be positive." + ) probabilities = [validator["stake"] / total_stake for validator in nodes] selected_validators = rng.choice( diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index 6a010b338..392ea78aa 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -114,6 +114,10 @@ def debit_account_balance(self, account_address: str, amount: int) -> bool: """Atomic conditional debit. Returns False if insufficient balance.""" if amount <= 0: return True + try: + account_address = to_checksum_address(account_address) + except Exception: + pass result = self.session.execute( text( "UPDATE current_state SET balance = balance - :amount " @@ -127,6 +131,10 @@ def credit_account_balance(self, account_address: str, amount: int): """Atomic credit. Creates account if it doesn't exist.""" if amount <= 0: return + try: + account_address = to_checksum_address(account_address) + except Exception: + pass self.session.execute( text( "INSERT INTO current_state (id, data, balance) " diff --git a/backend/protocol_rpc/transactions_parser.py b/backend/protocol_rpc/transactions_parser.py index 039dff817..f2c4dbc73 100644 --- a/backend/protocol_rpc/transactions_parser.py +++ b/backend/protocol_rpc/transactions_parser.py @@ -697,17 +697,21 @@ def decode_method_send_data(self, data: str) -> DecodedMethodSendData: def decode_method_call_data(self, data: str) -> DecodedMethodCallData: raw_bytes = eth_utils.hexadecimal.decode_hex(data) - # Remove the null byte - if raw_bytes[-1] == 0: + # Newer clients send rlp([calldata, leader_only]). The boolean is a + # single literal byte in this encoding, for both false and true. + if len(raw_bytes) > 1 and raw_bytes[-1] in (0, 1): raw_bytes = raw_bytes[:-1] - # Try to decode the outer list first - if raw_bytes[0] >= 0xF8: # Long list - raw_bytes = raw_bytes[2:] # Skip list prefix and length - elif raw_bytes[0] >= 0xC0: # Short list + # Strip the complete outer list header. For a long list the prefix + # encodes the number of following length bytes; the header grows + # from two to three bytes once the payload reaches 256 bytes. + prefix = raw_bytes[0] + if prefix >= 0xF8: + raw_bytes = raw_bytes[1 + (prefix - 0xF7) :] + elif prefix >= 0xC0: # Short list raw_bytes = raw_bytes[1:] # Skip list prefix - # Now try to decode the inner string + # Now decode the inner calldata string. raw_bytes = rlp.decode(raw_bytes) return DecodedMethodCallData(raw_bytes) diff --git a/tests/db-sqlalchemy/test_execute_transfer_undetermined_finalization.py b/tests/db-sqlalchemy/test_execute_transfer_undetermined_finalization.py index 6b48376ae..87021b9ff 100644 --- a/tests/db-sqlalchemy/test_execute_transfer_undetermined_finalization.py +++ b/tests/db-sqlalchemy/test_execute_transfer_undetermined_finalization.py @@ -22,7 +22,6 @@ ) from backend.domain.types import Transaction, TransactionType, TransactionExecutionMode - SENDER = "0x" + "ab" * 20 RECIPIENT = "0x" + "cd" * 20 @@ -146,3 +145,21 @@ async def test_sufficient_balance_send_finalizes_without_undetermined( {"h": tx_hash}, ).one() assert row.status == "FINALIZED" + assert am.get_account_balance(SENDER) == 4000 + assert am.get_account_balance(RECIPIENT) == 1000 + + +def test_atomic_balance_helpers_normalize_addresses_and_accumulate(session: Session): + am = AccountsManager(session) + + am.update_account_balance(SENDER, 1000) + session.commit() + + assert am.debit_account_balance(SENDER.lower(), 400) is True + assert am.debit_account_balance(SENDER.lower(), 700) is False + am.credit_account_balance(RECIPIENT.lower(), 250) + am.credit_account_balance(RECIPIENT.lower(), 350) + session.commit() + + assert am.get_account_balance(SENDER) == 600 + assert am.get_account_balance(RECIPIENT) == 600 diff --git a/tests/unit/test_transactions_parser.py b/tests/unit/test_transactions_parser.py index e2c618ba5..baec83f6a 100644 --- a/tests/unit/test_transactions_parser.py +++ b/tests/unit/test_transactions_parser.py @@ -720,6 +720,51 @@ def test_decode_signed_transaction_submit_appeal_uses_value_as_bond(monkeypatch) assert decoded.data.top_up_and_submit is False +# --- decode_method_call_data ------------------------------------------------- +# Wire format produced by genlayer-js readContract / eth_call is +# rlp([calldata, leader_only]); see genlayer-js src/contracts/actions.ts. + + +def _call_wire(calldata_bytes: bytes, leader_only: bool = False) -> str: + payload = encode([calldata_bytes, b"\x01" if leader_only else b"\x00"]) + return "0x" + payload.hex() + + +def _opaque_calldata(size: int) -> bytes: + return bytes([(3 << 3) | 6]) + b"\x41" * (size - 1) + + +@pytest.mark.parametrize("size", [1, 5, 100, 252, 253, 300, 1000, 70000]) +def test_decode_method_call_data_roundtrip(transaction_parser, size): + calldata_bytes = _opaque_calldata(size) + decoded = transaction_parser.decode_method_call_data(_call_wire(calldata_bytes)) + assert decoded.calldata == calldata_bytes + + +@pytest.mark.parametrize("size", [5, 253, 1000]) +def test_decode_method_call_data_leader_only(transaction_parser, size): + calldata_bytes = _opaque_calldata(size) + decoded = transaction_parser.decode_method_call_data( + _call_wire(calldata_bytes, leader_only=True) + ) + assert decoded.calldata == calldata_bytes + + +def test_decode_method_call_data_long_list_header_boundary(transaction_parser): + # RLP switches from a 2-byte to a 3-byte list header at 256 payload bytes, + # which lands between these two calldata sizes. + for size in (252, 253): + calldata_bytes = _opaque_calldata(size) + decoded = transaction_parser.decode_method_call_data(_call_wire(calldata_bytes)) + assert decoded.calldata == calldata_bytes + + +@pytest.mark.parametrize("data", ["0x", "0x00", "0x01"]) +def test_decode_method_call_data_degenerate_input(transaction_parser, data): + # Too short to be a wrapped payload; must not raise. + transaction_parser.decode_method_call_data(data) + + def test_decode_latest_submit_appeal_preserves_expected_decision_id(monkeypatch): monkeypatch.setattr( "backend.protocol_rpc.transactions_parser.Account.recover_transaction", diff --git a/tests/unit/test_vrf.py b/tests/unit/test_vrf.py index 95473770e..925f91854 100644 --- a/tests/unit/test_vrf.py +++ b/tests/unit/test_vrf.py @@ -1,6 +1,10 @@ -from backend.consensus.vrf import get_validators_for_transaction from unittest.mock import Mock +import pytest + +from backend.consensus.errors import NoValidatorsAvailableError +from backend.consensus.vrf import get_validators_for_transaction + def list_of_dicts_to_set(list_of_dicts: list[dict]) -> set: return set(map(lambda x: tuple(x.items()), list_of_dicts)) @@ -66,3 +70,27 @@ def choice_mock(a, p, size, replace): rng.choice.assert_called_once() assert validators == [{"stake": 3}, {"stake": 2}, {"stake": 1}] + + +def test_get_validators_for_transaction_rejects_non_positive_total_stake(): + nodes = [{"stake": 0}, {"stake": 0}, {"stake": 0}] + + with pytest.raises(NoValidatorsAvailableError, match="must be positive"): + get_validators_for_transaction(nodes, 2) + + +def test_get_validators_for_transaction_creates_rng_per_call(monkeypatch): + nodes = [{"stake": 1}, {"stake": 2}] + first_rng = Mock() + second_rng = Mock() + first_rng.choice.return_value = [nodes[0]] + second_rng.choice.return_value = [nodes[1]] + default_rng = Mock(side_effect=[first_rng, second_rng]) + monkeypatch.setattr("backend.consensus.vrf.np.random.default_rng", default_rng) + + assert get_validators_for_transaction(nodes, 1) == [nodes[0]] + assert get_validators_for_transaction(nodes, 1) == [nodes[1]] + + assert default_rng.call_count == 2 + first_rng.choice.assert_called_once() + second_rng.choice.assert_called_once() From 935ea435dc3d57e5b21e22c33e7cb5ee0e6ad955 Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 31 Aug 2026 13:31:14 +0100 Subject: [PATCH 36/55] fix(rpc): hide unexpected server details --- backend/protocol_rpc/fastapi_server.py | 10 +++++++--- tests/unit/test_rate_limit_middleware.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/backend/protocol_rpc/fastapi_server.py b/backend/protocol_rpc/fastapi_server.py index c797e8b60..0296bf6d4 100644 --- a/backend/protocol_rpc/fastapi_server.py +++ b/backend/protocol_rpc/fastapi_server.py @@ -1,5 +1,6 @@ # backend/protocol_rpc/fastapi_server.py +import logging import os from contextlib import asynccontextmanager from typing import Any @@ -31,6 +32,7 @@ install_log_redaction() +logger = logging.getLogger(__name__) SENTRY_DSN = os.getenv("SENTRY_DSN", None) if SENTRY_DSN: @@ -134,12 +136,14 @@ async def jsonrpc_endpoint( return await rpc_router.handle_http_request(request) except ClientDisconnect: return Response(status_code=204) - except Exception as exc: - # Ensure JSON-RPC compliant error response instead of framework HTML pages + except Exception: + # Preserve the unexpected exception server-side without disclosing + # database, provider, or infrastructure details to an RPC caller. + logger.exception("Unhandled JSON-RPC request failure") error = { "code": -32603, "message": "Internal error", - "data": {"detail": str(exc)}, + "data": {"detail": "An unexpected server error occurred."}, } return JSONResponse(content={"jsonrpc": "2.0", "error": error, "id": None}) diff --git a/tests/unit/test_rate_limit_middleware.py b/tests/unit/test_rate_limit_middleware.py index 4d7ef74d0..144de0324 100644 --- a/tests/unit/test_rate_limit_middleware.py +++ b/tests/unit/test_rate_limit_middleware.py @@ -1,6 +1,7 @@ """Unit tests for RateLimitMiddleware.""" import json +import logging import pytest from unittest.mock import AsyncMock, MagicMock @@ -117,6 +118,27 @@ def test_rpc_app_cors_wraps_rate_limiting_without_credentials(): assert rate_limit_class is RateLimitMiddleware +@pytest.mark.asyncio +async def test_rpc_app_hides_unhandled_exception_details(caplog): + from backend.protocol_rpc.fastapi_server import jsonrpc_endpoint + + leaked_detail = "postgresql://admin:secret@database/internal" + rpc_router = AsyncMock() + rpc_router.handle_http_request.side_effect = RuntimeError(leaked_detail) + + with caplog.at_level(logging.ERROR, logger="backend.protocol_rpc.fastapi_server"): + response = await jsonrpc_endpoint(MagicMock(), rpc_router=rpc_router) + + payload = json.loads(response.body.decode()) + assert payload["error"] == { + "code": -32603, + "message": "Internal error", + "data": {"detail": "An unexpected server error occurred."}, + } + assert leaked_detail not in response.body.decode() + assert "Unhandled JSON-RPC request failure" in caplog.text + + class TestMiddlewareRateLimiting: @pytest.mark.asyncio async def test_allows_when_under_limit(self): From 6b5994c53cfabfb9e351775211b9f90a83bf6c71 Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 31 Aug 2026 15:18:22 +0100 Subject: [PATCH 37/55] fix(studio): let fee estimation measure full execution --- backend/protocol_rpc/endpoints.py | 26 +++++++++++++++---- .../unit/test_contract_not_found_handling.py | 7 ++++- tests/unit/test_studio_fees.py | 24 +++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 03574a7aa..a2bf03de6 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -50,6 +50,7 @@ from backend.protocol_rpc.transactions_parser import TransactionParser from backend.protocol_rpc.fees import ( FEE_ACCOUNTING_KEY, + GENVM_UNMETERED_DATA_FEE_BUCKET, VALIDATORS_PER_ROUND, FeeValidationError, StudioFeePolicy, @@ -1642,8 +1643,12 @@ async def _gen_call_with_validator( sender=from_address, user_value=call_value, ) + unmeter_execution_for_fee_estimate = bool( + params.get("_allow_low_execution_budget_for_estimate") + ) genvm_fee_accounting = _effective_simulation_fee_accounting_for_genvm( - simulation_fee_accounting + simulation_fee_accounting, + unmeter_execution=unmeter_execution_for_fee_estimate, ) if not accounts_manager.is_valid_address(from_address): raise InvalidAddressError(from_address) @@ -1812,7 +1817,8 @@ async def execute(active_fee_accounting): policy, ) genvm_fee_accounting = _effective_simulation_fee_accounting_for_genvm( - simulation_fee_accounting + simulation_fee_accounting, + unmeter_execution=unmeter_execution_for_fee_estimate, ) receipt = await execute(genvm_fee_accounting) except ContractNotFoundError as e: @@ -2923,6 +2929,8 @@ def _simulation_fee_accounting( def _effective_simulation_fee_accounting_for_genvm( accounting: dict | None, + *, + unmeter_execution: bool = False, ) -> dict | None: if not accounting: return accounting @@ -2937,9 +2945,17 @@ def _effective_simulation_fee_accounting_for_genvm( execution_budget_per_round = int(fees["executionBudgetPerRound"]) # Consensus admits against the proposal-only receipt floor, while GenVM # reserves proposal/reveal/nondeterministic-output start costs before user - # code. Estimation must temporarily satisfy the latter or it cannot run far - # enough to recommend a realistic budget. - floor = policy.genvm_start_budget_floor() + # code. A fee estimate must additionally let the complete simulated write + # run so it can observe the required budget; capping that measurement at + # the startup floor makes any storage write fail exactly at the floor and + # prevents the estimator from returning its higher recommendation. Keep + # message allocations/budgets exact, but make the parent's execution bucket + # unmetered for this read-only measurement pass. + floor = ( + GENVM_UNMETERED_DATA_FEE_BUCKET + if unmeter_execution + else policy.genvm_start_budget_floor() + ) if execution_budget_per_round <= 0 or execution_budget_per_round >= floor: return accounting diff --git a/tests/unit/test_contract_not_found_handling.py b/tests/unit/test_contract_not_found_handling.py index a7484fce2..816432784 100644 --- a/tests/unit/test_contract_not_found_handling.py +++ b/tests/unit/test_contract_not_found_handling.py @@ -328,6 +328,7 @@ async def test_fee_estimate_discovery_restarts_from_a_fresh_snapshot(self): "data": "0x1234", "to": "0x" + "ab" * 20, "from": "0x" + "cd" * 20, + "_allow_low_execution_budget_for_estimate": True, "_discover_message_allocations_for_estimate": True, } @@ -345,7 +346,7 @@ async def test_fee_estimate_discovery_restarts_from_a_fresh_snapshot(self): ), patch( "backend.protocol_rpc.endpoints._effective_simulation_fee_accounting_for_genvm", side_effect=[{"initial": True}, exact_genvm_accounting], - ), patch( + ) as effective_accounting, patch( "backend.protocol_rpc.endpoints.fee_accounting_with_discovered_messages", return_value=rebuilt_accounting, ), patch( @@ -379,6 +380,10 @@ async def test_fee_estimate_discovery_restarts_from_a_fresh_snapshot(self): metered_node.run_contract.await_args.kwargs["fee_accounting"] == exact_genvm_accounting ) + assert all( + call.kwargs["unmeter_execution"] is True + for call in effective_accounting.call_args_list + ) class TestEthCallContractNotFoundHandling: diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index d8066f57a..b0eead61d 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -48,6 +48,7 @@ get_transaction_status_details, get_transaction_status, _stage_simulated_call_value, + _effective_simulation_fee_accounting_for_genvm, _simulation_fee_accounting, _validate_fee_envelope, _with_default_simulation_fees, @@ -2206,6 +2207,29 @@ def test_default_simulation_fees_are_injected_for_fee_estimation(): assert _with_default_simulation_fees(caller_fees) == caller_fees +def test_fee_estimation_unmeters_only_the_genvm_execution_bucket(): + fees_distribution = _fees_distribution(execution_budget_per_round=1) + allocation = _allocation(budget=55) + accounting = { + "fees_distribution": fees_distribution, + "execution_budget_total": 1, + "message_fee_budget": 55, + "message_allocations": [allocation], + } + + adjusted = _effective_simulation_fee_accounting_for_genvm( + accounting, + unmeter_execution=True, + ) + + assert accounting["fees_distribution"]["executionBudgetPerRound"] == 1 + assert adjusted is not accounting + assert adjusted["fees_distribution"]["executionBudgetPerRound"] == (1 << 256) - 1 + assert adjusted["execution_budget_total"] == (1 << 256) - 1 + assert adjusted["message_fee_budget"] == 55 + assert adjusted["message_allocations"] == [allocation] + + @pytest.mark.asyncio async def test_sim_estimate_transaction_fees_returns_scenario_report_and_preset( monkeypatch, From df8b09d6e63a9424e62e0215233e5a23a8f3161d Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 31 Aug 2026 16:28:14 +0100 Subject: [PATCH 38/55] fix(fees): align Studio with GenVM fee buckets Treat GenVM v0.3 bucket 0 as the shared execution reservoir, bucket 1 as message fees, and buckets 2/3 as byte counters. Reconstruct the exact GenVM receipt meter to derive Consensus storage/event-write fees, and size recommendations for both chargeable execution and the enforced shared reservoir. --- .../transactions_processor.py | 13 +- backend/protocol_rpc/fees.py | 219 ++++++++++++++---- .../components/Simulator/TransactionItem.vue | 43 +++- frontend/src/types/responses.ts | 8 + .../TransactionItem.fees.behavioral.test.ts | 22 +- tests/unit/test_studio_fees.py | 194 ++++++++++++---- 6 files changed, 391 insertions(+), 108 deletions(-) diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index e88d725bf..0eef825c8 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -429,10 +429,21 @@ def _leader_receipt(consensus_data: dict | None) -> dict | None: @staticmethod def _storage_fee_used(accounting: dict) -> int: report = accounting.get("execution_fee_report") or {} + chargeable = ( + report.get("chargeableExecution") if isinstance(report, dict) else {} + ) + if isinstance(chargeable, dict) and chargeable.get("storage") is not None: + return int(chargeable.get("storage", 0) or 0) + + chargeable_buckets = accounting.get("execution_fee_consumed_buckets") or [] + if len(chargeable_buckets) > 1: + return int(chargeable_buckets[1]) + + # Legacy Studio receipts exposed storage as a distinct raw GenVM + # bucket. Retain this final fallback for persisted pre-v0.123 data. genvm_buckets = report.get("genvmBuckets") if isinstance(report, dict) else {} if isinstance(genvm_buckets, dict): return int(genvm_buckets.get("storage", 0) or 0) - consumed_buckets = accounting.get("genvm_fee_consumed_buckets") or [] if len(consumed_buckets) > 1: return int(consumed_buckets[1]) diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 65686b979..88e82528d 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -106,6 +106,17 @@ DEFAULT_MIN_COMMIT_TIMEUNITS = 30 DEFAULT_MAX_COMMIT_TIMEUNITS = 600 GENVM_UNMETERED_DATA_FEE_BUCKET = (1 << 256) - 1 +# GenVM v0.3's fee config deliberately shares one reservoir for all execution +# costs. The remaining fee bucket and two metadata counters are stable parts of +# the manager API pinned in third_party/genvm/version: +# 0: storage + receipt + nondeterministic output + event costs +# 1: outbound-message fee reservations +# 2: raw nondeterministic-output bytes (metadata, not a fee) +# 3: canonical SubmittedMessage payload bytes (metadata, not a fee) +GENVM_EXECUTION_FEE_BUCKET = 0 +GENVM_MESSAGE_FEE_BUCKET = 1 +GENVM_NONDET_OUTPUT_BYTES_BUCKET = 2 +GENVM_SUBMITTED_MESSAGE_BYTES_BUCKET = 3 class FeeValidationError(ValueError): @@ -3078,7 +3089,7 @@ def record_execution_fee_consumption( policy, receipt, ) - execution_bucket_report = _genvm_fee_bucket_report( + execution_bucket_report = _chargeable_execution_bucket_report( execution_consumed, execution_budget_per_round=_execution_budget_per_round(updated), ) @@ -3103,8 +3114,8 @@ def record_execution_fee_consumption( updated["execution_fee_report"][ "budgetExhaustionReason" ] = budget_exhaustion_reason - if len(consumed) > 2: - updated["genvm_message_fee_consumed"] = int(consumed[2]) + if len(consumed) > GENVM_MESSAGE_FEE_BUCKET: + updated["genvm_message_fee_consumed"] = int(consumed[GENVM_MESSAGE_FEE_BUCKET]) _attach_message_fee_accounting_report(updated) _attach_recommended_fee_preset(updated, policy) return updated @@ -3386,8 +3397,11 @@ def _record_historical_execution_fee_consumption( ) final_consumed = _receipt_data_fees_consumed(receipt) + final_fee_report = ( + _receipt_fee_report(receipt, policy) if final_consumed is not None else None + ) storage_fee = ( - _chargeable_storage_fee(receipt, final_consumed) + _chargeable_storage_fee(receipt, final_consumed, final_fee_report) if final_consumed is not None else _bucket_value( list(updated.get("execution_fee_consumed_buckets") or []), @@ -5474,9 +5488,7 @@ def fee_accounting_with_discovered_messages( def _execution_fee_buckets(consumed: list[int]) -> list[int]: - if len(consumed) <= 2: - return consumed - return consumed[:2] + return [_bucket_value(consumed, GENVM_EXECUTION_FEE_BUCKET)] def _chargeable_execution_fee_buckets( @@ -5485,23 +5497,34 @@ def _chargeable_execution_fee_buckets( policy: StudioFeePolicy, receipt: Any | None = None, ) -> list[int]: - storage_fee = _chargeable_storage_fee(receipt, consumed) - if policy.receipt_gas_price <= 0 or not isinstance(fee_report, dict): + if not isinstance(fee_report, dict): return [ - _bucket_value(consumed, 0), - storage_fee, + _bucket_value(consumed, GENVM_EXECUTION_FEE_BUCKET), + 0, ] return [ _receipt_report_chargeable_fee(fee_report), - storage_fee, + _chargeable_storage_fee(receipt, consumed, fee_report), ] -def _chargeable_storage_fee(receipt: Any | None, consumed: list[int]) -> int: +def _chargeable_storage_fee( + receipt: Any | None, + consumed: list[int], + fee_report: dict[str, Any] | None = None, +) -> int: if receipt is not None and not _receipt_execution_allows_messages(receipt): return 0 - return _bucket_value(consumed, 1) + shared_execution = _bucket_value(consumed, GENVM_EXECUTION_FEE_BUCKET) + if not isinstance(fee_report, dict): + return shared_execution + # GenVM v0.3 shares receipt and storage charges in bucket 0. Its complete + # receipt charge includes the always-reserved empty message-reveal cost, + # whereas Consensus charges only the proposal and any actual reveal. The + # remainder is therefore the storage/event-write fee supplied to Consensus. + genvm_receipt_fee = int(fee_report.get("totalStudioMeteredFee", 0) or 0) + return max(0, shared_execution - genvm_receipt_fee) def _receipt_report_chargeable_fee(fee_report: dict[str, Any]) -> int: @@ -5533,35 +5556,52 @@ def _genvm_fee_bucket_report( *, execution_budget_per_round: int = 0, ) -> dict[str, Any]: - receipt_and_nondet_output = _bucket_value(consumed, 0) - storage = _bucket_value(consumed, 1) - message = _bucket_value(consumed, 2) - total_execution = receipt_and_nondet_output + storage - buckets = [ - { - "index": 0, - "name": "receiptAndNondetOutput", - "consumed": receipt_and_nondet_output, - }, - {"index": 1, "name": "storage", "consumed": storage}, - ] - if len(consumed) > 2: - buckets.append({"index": 2, "name": "message", "consumed": message}) + execution = _bucket_value(consumed, GENVM_EXECUTION_FEE_BUCKET) + message = _bucket_value(consumed, GENVM_MESSAGE_FEE_BUCKET) + nondet_output_bytes = _bucket_value(consumed, GENVM_NONDET_OUTPUT_BYTES_BUCKET) + submitted_message_bytes = _bucket_value( + consumed, GENVM_SUBMITTED_MESSAGE_BYTES_BUCKET + ) + buckets = [] + bucket_definitions = ( + (GENVM_EXECUTION_FEE_BUCKET, "execution", "fee"), + (GENVM_MESSAGE_FEE_BUCKET, "message", "fee"), + ( + GENVM_NONDET_OUTPUT_BYTES_BUCKET, + "nondeterministicOutputBytes", + "bytes", + ), + ( + GENVM_SUBMITTED_MESSAGE_BYTES_BUCKET, + "submittedMessageBytes", + "bytes", + ), + ) + for index, name, unit in bucket_definitions: + if len(consumed) > index: + buckets.append( + { + "index": index, + "name": name, + "unit": unit, + "consumed": _bucket_value(consumed, index), + } + ) report = { - "receiptAndNondetOutput": receipt_and_nondet_output, - "storage": storage, + "layout": "genvm-v0.3", + "execution": execution, "message": message, - "totalExecution": total_execution, - "totalWithMessage": sum(int(value) for value in consumed), + "nondeterministicOutputBytes": nondet_output_bytes, + "submittedMessageBytes": submitted_message_bytes, + "totalExecution": execution, + "totalWithMessage": execution + message, "buckets": buckets, } - overrun = max(0, total_execution - execution_budget_per_round) + overrun = max(0, execution - execution_budget_per_round) report.update( { "executionBudgetPerRound": execution_budget_per_round, - "executionBudgetRemaining": max( - 0, execution_budget_per_round - total_execution - ), + "executionBudgetRemaining": max(0, execution_budget_per_round - execution), "executionBudgetOverrun": overrun, "executionBudgetExceeded": overrun > 0, } @@ -5569,6 +5609,34 @@ def _genvm_fee_bucket_report( return report +def _chargeable_execution_bucket_report( + consumed: list[int], + *, + execution_budget_per_round: int = 0, +) -> dict[str, Any]: + receipt = _bucket_value(consumed, 0) + storage = _bucket_value(consumed, 1) + total_execution = receipt + storage + overrun = max(0, total_execution - execution_budget_per_round) + return { + "layout": "consensus-chargeable", + "receipt": receipt, + "storage": storage, + "totalExecution": total_execution, + "totalWithMessage": total_execution, + "buckets": [ + {"index": 0, "name": "receipt", "unit": "fee", "consumed": receipt}, + {"index": 1, "name": "storage", "unit": "fee", "consumed": storage}, + ], + "executionBudgetPerRound": execution_budget_per_round, + "executionBudgetRemaining": max( + 0, execution_budget_per_round - total_execution + ), + "executionBudgetOverrun": overrun, + "executionBudgetExceeded": overrun > 0, + } + + def _execution_metering_report( *, chargeable_bucket_report: dict[str, Any], @@ -5671,10 +5739,22 @@ def recommended_fee_preset( execution_floor = max(execution_floor, policy.genvm_start_budget_floor()) observed_execution = _observed_chargeable_execution_fee(accounting, report) + genvm_execution_required = _int_report_field( + ( + report.get("genvmBuckets") + if isinstance(report.get("genvmBuckets"), dict) + else {} + ), + "execution", + ) + observed_budget_requirement = max( + observed_execution, + genvm_execution_required, + ) recommended_execution = int(fees["executionBudgetPerRound"]) - if observed_execution > 0: + if observed_budget_requirement > 0: recommended_execution = max( - _with_padding(observed_execution, padding_bps), + _with_padding(observed_budget_requirement, padding_bps), execution_floor, ) @@ -5713,6 +5793,7 @@ def recommended_fee_preset( "messageBudgetMode": message_budget_mode, "observed": { "executionFee": observed_execution, + "genvmExecutionRequired": genvm_execution_required, "messageFeeBudget": observed_message_budget, "declaredMessageFees": declared_message, "externalMessageReserved": external_reserved, @@ -5832,6 +5913,9 @@ def _receipt_fee_report( receipt_bytes = policy.estimate_propose_receipt_bytes(eq_outputs_length) proposal_gas = policy.estimate_propose_receipt_gas(receipt_bytes) proposal_fee = proposal_gas * policy.receipt_gas_price + empty_message_reveal_fee = ( + policy.estimate_message_reveal_gas(0, 0) * policy.receipt_gas_price + ) report: dict[str, Any] = { "receiptGasPrice": policy.receipt_gas_price, "proposalReceipt": { @@ -5841,7 +5925,11 @@ def _receipt_fee_report( "fee": proposal_fee, }, "totalEstimatedFee": proposal_fee, - "totalStudioMeteredFee": proposal_fee, + # GenVM reserves an empty message reveal at startup even when the + # execution emits no messages. Keep this full meter value separate + # from the lower Consensus charge so shared-bucket storage can be + # recovered exactly. + "totalStudioMeteredFee": proposal_fee + empty_message_reveal_fee, } submitted_messages, message_reports = ( @@ -5881,11 +5969,62 @@ def _receipt_fee_report( "messages": message_reports, } report["totalEstimatedFee"] += consensus_message_fee - report["totalStudioMeteredFee"] += message_fee + report["totalStudioMeteredFee"] = proposal_fee + message_fee + + consumed = _receipt_data_fees_consumed(receipt) + if consumed is not None and len(consumed) > GENVM_SUBMITTED_MESSAGE_BYTES_BUCKET: + report["totalStudioMeteredFee"] = _genvm_receipt_metered_fee( + receipt, + policy, + consumed, + message_count=len(submitted_messages), + ) return report +def _genvm_receipt_metered_fee( + receipt: Any, + policy: StudioFeePolicy, + consumed: list[int], + *, + message_count: int, +) -> int: + """Reproduce the receipt-related part of GenVM v0.3 bucket 0. + + Storage, receipt writes, nondeterministic output, and event writes share + the same enforced reservoir. GenVM's metadata counters let Studio remove + only the receipt portion and pass the remaining storage/event-write charge + to Consensus without weakening the unified cap. + """ + + receipt_fee_per_byte = int(policy.receipt_gas_price) * int( + policy.calldata_gas_per_byte + ) + changed_slot_fee = int(policy.receipt_gas_price) * int(policy.gas_per_changed_slot) + total = int(policy.genvm_start_budget_floor()) + + eq_outputs = _receipt_eq_outputs(receipt) + if eq_outputs: + total += sum( + (64 + ((len(output) + 31) // 32) * 32) * receipt_fee_per_byte + for output in eq_outputs + ) + else: + # Actual v0.3 receipts expose the output values. Keep the byte counter + # as a deterministic fallback for imported or synthetic receipts. + raw_output_bytes = _bucket_value(consumed, GENVM_NONDET_OUTPUT_BYTES_BUCKET) + if raw_output_bytes > 0: + total += (64 + ((raw_output_bytes + 31) // 32) * 32) * receipt_fee_per_byte + + submitted_message_bytes = _bucket_value( + consumed, GENVM_SUBMITTED_MESSAGE_BYTES_BUCKET + ) + total += submitted_message_bytes * receipt_fee_per_byte + total += max(0, int(message_count)) * changed_slot_fee + return total + + def _receipt_eq_blocks_outputs_length(receipt: Any) -> int: genvm_result = _receipt_genvm_result(receipt) if isinstance(genvm_result, dict): diff --git a/frontend/src/components/Simulator/TransactionItem.vue b/frontend/src/components/Simulator/TransactionItem.vue index 5271462fb..6d1692020 100644 --- a/frontend/src/components/Simulator/TransactionItem.vue +++ b/frontend/src/components/Simulator/TransactionItem.vue @@ -137,10 +137,13 @@ const genvmBuckets = computed(() => { ); }); const chargeableBucketRows = computed(() => { - return feeBucketRows(feeReport.value?.chargeableExecution ?? null); + return feeBucketRows( + feeReport.value?.chargeableExecution ?? null, + 'chargeable', + ); }); const genvmBucketRows = computed(() => { - return feeBucketRows(genvmBuckets.value, 'GenVM message meter'); + return feeBucketRows(genvmBuckets.value, 'genvm'); }); const messageFees = computed(() => feeReport.value?.messageFees ?? null); @@ -275,24 +278,47 @@ function formatFeeDistributionValue(label: unknown, value: unknown): string { function feeBucketRows( bucket: StudioExecutionFeeReport['genvmBuckets'] | null | undefined, - messageLabel = 'Message meter', + layout: 'chargeable' | 'genvm', ) { if (!bucket) return []; + const resourceRows = + layout === 'genvm' + ? [ + ['Shared execution meter', bucket.execution, 'fee'], + ['GenVM message meter', bucket.message, 'fee'], + [ + 'Nondeterministic output', + bucket.nondeterministicOutputBytes, + 'bytes', + ], + ['Submitted message payload', bucket.submittedMessageBytes, 'bytes'], + ['Total with message', bucket.totalWithMessage, 'fee'], + ] + : [ + [ + 'Receipt used', + bucket.receipt ?? bucket.receiptAndNondetOutput, + 'fee', + ], + ['Storage/event writes used', bucket.storage, 'fee'], + ]; return [ - ['Receipt/nondet used', bucket.receiptAndNondetOutput, 'fee'], - ['Storage used', bucket.storage, 'fee'], + ...resourceRows, ['Total execution', bucket.totalExecution, 'fee'], ['Execution budget', bucket.executionBudgetPerRound, 'fee'], ['Budget remaining', bucket.executionBudgetRemaining, 'fee'], ['Budget overrun', bucket.executionBudgetOverrun, 'fee'], ['Budget exceeded', bucket.executionBudgetExceeded, 'boolean'], - [messageLabel, bucket.message, 'fee'], - ['Total with message', bucket.totalWithMessage, 'fee'], ] .filter(([, value]) => value !== undefined && value !== null) .map(([label, value, kind]) => ({ label: String(label), - value: kind === 'boolean' ? String(value) : formatFeeAmount(value), + value: + kind === 'boolean' + ? String(value) + : kind === 'bytes' + ? `${formatNumber(value)} bytes` + : formatFeeAmount(value), })); } @@ -400,6 +426,7 @@ const recommendedObservedRows = computed(() => { if (!observed) return []; return [ ['Execution fee', observed.executionFee], + ['GenVM execution required', observed.genvmExecutionRequired], ['Message fee budget', observed.messageFeeBudget], ['Declared message fees', observed.declaredMessageFees], ['External reserved', observed.externalMessageReserved], diff --git a/frontend/src/types/responses.ts b/frontend/src/types/responses.ts index ae9e38cd7..589fde52f 100644 --- a/frontend/src/types/responses.ts +++ b/frontend/src/types/responses.ts @@ -66,13 +66,20 @@ export interface StudioExecutionFeeReportMessage { export interface StudioGenvmFeeBucket { index?: string | number; name?: string; + unit?: 'fee' | 'bytes' | (string & {}); consumed?: string | number; } export interface StudioGenvmFeeBucketReport { + layout?: 'genvm-v0.3' | 'consensus-chargeable' | (string & {}); + execution?: string | number; + receipt?: string | number; + // Legacy pre-v0.123 persisted reports may still contain this field. receiptAndNondetOutput?: string | number; storage?: string | number; message?: string | number; + nondeterministicOutputBytes?: string | number; + submittedMessageBytes?: string | number; totalExecution?: string | number; totalWithMessage?: string | number; executionBudgetPerRound?: string | number; @@ -140,6 +147,7 @@ export interface StudioRecommendedFeePreset { | (string & {}); observed?: { executionFee?: string | number; + genvmExecutionRequired?: string | number; messageFeeBudget?: string | number; declaredMessageFees?: string | number; externalMessageReserved?: string | number; diff --git a/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts b/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts index 231b9bd60..16b13aa1b 100644 --- a/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts +++ b/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts @@ -85,6 +85,7 @@ const feeAccounting = { }, observed: { executionFee: '400000', + genvmExecutionRequired: '400000', messageFeeBudget: '55000000000000000', declaredMessageFees: '55000000000000000', externalMessageReserved: '700', @@ -136,9 +137,11 @@ const feeAccounting = { ], }, genvmBuckets: { - receiptAndNondetOutput: '300000', - storage: '100000', + layout: 'genvm-v0.3', + execution: '400000', message: '1234', + nondeterministicOutputBytes: '2', + submittedMessageBytes: '320', totalExecution: '400000', totalWithMessage: '401234', executionBudgetPerRound: '500000', @@ -147,9 +150,9 @@ const feeAccounting = { executionBudgetExceeded: false, }, chargeableExecution: { - receiptAndNondetOutput: '314416', - storage: '0', - message: '0', + layout: 'consensus-chargeable', + receipt: '314416', + storage: '85584', totalExecution: '400000', totalWithMessage: '400000', executionBudgetPerRound: '500000', @@ -159,8 +162,8 @@ const feeAccounting = { }, executionMetering: { chargeableExecutionFee: '400000', - genvmReportedExecution: '401234', - genvmDeltaFromChargeable: '1234', + genvmReportedExecution: '400000', + genvmDeltaFromChargeable: '0', }, messageFees: { budget: '55000000000000000', @@ -261,7 +264,10 @@ describe('TransactionItem fee accounting display', () => { expect(text).toContain('External executor reimbursed'); expect(text).toContain('Chargeable buckets'); expect(text).toContain('GenVM raw buckets'); - expect(text).toContain('Receipt/nondet used'); + expect(text).toContain('Receipt used'); + expect(text).toContain('Storage/event writes used'); + expect(text).toContain('Shared execution meter'); + expect(text).toContain('Nondeterministic output'); expect(text).toContain('Budget remaining'); expect(text).toContain('Budget exceeded'); expect(text).toContain('false'); diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index b0eead61d..327f8b95c 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -144,6 +144,7 @@ record_reveal_message_fees, refund_failed_external_message_fee, required_fee_deposit, + _receipt_fee_report, settle_fee_accounting, _settlement_storage_recipient_count, stamp_receipt_execution_policy, @@ -2376,14 +2377,16 @@ async def fake_sim_call(**kwargs): { "genvm_result": { "data_fee_bucket_totals": [ - execution_budget, execution_budget, message_budget, + GENVM_UNMETERED_DATA_FEE_BUCKET, + GENVM_UNMETERED_DATA_FEE_BUCKET, ], "data_fees_remaining": [ execution_budget - 80, - execution_budget, 0, + GENVM_UNMETERED_DATA_FEE_BUCKET, + GENVM_UNMETERED_DATA_FEE_BUCKET, ], }, "pending_transactions": [ @@ -3425,21 +3428,23 @@ def test_execution_fee_consumption_derives_spend_from_genvm_bucket_remaining(): ) receipt = { "genvm_result": { - "data_fee_bucket_totals": [100, 80, 60], - "data_fees_remaining": [70, 90, 10], + "data_fee_bucket_totals": [100, 60, 50, 40], + "data_fees_remaining": [70, 10, 50, 40], } } recorded = record_execution_fee_consumption(accounting, receipt) settled, refund = settle_fee_accounting(recorded) - assert recorded["genvm_fee_consumed_buckets"] == [30, 0, 50] - assert recorded["execution_fee_consumed_buckets"] == [30, 0] + assert recorded["genvm_fee_consumed_buckets"] == [30, 50, 0, 0] + assert recorded["execution_fee_consumed_buckets"] == [0, 30] assert recorded["execution_fee_consumed"] == 30 assert recorded["genvm_fee_bucket_report"] == { - "receiptAndNondetOutput": 30, - "storage": 0, + "layout": "genvm-v0.3", + "execution": 30, "message": 50, + "nondeterministicOutputBytes": 0, + "submittedMessageBytes": 0, "totalExecution": 30, "totalWithMessage": 80, "executionBudgetPerRound": 100, @@ -3447,9 +3452,20 @@ def test_execution_fee_consumption_derives_spend_from_genvm_bucket_remaining(): "executionBudgetOverrun": 0, "executionBudgetExceeded": False, "buckets": [ - {"index": 0, "name": "receiptAndNondetOutput", "consumed": 30}, - {"index": 1, "name": "storage", "consumed": 0}, - {"index": 2, "name": "message", "consumed": 50}, + {"index": 0, "name": "execution", "unit": "fee", "consumed": 30}, + {"index": 1, "name": "message", "unit": "fee", "consumed": 50}, + { + "index": 2, + "name": "nondeterministicOutputBytes", + "unit": "bytes", + "consumed": 0, + }, + { + "index": 3, + "name": "submittedMessageBytes", + "unit": "bytes", + "consumed": 0, + }, ], } assert ( @@ -3512,7 +3528,7 @@ def test_execution_fee_consumption_reports_execution_budget_overrun(): ) receipt = { "genvm_result": { - "data_fees_consumed": [80, 35, 0], + "data_fees_consumed": [115, 0, 0, 0], } } @@ -3541,7 +3557,7 @@ def test_execution_fee_consumption_reports_zero_execution_budget_overrun(): ) receipt = { "genvm_result": { - "data_fees_consumed": [1, 0, 0], + "data_fees_consumed": [1, 0, 0, 0], } } @@ -3571,7 +3587,7 @@ def test_execution_fee_report_preserves_genvm_budget_exhaustion_reason(): "genvm_result": { "budgetExhaustionReason": "MessageBudgetExceeded", "error_code": "ExecutionBudgetExceeded", - "data_fees_consumed": [10, 0, 90], + "data_fees_consumed": [10, 90, 0, 0], } } @@ -3595,7 +3611,7 @@ def test_budget_exhaustion_discards_receipt_messages_from_fee_consumption(): receipt = { "genvm_result": { "budgetExhaustionReason": "MessageBudgetExceeded", - "data_fees_consumed": [10, 3, 55], + "data_fees_consumed": [10, 55, 3, 0], }, "pending_transactions": [ { @@ -3620,7 +3636,7 @@ def test_budget_exhaustion_discards_receipt_messages_from_fee_consumption(): report = recorded["execution_fee_report"] assert recorded["message_fee_consumed"] == 0 assert recorded["genvm_message_fee_consumed"] == 55 - assert recorded["execution_fee_consumed_buckets"] == [10, 0] + assert recorded["execution_fee_consumed_buckets"] == [0, 0] assert "message_fees_recorded_from_receipt" not in recorded assert "messageReveal" not in report assert report["budgetExhaustionReason"] == "MessageBudgetExceeded" @@ -3707,8 +3723,8 @@ def test_simulation_fee_consumption_fills_mode2_payload_from_allocation(): ) receipt = { "genvm_result": { - "data_fee_bucket_totals": [1_000, 1_000, 75], - "data_fees_remaining": [920, 1_000, 20], + "data_fee_bucket_totals": [1_000, 75, 100, 100], + "data_fees_remaining": [920, 20, 100, 100], }, "pending_transactions": [ { @@ -3731,8 +3747,8 @@ def test_simulation_fee_consumption_fills_mode2_payload_from_allocation(): ) message = recorded["execution_fee_report"]["messageReveal"]["messages"][0] assert recorded["execution_fee_consumed"] == 80 - assert recorded["execution_fee_consumed_buckets"] == [80, 0] - assert recorded["genvm_fee_consumed_buckets"] == [80, 0, 55] + assert recorded["execution_fee_consumed_buckets"] == [0, 80] + assert recorded["genvm_fee_consumed_buckets"] == [80, 55, 0, 0] assert recorded["genvm_message_fee_consumed"] == 55 assert recorded["message_fee_consumed"] == 75 assert recorded["allocation_consumed"] == {"0": 75} @@ -4323,14 +4339,18 @@ def test_execution_fee_consumption_ignores_genvm_message_reveal_precharge_withou receipt_wrapper_bytes=0, ) proposal_fee = policy.estimate_propose_receipt_gas(0) * policy.receipt_gas_price - reveal_precharge = ( - policy.estimate_message_reveal_gas(0, 0) * policy.receipt_gas_price - ) + genvm_receipt_fee = policy.genvm_start_budget_floor() + genvm_nonchargeable_overhead = genvm_receipt_fee - proposal_fee storage_fee = 15 receipt = { "genvm_result": { "eqBlocksOutputsLength": 0, - "data_fees_consumed": [proposal_fee + reveal_precharge, storage_fee], + "data_fees_consumed": [ + genvm_receipt_fee + storage_fee, + 0, + 0, + 0, + ], } } @@ -4349,11 +4369,57 @@ def test_execution_fee_consumption_ignores_genvm_message_reveal_precharge_withou assert report["genvmBuckets"]["executionBudgetExceeded"] is True assert report["executionMetering"] == { "chargeableExecutionFee": proposal_fee + storage_fee, - "genvmReportedExecution": proposal_fee + reveal_precharge + storage_fee, - "genvmDeltaFromChargeable": reveal_precharge, + "genvmReportedExecution": genvm_receipt_fee + storage_fee, + "genvmDeltaFromChargeable": genvm_nonchargeable_overhead, } +def test_recommended_fee_preset_funds_shared_genvm_execution_with_storage(): + policy = StudioFeePolicy( + gen_per_time_unit=1, + storage_unit_price=1, + receipt_gas_price=2, + intrinsic_gas=10, + bootloader_overhead=20, + gas_per_changed_slot=3, + calldata_gas_per_byte=4, + fixed_propose_receipt_gas=30, + fixed_message_reveal_gas=40, + receipt_wrapper_bytes=0, + ) + fees_distribution = _fees_distribution(execution_budget_per_round=5_000) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5, policy), + user_value=0, + sender="0x1111111111111111111111111111111111111111", + policy=policy, + ) + proposal_fee = policy.estimate_propose_receipt_gas(0) * policy.receipt_gas_price + genvm_receipt_fee = policy.genvm_start_budget_floor() + storage_fee = 10_000 + genvm_execution_required = genvm_receipt_fee + storage_fee + receipt = { + "genvm_result": { + "eqBlocksOutputsLength": 0, + "data_fees_consumed": [genvm_execution_required, 0, 0, 0], + } + } + + recorded = record_execution_fee_consumption(accounting, receipt, policy) + preset = recorded["recommended_fee_preset"] + + assert recorded["execution_fee_consumed"] == proposal_fee + storage_fee + assert recorded["execution_fee_consumed_buckets"] == [proposal_fee, storage_fee] + assert preset["observed"]["executionFee"] == proposal_fee + storage_fee + assert preset["observed"]["genvmExecutionRequired"] == genvm_execution_required + assert ( + preset["distribution"]["executionBudgetPerRound"] + == (genvm_execution_required * DEFAULT_PRICE_CAP_HEADROOM_BPS + 9_999) // 10_000 + ) + + def test_execution_fee_consumption_charges_consensus_message_reveal_fee_only(): accounting = create_fee_accounting( fees_distribution=_fees_distribution(execution_budget_per_round=1_000), @@ -4376,7 +4442,8 @@ def test_execution_fee_consumption_charges_consensus_message_reveal_fee_only(): receipt = { "genvm_result": { "eqBlocksOutputsLength": 0, - "data_fees_consumed": [999, storage_fee], + # calldata dynamic bytes (96) + empty allocation subtree (64) + "data_fees_consumed": [0, 0, 0, 160], }, "pending_transactions": [ { @@ -4390,6 +4457,13 @@ def test_execution_fee_consumption_charges_consensus_message_reveal_fee_only(): } ], } + genvm_receipt_fee = _receipt_fee_report(receipt, policy)["totalStudioMeteredFee"] + receipt["genvm_result"]["data_fees_consumed"] = [ + genvm_receipt_fee + storage_fee, + 0, + 0, + 160, + ] recorded = record_execution_fee_consumption(accounting, receipt, policy) report = recorded["execution_fee_report"] @@ -4399,7 +4473,7 @@ def test_execution_fee_consumption_charges_consensus_message_reveal_fee_only(): assert message_reveal["fee"] > message_reveal["consensusAdditionalFee"] assert report["totalEstimatedFee"] == chargeable_receipt_fee - assert report["totalStudioMeteredFee"] == proposal_fee + message_reveal["fee"] + assert report["totalStudioMeteredFee"] == genvm_receipt_fee assert recorded["execution_fee_consumed_buckets"] == [ chargeable_receipt_fee, storage_fee, @@ -4407,9 +4481,9 @@ def test_execution_fee_consumption_charges_consensus_message_reveal_fee_only(): assert recorded["execution_fee_consumed"] == chargeable_receipt_fee + storage_fee assert report["executionMetering"] == { "chargeableExecutionFee": chargeable_receipt_fee + storage_fee, - "genvmReportedExecution": 999 + storage_fee, + "genvmReportedExecution": genvm_receipt_fee + storage_fee, "genvmDeltaFromChargeable": ( - 999 + storage_fee - chargeable_receipt_fee - storage_fee + genvm_receipt_fee + storage_fee - chargeable_receipt_fee - storage_fee ), } @@ -4535,7 +4609,7 @@ def test_execution_fee_consumption_attaches_padded_recommended_fee_preset(): policy=policy, ) receipt = { - "genvm_result": {"data_fees_consumed": [80, 20, 130]}, + "genvm_result": {"data_fees_consumed": [100, 130, 0, 0]}, "pending_transactions": [ { "messageType": "Internal", @@ -4575,6 +4649,7 @@ def test_execution_fee_consumption_attaches_padded_recommended_fee_preset(): ) assert preset["observed"] == { "executionFee": 100, + "genvmExecutionRequired": 100, "messageFeeBudget": 130, "declaredMessageFees": 130, "externalMessageReserved": 0, @@ -4916,7 +4991,7 @@ def test_execution_fee_consumption_ignores_messages_from_error_receipt(): "execution_result": "ERROR", "genvm_result": { "eq_blocks_outputs_length": 0, - "data_fees_consumed": [12, 34, 56], + "data_fees_consumed": [12, 56, 34, 0], "messageFeesConsumed": 1_000, }, "pending_transactions": [ @@ -4941,16 +5016,16 @@ def test_execution_fee_consumption_ignores_messages_from_error_receipt(): assert recorded["external_message_fee_reserved"] == 0 assert "messageReveal" not in recorded["execution_fee_report"] assert recorded["genvm_message_fee_consumed"] == 56 - assert recorded["execution_fee_consumed_buckets"] == [12, 0] - assert recorded["execution_fee_consumed"] == 12 + assert recorded["execution_fee_consumed_buckets"] == [0, 0] + assert recorded["execution_fee_consumed"] == 0 assert ( - recorded["execution_fee_report"]["chargeableExecution"]["totalExecution"] == 12 + recorded["execution_fee_report"]["chargeableExecution"]["totalExecution"] == 0 ) - assert recorded["execution_fee_report"]["genvmBuckets"]["totalExecution"] == 46 + assert recorded["execution_fee_report"]["genvmBuckets"]["totalExecution"] == 12 assert recorded["execution_fee_report"]["executionMetering"] == { - "chargeableExecutionFee": 12, - "genvmReportedExecution": 46, - "genvmDeltaFromChargeable": 34, + "chargeableExecutionFee": 0, + "genvmReportedExecution": 12, + "genvmDeltaFromChargeable": 12, } @@ -4976,7 +5051,7 @@ def test_execution_fee_consumption_discards_storage_fee_for_error_receipt(): "execution_result": "FinishedWithError", "genvm_result": { "eqBlocksOutputsLength": 5, - "data_fees_consumed": [999, 321], + "data_fees_consumed": [999, 321, 56, 78], }, "pending_transactions": [ { @@ -5000,11 +5075,14 @@ def test_execution_fee_consumption_discards_storage_fee_for_error_receipt(): assert recorded["execution_fee_consumed_buckets"] == [proposal_fee, 0] assert recorded["execution_fee_consumed"] == proposal_fee assert report["chargeableExecution"]["storage"] == 0 - assert report["genvmBuckets"]["storage"] == 321 + assert report["genvmBuckets"]["execution"] == 999 + assert report["genvmBuckets"]["message"] == 321 + assert report["genvmBuckets"]["nondeterministicOutputBytes"] == 56 + assert report["genvmBuckets"]["submittedMessageBytes"] == 78 assert report["executionMetering"] == { "chargeableExecutionFee": proposal_fee, - "genvmReportedExecution": 999 + 321, - "genvmDeltaFromChargeable": 999 + 321 - proposal_fee, + "genvmReportedExecution": 999, + "genvmDeltaFromChargeable": 999 - proposal_fee, } @@ -5365,7 +5443,7 @@ def test_settlement_refunds_storage_division_dust_like_consensus(): settled, refund = settle_fee_accounting( accounting, - receipt={"genvm_result": {"data_fees_consumed": [0, 5]}}, + receipt={"genvm_result": {"data_fees_consumed": [5, 0, 0, 0]}}, actual_final_round=0, num_of_validators=5, consensus_history=history, @@ -5414,7 +5492,7 @@ def test_settlement_refunds_all_storage_when_zero_value_round_tracks_nobody(): settled, refund = settle_fee_accounting( accounting, - receipt={"genvm_result": {"data_fees_consumed": [0, 5]}}, + receipt={"genvm_result": {"data_fees_consumed": [5, 0, 0, 0]}}, actual_final_round=0, num_of_validators=5, consensus_history=history, @@ -6352,7 +6430,16 @@ def attempt(leader_index: int, eq_outputs_length: int) -> tuple[dict, list[dict] first_proposal, first_validators = attempt(0, 0) final_proposal, final_validators = attempt(1, 64) - final_proposal["genvm_result"]["data_fees_consumed"] = [0, 37] + final_proposal["genvm_result"]["data_fees_consumed"] = [0, 0, 0, 0] + final_genvm_receipt_fee = _receipt_fee_report(final_proposal, policy)[ + "totalStudioMeteredFee" + ] + final_proposal["genvm_result"]["data_fees_consumed"] = [ + final_genvm_receipt_fee + 37, + 0, + 0, + 0, + ] history = { "consensus_results": [ { @@ -12898,11 +12985,16 @@ def test_transaction_payload_includes_canonical_fee_object_with_decimal_strings( policy=StudioFeePolicy(storage_unit_price=1, receipt_gas_price=1), allow_low_execution_budget=True, ) - recorded = record_execution_fee_consumption( - accounting, - {"genvm_result": {"data_fees_consumed": [20, 3, 0]}}, - StudioFeePolicy(storage_unit_price=1, receipt_gas_price=1), - ) + policy = StudioFeePolicy(storage_unit_price=1, receipt_gas_price=1) + receipt = {"genvm_result": {"data_fees_consumed": [0, 0, 0, 0]}} + genvm_receipt_fee = _receipt_fee_report(receipt, policy)["totalStudioMeteredFee"] + receipt["genvm_result"]["data_fees_consumed"] = [ + genvm_receipt_fee + 3, + 0, + 0, + 0, + ] + recorded = record_execution_fee_consumption(accounting, receipt, policy) parsed = TransactionsProcessor._parse_transaction_data( _processor_transaction(accounting=recorded, execution_result="SUCCESS") From 1967f26cff2341078d7ff35e2232757edaf2ed40 Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 31 Aug 2026 17:45:42 +0100 Subject: [PATCH 39/55] fix(fees): use canonical allocation wildcard --- backend/node/genvm/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/node/genvm/base.py b/backend/node/genvm/base.py index 0b53d683a..94225caaa 100644 --- a/backend/node/genvm/base.py +++ b/backend/node/genvm/base.py @@ -84,7 +84,9 @@ MESSAGE_TYPE_EXTERNAL = 0 MESSAGE_TYPE_INTERNAL = 1 NODE_ROOT_SENTINEL = (1 << 256) - 1 -CALL_KEY_WILDCARD = "0x" + ("0" * 64) +# Consensus reserves keccak256("") as the wildcard. bytes32(0) is the real +# call key for deploys and unnamed calls, so the two must never be aliased. +CALL_KEY_WILDCARD = "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" @dataclass(frozen=True) From 5e0eefcf012e8bd8e1937be8597df39723e8219e Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 31 Aug 2026 17:58:39 +0100 Subject: [PATCH 40/55] chore(deps): pin corrected fee SDK heads --- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- requirements.test.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d5168b882..7737c2af8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#72be6986379e1aa144bf838c040954ac7e8eef47", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#c8784291daf8df19238f55c466780f63e5b3aef0", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", @@ -11080,7 +11080,7 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+https://github.com/genlayerlabs/genlayer-js.git#72be6986379e1aa144bf838c040954ac7e8eef47", + "resolved": "git+https://github.com/genlayerlabs/genlayer-js.git#c8784291daf8df19238f55c466780f63e5b3aef0", "integrity": "sha512-W8vnalIYLzFrU+PKv2e0aEG19kmNXGX9hApDfScrXC90zI8ijAeKrt+FZrWC52f0hnJrsTkB7S5tdiYYQCZ2tw==", "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index d0b8510f9..860d23cfa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,7 +39,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#72be6986379e1aa144bf838c040954ac7e8eef47", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#c8784291daf8df19238f55c466780f63e5b3aef0", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", diff --git a/requirements.test.txt b/requirements.test.txt index 18f17c82e..dade8bb8c 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -8,6 +8,6 @@ pytest-asyncio==1.3.0 pytest-timeout==2.4.0 # Immutable resolution-kernel train consumers. Replace these with the released # v0.19/v0.30 package versions when the coordinated prerelease is published. -genlayer-py @ git+https://github.com/genlayerlabs/genlayer-py.git@9ee438604c6a4d8e6783b23d61e360aa3405d893 +genlayer-py @ git+https://github.com/genlayerlabs/genlayer-py.git@d127609e7503fddadff016c823e7315ec329cb36 genlayer-test @ git+https://github.com/genlayerlabs/genlayer-testing-suite.git@478d939590cbdbc7c9179e0c2e0013e95059dc36 httpx==0.28.1 From a3a84fef23305ce30ce3397ea08586035392e8d7 Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 31 Aug 2026 23:44:28 +0100 Subject: [PATCH 41/55] chore(deps): repin restacked fee SDK heads --- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- requirements.test.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7737c2af8..e7801c303 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#c8784291daf8df19238f55c466780f63e5b3aef0", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#b9a7bb2305f9b069d79f348d85e62a1b43d50b48", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", @@ -11080,7 +11080,7 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+https://github.com/genlayerlabs/genlayer-js.git#c8784291daf8df19238f55c466780f63e5b3aef0", + "resolved": "git+https://github.com/genlayerlabs/genlayer-js.git#b9a7bb2305f9b069d79f348d85e62a1b43d50b48", "integrity": "sha512-W8vnalIYLzFrU+PKv2e0aEG19kmNXGX9hApDfScrXC90zI8ijAeKrt+FZrWC52f0hnJrsTkB7S5tdiYYQCZ2tw==", "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 860d23cfa..27002a96e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,7 +39,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#c8784291daf8df19238f55c466780f63e5b3aef0", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#b9a7bb2305f9b069d79f348d85e62a1b43d50b48", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", diff --git a/requirements.test.txt b/requirements.test.txt index dade8bb8c..23d0b507c 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -8,6 +8,6 @@ pytest-asyncio==1.3.0 pytest-timeout==2.4.0 # Immutable resolution-kernel train consumers. Replace these with the released # v0.19/v0.30 package versions when the coordinated prerelease is published. -genlayer-py @ git+https://github.com/genlayerlabs/genlayer-py.git@d127609e7503fddadff016c823e7315ec329cb36 +genlayer-py @ git+https://github.com/genlayerlabs/genlayer-py.git@54cdc8501afd606412f516e228d3738d2e01c42e genlayer-test @ git+https://github.com/genlayerlabs/genlayer-testing-suite.git@478d939590cbdbc7c9179e0c2e0013e95059dc36 httpx==0.28.1 From f2d7b904717b73e0743601828ee21074dc95ae57 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 00:46:17 +0100 Subject: [PATCH 42/55] fix(fees): match internal deploy allocations --- backend/consensus/base.py | 10 +++++++++- tests/unit/test_studio_fees.py | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 8403c18a8..dba6526b1 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -4724,6 +4724,14 @@ def _pending_transaction_fee_payload( on: Literal["accepted", "finalized"], ) -> dict[str, Any]: message_type = 0 if pending_transaction.is_eth_send else 1 + # GenVM represents an internal deployment with the abbreviated ``0x`` + # address, while the transaction envelope and Consensus allocation tree + # encode its recipient as address(0). Match against that canonical form; + # otherwise a quote discovered by sim_estimateTransactionFees succeeds in + # GenVM but is rejected during reveal with MessageNoMatchingAllocation. + recipient = ( + ZERO_ADDRESS if pending_transaction.is_deploy() else pending_transaction.address + ) call_key = pending_transaction.call_key if message_type == 0: call_key = derive_external_message_call_key( @@ -4732,7 +4740,7 @@ def _pending_transaction_fee_payload( ) return { "messageType": message_type, - "recipient": pending_transaction.address, + "recipient": recipient, "value": pending_transaction.value, "data": pending_transaction.calldata, "onAcceptance": on == "accepted", diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 327f8b95c..ee0c5f555 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -19,6 +19,7 @@ _dispatch_messages_for_phase, _emit_messages, _get_messages_data, + _pending_transaction_fee_payload, _runtime_rotation_limit, _studio_child_transaction_id, _validators_in_frozen_selection_pool, @@ -9903,6 +9904,41 @@ def test_internal_deployment_descriptor_stays_zero_address_and_carries_salt(): assert pending.address == "0x" +def test_internal_deployment_fee_payload_matches_zero_address_allocation(): + fee_params = _encode_internal_fee_params() + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=55), + message_allocations=[ + _allocation( + recipient="0x0000000000000000000000000000000000000000", + call_key=EMPTY_CALL_KEY, + budget=55, + fee_params=fee_params, + ) + ], + num_of_validators=5, + submitted_value=1155, + user_value=0, + ) + pending = PendingTransaction( + address="0x", + calldata=b"\x12\x34", + code=b"contract source", + salt_nonce=42, + on="accepted", + value=0, + call_key=EMPTY_CALL_KEY, + ) + + payload = _pending_transaction_fee_payload(pending, "accepted") + matched = fill_message_fee_payload_from_allocation(accounting, payload) + + assert payload["recipient"] == "0x0000000000000000000000000000000000000000" + assert payload["callKey"] == EMPTY_CALL_KEY + assert matched["declaredBudget"] == 55 + assert matched["feeParams"] == "0x" + fee_params.hex() + + def test_flat_array_message_dispatch_ignores_bad_receipt_subtree_and_inherits_parent( monkeypatch, ): From 1c0e6e094893c167c5748f7b8e6320f0f880b5e5 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 02:31:00 +0100 Subject: [PATCH 43/55] fix(consensus): bind salted children to GenVM addresses --- backend/consensus/base.py | 29 +++++++++++++++++------ backend/protocol_rpc/ghost_factory.py | 34 ++++++++++++++++++++++++++- tests/unit/test_ghost_factory.py | 22 +++++++++++++++++ tests/unit/test_studio_fees.py | 6 +++-- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index dba6526b1..f64058960 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -40,7 +40,7 @@ LLMProvider, Validator, ) -from backend.node.base import Node +from backend.node.base import Node, get_simulator_chain_id from backend.node.types import ( ExecutionMode, Receipt, @@ -55,7 +55,10 @@ EventScope, ) from backend.protocol_rpc.types import ZERO_ADDRESS -from backend.protocol_rpc.ghost_factory import GhostFactoryConfig +from backend.protocol_rpc.ghost_factory import ( + GhostFactoryConfig, + genvm_salted_child_address, +) from backend.protocol_rpc.fees import ( FEE_ACCOUNTING_KEY, VALIDATORS_PER_ROUND, @@ -4535,11 +4538,23 @@ def _author_message_phase_locally( and transaction_type == TransactionType.DEPLOY_CONTRACT.value ): salt_nonce = int(message_payload.get("saltNonce", 0) or 0) - actual_recipient = factory.address_for( - salt_nonce, - successful_deployments, - namespace=context.transaction.to_address, - ) + if salt_nonce == 0: + actual_recipient = factory.address_for( + salt_nonce, + successful_deployments, + namespace=context.transaction.to_address, + ) + else: + # A salted deployment returns its address during the parent's + # GenVM execution. Bind the accepted child to that exact + # contract-visible identity; independently allocating an EVM + # GhostFactory address leaves the parent pointing at a contract + # that Studio never materialized (GAP-016). + actual_recipient = genvm_salted_child_address( + context.transaction.to_address, + salt_nonce, + get_simulator_chain_id(), + ) normalized = actual_recipient.lower() if ( normalized in batch_deployments diff --git a/backend/protocol_rpc/ghost_factory.py b/backend/protocol_rpc/ghost_factory.py index 35a7db5a2..22ec6a5f0 100644 --- a/backend/protocol_rpc/ghost_factory.py +++ b/backend/protocol_rpc/ghost_factory.py @@ -15,7 +15,6 @@ import rlp from eth_utils import is_address, keccak, to_bytes, to_checksum_address - UINT256_MAX = (1 << 256) - 1 # These identify Studio's virtual v0.6 protocol deployment. Operators that @@ -103,6 +102,39 @@ def address_for( return to_checksum_address("0x" + digest[-20:].hex()) +def genvm_salted_child_address( + contract_address: str, + salt_nonce: int, + chain_id: int, +) -> str: + """Return the address exposed by GenVM for a salted child deployment. + + GenVM makes the address synchronously visible to the parent contract, so + Studio must materialize the child under that exact identity. This is the + runner's public ``create2_address(contract, salt, chain)`` formula; it is + intentionally separate from the EVM GhostFactory formula used to allocate + top-level deployments. + """ + + if not is_address(contract_address): + raise InvalidGhostFactoryConfiguration( + "salted child deployment requires a GenVM contract address" + ) + salt_nonce = _coerce_uint256("salt_nonce", salt_nonce) + if salt_nonce == 0: + raise InvalidGhostFactoryConfiguration( + "salted child deployment requires a non-zero salt_nonce" + ) + chain_id = _coerce_uint256("chain_id", chain_id) + digest = keccak( + b"\x01" + + to_bytes(hexstr=to_checksum_address(contract_address)) + + salt_nonce.to_bytes(32, "big") + + chain_id.to_bytes(32, "big") + ) + return to_checksum_address("0x" + digest[:20].hex()) + + def _configured_address(name: str, default: str) -> str: value = os.getenv(name, default) if not is_address(value): diff --git a/tests/unit/test_ghost_factory.py b/tests/unit/test_ghost_factory.py index d4f4bc8ad..9df90a6b4 100644 --- a/tests/unit/test_ghost_factory.py +++ b/tests/unit/test_ghost_factory.py @@ -3,6 +3,7 @@ from backend.protocol_rpc.ghost_factory import ( GhostFactoryConfig, InvalidGhostFactoryConfiguration, + genvm_salted_child_address, ) @@ -55,6 +56,27 @@ def test_create2_salt_is_scoped_to_authenticated_sender(): assert first != second +def test_genvm_salted_child_address_matches_runner_public_formula(): + assert ( + genvm_salted_child_address( + "0xb9c60a4375eF3d3D9eF3D6Db31954cADE090A124", + 1, + 61999, + ) + == "0x2f4C8a3F29368b8849248E8F84a6993Da2a315E2" + ) + + +@pytest.mark.parametrize("salt_nonce", [0, -1, 1 << 256]) +def test_genvm_salted_child_address_rejects_invalid_salt(salt_nonce): + with pytest.raises(InvalidGhostFactoryConfiguration): + genvm_salted_child_address( + "0x1111111111111111111111111111111111111111", + salt_nonce, + 61999, + ) + + @pytest.mark.parametrize( ("name", "value"), [ diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index ee0c5f555..235ea25f9 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -36,6 +36,7 @@ get_tx_execution_hash, ) from backend.protocol_rpc.exceptions import JSONRPCError, NotFoundError +from backend.protocol_rpc.ghost_factory import genvm_salted_child_address from backend.protocol_rpc.endpoints import ( _available_appeal_validator_count, _current_fee_round, @@ -1890,9 +1891,10 @@ def child(recipient, tx_type, occurrence, salt=0): zero = "0x" + ("00" * 32) assert processor.locked is True + expected_salted_child = genvm_salted_child_address(registered, 42, 61999) assert {address.lower() for address in processor.locked_recipients} == { registered.lower(), - "0x4e0065451873eaf51af1c7e00256a5db0f8a80ad", + expected_salted_child.lower(), } assert receipt["tx_ids_hex"] == [ _studio_child_transaction_id(parent_hash, "accepted", first_occurrence), @@ -1902,7 +1904,7 @@ def child(recipient, tx_type, occurrence, salt=0): ] assert receipt["recipients"][0] == Web3.to_checksum_address(registered) assert receipt["recipients"][1] == Web3.to_checksum_address(missing) - assert receipt["recipients"][2] == "0x4E0065451873eaf51AF1C7E00256A5db0f8a80aD" + assert receipt["recipients"][2] == expected_salted_child assert receipt["recipients"][3] == Web3.to_checksum_address( "0x0000000000000000000000000000000000000000" ) From c2388ecbefccc8f3a54464abe188bb22d2ef41fd Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 03:38:35 +0100 Subject: [PATCH 44/55] fix(fees): fund discovered child execution --- backend/protocol_rpc/fees.py | 17 +++++++++++++---- tests/unit/test_studio_fees.py | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 88e82528d..655101814 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -5344,10 +5344,19 @@ def discovered_message_fee_allocations( validator_timeunits = DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION rotations = [int((fees.get("rotations") or [0])[0])] - execution_budget = max( - int(fees["executionBudgetPerRound"]), - int(policy.message_fee_params_budget_floor()), - int(policy.genvm_start_budget_floor()), + # The allocation becomes the child transaction's complete per-round + # execution reservoir. Funding it at the GenVM startup floor leaves no + # budget for the child's first storage write (a constructor that stores one + # field therefore finalizes as ``out_of storage``). Apply the same 20% + # execution headroom used by recommended_fee_preset so discovered children + # can execute beyond startup rather than merely enter the VM. + execution_budget = _with_padding( + max( + int(fees["executionBudgetPerRound"]), + int(policy.message_fee_params_budget_floor()), + int(policy.genvm_start_budget_floor()), + ), + DEFAULT_PRICE_CAP_HEADROOM_BPS, ) max_gen_price = max( int(fees["maxPriceGenPerTimeUnit"]), diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 235ea25f9..d7ccc30ff 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -2145,6 +2145,41 @@ def test_discovered_message_allocations_are_exact_and_consensus_valid(): ) +def test_discovered_internal_deploy_allocation_funds_child_execution_after_startup(): + policy = StudioFeePolicy.from_env() + fees_distribution = _env_fees_distribution(execution_budget_per_round=0) + receipt = { + "pending_transactions": [ + { + "messageType": "Internal", + "address": "0x", + "on": "accepted", + "salt_nonce": 1, + "call_key": EMPTY_CALL_KEY, + } + ] + } + + allocation = discovered_message_fee_allocations( + receipt, + fees_distribution, + policy, + )[0] + internal_params = decode_internal_message_fee_params(allocation["feeParams"]) + startup_floor = max( + policy.message_fee_params_budget_floor(), + policy.genvm_start_budget_floor(), + ) + + assert allocation["recipient"] == "0x0000000000000000000000000000000000000000" + assert ( + internal_params["executionBudgetPerRound"] + == (startup_floor * DEFAULT_PRICE_CAP_HEADROOM_BPS + 9_999) // 10_000 + ) + assert internal_params["executionBudgetPerRound"] > startup_floor + assert allocation["budget"] == min_message_primary_fees(internal_params, policy) + + def test_discovered_messages_rebuild_simulation_accounting_and_deposit(): policy = StudioFeePolicy.from_env() fees_distribution = _env_fees_distribution() From fafcdf3c66ed1020645beaf8875123a7ae47ef77 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 04:06:14 +0100 Subject: [PATCH 45/55] fix(rpc): expose mined envelope reverts --- backend/protocol_rpc/endpoints.py | 9 ++++++++- tests/unit/test_rpc_methods.py | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index a2bf03de6..dcbcbf6e4 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -3546,7 +3546,7 @@ def get_transaction_receipt( if not transaction: if envelope is None: return None - return { + receipt = { "transactionHash": transaction_hash, "transactionIndex": hex(0), "blockHash": transaction_hash, @@ -3562,6 +3562,13 @@ def get_transaction_receipt( "logsBloom": "0x" + "00" * 256, "status": hex(1 if envelope.success else 0), } + # Preserve the standard status-0 receipt boundary while giving SDKs a + # Studio-local equivalent of the revert data they normally learn from + # an execution-chain gas estimate. This is additive metadata; EVM + # clients that only understand the standard receipt fields ignore it. + if not envelope.success and envelope.error: + receipt["revertReason"] = envelope.error + return receipt protocol_to_addr = envelope.to_address if envelope is not None else None to_addr = protocol_to_addr or transaction.get("to_address") diff --git a/tests/unit/test_rpc_methods.py b/tests/unit/test_rpc_methods.py index a244e6122..1d3698e43 100644 --- a/tests/unit/test_rpc_methods.py +++ b/tests/unit/test_rpc_methods.py @@ -179,12 +179,14 @@ def test_eth_transaction_receipt_reports_reverted_lifecycle_envelope(): from_address="0x1111111111111111111111111111111111111111", to_address="0x2222222222222222222222222222222222222222", success=False, + error="InsufficientFees", ) receipt = endpoints.get_transaction_receipt(transactions_processor, "0xabc") assert receipt["transactionHash"] == "0xabc" assert receipt["status"] == "0x0" + assert receipt["revertReason"] == "InsufficientFees" assert receipt["logs"] == [] From 700d9eb689659ef5a91a4b2e05920b48e5a13a21 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 04:10:29 +0100 Subject: [PATCH 46/55] chore(deps): repin receipt-aware JS SDK --- frontend/package-lock.json | 6 +++--- frontend/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e7801c303..53ba653c7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#b9a7bb2305f9b069d79f348d85e62a1b43d50b48", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#49775304ba0b5073c97d0ac31c41e1b62324a5f1", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", @@ -11080,8 +11080,8 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+https://github.com/genlayerlabs/genlayer-js.git#b9a7bb2305f9b069d79f348d85e62a1b43d50b48", - "integrity": "sha512-W8vnalIYLzFrU+PKv2e0aEG19kmNXGX9hApDfScrXC90zI8ijAeKrt+FZrWC52f0hnJrsTkB7S5tdiYYQCZ2tw==", + "resolved": "git+https://github.com/genlayerlabs/genlayer-js.git#49775304ba0b5073c97d0ac31c41e1b62324a5f1", + "integrity": "sha512-GXwoRfH5Dw5KY5YD3tQbTr4tEwXqDVhOD0Pbf+kP9ApvvGVrI6pfs/DTylHousGSYMpJPdQObNQcRuhcV4uv0Q==", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", diff --git a/frontend/package.json b/frontend/package.json index 27002a96e..4de29f874 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,7 +39,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#b9a7bb2305f9b069d79f348d85e62a1b43d50b48", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#49775304ba0b5073c97d0ac31c41e1b62324a5f1", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", From 1481b7cbb1b661ca73bf2bd6bf4bbc081a9244bc Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 06:03:48 +0100 Subject: [PATCH 47/55] chore(deps): repin topup-safe JS SDK --- frontend/package-lock.json | 6 +++--- frontend/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 53ba653c7..cf4de8c06 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#49775304ba0b5073c97d0ac31c41e1b62324a5f1", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#2b97d27e5498a797e60d0acf2e10019abc76f0d2", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", @@ -11080,8 +11080,8 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+https://github.com/genlayerlabs/genlayer-js.git#49775304ba0b5073c97d0ac31c41e1b62324a5f1", - "integrity": "sha512-GXwoRfH5Dw5KY5YD3tQbTr4tEwXqDVhOD0Pbf+kP9ApvvGVrI6pfs/DTylHousGSYMpJPdQObNQcRuhcV4uv0Q==", + "resolved": "git+https://github.com/genlayerlabs/genlayer-js.git#2b97d27e5498a797e60d0acf2e10019abc76f0d2", + "integrity": "sha512-W8vnalIYLzFrU+PKv2e0aEG19kmNXGX9hApDfScrXC90zI8ijAeKrt+FZrWC52f0hnJrsTkB7S5tdiYYQCZ2tw==", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", diff --git a/frontend/package.json b/frontend/package.json index 4de29f874..3a5864eb4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,7 +39,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#49775304ba0b5073c97d0ac31c41e1b62324a5f1", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#2b97d27e5498a797e60d0acf2e10019abc76f0d2", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", From 0682780f77bac1e827283a5f6b00652a0c0c2409 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 08:39:58 +0100 Subject: [PATCH 48/55] fix(fees): align envelope and shared allocations --- backend/protocol_rpc/endpoints.py | 25 +++++++++++++++ backend/protocol_rpc/fees.py | 16 ++++++++-- tests/unit/test_rpc_methods.py | 21 +++++++++++++ tests/unit/test_studio_fees.py | 51 ++++++++++++++++++++++++++----- 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index dcbcbf6e4..e736c7c4a 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -1873,6 +1873,31 @@ def get_transaction_by_hash( ) if transaction is None: + envelope = transactions_processor.get_evm_envelope(transaction_hash) + if envelope is not None: + # Lifecycle calls and post-admission execution reverts are mined + # EVM envelopes but do not create GenLayer transaction rows. Viem + # polls eth_getTransactionByHash before reading the receipt, so + # expose the durable envelope through the matching Ethereum + # endpoint instead of making a valid status-0/1 receipt + # undiscoverable. + return { + "blockHash": transaction_hash, + "blockNumber": hex(0), + "from": envelope.from_address, + "gas": hex(0), + "gasPrice": hex(0), + "hash": transaction_hash, + "input": "0x", + "nonce": hex(int(envelope.nonce)), + "r": "0x" + "0" * 64, + "s": "0x" + "0" * 64, + "to": envelope.to_address, + "transactionIndex": hex(0), + "type": hex(0), + "v": hex(0), + "value": hex(0), + } raise NotFoundError( message=f"Transaction {transaction_hash} not found", data={"hash": transaction_hash}, diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 655101814..33449c8d7 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -2608,10 +2608,22 @@ def fill_message_fee_payload_from_allocation( if bool(allocation["onAcceptance"]) != bool(message.get("onAcceptance", False)): raise MessageEmissionPhaseMismatch("MessageEmissionPhaseMismatch") - if int(updated.get("declaredBudget", 0) or 0) == 0: - updated["declaredBudget"] = int(allocation["budget"]) if not _message_has_fee_params(updated): updated["feeParams"] = allocation["feeParams"] + if int(updated.get("declaredBudget", 0) or 0) == 0: + # The allocation budget is a cumulative ceiling for every occurrence + # with this key. It is not the declared budget of each occurrence. + # GenVM/Consensus assigns an occurrence its minimum primary funding + # plus the allocation subtree it must carry into the child. Using the + # aggregate ceiling here overcharges the first message and makes a + # second same-key occurrence fail with MessageBudgetExceeded. + fee_params = decode_internal_message_fee_params(updated["feeParams"]) + policy = execution_policy_for_accounting(accounting) + updated["declaredBudget"] = _internal_allocation_min_required( + allocation, + fee_params, + policy, + ) + _child_allocation_budget_sum(allocations, index) updated["callKey"] = _normalize_call_key( updated.get("callKey", allocation["callKey"]) ) diff --git a/tests/unit/test_rpc_methods.py b/tests/unit/test_rpc_methods.py index 1d3698e43..96c225f44 100644 --- a/tests/unit/test_rpc_methods.py +++ b/tests/unit/test_rpc_methods.py @@ -190,6 +190,27 @@ def test_eth_transaction_receipt_reports_reverted_lifecycle_envelope(): assert receipt["logs"] == [] +def test_eth_get_transaction_by_hash_reports_envelope_without_protocol_row(): + transactions_processor = MagicMock() + transactions_processor.get_transaction_by_hash.return_value = None + transactions_processor.get_evm_envelope.return_value = SimpleNamespace( + from_address="0x1111111111111111111111111111111111111111", + to_address="0x2222222222222222222222222222222222222222", + nonce=7, + ) + + transaction = endpoints.get_transaction_by_hash( + transactions_processor, + "0x" + "ab" * 32, + ) + + assert transaction["hash"] == "0x" + "ab" * 32 + assert transaction["from"] == "0x1111111111111111111111111111111111111111" + assert transaction["to"] == "0x2222222222222222222222222222222222222222" + assert transaction["nonce"] == "0x7" + assert transaction["blockNumber"] == "0x0" + + def test_eth_get_block_by_number_requests_no_contract_snapshot_for_full_tx(): transactions_processor = MagicMock() transactions_processor.get_transactions_for_block.return_value = { diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index d7ccc30ff..fb2021f85 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -3788,24 +3788,24 @@ def test_simulation_fee_consumption_fills_mode2_payload_from_allocation(): assert recorded["execution_fee_consumed_buckets"] == [0, 80] assert recorded["genvm_fee_consumed_buckets"] == [80, 55, 0, 0] assert recorded["genvm_message_fee_consumed"] == 55 - assert recorded["message_fee_consumed"] == 75 - assert recorded["allocation_consumed"] == {"0": 75} + assert recorded["message_fee_consumed"] == 55 + assert recorded["allocation_consumed"] == {"0": 55} assert recorded["message_fees_recorded_from_receipt"] is True assert recorded["execution_fee_report"]["messageFees"] == { "budget": 75, - "declaredConsumed": 75, + "declaredConsumed": 55, "genvmMeteredConsumed": 55, "declaredRefunded": 0, - "remaining": 0, - "meteringDelta": 20, + "remaining": 20, + "meteringDelta": 0, } assert message["messageFeeMode"] == "mode2" assert message["feeParams"] == "0x" + fee_params.hex() - assert message["declaredBudget"] == 75 + assert message["declaredBudget"] == 55 assert message["allocationSubtree"] == "0x" assert message["allocationSubtreeBytes"] == 0 - assert recorded_again["message_fee_consumed"] == 75 - assert recorded_again["allocation_consumed"] == {"0": 75} + assert recorded_again["message_fee_consumed"] == 55 + assert recorded_again["allocation_consumed"] == {"0": 55} def test_settlement_refreshes_message_fee_report_after_message_refund(): @@ -7073,6 +7073,41 @@ def test_fill_message_fee_payload_from_allocation_uses_matching_policy_and_subtr assert resolved[1]["budget"] == 56 +def test_fill_message_fee_payload_uses_per_occurrence_budget_for_shared_allocation(): + fee_params = _encode_internal_fee_params() + recipient = "0x2222222222222222222222222222222222222222" + accounting = create_fee_accounting( + fees_distribution=_fees_distribution(total_message_fees=110), + message_allocations=[ + _allocation( + recipient=recipient, + budget=110, + fee_params=fee_params, + ) + ], + num_of_validators=5, + submitted_value=1210, + user_value=0, + ) + raw_message = { + "messageType": 1, + "recipient": recipient, + "onAcceptance": True, + "declaredBudget": 0, + "callKey": "0x" + "0" * 64, + } + + messages = [ + fill_message_fee_payload_from_allocation(accounting, raw_message) + for _ in range(2) + ] + updated = consume_message_fees(accounting, messages) + + assert [message["declaredBudget"] for message in messages] == [55, 55] + assert updated["message_fee_consumed"] == 110 + assert updated["allocation_consumed"] == {"0": 110} + + def test_flat_array_message_fee_payload_ignores_mismatched_receipt_subtree(): fee_params = _encode_internal_fee_params() child_fee_params = _encode_internal_fee_params(leader_timeunits=6) From 7b8d885835ddb7655100e191b7d0f405d16d3d3c Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 08:43:07 +0100 Subject: [PATCH 49/55] chore(deps): repin message event SDK --- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cf4de8c06..8e3a2026a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#2b97d27e5498a797e60d0acf2e10019abc76f0d2", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#bc1fc3203ded80bb601366412137fafbb0669fab", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", @@ -11080,7 +11080,7 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+https://github.com/genlayerlabs/genlayer-js.git#2b97d27e5498a797e60d0acf2e10019abc76f0d2", + "resolved": "git+https://github.com/genlayerlabs/genlayer-js.git#bc1fc3203ded80bb601366412137fafbb0669fab", "integrity": "sha512-W8vnalIYLzFrU+PKv2e0aEG19kmNXGX9hApDfScrXC90zI8ijAeKrt+FZrWC52f0hnJrsTkB7S5tdiYYQCZ2tw==", "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 3a5864eb4..e2f7e86c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,7 +39,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#2b97d27e5498a797e60d0acf2e10019abc76f0d2", + "genlayer-js": "git+https://github.com/genlayerlabs/genlayer-js.git#bc1fc3203ded80bb601366412137fafbb0669fab", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", From ffc0a9f58ba09a95739b2e9ebe1a03c53be01790 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 11:37:58 +0100 Subject: [PATCH 50/55] fix(validators): replace pools atomically --- backend/protocol_rpc/endpoints.py | 46 ++++++++++ backend/protocol_rpc/rpc_methods.py | 13 +++ backend/validators/__init__.py | 84 +++++++++++++++---- tests/integration/icontracts/conftest.py | 71 ++++++++-------- tests/unit/test_simulator_sessions.py | 47 +++++++++++ tests/unit/test_validator_manager_snapshot.py | 62 ++++++++++++-- 6 files changed, 267 insertions(+), 56 deletions(-) diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index e736c7c4a..ebbe4e83b 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -752,6 +752,52 @@ async def create_validator( ) +@check_forbidden_method_in_hosted_studio +async def replace_validators( + session: Session, + validators_manager: validators.Manager, + validator_configs: list[dict], +) -> list[dict]: + """Atomically replace the simulator validator pool with one GenVM reload.""" + + accounts_manager = AccountsManager(session) + replacement_validators: list[Validator] = [] + + for validator_config in validator_configs: + stake = int(validator_config["stake"]) + provider = str(validator_config["provider"]) + model = str(validator_config["model"]) + config = validator_config.get("config") + plugin = validator_config.get("plugin") + plugin_config = validator_config.get("plugin_config") + + if config is None or plugin is None or plugin_config is None: + llm_provider = get_default_provider_for(provider, model) + else: + llm_provider = LLMProvider( + provider=provider, + model=model, + config=config, + plugin=plugin, + plugin_config=plugin_config, + ) + validate_provider(llm_provider) + + account = accounts_manager.create_new_account() + replacement_validators.append( + Validator( + address=account.address, + private_key=account.key, + stake=stake, + llmprovider=llm_provider, + ) + ) + + return await validators_manager.registry.replace_all_validators( + replacement_validators + ) + + @check_forbidden_method_in_hosted_studio async def create_random_validator( session: Session, diff --git a/backend/protocol_rpc/rpc_methods.py b/backend/protocol_rpc/rpc_methods.py index 6bee280b9..0a21457bf 100644 --- a/backend/protocol_rpc/rpc_methods.py +++ b/backend/protocol_rpc/rpc_methods.py @@ -124,6 +124,19 @@ async def create_validator( ) +@rpc.method("sim_replaceValidators") +async def replace_validators( + validator_configs: list[dict], + session: Session = Depends(get_db_session), + validators_manager=Depends(get_validators_manager), +) -> list[dict]: + return await impl.replace_validators( + session=session, + validators_manager=validators_manager, + validator_configs=validator_configs, + ) + + @rpc.method("sim_createRandomValidator") async def create_random_validator( stake: int, diff --git a/backend/validators/__init__.py b/backend/validators/__init__.py index ec6013b85..81d7841a2 100644 --- a/backend/validators/__init__.py +++ b/backend/validators/__init__.py @@ -4,6 +4,8 @@ import typing import contextlib import dataclasses +import hashlib +import json import os import random @@ -152,6 +154,34 @@ class Snapshot: from backend.node.base import LLMConfig, Manager as GenVMManager +def _registry_fingerprint(validators: list[dict]) -> str: + """Return a stable identity for the validator configuration GenVM consumes.""" + + relevant_fields = ( + "address", + "stake", + "provider", + "model", + "config", + "plugin", + "plugin_config", + ) + canonical = [ + {field: validator.get(field) for field in relevant_fields} + for validator in sorted( + validators, + key=lambda validator: str(validator.get("address") or "").lower(), + ) + ] + encoded = json.dumps( + canonical, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode() + return hashlib.sha256(encoded).hexdigest() + + class Manager: registry: vr.ModifiableValidatorsRegistry @@ -161,6 +191,7 @@ def __init__( self.genvm_manager = genvm_manager self._cached_snapshot = None + self._cached_registry_fingerprint: str | None = None self.registry = ModifiableValidatorsRegistryInterceptor( self, validators_registry_session @@ -172,12 +203,30 @@ async def restart(self): # creates the general Snapshot with: # - SingleValidatorSnapshot (validator, genvm_host_data) # - the genvm_config_path - new_validators = await self._get_snap_from_registry() - # Registers all the validators providers and models to the LLM module - await self._change_providers_from_snapshot(new_validators) - - async def _get_snap_from_registry(self) -> Snapshot: - cur_validators_as_dict = self.registry.get_all_validators() + async with self._restart_llm_lock: + # Read inside the lock. A delayed event must not overwrite a newer + # snapshot that another restart loaded while this coroutine waited. + registry_validators = self.registry.get_all_validators() + registry_fingerprint = _registry_fingerprint(registry_validators) + # Registers all the validators providers and models to the LLM + # module. Redis delivers one notification per mutation and those + # notifications can lag behind the database. Avoid stopping a + # healthy module for a state that is already loaded. + if ( + self._cached_snapshot is not None + and self._cached_registry_fingerprint == registry_fingerprint + ): + logger.info("Validator registry is already loaded; skipping restart") + return + new_validators = await self._get_snap_from_registry(registry_validators) + await self._change_providers_from_snapshot_locked(new_validators) + self._cached_registry_fingerprint = registry_fingerprint + + async def _get_snap_from_registry( + self, cur_validators_as_dict: list[dict] | None = None + ) -> Snapshot: + if cur_validators_as_dict is None: + cur_validators_as_dict = self.registry.get_all_validators() logger.info( f"ValidatorManager retrieved {len(cur_validators_as_dict)} validators from registry", ) @@ -252,14 +301,17 @@ async def snapshot(self): "Validators manager snapshot not initialized. " "Ensure restart() was called successfully." ) - # Wipe+recreate can leave the cache empty after the last delete's - # LLM restart while new validators are already committed. Create - # events sit behind those restarts; re-read before freezing a - # 0-node copy for exec. - if not self._cached_snapshot.nodes: - fresh = await self._get_snap_from_registry() - if fresh.nodes: - await self._change_providers_from_snapshot_locked(fresh) + # Validator events are asynchronous across RPC and consensus + # processes. The database commit is authoritative, so verify the + # entire cached configuration before freezing a transaction's + # selection pool. Checking only an empty cache misses partial + # wipe/recreate states (for example a cached 4-of-5 pool). + registry_validators = self.registry.get_all_validators() + registry_fingerprint = _registry_fingerprint(registry_validators) + if self._cached_registry_fingerprint != registry_fingerprint: + fresh = await self._get_snap_from_registry(registry_validators) + await self._change_providers_from_snapshot_locked(fresh) + self._cached_registry_fingerprint = registry_fingerprint snap = deepcopy(self._cached_snapshot) yield snap @@ -337,9 +389,7 @@ async def _change_providers_from_snapshot_locked(self, snap: Snapshot): @contextlib.asynccontextmanager async def do_write(self): yield - - new_validators = await self._get_snap_from_registry() - await self._change_providers_from_snapshot(new_validators) + await self.restart() async def _notify_validator_change(self, event_type: str, data: dict): """ diff --git a/tests/integration/icontracts/conftest.py b/tests/integration/icontracts/conftest.py index d23698d9d..760579f71 100644 --- a/tests/integration/icontracts/conftest.py +++ b/tests/integration/icontracts/conftest.py @@ -45,14 +45,15 @@ def get_mock_provider_config() -> dict[str, str]: @pytest.fixture def setup_validators(): created_validator_addresses = [] + replaced_validator_pool = False def _setup( mock_response: Any = None, mock_web_response: Any = None, ) -> None: - nonlocal created_validator_addresses + nonlocal created_validator_addresses, replaced_validator_pool if mock_llms(): - # Wipe ALL existing validators before seeding this test's mocks. + # Atomically replace ALL existing validators with this test's mocks. # Without this, a prior test's validator (which has different # mock_response, possibly even none) can be picked into this # test's consensus round via VRF, disagree with the leader's @@ -62,38 +63,34 @@ def _setup( # assertion fails. The xdist_group marker serializes these # tests onto a single worker, so this wipe is safe for the # parallel CI run. - delete_all = post_request_localhost( - payload("sim_deleteAllValidators") - ).json() - assert has_success_status(delete_all) - mock_cfg = get_mock_provider_config() - # Mock mode: create validators with specific mock_response for this test - for _ in range(5): - result = post_request_localhost( - payload( - "sim_createValidator", - 8, - mock_cfg["provider"], - mock_cfg["model"], - {"temperature": 0.75, "max_tokens": 500}, - "openai-compatible", - { - "api_key_env_var": mock_cfg["api_key_env_var"], - "api_url": mock_cfg["api_url"], - "mock_response": ( - mock_response if mock_response is not None else {} - ), - "mock_web_response": ( - mock_web_response - if mock_web_response is not None - else {} - ), - }, - ) - ).json() - assert has_success_status(result) - created_validator_addresses.append(result["result"]["address"]) + validator_config = { + "stake": 8, + "provider": mock_cfg["provider"], + "model": mock_cfg["model"], + "config": {"temperature": 0.75, "max_tokens": 500}, + "plugin": "openai-compatible", + "plugin_config": { + "api_key_env_var": mock_cfg["api_key_env_var"], + "api_url": mock_cfg["api_url"], + "mock_response": ( + mock_response if mock_response is not None else {} + ), + "mock_web_response": ( + mock_web_response if mock_web_response is not None else {} + ), + }, + } + result = post_request_localhost( + payload("sim_replaceValidators", [validator_config] * 5) + ).json() + assert has_success_status(result) + assert len(result["result"]) == 5 + created_validator_addresses.extend( + validator["address"] for validator in result["result"] + ) + assert len(set(created_validator_addresses)) == 5 + replaced_validator_pool = True else: cfg = get_provider_config() # Non-mock mode: only create the validators that are still missing @@ -121,7 +118,13 @@ def _setup( yield _setup - # Only delete validators that THIS test created (not all validators) + # The next mock-backed test atomically replaces this pool. Deleting each + # validator here would emit five more asynchronous GenVM reloads and can + # interrupt transactions that are still completing acceptance effects. + if replaced_validator_pool: + return + + # Only delete validators that THIS non-mock test created. for address in created_validator_addresses: delete_result = post_request_localhost( payload("sim_deleteValidator", address) diff --git a/tests/unit/test_simulator_sessions.py b/tests/unit/test_simulator_sessions.py index 989dc52b9..6783a18d6 100644 --- a/tests/unit/test_simulator_sessions.py +++ b/tests/unit/test_simulator_sessions.py @@ -257,6 +257,53 @@ async def test_create_validator_uses_request_scoped_session(monkeypatch): assert validator_arg.stake == 10 +@pytest.mark.asyncio +async def test_replace_validators_is_atomic_and_uses_request_session(monkeypatch): + session = object() + accounts_manager_instance = MagicMock() + accounts_manager_instance.create_new_account.side_effect = [ + SimpleNamespace(address="0x1", key="k1"), + SimpleNamespace(address="0x2", key="k2"), + ] + registry_instance = SimpleNamespace( + replace_all_validators=AsyncMock( + return_value=[{"address": "0x1"}, {"address": "0x2"}] + ) + ) + validators_manager = SimpleNamespace(registry=registry_instance) + + monkeypatch.setattr(endpoints, "validate_provider", lambda _provider: None) + monkeypatch.setattr( + endpoints, + "AccountsManager", + lambda s: accounts_manager_instance if s is session else None, + ) + + validator_config = { + "stake": 8, + "provider": "openrouter", + "model": "test-model", + "config": {"temperature": 0.75}, + "plugin": "openai-compatible", + "plugin_config": { + "api_key_env_var": "OPENROUTERAPIKEY", + "api_url": "https://openrouter.ai/api", + "mock_response": {}, + }, + } + result = await endpoints.replace_validators( + session, + validators_manager, + [validator_config, validator_config], + ) + + assert result == [{"address": "0x1"}, {"address": "0x2"}] + registry_instance.replace_all_validators.assert_awaited_once() + replacements = registry_instance.replace_all_validators.await_args.args[0] + assert [validator.address for validator in replacements] == ["0x1", "0x2"] + assert [validator.stake for validator in replacements] == [8, 8] + + @pytest.mark.asyncio async def test_create_random_validators_use_request_session(monkeypatch): session = object() diff --git a/tests/unit/test_validator_manager_snapshot.py b/tests/unit/test_validator_manager_snapshot.py index 470a126af..e084f5892 100644 --- a/tests/unit/test_validator_manager_snapshot.py +++ b/tests/unit/test_validator_manager_snapshot.py @@ -2,7 +2,12 @@ import pytest -from backend.validators import Manager, Snapshot, SingleValidatorSnapshot +from backend.validators import ( + Manager, + Snapshot, + SingleValidatorSnapshot, + _registry_fingerprint, +) from tests.unit.test_fallback_validator_model_host_data import create_test_validator @@ -35,6 +40,7 @@ def _registry_validator(address: str = "0xabc") -> dict: async def test_snapshot_reloads_when_cache_empty_but_registry_has_validators(): manager = _make_manager() manager._cached_snapshot = Snapshot(nodes=[]) + manager._cached_registry_fingerprint = _registry_fingerprint([]) manager.registry.get_all_validators = Mock(return_value=[_registry_validator()]) async with manager.snapshot() as snap: @@ -48,6 +54,7 @@ async def test_snapshot_reloads_when_cache_empty_but_registry_has_validators(): async def test_snapshot_stays_empty_when_registry_empty(): manager = _make_manager() manager._cached_snapshot = Snapshot(nodes=[]) + manager._cached_registry_fingerprint = _registry_fingerprint([]) manager.registry.get_all_validators = Mock(return_value=[]) async with manager.snapshot() as snap: @@ -57,19 +64,64 @@ async def test_snapshot_stays_empty_when_registry_empty(): @pytest.mark.asyncio -async def test_snapshot_uses_cached_nodes_without_refresh(): +async def test_snapshot_uses_cached_nodes_when_registry_matches(): manager = _make_manager() - validator = create_test_validator("0xcached", "openai", "gpt-4o") + registry_validator = _registry_validator("0xcached") + validator = create_test_validator("0xcached", "openrouter", "test-model") manager._cached_snapshot = Snapshot( nodes=[SingleValidatorSnapshot(validator, {"studio_llm_id": "node-0xcached"})] ) - manager.registry.get_all_validators = Mock() + manager._cached_registry_fingerprint = _registry_fingerprint([registry_validator]) + manager.registry.get_all_validators = Mock(return_value=[registry_validator]) async with manager.snapshot() as snap: assert len(snap.nodes) == 1 assert snap.nodes[0].validator.address == "0xcached" - manager.registry.get_all_validators.assert_not_called() + manager.registry.get_all_validators.assert_called_once() + manager.genvm_manager.start_module.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_snapshot_reloads_partial_nonempty_cache_from_registry(): + manager = _make_manager() + cached_validator = create_test_validator("0xcached", "openai", "gpt-4o") + manager._cached_snapshot = Snapshot( + nodes=[ + SingleValidatorSnapshot( + cached_validator, {"studio_llm_id": "node-0xcached"} + ) + ] + ) + manager._cached_registry_fingerprint = _registry_fingerprint( + [_registry_validator("0xcached")] + ) + registry_validators = [ + _registry_validator("0xfresh-1"), + _registry_validator("0xfresh-2"), + ] + manager.registry.get_all_validators = Mock(return_value=registry_validators) + + async with manager.snapshot() as snap: + assert [node.validator.address for node in snap.nodes] == [ + "0xfresh-1", + "0xfresh-2", + ] + + manager.genvm_manager.start_module.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_restart_skips_duplicate_event_for_loaded_registry(): + manager = _make_manager() + registry_validators = [_registry_validator()] + manager.registry.get_all_validators = Mock(return_value=registry_validators) + + await manager.restart() + await manager.restart() + + manager.genvm_manager.stop_module.assert_awaited_once() + manager.genvm_manager.start_module.assert_awaited_once() @pytest.mark.asyncio From 69597cefa0a9af79fbbbd5d643550e5e0e2ab907 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 18:12:13 +0100 Subject: [PATCH 51/55] test(fees): lock fresh appeal capacity Cover the two topology boundaries behind validator appeals: a five-validator pool is exhausted by the initial committee, while twelve identities provide the full fresh seven-seat first jury and preserve the exact Consensus quote. --- tests/unit/test_studio_fees.py | 85 ++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index fb2021f85..0011fbeba 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -12017,6 +12017,91 @@ def test_appeal_capacity_uses_frozen_pool_identities_and_live_availability(): assert available == 2 +def _accepted_first_round_with_frozen_pool(pool_size: int): + accounting = _env_fee_accounting() + accounting["selection_pool_addresses"] = [ + f"0x{index:040x}" for index in range(1, pool_size + 1) + ] + accounting["selection_pool_count"] = pool_size + tx = _fee_accounted_tx(status="ACCEPTED", accounting=accounting) + tx.update( + timestamp_awaiting_finalization=1_000, + appeal_processing_time=0, + appeal_failed=0, + appealed=False, + execution_mode="NORMAL", + consensus_data={ + "leader_receipt": [ + _history_receipt( + mode="leader", + address="0x0000000000000000000000000000000000000001", + ) + ], + "validators": [ + _history_receipt( + mode="validator", + address=f"0x{index:040x}", + ) + for index in range(2, 6) + ], + }, + consensus_history={ + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [ + _history_receipt( + mode="leader", + address="0x0000000000000000000000000000000000000001", + ) + ], + "validator_results": [], + } + ] + }, + ) + return tx, accounting + + +def test_latest_appeal_charge_rejects_when_initial_committee_exhausts_pool( + monkeypatch, +): + """Five initial identities leave no fresh validator-appeal juror.""" + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_001) + tx, _accounting = _accepted_first_round_with_frozen_pool(5) + + with pytest.raises(InvalidTransactionError, match="CanNotAppeal"): + estimate_latest_appeal_charge( + _FakeTransactionsProcessor(tx), {"txId": tx["hash"]} + ) + + +def test_latest_appeal_charge_uses_full_first_jury_with_twelve_validator_pool( + monkeypatch, +): + """Five initial identities plus seven fresh identities fund the full jury.""" + monkeypatch.setenv("VITE_FINALITY_WINDOW", "30") + monkeypatch.setattr("backend.protocol_rpc.endpoints.time.time", lambda: 1_001) + tx, accounting = _accepted_first_round_with_frozen_pool(12) + expected = calculate_appeal_charge( + accounting["fees_distribution"], + current_round=0, + status="ACCEPTED", + terminal_committee_upper_bound=11, + available_appeal_validators=7, + policy=StudioFeePolicy.from_env(), + ) + + quote = estimate_latest_appeal_charge( + _FakeTransactionsProcessor(tx), {"txId": tx["hash"]} + ) + + assert expected["juryCount"] == 7 + assert int(quote["bond"]) == expected["bond"] + assert int(quote["funding"]) == expected["funding"] + + def test_execution_selection_rejects_post_activation_validator_additions(): validators = [ {"address": f"0x{index:040x}", "stake": 1} for index in (1, 2, 3, 4, 6) From 9e715f259b34d9837f09dc0282bda544b1035c3b Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 18:47:51 +0100 Subject: [PATCH 52/55] fix(release): harden studio parity boundaries --- .env.example | 1 - backend/consensus/worker.py | 27 +- backend/database_handler/accounts_manager.py | 5 +- .../e4f6a8c0d2b4_add_evm_envelope_ledger.py | 2 +- .../transactions_processor.py | 14 +- backend/protocol_rpc/endpoints.py | 347 ++++++++---------- backend/protocol_rpc/fees.py | 10 +- backend/protocol_rpc/ghost_factory.py | 11 +- backend/protocol_rpc/types.py | 6 +- backend/rollup/consensus_service.py | 2 +- docker-compose.yml | 1 - docker/Dockerfile.backend | 29 +- .../components/Simulator/TransactionItem.vue | 2 +- .../TransactionItem.fees.behavioral.test.ts | 6 + .../contracts/v2_contracts/ConsensusMain.sol | 3 +- .../test/deploy/001_deploy_contracts.test.js | 24 ++ scripts/prepare-genvm-source-build.sh | 5 + tests/db-sqlalchemy/accounts_manager_test.py | 60 +++ .../evm_envelope_migration_backfill_test.py | 70 ++++ tests/hardhat/test_hardhat.py | 3 + tests/integration/conftest.py | 13 +- .../consensus/test_appeal_state_restore.py | 26 +- tests/unit/test_ghost_factory.py | 2 - tests/unit/test_integration_fee_defaults.py | 7 + tests/unit/test_shared_genvm_image_layout.py | 17 + tests/unit/test_studio_fees.py | 55 +++ tests/unit/test_worker_transient_fatal.py | 24 ++ 27 files changed, 528 insertions(+), 244 deletions(-) create mode 100644 tests/db-sqlalchemy/evm_envelope_migration_backfill_test.py create mode 100644 tests/unit/test_integration_fee_defaults.py diff --git a/.env.example b/.env.example index dbf5f6856..87452169d 100644 --- a/.env.example +++ b/.env.example @@ -78,7 +78,6 @@ GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES='1024' # across RPC replicas. Override all three only when intentionally mirroring a # concrete Consensus deployment (GhostFactory, CreationPhase, bytecodeEVM). GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS='0x130e09c996462963A6398CA04e1011e6ef9d68a6' -GENLAYER_STUDIO_CREATION_PHASE_ADDRESS='0x6AF00387f985684f159d673b15265E651B9E29f8' GENLAYER_STUDIO_GHOST_BYTECODE_HASH='0x4249189346902cb6f0afc196d5719e1b4cbc861a700ae3b0c6dcbe51e5e566de' GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE='2' diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index 022dcc30c..be7f52316 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -376,6 +376,10 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: WHERE t2.to_address IS NOT DISTINCT FROM t.to_address AND t2.blocked_at IS NOT NULL AND t2.hash != t.hash + AND ( + t2.status NOT IN ('FINALIZED', 'CANCELED') + OR t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) + ) ) AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) ORDER BY t.queue_order ASC @@ -457,6 +461,10 @@ async def claim_next_appeal(self, session: Session) -> Optional[dict]: WHERE t2.to_address IS NOT DISTINCT FROM t.to_address AND t2.blocked_at IS NOT NULL AND t2.hash != t.hash + AND ( + t2.status NOT IN ('FINALIZED', 'CANCELED') + OR t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) + ) ) AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) ORDER BY t.queue_order ASC @@ -2297,14 +2305,17 @@ async def _try_claim_work(self, session: Session) -> bool: finalization_data = await self.claim_next_finalization(session) if finalization_data: - logger.debug( - f"[Worker {self.worker_id}] Claimed finalization for transaction {finalization_data['hash']}" - ) - task = asyncio.create_task( - self._process_finalization_task(finalization_data) - ) - self._active_tasks.add(task) - return True + tx_hash = finalization_data["hash"] + if not self._is_in_backoff(finalization_data): + logger.debug( + f"[Worker {self.worker_id}] Claimed finalization for transaction {tx_hash}" + ) + task = asyncio.create_task( + self._process_finalization_task(finalization_data) + ) + self._active_tasks.add(task) + return True + self.release_transaction(session, tx_hash) transaction_data = await self.claim_next_transaction(session) if transaction_data: diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index 392ea78aa..ee34a69ec 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -305,7 +305,7 @@ def abort_tx_appeal_admission_once( accounting, reason=reason, ) - if refund <= 0 or not recipient: + if not recipient: return 0 data[FEE_ACCOUNTING_KEY] = updated @@ -320,7 +320,8 @@ def abort_tx_appeal_admission_once( # expiring an unflushed row would resurrect the paid appeal and make a # retry refund it a second time. self.session.flush() - self.credit_account_balance(str(recipient), refund) + if refund > 0: + self.credit_account_balance(str(recipient), refund) self.session.expire_all() return refund diff --git a/backend/database_handler/migration/versions/e4f6a8c0d2b4_add_evm_envelope_ledger.py b/backend/database_handler/migration/versions/e4f6a8c0d2b4_add_evm_envelope_ledger.py index dd853354a..3cff678a2 100644 --- a/backend/database_handler/migration/versions/e4f6a8c0d2b4_add_evm_envelope_ledger.py +++ b/backend/database_handler/migration/versions/e4f6a8c0d2b4_add_evm_envelope_ledger.py @@ -61,7 +61,7 @@ def upgrade() -> None: AND from_address IS NOT NULL AND nonce IS NOT NULL AND nonce >= 0 - ORDER BY lower(from_address), nonce, created_at DESC, hash DESC + ORDER BY lower(from_address), nonce, created_at DESC NULLS LAST, hash DESC ON CONFLICT DO NOTHING """ ) diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index 0eef825c8..c87544478 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -716,6 +716,12 @@ def insert_transaction( def _process_round_data(self, transaction_data: dict) -> dict: """Process round data and prepare transaction data.""" + vote_nonce = ( + 0 + if transaction_data.get("nonce") is None + else int(transaction_data["nonce"]) + ) + completed_rounds = completed_consensus_rounds( transaction_data.get("consensus_history") ) @@ -754,9 +760,7 @@ def _process_round_data(self, transaction_data: dict) -> dict: validator_votes.append(vote_number) leader_address = leader["node_config"]["address"] validator_votes_hash.append( - get_validator_vote_hash( - leader_address, vote_number, transaction_data["nonce"] - ) + get_validator_vote_hash(leader_address, vote_number, vote_nonce) ) round_validators.append(leader_address) @@ -768,9 +772,7 @@ def _process_round_data(self, transaction_data: dict) -> dict: validator_votes.append(vote_number) validator_address = validator["node_config"]["address"] validator_votes_hash.append( - get_validator_vote_hash( - validator_address, vote_number, transaction_data["nonce"] - ) + get_validator_vote_hash(validator_address, vote_number, vote_nonce) ) round_validators.append(validator_address) # Handle upgrade transactions specially - they bypass consensus diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index ebbe4e83b..f89582873 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -7,6 +7,7 @@ import eth_utils import logging from contextlib import asynccontextmanager +from dataclasses import dataclass from functools import partial, wraps from typing import Any, Final, NoReturn, get_args from backend.protocol_rpc.exceptions import ( @@ -2014,6 +2015,19 @@ def get_transaction_status_details( TransactionStatus.VALIDATORS_TIMEOUT.value, } + +@dataclass(frozen=True) +class _AppealAdmissionContext: + status: str + deadline: float | None + fee_accounting: dict[str, Any] + current_round: int + validator_count: int + normal_leader_count: int + available_appeal_validators: int | None + leader_timeout_live_seats: int | None + + _PROTOCOL_TRANSACTION_STATUS_NAMES = ( "Uninitialized", "Pending", @@ -2089,6 +2103,104 @@ def _transaction_decision_id(transaction: dict) -> int: return history_current_decision_id(transaction.get("consensus_history")) +def _appeal_admission_context( + transaction: dict, + *, + session: Session | None, + expected_decision_id: int | None = None, + require_decision_binding: bool = False, +) -> _AppealAdmissionContext: + """Resolve the eligibility inputs shared by appeal quote and admission.""" + status = str(transaction.get("status") or "") + if ( + status not in _DECISION_STATUSES + or bool(transaction.get("appealed")) + or has_terminal_validator_appeal(transaction.get("consensus_history")) + ): + raise InvalidTransactionError("CanNotAppeal") + + deadline = _transaction_appeal_deadline(transaction) + if deadline is not None and time.time() >= deadline: + raise InvalidTransactionError("CanNotAppeal") + if require_decision_binding and ( + expected_decision_id is None + or int(expected_decision_id) != _transaction_decision_id(transaction) + ): + raise InvalidTransactionError("CanNotAppeal") + + fee_accounting = (transaction.get("data") or {}).get(FEE_ACCOUNTING_KEY) + if fee_accounting is None: + raise InvalidTransactionError("FeeAccountingMissing") + if acceptance_dispatch_pending(fee_accounting): + raise InvalidTransactionError("CanNotAppeal") + + frozen_pool_addresses = fee_accounting.get("selection_pool_addresses") + live_pool_addresses = None + if session is not None and isinstance(frozen_pool_addresses, list): + live_pool_addresses = [ + validator.get("address") + for validator in ValidatorsRegistry(session).get_all_validators( + include_private_key=False + ) + ] + frozen_pool_count = fee_accounting.get("selection_pool_count") + validator_count = ( + int(frozen_pool_count) + if frozen_pool_count is not None + else ( + ValidatorsRegistry(session).count_validators() + if session is not None + else int( + fee_accounting.get("num_of_initial_validators") + or transaction.get("num_of_initial_validators") + or 5 + ) + ) + ) + current_round = _current_fee_round(transaction.get("consensus_history")) + available_appeal_validators = _available_appeal_validator_count( + transaction, + validator_count, + frozen_pool_addresses=frozen_pool_addresses, + live_pool_addresses=live_pool_addresses, + ) + if ( + status + in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, + } + and available_appeal_validators == 0 + ): + raise InvalidTransactionError("CanNotAppeal") + if ( + status == TransactionStatus.UNDETERMINED.value + and available_appeal_validators is not None + and available_appeal_validators + < VALIDATORS_PER_ROUND[min(current_round + 1, len(VALIDATORS_PER_ROUND) - 1)] + ): + raise InvalidTransactionError("CanNotAppeal") + + leader_timeout_live_seats = None + if status == TransactionStatus.LEADER_TIMEOUT.value: + live_validators = transaction.get("leader_timeout_validators") + if isinstance(live_validators, list): + # The removed leader changes replay membership, not the configured + # source committee used to price the leader-timeout bond. + leader_timeout_live_seats = len(live_validators) + 1 + + return _AppealAdmissionContext( + status=status, + deadline=deadline, + fee_accounting=fee_accounting, + current_round=current_round, + validator_count=validator_count, + normal_leader_count=_normal_leader_count(transaction.get("consensus_history")), + available_appeal_validators=available_appeal_validators, + leader_timeout_live_seats=leader_timeout_live_seats, + ) + + def _transaction_resolution_source_code( transaction: dict, *, status: str, current_round: int ) -> int: @@ -2217,90 +2329,22 @@ def estimate_latest_appeal_charge( data={"hash": transaction_hash}, ) - status = str(transaction.get("status") or "") - if ( - status not in _DECISION_STATUSES - or bool(transaction.get("appealed")) - or has_terminal_validator_appeal(transaction.get("consensus_history")) - ): - raise InvalidTransactionError("CanNotAppeal") - deadline = _transaction_appeal_deadline(transaction) - if deadline is not None and time.time() >= deadline: - raise InvalidTransactionError("CanNotAppeal") - - fee_accounting = (transaction.get("data") or {}).get(FEE_ACCOUNTING_KEY) - if fee_accounting is None: - raise InvalidTransactionError("FeeAccountingMissing") - if acceptance_dispatch_pending(fee_accounting): - raise InvalidTransactionError("CanNotAppeal") - session = getattr(transactions_processor, "session", None) - frozen_pool_addresses = fee_accounting.get("selection_pool_addresses") - live_pool_addresses = None - if session is not None and isinstance(frozen_pool_addresses, list): - live_pool_addresses = [ - validator.get("address") - for validator in ValidatorsRegistry(session).get_all_validators( - include_private_key=False - ) - ] - frozen_pool_count = fee_accounting.get("selection_pool_count") - validator_count = ( - int(frozen_pool_count) - if frozen_pool_count is not None - else ( - ValidatorsRegistry(session).count_validators() - if session is not None - else int( - fee_accounting.get("num_of_initial_validators") - or transaction.get("num_of_initial_validators") - or 5 - ) - ) - ) - current_round = _current_fee_round(transaction.get("consensus_history")) - available_appeal_validators = _available_appeal_validator_count( + context = _appeal_admission_context( transaction, - validator_count, - frozen_pool_addresses=frozen_pool_addresses, - live_pool_addresses=live_pool_addresses, + session=getattr(transactions_processor, "session", None), ) - if ( - status - in { - TransactionStatus.ACCEPTED.value, - TransactionStatus.VALIDATORS_TIMEOUT.value, - } - and available_appeal_validators == 0 - ): - raise InvalidTransactionError("CanNotAppeal") - if ( - status == TransactionStatus.UNDETERMINED.value - and available_appeal_validators is not None - and available_appeal_validators - < VALIDATORS_PER_ROUND[min(current_round + 1, len(VALIDATORS_PER_ROUND) - 1)] - ): - raise InvalidTransactionError("CanNotAppeal") - live_seats = None - if status == TransactionStatus.LEADER_TIMEOUT.value: - live_validators = transaction.get("leader_timeout_validators") - if isinstance(live_validators, list): - # Studio persists the survivors after removing the timed-out - # leader; Consensus applies its eligibility gate to the original - # committee before that removal. - live_seats = len(live_validators) + 1 charge = calculate_appeal_charge( - fee_accounting["fees_distribution"], - current_round=current_round, - status=status, + context.fee_accounting["fees_distribution"], + current_round=context.current_round, + status=context.status, terminal_committee_upper_bound=max( 0, - validator_count - - _normal_leader_count(transaction.get("consensus_history")), + context.validator_count - context.normal_leader_count, ), - available_appeal_validators=available_appeal_validators, - leader_timeout_live_seats=live_seats, + available_appeal_validators=context.available_appeal_validators, + leader_timeout_live_seats=context.leader_timeout_live_seats, policy=funding_policy_for_accounting( - fee_accounting, + context.fee_accounting, StudioFeePolicy.from_env(), ), ) @@ -2309,7 +2353,9 @@ def estimate_latest_appeal_charge( "decisionId": str(decision_id), "bond": str(int(charge["bond"])), "funding": str(int(charge["funding"])), - "appealDeadline": str(math.ceil(deadline)) if deadline is not None else "0", + "appealDeadline": ( + str(math.ceil(context.deadline)) if context.deadline is not None else "0" + ), } @@ -2667,122 +2713,43 @@ def _handle_appeal_or_top_up_and_submit( if tx is None: raise NotFoundError(message=TRANSACTION_NOT_FOUND_MESSAGE, data={"hash": tx_id}) - status = str(tx.get("status") or "") - if status not in { - TransactionStatus.ACCEPTED.value, - TransactionStatus.UNDETERMINED.value, - TransactionStatus.LEADER_TIMEOUT.value, - TransactionStatus.VALIDATORS_TIMEOUT.value, - } or bool(tx.get("appealed")): - raise InvalidTransactionError("CanNotAppeal") - if has_terminal_validator_appeal(tx.get("consensus_history")): - raise InvalidTransactionError("CanNotAppeal") - appeal_deadline = _transaction_appeal_deadline(tx) - if appeal_deadline is not None: - # Consensus rejects at `block.timestamp >= appealDeadline`; do the - # same at the RPC admission boundary instead of relying on the - # asynchronous finalization worker to win the race first. - if time.time() >= appeal_deadline: - raise InvalidTransactionError("CanNotAppeal") - consensus_history = tx.get("consensus_history") expected_decision_id = decoded_rollup_transaction.data.expected_decision_id - current_round = _current_fee_round(consensus_history) + # v0.6 binds every appeal to one exact materialized decision. Older SDKs + # may still decode without this argument, but admission rejects them. + context = _appeal_admission_context( + tx, + session=getattr(accounts_manager, "session", None), + expected_decision_id=expected_decision_id, + require_decision_binding=True, + ) # New Studio decisions persist their exact materialized ID. Legacy rows # derive the same monotonic ordinal from alternating normal/appeal history. current_decision_id = _transaction_decision_id(tx) - # v0.6 binds every appeal to one exact materialized decision. Older SDKs - # emitted selectors without this argument; Studio may still decode those - # transactions to surface a canonical error, but must never admit the - # unbound appeal when deployed Consensus would reject it. - if expected_decision_id is None or int(expected_decision_id) != current_decision_id: - raise InvalidTransactionError("CanNotAppeal") - fee_accounting = (tx.get("data") or {}).get(FEE_ACCOUNTING_KEY) - if fee_accounting is None: - raise InvalidTransactionError("FeeAccountingMissing") - if acceptance_dispatch_pending(fee_accounting): - raise InvalidTransactionError("CanNotAppeal") - if fee_accounting is not None: - session = getattr(accounts_manager, "session", None) - frozen_pool_count = fee_accounting.get("selection_pool_count") - frozen_pool_addresses = fee_accounting.get("selection_pool_addresses") - live_pool_addresses = None - if session is not None and isinstance(frozen_pool_addresses, list): - live_pool_addresses = [ - validator.get("address") - for validator in ValidatorsRegistry(session).get_all_validators( - include_private_key=False - ) - ] - validator_count = ( - int(frozen_pool_count) - if frozen_pool_count is not None - else ( - ValidatorsRegistry(session).count_validators() - if session is not None - else int( - fee_accounting.get("num_of_initial_validators") - or tx.get("num_of_initial_validators") - or 5 - ) - ) - ) - normal_leader_count = _normal_leader_count(consensus_history) - available_appeal_validators = _available_appeal_validator_count( - tx, - validator_count, - frozen_pool_addresses=frozen_pool_addresses, - live_pool_addresses=live_pool_addresses, - ) - if ( - status - in { - TransactionStatus.ACCEPTED.value, - TransactionStatus.VALIDATORS_TIMEOUT.value, - } - and available_appeal_validators == 0 - ): - raise InvalidTransactionError("CanNotAppeal") - if ( - status == TransactionStatus.UNDETERMINED.value - and available_appeal_validators is not None - and available_appeal_validators - < VALIDATORS_PER_ROUND[ - min(current_round + 1, len(VALIDATORS_PER_ROUND) - 1) - ] - ): - raise InvalidTransactionError("CanNotAppeal") - replacement_rotations = None - leader_timeout_live_seats = None - if status == TransactionStatus.LEADER_TIMEOUT.value: - live_validators = tx.get("leader_timeout_validators") - if isinstance(live_validators, list): - leader_timeout_live_seats = len(live_validators) + 1 - - def prepare_fee_accounting(current_fee_accounting): - if current_fee_accounting is None: - raise FeeValidationError("FeeAccountingMissing") - updated = record_appeal_bond( + def prepare_fee_accounting(current_fee_accounting): + if current_fee_accounting is None: + raise FeeValidationError("FeeAccountingMissing") + updated = record_appeal_bond( + current_fee_accounting, + amount=decoded_rollup_transaction.total_spend, + appealer=decoded_rollup_transaction.from_address, + current_round=context.current_round, + status=context.status, + fees_distribution=decoded_rollup_transaction.data.fees_distribution, + top_up_and_submit=decoded_rollup_transaction.data.top_up_and_submit, + terminal_committee_upper_bound=max( + 0, + context.validator_count - context.normal_leader_count, + ), + available_appeal_validators=context.available_appeal_validators, + replacement_rotations=None, + leader_timeout_live_seats=context.leader_timeout_live_seats, + policy=funding_policy_for_accounting( current_fee_accounting, - amount=decoded_rollup_transaction.total_spend, - appealer=decoded_rollup_transaction.from_address, - current_round=current_round, - status=status, - fees_distribution=decoded_rollup_transaction.data.fees_distribution, - top_up_and_submit=decoded_rollup_transaction.data.top_up_and_submit, - terminal_committee_upper_bound=max( - 0, - validator_count - normal_leader_count, - ), - available_appeal_validators=available_appeal_validators, - replacement_rotations=replacement_rotations, - leader_timeout_live_seats=leader_timeout_live_seats, - policy=funding_policy_for_accounting( - current_fee_accounting, - StudioFeePolicy.from_env(), - ), - ) - return updated, int(updated["appeal_bonds"][-1]["surplusRefund"]) + StudioFeePolicy.from_env(), + ), + ) + return updated, int(updated["appeal_bonds"][-1]["surplusRefund"]) submitted_at = int(time.time()) failed_reduction = float( @@ -2796,8 +2763,8 @@ def prepare_fee_accounting(current_fee_accounting): expected_decision_id=current_decision_id, submitted_at=submitted_at, appeal_deadline=( - int(appeal_deadline) - if appeal_deadline is not None + int(context.deadline) + if context.deadline is not None else submitted_at + int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) ), retention_bps=round((1.0 - failed_reduction) * 10_000), diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 33449c8d7..2ae086c42 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -1890,7 +1890,7 @@ def create_child_fee_accounting( *, message: dict[str, Any], parent_fees_distribution: dict[str, Any] | None, - message_allocations: list[dict[str, Any]] | None = None, + message_allocations: list[dict[str, Any]] | str | None = None, sender: str | None = None, policy: StudioFeePolicy | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: @@ -1942,9 +1942,15 @@ def create_child_fee_accounting( if parent_fees_distribution else normalize_fees_distribution({}) ) + # Old GenVM receipts may expose the allocation subtree as its encoded hex + # form. Preserve that raw value in the receipt/hash path, but never iterate + # its characters as FlatArrays allocation nodes here. + allocation_nodes = ( + message_allocations if isinstance(message_allocations, list) else [] + ) child_message_allocations = _child_allocations_from_message_subtree( message, - message_allocations or [], + allocation_nodes, ) # Mode 1 children have no allocation subtree but still receive the remainder # of their declared budget as a message-fee bucket for their own children. diff --git a/backend/protocol_rpc/ghost_factory.py b/backend/protocol_rpc/ghost_factory.py index 22ec6a5f0..f4a2218ab 100644 --- a/backend/protocol_rpc/ghost_factory.py +++ b/backend/protocol_rpc/ghost_factory.py @@ -18,10 +18,9 @@ UINT256_MAX = (1 << 256) - 1 # These identify Studio's virtual v0.6 protocol deployment. Operators that -# intentionally mirror a concrete EVM deployment can override all three values -# with that deployment's GhostFactory, CreationPhase, and bytecodeEVM values. +# intentionally mirror a concrete EVM deployment can override the factory, +# bytecodeEVM hash, and initial factory nonce. DEFAULT_GHOST_FACTORY_ADDRESS = "0x130e09c996462963A6398CA04e1011e6ef9d68a6" -DEFAULT_CREATION_PHASE_ADDRESS = "0x6AF00387f985684f159d673b15265E651B9E29f8" DEFAULT_GHOST_BYTECODE_HASH = ( "0x4249189346902cb6f0afc196d5719e1b4cbc861a700ae3b0c6dcbe51e5e566de" ) @@ -35,7 +34,6 @@ class InvalidGhostFactoryConfiguration(ValueError): @dataclass(frozen=True) class GhostFactoryConfig: factory_address: str - creation_phase_address: str bytecode_hash: bytes initial_nonce: int @@ -45,10 +43,6 @@ def from_env(cls) -> "GhostFactoryConfig": "GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS", DEFAULT_GHOST_FACTORY_ADDRESS, ) - creation_phase_address = _configured_address( - "GENLAYER_STUDIO_CREATION_PHASE_ADDRESS", - DEFAULT_CREATION_PHASE_ADDRESS, - ) bytecode_hash = _configured_bytes32( "GENLAYER_STUDIO_GHOST_BYTECODE_HASH", DEFAULT_GHOST_BYTECODE_HASH, @@ -59,7 +53,6 @@ def from_env(cls) -> "GhostFactoryConfig": ) return cls( factory_address=factory_address, - creation_phase_address=creation_phase_address, bytecode_hash=bytecode_hash, initial_nonce=initial_nonce, ) diff --git a/backend/protocol_rpc/types.py b/backend/protocol_rpc/types.py index 190763e28..fef192271 100644 --- a/backend/protocol_rpc/types.py +++ b/backend/protocol_rpc/types.py @@ -29,7 +29,7 @@ def to_json(self) -> dict[str]: @dataclass class DecodedsubmitAppealDataArgs: - tx_id: str + tx_id: str | bytes expected_decision_id: int | None = None fees_distribution: dict | None = None top_up_and_submit: bool = False @@ -37,13 +37,13 @@ class DecodedsubmitAppealDataArgs: @dataclass class DecodedTopUpFeesDataArgs: - tx_id: str + tx_id: str | bytes fees_distribution: dict @dataclass class DecodedFinalizeTransactionDataArgs: - tx_id: str + tx_id: str | bytes expected_decision_id: int | None = None diff --git a/backend/rollup/consensus_service.py b/backend/rollup/consensus_service.py index cf351a638..eef5de084 100644 --- a/backend/rollup/consensus_service.py +++ b/backend/rollup/consensus_service.py @@ -283,7 +283,7 @@ def add_transaction( from_address: str, retry: bool = True, calldata: str | HexBytes | None = None, - ) -> str: + ) -> Dict[str, Any] | None: """ Forward a transaction to the consensus rollup and wait for NewTransaction event """ diff --git a/docker-compose.yml b/docker-compose.yml index 513ddc64e..1a1c1dff1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -151,7 +151,6 @@ services: - GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS=${GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS:-100000} - GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES=${GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES:-1024} - GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS=${GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS:-0x130e09c996462963A6398CA04e1011e6ef9d68a6} - - GENLAYER_STUDIO_CREATION_PHASE_ADDRESS=${GENLAYER_STUDIO_CREATION_PHASE_ADDRESS:-0x6AF00387f985684f159d673b15265E651B9E29f8} - GENLAYER_STUDIO_GHOST_BYTECODE_HASH=${GENLAYER_STUDIO_GHOST_BYTECODE_HASH:-0x4249189346902cb6f0afc196d5719e1b4cbc861a700ae3b0c6dcbe51e5e566de} - GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE=${GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE:-2} # Per-recipient defaults to Consensus Queues.maxPendingTxsPerRecipient diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index 54f89da5d..b09c663af 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -36,6 +36,7 @@ SHELL ["/nix/var/nix/profiles/default/bin/bash", "-x", "-c"] # `runners-all` closure here from a sandboxed Nix. COPY .genvm-nix-closure/ /genvm-nix-closure/ COPY docker/genvm-source-build.nix.conf /genvm-source-build.nix.conf +COPY third_party/genvm/version /genvm-default-version # Source builds bind Studio to a genvm-manager branch/SHA. The flake target # follows TARGETARCH (amd64-linux / arm64-linux); runners are included by @@ -49,18 +50,22 @@ COPY docker/genvm-source-build.nix.conf /genvm-source-build.nix.conf # nothing from the cache, and builds from source instead of failing. RUN --mount=type=secret,id=nix_netrc,target=/etc/nix/netrc,required=false \ set -euo pipefail ; \ - if [[ -n "$GENVM_TAG" && -n "$GENVM_REF" ]]; then \ + effective_ref="$GENVM_REF" ; \ + if [[ -z "$GENVM_TAG" && -z "$effective_ref" ]]; then \ + effective_ref="$(< /genvm-default-version)" ; \ + fi ; \ + if [[ -n "$GENVM_TAG" && -n "$effective_ref" ]]; then \ echo "ERROR: GENVM_TAG and GENVM_REF are mutually exclusive" ; exit 1 ; \ fi ; \ source_selected=0 ; \ - if [[ "$GENVM_SOURCE_MODE" == "source" || ( -z "$GENVM_SOURCE_MODE" && "$GENVM_REF" =~ ^.+:[0-9a-fA-F]{7,40}$ ) ]]; then \ + if [[ "$GENVM_SOURCE_MODE" == "source" || ( -z "$GENVM_SOURCE_MODE" && "$effective_ref" =~ ^.+:[0-9a-fA-F]{7,40}$ ) ]]; then \ source_selected=1 ; \ fi ; \ if [[ "$source_selected" == "1" ]]; then \ - if [[ -z "$GENVM_REF" ]]; then \ + if [[ -z "$effective_ref" ]]; then \ echo "ERROR: GENVM_SOURCE_MODE=source requires GENVM_REF" ; exit 1 ; \ fi ; \ - SOURCE_REF="$GENVM_REF" ; \ + SOURCE_REF="$effective_ref" ; \ if [[ "$SOURCE_REF" =~ ^.+:([0-9a-fA-F]{7,40})$ ]]; then \ SOURCE_REF="${BASH_REMATCH[1]}" ; \ fi ; \ @@ -160,7 +165,11 @@ COPY third_party/genvm/version /genvm-default-version COPY docker/scripts/download_genvm.sh /usr/local/bin/download-genvm COPY --from=genvm-runner-pin /genvm-pin/py-genlayer-pins /genvm-runner-pins RUN set -euo pipefail ; \ - if [[ -n "$GENVM_TAG" && -n "$GENVM_REF" ]]; then \ + effective_ref="$GENVM_REF" ; \ + if [[ -z "$GENVM_TAG" && -z "$effective_ref" ]]; then \ + effective_ref="$(< /genvm-default-version)" ; \ + fi ; \ + if [[ -n "$GENVM_TAG" && -n "$effective_ref" ]]; then \ echo "ERROR: GENVM_TAG and GENVM_REF are mutually exclusive" ; exit 1 ; \ fi ; \ case "$GENVM_SOURCE_MODE" in \ @@ -171,7 +180,7 @@ RUN set -euo pipefail ; \ if [[ -z "$mode" ]]; then \ if [[ -x /genvm-prebuilt/bin/genvm-modules ]]; then \ mode=prebuilt ; \ - elif [[ "$GENVM_REF" =~ ^.+:[0-9a-fA-F]{7,40}$ ]]; then \ + elif [[ "$effective_ref" =~ ^.+:[0-9a-fA-F]{7,40}$ ]]; then \ mode=source ; \ else \ mode=release ; \ @@ -186,16 +195,16 @@ RUN set -euo pipefail ; \ cp -a /genvm-prebuilt/. /genvm/ ; \ rm -f /genvm/.gitkeep ; \ elif [[ "$mode" == "source" ]]; then \ - if [[ -z "$GENVM_REF" ]]; then \ + if [[ -z "$effective_ref" ]]; then \ echo "ERROR: source mode requires GENVM_REF (a git ref/SHA or :)" ; exit 1 ; \ fi ; \ if [[ ! -x /genvm/bin/genvm-modules ]]; then \ echo "ERROR: the GenVM source stage did not produce /genvm/bin/genvm-modules" ; exit 1 ; \ fi ; \ - printf '%s\n' "$GENVM_REF" > /genvm/version ; \ - echo "Using GenVM built from source at $GENVM_REF" ; \ + printf '%s\n' "$effective_ref" > /genvm/version ; \ + echo "Using GenVM built from source at $effective_ref" ; \ else \ - if [[ -n "$GENVM_REF" ]]; then \ + if [[ -n "$effective_ref" ]]; then \ echo "ERROR: release mode does not accept GENVM_REF; use GENVM_SOURCE_MODE=source" ; exit 1 ; \ fi ; \ release_version="${GENVM_TAG:-$(< /genvm-default-version)}" ; \ diff --git a/frontend/src/components/Simulator/TransactionItem.vue b/frontend/src/components/Simulator/TransactionItem.vue index 6d1692020..4f73b2c44 100644 --- a/frontend/src/components/Simulator/TransactionItem.vue +++ b/frontend/src/components/Simulator/TransactionItem.vue @@ -74,7 +74,7 @@ const props = defineProps<{ }>(); const finalityWindowAppealFailedReduction = ref( - getRuntimeConfigNumber('VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION', 0.2), + getRuntimeConfigNumber('VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION', 0), ); const isDetailsModalOpen = ref(false); diff --git a/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts b/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts index 16b13aa1b..823305eee 100644 --- a/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts +++ b/frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts @@ -1,6 +1,7 @@ import { mount } from '@vue/test-utils'; import { describe, expect, it, vi } from 'vitest'; import TransactionItem from '@/components/Simulator/TransactionItem.vue'; +import { getRuntimeConfigNumber } from '@/utils/runtimeConfig'; const setTransactionAppealMock = vi.fn(); @@ -249,6 +250,11 @@ describe('TransactionItem fee accounting display', () => { }, }); + expect(getRuntimeConfigNumber).toHaveBeenCalledWith( + 'VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION', + 0, + ); + await wrapper.trigger('click'); const text = wrapper.text(); diff --git a/hardhat/contracts/v2_contracts/ConsensusMain.sol b/hardhat/contracts/v2_contracts/ConsensusMain.sol index a6d832d2e..f80a57608 100644 --- a/hardhat/contracts/v2_contracts/ConsensusMain.sol +++ b/hardhat/contracts/v2_contracts/ConsensusMain.sol @@ -368,7 +368,8 @@ contract ConsensusMain is senderWord > type(uint160).max || recipientWord > type(uint160).max || txCalldataOffset < FEE_AWARE_PARAMS_HEAD_SIZE || - txCalldataOffset % 32 != 0 + txCalldataOffset % 32 != 0 || + txCalldataOffset > type(uint256).max - tupleStart ) revert InvalidFeeAwareTransactionEncoding(); uint256 txCalldataLengthOffset = tupleStart + txCalldataOffset; diff --git a/hardhat/test/deploy/001_deploy_contracts.test.js b/hardhat/test/deploy/001_deploy_contracts.test.js index 90a9a94ef..f86e030d9 100644 --- a/hardhat/test/deploy/001_deploy_contracts.test.js +++ b/hardhat/test/deploy/001_deploy_contracts.test.js @@ -81,6 +81,30 @@ describe("Deploy Script", function () { expect(mainContracts.genManager).to.equal(consensusManagerAddress); }); + it("should reject overflowing fee-aware calldata offsets with the protocol error", async function() { + const tupleOffset = ethers.zeroPadValue(ethers.toBeHex(32), 32).slice(2); + const words = Array(10).fill("0".repeat(64)); + words[8] = "f".repeat(64); + const data = `0x35a251fb${tupleOffset}${words.join("")}`; + + let error; + try { + await owner.call({ + to: await contracts.ConsensusMain.getAddress(), + data, + }); + } catch (caught) { + error = caught; + } + + expect(error).to.not.equal(undefined); + expect(error.data).to.equal( + contracts.ConsensusMain.interface.getError( + "InvalidFeeAwareTransactionEncoding" + ).selector + ); + }); + it("should have initialized Transactions with all its dependencies", async function() { const consensusMainAddress = await contracts.ConsensusMain.getAddress(); const contracts_ = await contracts.Transactions.contracts(); diff --git a/scripts/prepare-genvm-source-build.sh b/scripts/prepare-genvm-source-build.sh index f3d77e15a..fefc2ae96 100755 --- a/scripts/prepare-genvm-source-build.sh +++ b/scripts/prepare-genvm-source-build.sh @@ -6,6 +6,11 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" GENVM_REF=${GENVM_REF:-} GENVM_SOURCE_MODE=${GENVM_SOURCE_MODE:-} +GENVM_TAG=${GENVM_TAG:-} + +if [[ -z "$GENVM_TAG" && -z "$GENVM_REF" ]]; then + GENVM_REF="$(< "$REPO_ROOT/third_party/genvm/version")" +fi source_selected=0 if [[ "$GENVM_SOURCE_MODE" == "source" || ( -z "$GENVM_SOURCE_MODE" && "$GENVM_REF" =~ ^.+:[0-9a-fA-F]{7,40}$ ) ]]; then diff --git a/tests/db-sqlalchemy/accounts_manager_test.py b/tests/db-sqlalchemy/accounts_manager_test.py index 1ead9e555..378138505 100644 --- a/tests/db-sqlalchemy/accounts_manager_test.py +++ b/tests/db-sqlalchemy/accounts_manager_test.py @@ -291,6 +291,66 @@ def test_abort_tx_appeal_admission_refunds_charge_and_restores_decision( assert restored["fees_distribution"] == accounting["fees_distribution"] +def test_abort_zero_value_appeal_admission_still_persists_rollback( + accounts_manager: AccountsManager, + session: Session, +): + sender = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501" + appealer = "0x1111111111111111111111111111111111111111" + tx_hash = "0x" + "a0" * 32 + fees = _fees_distribution( + leader_timeunits=0, + validator_timeunits=0, + appeals=0, + rotations=[0], + ) + accounting = create_fee_accounting( + fees_distribution=fees, + num_of_validators=5, + submitted_value=0, + user_value=0, + sender=sender, + ) + recorded = record_appeal_bond( + accounting, + amount=0, + appealer=appealer, + current_round=0, + status="ACCEPTED", + ) + assert recorded["appeal_bonds"][-1]["requiredCharge"] == 0 + _insert_fee_accounted_transaction( + session, + sender=sender, + accounting=recorded, + tx_hash=tx_hash, + ) + tx_model = session.query(Transactions).filter_by(hash=tx_hash).one() + tx_model.appealed = True + tx_model.timestamp_appeal = 123 + tx_model.consensus_history = { + ACTIVE_APPEAL_BASIS_KEY: {"decisionId": 1, "submittedAt": 123} + } + tx_model.data = { + **tx_model.data, + APPEAL_RECOVERY_SNAPSHOT_KEY: {"status": "ACCEPTED"}, + } + session.commit() + + assert accounts_manager.abort_tx_appeal_admission_once(tx_hash) == 0 + session.commit() + session.expire_all() + + tx = session.query(Transactions).filter_by(hash=tx_hash).one() + restored = tx.data[FEE_ACCOUNTING_KEY] + assert tx.appealed is False + assert tx.timestamp_appeal is None + assert ACTIVE_APPEAL_BASIS_KEY not in tx.consensus_history + assert APPEAL_RECOVERY_SNAPSHOT_KEY not in tx.data + assert restored["appeal_bonds"] == [] + assert restored["aborted_appeals"][-1]["refund"] == 0 + + def test_appeal_admission_rechecks_pending_acceptance_effects_under_lock( session: Session, ): diff --git a/tests/db-sqlalchemy/evm_envelope_migration_backfill_test.py b/tests/db-sqlalchemy/evm_envelope_migration_backfill_test.py new file mode 100644 index 000000000..0fe6a03c3 --- /dev/null +++ b/tests/db-sqlalchemy/evm_envelope_migration_backfill_test.py @@ -0,0 +1,70 @@ +"""Regression coverage for the legacy EVM-envelope ledger backfill.""" + +import os + +from alembic import command +from sqlalchemy import Engine, text + +from conftest import _alembic_config + +PRE_ENVELOPE_LEDGER_REVISION = "d2e3f4a5b6c7" + + +def test_envelope_backfill_prefers_dated_duplicate_over_null_timestamp( + migrated_engine: Engine, +) -> None: + cfg = _alembic_config(os.environ["POSTGRES_URL"]) + sender = "0x" + "ab" * 20 + null_timestamp_hash = "0x" + "ff" * 32 + dated_hash = "0x" + "11" * 32 + + try: + command.downgrade(cfg, PRE_ENVELOPE_LEDGER_REVISION) + with migrated_engine.connect() as conn: + for tx_hash, from_address, created_at in ( + (null_timestamp_hash, sender.upper(), None), + (dated_hash, sender, "2026-08-28T12:00:00+00:00"), + ): + conn.execute( + text( + """ + INSERT INTO transactions ( + hash, from_address, nonce, value, type, + leader_only, appealed, appeal_undetermined, + appeal_leader_timeout, appeal_validators_timeout, + created_at + ) VALUES ( + :hash, :from_address, 7, 0, 2, + false, false, false, false, false, + :created_at + ) + """ + ), + { + "hash": tx_hash, + "from_address": from_address, + "created_at": created_at, + }, + ) + conn.commit() + + command.upgrade(cfg, "head") + + with migrated_engine.connect() as conn: + envelope_hash = conn.execute( + text( + """ + SELECT hash + FROM evm_envelopes + WHERE from_address = lower(:sender) AND nonce = 7 + """ + ), + {"sender": sender}, + ).scalar_one() + + assert envelope_hash == dated_hash + finally: + command.upgrade(cfg, "head") + with migrated_engine.connect() as conn: + conn.execute(text("TRUNCATE TABLE evm_envelopes, transactions CASCADE")) + conn.commit() diff --git a/tests/hardhat/test_hardhat.py b/tests/hardhat/test_hardhat.py index 093f7bbe3..b57ef4a7a 100644 --- a/tests/hardhat/test_hardhat.py +++ b/tests/hardhat/test_hardhat.py @@ -2,6 +2,7 @@ import json from eth_account import Account import os +import pytest def test_eth_account(): @@ -42,6 +43,8 @@ def connect_to_hardhat(): Exception: If the connection to the Hardhat network fails. """ hardhat_url = os.environ.get("HARDHAT_URL") + if not hardhat_url: + pytest.skip("HARDHAT_URL is not configured") web3 = Web3(Web3.HTTPProvider(hardhat_url)) # Check connection diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 3dfaa75e7..872d68407 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -55,6 +55,11 @@ def _is_expected_simulation_execution_failure(error: Exception) -> bool: return "execution failed" in str(error).lower() +def _fees_argument_was_omitted(call_kwargs: dict) -> bool: + """Distinguish SDK omission from an intentional gasless ``fees=None``.""" + return "fees" not in call_kwargs + + def _estimate_deploy_fees( client: GenLayerClient, *, @@ -104,8 +109,8 @@ def use_fee_aware_sdk_defaults(): original_write = GenLayerClient.write_contract @wraps(original_deploy) - def deploy_with_default_fees(client, *args, **call_kwargs): - if call_kwargs.get("fees") is None: + def deploy_with_default_fees(client: GenLayerClient, *args, **call_kwargs): + if _fees_argument_was_omitted(call_kwargs): fees = None default_fees = _default_studio_fees(client) if default_fees is not None: @@ -134,8 +139,8 @@ def deploy_with_default_fees(client, *args, **call_kwargs): return original_deploy(client, *args, **call_kwargs) @wraps(original_write) - def write_with_default_fees(client, *args, **call_kwargs): - if call_kwargs.get("fees") is None: + def write_with_default_fees(client: GenLayerClient, *args, **call_kwargs): + if _fees_argument_was_omitted(call_kwargs): fees = None default_fees = _default_studio_fees(client) if default_fees is not None: diff --git a/tests/unit/consensus/test_appeal_state_restore.py b/tests/unit/consensus/test_appeal_state_restore.py index 19aec6412..fb46e1e36 100644 --- a/tests/unit/consensus/test_appeal_state_restore.py +++ b/tests/unit/consensus/test_appeal_state_restore.py @@ -360,7 +360,9 @@ async def test_returning_clause_selects_snapshot_and_history_columns(self): assert "transactions.consensus_history" in returning_clause @pytest.mark.asyncio - async def test_claim_waits_for_any_inflight_descendant_on_same_contract(self): + async def test_claim_waits_for_live_or_nonterminal_descendants_on_same_contract( + self, + ): session = Mock() session.execute.return_value.first.return_value = None @@ -372,9 +374,29 @@ async def test_claim_waits_for_any_inflight_descendant_on_same_contract(self): "AND pg_try_advisory", 1 )[0] assert "t2.blocked_at IS NOT NULL" in blocked_guard - assert "t2.blocked_at >" not in blocked_guard + assert "t2.status NOT IN ('FINALIZED', 'CANCELED')" in blocked_guard + assert "t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL)" in blocked_guard assert "t2.appealed = true" not in blocked_guard + @pytest.mark.asyncio + async def test_finalization_ignores_only_stale_terminal_descendant_leases(self): + session = Mock() + session.execute.return_value.first.return_value = None + + worker = _make_worker() + worker.consensus_algorithm = SimpleNamespace( + finality_window_time=1800, + finality_window_appeal_failed_reduction=0, + ) + await worker.claim_next_finalization(session) + + executed_sql = str(session.execute.call_args[0][0]) + blocked_guard = executed_sql.split("Ensure no other transaction", 1)[1].split( + "AND pg_try_advisory", 1 + )[0] + assert "t2.status NOT IN ('FINALIZED', 'CANCELED')" in blocked_guard + assert "t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL)" in blocked_guard + @pytest.mark.asyncio async def test_claimed_appeal_hydrates_transaction_snapshot(self): """End-to-end over the claimed dict: Transaction.from_dict must produce diff --git a/tests/unit/test_ghost_factory.py b/tests/unit/test_ghost_factory.py index 9df90a6b4..dee1321e6 100644 --- a/tests/unit/test_ghost_factory.py +++ b/tests/unit/test_ghost_factory.py @@ -10,7 +10,6 @@ def test_default_virtual_factory_matches_frozen_create_vectors(monkeypatch): for name in ( "GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS", - "GENLAYER_STUDIO_CREATION_PHASE_ADDRESS", "GENLAYER_STUDIO_GHOST_BYTECODE_HASH", "GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE", ): @@ -81,7 +80,6 @@ def test_genvm_salted_child_address_rejects_invalid_salt(salt_nonce): ("name", "value"), [ ("GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS", "not-an-address"), - ("GENLAYER_STUDIO_CREATION_PHASE_ADDRESS", "0x12"), ("GENLAYER_STUDIO_GHOST_BYTECODE_HASH", "0x1234"), ("GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE", "-1"), ], diff --git a/tests/unit/test_integration_fee_defaults.py b/tests/unit/test_integration_fee_defaults.py new file mode 100644 index 000000000..baf194461 --- /dev/null +++ b/tests/unit/test_integration_fee_defaults.py @@ -0,0 +1,7 @@ +from tests.integration.conftest import _fees_argument_was_omitted + + +def test_fee_default_injection_preserves_explicit_gasless_none(): + assert _fees_argument_was_omitted({}) is True + assert _fees_argument_was_omitted({"fees": None}) is False + assert _fees_argument_was_omitted({"fees": {"feeValue": 1}}) is False diff --git a/tests/unit/test_shared_genvm_image_layout.py b/tests/unit/test_shared_genvm_image_layout.py index 4d9a75373..b732c6bb7 100644 --- a/tests/unit/test_shared_genvm_image_layout.py +++ b/tests/unit/test_shared_genvm_image_layout.py @@ -48,6 +48,23 @@ def test_backend_services_share_one_genvm_parent(): assert "USER worker-user" in dockerfile +def test_default_genvm_binding_selects_source_mode_in_both_build_stages(): + dockerfile = DOCKERFILE.read_text() + source_stage = _stage_body(dockerfile, "genvm-source-build") + runtime_stage = _stage_body(dockerfile, "genvm-runtime") + prepare_script = ( + REPO_ROOT / "scripts" / "prepare-genvm-source-build.sh" + ).read_text() + + assert ( + dockerfile.count("COPY third_party/genvm/version /genvm-default-version") == 2 + ) + for stage in (source_stage, runtime_stage): + assert 'effective_ref="$(< /genvm-default-version)"' in stage + assert '"$effective_ref" =~ ^.+:[0-9a-fA-F]{7,40}$' in stage + assert 'GENVM_REF="$(< "$REPO_ROOT/third_party/genvm/version")"' in prepare_script + + def test_debug_target_installs_debugpy_with_the_pip_cache(): dockerfile = DOCKERFILE.read_text() debug_stage = _stage_body(dockerfile, "debug") diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 0011fbeba..b49c2722d 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -33,6 +33,7 @@ from backend.database_handler.accounts_manager import _infer_final_round from backend.database_handler.transactions_processor import ( TransactionsProcessor, + get_validator_vote_hash, get_tx_execution_hash, ) from backend.protocol_rpc.exceptions import JSONRPCError, NotFoundError @@ -9374,6 +9375,26 @@ def test_create_child_fee_accounting_installs_child_allocation_subtree(): assert updated["allocation_consumed"] == {"0": 55} +def test_create_child_fee_accounting_does_not_traverse_encoded_allocation_subtree(): + child_fees, child_accounting = create_child_fee_accounting( + message={ + "messageType": 1, + "recipient": "0x3333333333333333333333333333333333333333", + "value": 0, + "onAcceptance": True, + "feeParams": _encode_internal_fee_params(), + "declaredBudget": 55, + "callKey": EMPTY_CALL_KEY, + }, + parent_fees_distribution=_fees_distribution(), + message_allocations="0x0102ff", + sender="0x1111111111111111111111111111111111111111", + ) + + assert child_fees["totalMessageFees"] == 0 + assert child_accounting["message_allocations"] == [] + + def test_create_child_fee_accounting_strips_leaf_matched_root_subtree(): fee_params = _encode_internal_fee_params() child_fees, child_accounting = create_child_fee_accounting( @@ -12940,6 +12961,40 @@ def test_transaction_round_payload_uses_raw_round_index_and_ignores_rotation_eve assert result["last_round"]["validator_votes"] == [1] +def test_transaction_round_payload_normalizes_legacy_null_nonce(): + processor = TransactionsProcessor.__new__(TransactionsProcessor) + leader_address = "0x1111111111111111111111111111111111111111" + leader = { + "vote": "agree", + "execution_result": "SUCCESS", + "node_config": {"address": leader_address}, + } + transaction = { + "consensus_history": { + "consensus_results": [ + { + "consensus_round": "Accepted", + "leader_result": [leader, leader], + "validator_results": [], + } + ] + }, + "data": {}, + "nonce": None, + "config_rotation_rounds": 0, + "rotation_count": 0, + "type": TransactionType.RUN_CONTRACT, + "status": "ACCEPTED", + "execution_mode": "NORMAL", + } + + result = processor._process_round_data(transaction) + + assert result["last_round"]["validator_votes_hash"] == [ + get_validator_vote_hash(leader_address, 1, 0) + ] + + def test_transaction_round_payload_handles_empty_history_and_raw_appeal_gaps(): processor = TransactionsProcessor.__new__(TransactionsProcessor) base = { diff --git a/tests/unit/test_worker_transient_fatal.py b/tests/unit/test_worker_transient_fatal.py index d6dc459f7..78ea59d09 100644 --- a/tests/unit/test_worker_transient_fatal.py +++ b/tests/unit/test_worker_transient_fatal.py @@ -295,6 +295,30 @@ async def test_paid_appeal_claim_honors_retry_backoff(): create_task.assert_not_called() +@pytest.mark.asyncio +async def test_acceptance_repair_finalization_honors_retry_backoff(): + worker = _make_worker() + session = MagicMock(spec=Session) + worker.claim_next_appeal = AsyncMock(return_value=None) + worker.claim_next_finalization = AsyncMock(return_value={"hash": "0xtx"}) + worker.claim_next_transaction = AsyncMock(return_value=None) + worker._generic_error_retries["0xtx"] = { + "count": 1, + "last_attempt": 100.0, + "last_error": "helper unavailable", + } + + with ( + patch("backend.consensus.worker.time.time", return_value=100.0), + patch.object(worker, "release_transaction") as release_transaction, + patch("backend.consensus.worker.asyncio.create_task") as create_task, + ): + assert await worker._try_claim_work(session) is False + + release_transaction.assert_called_once_with(session, "0xtx") + create_task.assert_not_called() + + @pytest.mark.asyncio async def test_generic_appeal_error_is_retryable_without_cancellation(): worker = _make_worker() From d0da103404afb97a43bdf6ce5a5da4dfe506a28b Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 19:02:17 +0100 Subject: [PATCH 53/55] test(ci): isolate fee default regression --- tests/common/fee_defaults.py | 3 +++ tests/integration/conftest.py | 10 +++------- tests/unit/test_integration_fee_defaults.py | 8 ++++---- 3 files changed, 10 insertions(+), 11 deletions(-) create mode 100644 tests/common/fee_defaults.py diff --git a/tests/common/fee_defaults.py b/tests/common/fee_defaults.py new file mode 100644 index 000000000..3117e91e5 --- /dev/null +++ b/tests/common/fee_defaults.py @@ -0,0 +1,3 @@ +def fees_argument_was_omitted(call_kwargs: dict) -> bool: + """Distinguish SDK omission from an intentional gasless ``fees=None``.""" + return "fees" not in call_kwargs diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 872d68407..2ae2de569 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -11,6 +11,7 @@ from genlayer_py.contracts.utils import make_calldata_object from web3.constants import ADDRESS_ZERO +from tests.common.fee_defaults import fees_argument_was_omitted from tests.integration import gltest_compat # gltest 0.24 discovers only the legacy base; remove after the gltest pin moves. @@ -55,11 +56,6 @@ def _is_expected_simulation_execution_failure(error: Exception) -> bool: return "execution failed" in str(error).lower() -def _fees_argument_was_omitted(call_kwargs: dict) -> bool: - """Distinguish SDK omission from an intentional gasless ``fees=None``.""" - return "fees" not in call_kwargs - - def _estimate_deploy_fees( client: GenLayerClient, *, @@ -110,7 +106,7 @@ def use_fee_aware_sdk_defaults(): @wraps(original_deploy) def deploy_with_default_fees(client: GenLayerClient, *args, **call_kwargs): - if _fees_argument_was_omitted(call_kwargs): + if fees_argument_was_omitted(call_kwargs): fees = None default_fees = _default_studio_fees(client) if default_fees is not None: @@ -140,7 +136,7 @@ def deploy_with_default_fees(client: GenLayerClient, *args, **call_kwargs): @wraps(original_write) def write_with_default_fees(client: GenLayerClient, *args, **call_kwargs): - if _fees_argument_was_omitted(call_kwargs): + if fees_argument_was_omitted(call_kwargs): fees = None default_fees = _default_studio_fees(client) if default_fees is not None: diff --git a/tests/unit/test_integration_fee_defaults.py b/tests/unit/test_integration_fee_defaults.py index baf194461..e56121b61 100644 --- a/tests/unit/test_integration_fee_defaults.py +++ b/tests/unit/test_integration_fee_defaults.py @@ -1,7 +1,7 @@ -from tests.integration.conftest import _fees_argument_was_omitted +from tests.common.fee_defaults import fees_argument_was_omitted def test_fee_default_injection_preserves_explicit_gasless_none(): - assert _fees_argument_was_omitted({}) is True - assert _fees_argument_was_omitted({"fees": None}) is False - assert _fees_argument_was_omitted({"fees": {"feeValue": 1}}) is False + assert fees_argument_was_omitted({}) is True + assert fees_argument_was_omitted({"fees": None}) is False + assert fees_argument_was_omitted({"fees": {"feeValue": 1}}) is False From 79b8aa8ade189bbef899afdaf5fff067b74ae609 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 20:22:11 +0100 Subject: [PATCH 54/55] test(ci): fund gltest default submissions --- tests/common/fee_defaults.py | 30 ++++++++++++++ tests/integration/gltest_compat.py | 3 ++ tests/unit/test_integration_fee_defaults.py | 43 ++++++++++++++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/tests/common/fee_defaults.py b/tests/common/fee_defaults.py index 3117e91e5..5892b0b6e 100644 --- a/tests/common/fee_defaults.py +++ b/tests/common/fee_defaults.py @@ -1,3 +1,33 @@ def fees_argument_was_omitted(call_kwargs: dict) -> bool: """Distinguish SDK omission from an intentional gasless ``fees=None``.""" return "fees" not in call_kwargs + + +def gltest_fees_are_unspecified(fees, fee_value) -> bool: + """Recognize gltest's default fee pair before it reaches the SDK. + + ``ContractFactory`` has no omitted-value sentinel and always forwards both + defaults. Dropping that pair lets the integration adapter obtain the + canonical Studio estimate. Direct SDK calls still preserve an explicit + ``fees=None`` as an intentional gasless request. + """ + return fees is None and fee_value is None + + +def forward_gltest_fee_kwargs(original, call, fees, fee_value) -> dict: + """Bridge gltest defaults without changing direct SDK call semantics.""" + if gltest_fees_are_unspecified(fees, fee_value): + return {} + return original(call, fees, fee_value) + + +def install_gltest_fee_bridge(contract_factory) -> None: + """Install the fee bridge on a gltest contract-factory module.""" + current = contract_factory._fee_kwargs + original = getattr(current, "_studio_original_fee_kwargs", current) + + def fee_aware(call, fees, fee_value): + return forward_gltest_fee_kwargs(original, call, fees, fee_value) + + fee_aware._studio_original_fee_kwargs = original + contract_factory._fee_kwargs = fee_aware diff --git a/tests/integration/gltest_compat.py b/tests/integration/gltest_compat.py index 71a224000..8cbf8045a 100644 --- a/tests/integration/gltest_compat.py +++ b/tests/integration/gltest_compat.py @@ -10,6 +10,8 @@ from gltest.artifacts.contract import ContractDefinition from gltest_cli.config.general import get_general_config +from tests.common.fee_defaults import install_gltest_fee_bridge + def _is_genlayer_contract_base(base: ast.expr) -> bool: if not isinstance(base, ast.Attribute): @@ -167,6 +169,7 @@ def find_contract_definition_from_path( def apply() -> None: + install_gltest_fee_bridge(contract_factory) contract_artifacts.search_path_by_class_name = search_path_by_class_name contract_artifacts.find_contract_definition_from_name = ( find_contract_definition_from_name diff --git a/tests/unit/test_integration_fee_defaults.py b/tests/unit/test_integration_fee_defaults.py index e56121b61..d1b484681 100644 --- a/tests/unit/test_integration_fee_defaults.py +++ b/tests/unit/test_integration_fee_defaults.py @@ -1,7 +1,48 @@ -from tests.common.fee_defaults import fees_argument_was_omitted +from types import SimpleNamespace + +from tests.common.fee_defaults import ( + fees_argument_was_omitted, + forward_gltest_fee_kwargs, + gltest_fees_are_unspecified, + install_gltest_fee_bridge, +) def test_fee_default_injection_preserves_explicit_gasless_none(): assert fees_argument_was_omitted({}) is True assert fees_argument_was_omitted({"fees": None}) is False assert fees_argument_was_omitted({"fees": {"feeValue": 1}}) is False + + +def test_gltest_default_fee_pair_is_treated_as_omitted(): + assert gltest_fees_are_unspecified(None, None) is True + assert gltest_fees_are_unspecified({"feeValue": 1}, None) is False + assert gltest_fees_are_unspecified(None, 1) is False + + def forwarded(_call, fees, fee_value): + return {"fees": fees, "fee_value": fee_value} + + assert forward_gltest_fee_kwargs(forwarded, object(), None, None) == {} + assert forward_gltest_fee_kwargs(forwarded, object(), {"feeValue": 1}, None) == { + "fees": {"feeValue": 1}, + "fee_value": None, + } + assert forward_gltest_fee_kwargs(forwarded, object(), None, 1) == { + "fees": None, + "fee_value": 1, + } + + +def test_gltest_fee_bridge_is_installed_and_idempotent(): + def forwarded(_call, fees, fee_value): + return {"fees": fees, "fee_value": fee_value} + + contract_factory = SimpleNamespace(_fee_kwargs=forwarded) + install_gltest_fee_bridge(contract_factory) + install_gltest_fee_bridge(contract_factory) + + assert contract_factory._fee_kwargs(object(), None, None) == {} + assert contract_factory._fee_kwargs(object(), {"feeValue": 1}, None) == { + "fees": {"feeValue": 1}, + "fee_value": None, + } From 6a353e425b69b30bca642e86c9bc4f0e6b1b10c7 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 22:07:12 +0100 Subject: [PATCH 55/55] test(ci): fund gltest write defaults gltest owns separate fee argument adapters for deploys and writes. Bridge both so their public None defaults are treated as omitted by the fee-aware integration fixture, while explicit direct SDK fees=None stays gasless. Add a dependency-free regression covering both entry points. --- tests/common/fee_defaults.py | 12 ++++++++++++ tests/integration/gltest_compat.py | 5 +++-- tests/unit/test_integration_fee_defaults.py | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/tests/common/fee_defaults.py b/tests/common/fee_defaults.py index 5892b0b6e..11d32ddc5 100644 --- a/tests/common/fee_defaults.py +++ b/tests/common/fee_defaults.py @@ -31,3 +31,15 @@ def fee_aware(call, fees, fee_value): fee_aware._studio_original_fee_kwargs = original contract_factory._fee_kwargs = fee_aware + + +def install_gltest_fee_bridges(contract_factory, contract) -> None: + """Install the bridge on both gltest transaction entry points. + + Deployments and writes have separate private ``_fee_kwargs`` helpers in + gltest. Both helpers turn their public ``None`` defaults into an explicit + SDK ``fees=None`` argument, so both must be adapted for fee-enabled Studio + integration runs. + """ + install_gltest_fee_bridge(contract_factory) + install_gltest_fee_bridge(contract) diff --git a/tests/integration/gltest_compat.py b/tests/integration/gltest_compat.py index 8cbf8045a..a95e7080f 100644 --- a/tests/integration/gltest_compat.py +++ b/tests/integration/gltest_compat.py @@ -6,11 +6,12 @@ import gltest.artifacts import gltest.artifacts.contract as contract_artifacts +import gltest.contracts.contract as contract_module import gltest.contracts.contract_factory as contract_factory from gltest.artifacts.contract import ContractDefinition from gltest_cli.config.general import get_general_config -from tests.common.fee_defaults import install_gltest_fee_bridge +from tests.common.fee_defaults import install_gltest_fee_bridges def _is_genlayer_contract_base(base: ast.expr) -> bool: @@ -169,7 +170,7 @@ def find_contract_definition_from_path( def apply() -> None: - install_gltest_fee_bridge(contract_factory) + install_gltest_fee_bridges(contract_factory, contract_module) contract_artifacts.search_path_by_class_name = search_path_by_class_name contract_artifacts.find_contract_definition_from_name = ( find_contract_definition_from_name diff --git a/tests/unit/test_integration_fee_defaults.py b/tests/unit/test_integration_fee_defaults.py index d1b484681..4afcbe2d9 100644 --- a/tests/unit/test_integration_fee_defaults.py +++ b/tests/unit/test_integration_fee_defaults.py @@ -5,6 +5,7 @@ forward_gltest_fee_kwargs, gltest_fees_are_unspecified, install_gltest_fee_bridge, + install_gltest_fee_bridges, ) @@ -46,3 +47,19 @@ def forwarded(_call, fees, fee_value): "fees": {"feeValue": 1}, "fee_value": None, } + + +def test_gltest_fee_bridges_cover_deploys_and_writes(): + def forwarded(_call, fees, fee_value): + return {"fees": fees, "fee_value": fee_value} + + contract_factory = SimpleNamespace(_fee_kwargs=forwarded) + contract = SimpleNamespace(_fee_kwargs=forwarded) + install_gltest_fee_bridges(contract_factory, contract) + + assert contract_factory._fee_kwargs(object(), None, None) == {} + assert contract._fee_kwargs(object(), None, None) == {} + assert contract._fee_kwargs(object(), None, 1) == { + "fees": None, + "fee_value": 1, + }