diff --git a/genlayer_py/client/genlayer_client.py b/genlayer_py/client/genlayer_client.py index 77392ca..9622052 100644 --- a/genlayer_py/client/genlayer_client.py +++ b/genlayer_py/client/genlayer_client.py @@ -341,9 +341,8 @@ def top_up_and_submit_appeal( ) -> HexStr: """Deposits appeal funding and submits an appeal. - On deployed Consensus, omitted decision/value inputs are resolved from - the authoritative appeal quote. Current Studio requires an explicit - value and does not accept ``expected_decision_id``. + Omitted decision/value inputs are resolved from the authoritative + appeal quote on both Studio and deployed Consensus. """ return top_up_and_submit_appeal( self=self, @@ -463,9 +462,8 @@ def appeal_transaction( ): """Appeals a consensus transaction to trigger a new round of validation. Returns the original transaction_id (appeals operate on the same tx). - Deployed Consensus fills missing decision/value inputs from its - authoritative quote. Current Studio requires an explicit value and does - not accept ``expected_decision_id``. + Missing decision/value inputs are filled from the authoritative quote + on both Studio and deployed Consensus. """ return appeal_transaction( self=self, @@ -492,10 +490,7 @@ def can_appeal( transaction_id: HexStr, expected_decision_id: Optional[int] = None, ) -> bool: - """Checks whether the exact active decision can be appealed on a network. - - This decision-bound read is not available on current Studio. - """ + """Checks whether the exact active decision can be appealed.""" return can_appeal( self=self, transaction_id=transaction_id, @@ -503,17 +498,11 @@ def can_appeal( ) def get_appeal_quote(self, transaction_id: HexStr) -> Dict[str, int]: - """Returns a network's latest decision id, appeal charges, and deadline. - - Current Studio has no decision-bound quote surface. - """ + """Returns the latest decision id, appeal charges, and deadline.""" return get_appeal_quote(self=self, transaction_id=transaction_id) def get_appeal_charge(self, transaction_id: HexStr) -> int: - """Returns the full appeal payment (bond plus induced-work funding). - - Current Studio has no decision-bound quote surface. - """ + """Returns the full appeal payment (bond plus induced-work funding).""" return get_appeal_charge(self=self, transaction_id=transaction_id) def get_min_appeal_bond(self, transaction_id: HexStr) -> int: diff --git a/genlayer_py/contracts/actions.py b/genlayer_py/contracts/actions.py index a704421..23661c8 100644 --- a/genlayer_py/contracts/actions.py +++ b/genlayer_py/contracts/actions.py @@ -39,6 +39,7 @@ build_estimated_fees_options_from_simulation, calculate_local_round_fees, create_fees_distribution, + create_top_up_fees_distribution, encode_fee_aware_add_transaction_data, extract_studio_fee_policy, fees_distribution_to_abi_tuple, @@ -233,31 +234,18 @@ def appeal_transaction( Appeals emit AppealStarted/TransactionActivated events (not NewTransaction), so we send the EVM tx directly instead of going through _send_transaction. - Deployed Consensus binds the appeal to the exact active decision. Current - Studio exposes its native ``submitAppeal(bytes32)`` entrypoint instead, so - callers must provide the value explicitly and cannot supply a decision id. + Both Studio and deployed Consensus bind the appeal to the exact active + decision and can resolve omitted decision/value inputs from the + authoritative appeal quote. The schedule-extending entry point is used for + every appeal because it accepts both pre-funded and unfunded next rounds; + ``submitAppeal`` rejects an unfunded next round before collecting its quoted + funding. """ sender_account = account if account is not None else self.local_account if sender_account is None: raise GenLayerError("No account set.") if self.chain.consensus_main_contract is None: raise GenLayerError("Consensus main contract not configured.") - if _is_studio_chain(self): - resolved_value = _resolve_studio_appeal_value(value, expected_decision_id) - encoded_data = _encode_submit_appeal_data( - self=self, - transaction_id=transaction_id, - studio_appeal_shape=True, - ) - _send_consensus_call( - self=self, - encoded_data=encoded_data, - sender_account=sender_account, - value=resolved_value, - operation_name="Appeal", - ) - return transaction_id - expected_decision_id, resolved_value = _resolve_appeal_parameters( self, transaction_id, @@ -265,9 +253,13 @@ def appeal_transaction( value=value, ) - encoded_data = _encode_submit_appeal_data( + encoded_data = _encode_fee_management_data( self=self, + function_name="topUpAndSubmitAppeal", transaction_id=transaction_id, + # Consensus derives the appeal shape from live state. This normalized + # zero schedule exists only for ABI compatibility. + distribution={}, expected_decision_id=expected_decision_id, ) @@ -291,8 +283,7 @@ def top_up_fees( ) -> HexStr: """Deposits additional fee budget for an existing consensus transaction. - Returns the backend RPC hash: an EVM transaction hash on network backends, - or the target GenLayer tx id on Studio/localnet. + Returns the signed EVM envelope hash on every backend. """ sender_account = account if account is not None else self.local_account encoded_data = _encode_fee_management_data( @@ -321,28 +312,9 @@ def top_up_and_submit_appeal( """Deposits appeal fee budget and submits an appeal in one consensus call. Returns the original GenLayer transaction id, matching appeal_transaction. - Current Studio exposes the native decision-free call shape. Deployed - Consensus uses the decision-bound train shape. + Both Studio and deployed Consensus use the decision-bound train shape. """ sender_account = account if account is not None else self.local_account - if _is_studio_chain(self): - resolved_value = _resolve_studio_appeal_value(value, expected_decision_id) - encoded_data = _encode_fee_management_data( - self=self, - function_name="topUpAndSubmitAppeal", - transaction_id=transaction_id, - distribution=distribution, - studio_appeal_shape=True, - ) - _send_consensus_call( - self=self, - encoded_data=encoded_data, - sender_account=sender_account, - value=resolved_value, - operation_name="Top up and submit appeal", - ) - return transaction_id - expected_decision_id, resolved_value = _resolve_appeal_parameters( self, transaction_id, @@ -427,8 +399,28 @@ def can_appeal( When no decision id is supplied, the latest active decision is read first. The guarded on-chain call returns ``False`` if that decision changes before - it is evaluated. Current Studio does not expose this decision-bound read. + it is evaluated. Studio uses its lifecycle and appeal-quote RPCs for the + same semantics. """ + if _is_studio_chain(self): + lifecycle = self.get_transaction_lifecycle(transaction_id) + if not lifecycle["decision_active"]: + return False + active_decision_id = lifecycle["decision_id"] + if ( + expected_decision_id is not None + and expected_decision_id != active_decision_id + ): + return False + try: + return get_appeal_quote(self, transaction_id)["decision_id"] == int( + active_decision_id + ) + except Exception as exc: + if "CanNotAppeal" in str(exc): + return False + raise + if self.chain.appeals_contract is None: raise GenLayerError("appeals_contract not configured for this chain") if expected_decision_id is None: @@ -448,10 +440,35 @@ def get_appeal_quote( ``total`` is the value to submit: the appeal bond plus induced-work funding. Pass ``decision_id`` back to the decision-guarded appeal methods. - Current Studio exposes no authoritative decision-bound appeal quote. """ if _is_studio_chain(self): - raise GenLayerError(STUDIO_APPEAL_QUOTE_UNSUPPORTED) + response = self.provider.make_request( + method="gen_estimateLatestAppealCharge", + params=[{"txId": transaction_id}], + ) + if not isinstance(response, dict): + raise GenLayerError( + "gen_estimateLatestAppealCharge returned an invalid response" + ) + if response.get("error") is not None: + raise GenLayerError( + f"gen_estimateLatestAppealCharge failed: {response['error']}" + ) + quote = response.get("result") + if not isinstance(quote, dict): + raise GenLayerError( + "gen_estimateLatestAppealCharge returned an invalid result" + ) + decision_id = int(quote["decisionId"]) + bond = int(quote["bond"]) + funding = int(quote["funding"]) + return { + "decision_id": decision_id, + "bond": bond, + "funding": funding, + "total": bond + funding, + "appeal_deadline": int(quote["appealDeadline"]), + } contract = _consensus_data_contract(self) decision_id, bond, funding, appeal_deadline = ( contract.functions.estimateLatestAppealCharge( @@ -552,15 +569,6 @@ def _resolve_appeal_parameters( ) -STUDIO_APPEAL_QUOTE_UNSUPPORTED = ( - "Decision-bound appeal quotes are not available on current Studio." -) -STUDIO_APPEAL_VALUE_UNRESOLVABLE = ( - "Cannot auto-resolve the appeal payment on current Studio; pass value explicitly." -) -STUDIO_DECISION_GUARD_UNSUPPORTED = "expected_decision_id is not supported by current Studio's decision-free appeal methods." - - def _is_studio_chain(self: GenLayerClient) -> bool: """Reports whether the client targets the studio-embedded consensus. @@ -570,18 +578,6 @@ def _is_studio_chain(self: GenLayerClient) -> bool: return self.chain.id == localnet.id -def _resolve_studio_appeal_value( - value: Optional[int], - expected_decision_id: Optional[int], -) -> int: - """Require the inputs that current Studio can faithfully honor.""" - if expected_decision_id is not None: - raise GenLayerError(STUDIO_DECISION_GUARD_UNSUPPORTED) - if value is None: - raise GenLayerError(STUDIO_APPEAL_VALUE_UNRESOLVABLE) - return value - - def _to_bytes32(self: GenLayerClient, hex_str: HexStr) -> bytes: """Convert a hex string to bytes32.""" if hex_str.startswith("0x"): @@ -666,9 +662,8 @@ def _encode_submit_appeal_data( self: GenLayerClient, transaction_id: HexStr, expected_decision_id: Optional[int] = None, - studio_appeal_shape: bool = False, ): - """Encode the chain's native submitAppeal entrypoint.""" + """Encode the decision-bound submitAppeal entrypoint.""" consensus_main_contract = self.w3.eth.contract( abi=self.chain.consensus_main_contract["abi"] ) @@ -677,11 +672,9 @@ def _encode_submit_appeal_data( transaction_id = transaction_id[2:] if len(transaction_id) > 64: raise ValueError("transaction_id too long for bytes32") - arguments = [self.w3.to_bytes(hexstr=transaction_id)] - if not studio_appeal_shape: - if expected_decision_id is None: - raise ValueError("submitAppeal requires expected_decision_id") - arguments.append(expected_decision_id) + if expected_decision_id is None: + raise ValueError("submitAppeal requires expected_decision_id") + arguments = [self.w3.to_bytes(hexstr=transaction_id), expected_decision_id] params = abi_encode(contract_fn.argument_types, arguments) function_selector = eth_utils.keccak(text=contract_fn.signature)[:4].hex() encoded_data = "0x" + function_selector + params.hex() @@ -702,16 +695,19 @@ def _encode_fee_management_data( transaction_id: HexStr, distribution: FeesDistributionInput, expected_decision_id: Optional[int] = None, - studio_appeal_shape: bool = False, ): """Encode the chain's native fee-management entrypoint.""" if function_name not in ("topUpFees", "topUpAndSubmitAppeal"): raise ValueError(f"Unsupported fee management function: {function_name}") tx_bytes = _to_bytes32(self, transaction_id) - fees_distribution = create_fees_distribution(distribution) + fees_distribution = ( + create_top_up_fees_distribution(distribution) + if function_name == "topUpFees" + else create_fees_distribution(distribution) + ) fees_tuple = fees_distribution_to_abi_tuple(fees_distribution) - if function_name == "topUpAndSubmitAppeal" and not studio_appeal_shape: + if function_name == "topUpAndSubmitAppeal": if expected_decision_id is None: raise ValueError("topUpAndSubmitAppeal requires expected_decision_id") argument_types = TOP_UP_AND_SUBMIT_APPEAL_ARGUMENT_TYPES @@ -852,6 +848,9 @@ def get_current_fee_policy(self: GenLayerClient) -> FeePolicyQuote: execution_budget_floor, local_execution_budget_floor, ), + # Live networks quote through FeeManager.calculateRoundFees; this + # field is only consumed by Studio's local mirror. + "timeUnitOverlayBps": 0, } try: @@ -1218,6 +1217,23 @@ def _format_rpc_error(error: Exception) -> str: return text +def _receipt_revert_reason(self: GenLayerClient, tx_hash: HexStr) -> Optional[str]: + """Read Studio's additive receipt reason without weakening EVM semantics.""" + try: + response = self.provider.make_request( + method="eth_getTransactionReceipt", params=[tx_hash] + ) + except Exception: + return None + if not isinstance(response, dict): + return None + receipt = response.get("result") + if not isinstance(receipt, dict): + return None + reason = receipt.get("revertReason") or receipt.get("error") + return reason if isinstance(reason, str) and reason.strip() else None + + def _send_consensus_call( self: GenLayerClient, encoded_data: HexStr, @@ -1249,13 +1265,12 @@ def _send_consensus_call( raise GenLayerError( f"{operation_name} failed: {_format_rpc_error(exc)}" ) from exc - if self.chain.id == localnet.id: - return tx_hash - tx_receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) if tx_receipt.status != 1: - raise GenLayerError(f"{operation_name} reverted: EVM tx {tx_hash}") + reason = _receipt_revert_reason(self, tx_hash) + suffix = f". {reason}" if reason else "" + raise GenLayerError(f"{operation_name} reverted: EVM tx {tx_hash}{suffix}") return tx_hash @@ -1298,9 +1313,11 @@ def _send_transaction( tx_receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) if tx_receipt.status != 1: + reason = _receipt_revert_reason(self, tx_hash) + suffix = f" {reason}" if reason else "" raise GenLayerError( f"Transaction reverted: EVM tx {tx_hash} to consensus contract " - f"{self.chain.consensus_main_contract['address']} was reverted." + f"{self.chain.consensus_main_contract['address']} was reverted.{suffix}" ) consensus_main_contract = self.w3.eth.contract( diff --git a/genlayer_py/transactions/__init__.py b/genlayer_py/transactions/__init__.py index ee9b531..4f215f6 100644 --- a/genlayer_py/transactions/__init__.py +++ b/genlayer_py/transactions/__init__.py @@ -9,6 +9,7 @@ build_estimated_fees_distribution, calculate_local_round_fees, create_fees_distribution, + create_top_up_fees_distribution, derive_external_message_call_key, deploy_call_key, derive_internal_message_call_key, @@ -23,6 +24,7 @@ def is_successful(transaction): return _is_successful(transaction) + __all__ = [ "CALL_KEY_DEPLOY", "CALL_KEY_UNNAMED", @@ -34,6 +36,7 @@ def is_successful(transaction): "build_estimated_fees_distribution", "calculate_local_round_fees", "create_fees_distribution", + "create_top_up_fees_distribution", "derive_external_message_call_key", "deploy_call_key", "derive_internal_message_call_key", diff --git a/genlayer_py/transactions/fees.py b/genlayer_py/transactions/fees.py index 59037c9..1831ddb 100644 --- a/genlayer_py/transactions/fees.py +++ b/genlayer_py/transactions/fees.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import IntEnum -from typing import Any, Optional, TypedDict, Union +from typing import Any, NotRequired, Optional, TypedDict, Union from eth_abi import encode as abi_encode from eth_typing import HexStr @@ -62,6 +62,12 @@ class InternalMessageFeeParamsInput(TypedDict, total=False): executionBudgetPerRound: BigNumberish execution_budget_per_round: BigNumberish rotations: list[BigNumberish] + maxPriceGenPerTimeUnit: BigNumberish + max_price_gen_per_time_unit: BigNumberish + storageFeeMaxGasPrice: BigNumberish + storage_fee_max_gas_price: BigNumberish + receiptFeeMaxGasPrice: BigNumberish + receipt_fee_max_gas_price: BigNumberish class ExternalMessageFeeParamsInput(TypedDict, total=False): @@ -110,6 +116,7 @@ class FeePolicyQuote(TypedDict): storageUnitPrice: int receiptGasPrice: int executionBudgetFloor: int + timeUnitOverlayBps: NotRequired[int] class FeeEstimateOptions(FeesDistributionInput, total=False): @@ -356,13 +363,11 @@ def _normalize_rotations( return normalized -def create_fees_distribution( - fee_distribution: Optional[FeesDistributionInput] = None, +def _create_fees_distribution( + fee_distribution: Optional[FeesDistributionInput], + appeal_rounds: int, + rotations: list[int], ) -> FeesDistribution: - appeal_rounds = to_uint( - _get(fee_distribution, "appealRounds", "appeal_rounds"), - "fees.distribution.appealRounds", - ) return { "leaderTimeunitsAllocation": to_uint( _get( @@ -397,11 +402,7 @@ def create_fees_distribution( _get(fee_distribution, "totalMessageFees", "total_message_fees"), "fees.distribution.totalMessageFees", ), - "rotations": _normalize_rotations( - _get(fee_distribution, "rotations"), - appeal_rounds, - "fees.distribution.rotations", - ), + "rotations": rotations, "maxPriceGenPerTimeUnit": to_uint( _get( fee_distribution, @@ -429,6 +430,50 @@ def create_fees_distribution( } +def create_fees_distribution( + fee_distribution: Optional[FeesDistributionInput] = None, +) -> FeesDistribution: + appeal_rounds = to_uint( + _get(fee_distribution, "appealRounds", "appeal_rounds"), + "fees.distribution.appealRounds", + ) + rotations = _normalize_rotations( + _get(fee_distribution, "rotations"), + appeal_rounds, + "fees.distribution.rotations", + ) + return _create_fees_distribution(fee_distribution, appeal_rounds, rotations) + + +def create_top_up_fees_distribution( + fee_distribution: Optional[FeesDistributionInput] = None, +) -> FeesDistribution: + """Normalize a Consensus top-up delta without resubmitting its schedule. + + Existing fee-aware transactions use appealRounds=0/rotations=[]. An + explicit non-empty schedule remains valid for first-time fee initialization. + """ + appeal_rounds = to_uint( + _get(fee_distribution, "appealRounds", "appeal_rounds"), + "fees.distribution.appealRounds", + ) + raw_rotations = _get(fee_distribution, "rotations") + if raw_rotations is None or len(raw_rotations) == 0: + if appeal_rounds != 0: + raise ValueError( + "fees.distribution.rotations must contain appealRounds + 1 " + "entries when appealRounds is non-zero." + ) + rotations = [] + else: + rotations = _normalize_rotations( + raw_rotations, + appeal_rounds, + "fees.distribution.rotations", + ) + return _create_fees_distribution(fee_distribution, appeal_rounds, rotations) + + def encode_internal_message_fee_params( params: Optional[InternalMessageFeeParamsInput] = None, ) -> HexStr: @@ -437,7 +482,7 @@ def encode_internal_message_fee_params( "internalMessageFeeParams.appealRounds", ) encoded = abi_encode( - ("(uint256,uint256,uint256,uint256,uint256[])",), + ("(uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)",), ( ( to_uint( @@ -470,6 +515,30 @@ def encode_internal_message_fee_params( appeal_rounds, "internalMessageFeeParams.rotations", ), + to_uint( + _get( + params, + "maxPriceGenPerTimeUnit", + "max_price_gen_per_time_unit", + ), + "internalMessageFeeParams.maxPriceGenPerTimeUnit", + ), + to_uint( + _get( + params, + "storageFeeMaxGasPrice", + "storage_fee_max_gas_price", + ), + "internalMessageFeeParams.storageFeeMaxGasPrice", + ), + to_uint( + _get( + params, + "receiptFeeMaxGasPrice", + "receipt_fee_max_gas_price", + ), + "internalMessageFeeParams.receiptFeeMaxGasPrice", + ), ), ), ) @@ -654,6 +723,10 @@ def extract_studio_fee_policy(config: Any) -> FeePolicyQuote: policy.get("receiptGasPrice"), "policy.receiptGasPrice", ) + time_unit_overlay_bps = _int_from_unknown( + policy.get("timeUnitOverlayBps", 0), + "policy.timeUnitOverlayBps", + ) intrinsic_gas = _int_from_unknown( policy.get("intrinsicGas", DEFAULT_INTRINSIC_GAS), "policy.intrinsicGas", @@ -714,6 +787,7 @@ def extract_studio_fee_policy(config: Any) -> FeePolicyQuote: "storageUnitPrice": storage_unit_price, "receiptGasPrice": receipt_gas_price, "executionBudgetFloor": execution_budget_floor, + "timeUnitOverlayBps": time_unit_overlay_bps, } @@ -1214,6 +1288,16 @@ def _calculate_fee_for_round( ) +def _validators_per_round_safe(round_index: int) -> int: + return VALIDATORS_PER_ROUND[ + min(max(0, int(round_index)), len(VALIDATORS_PER_ROUND) - 1) + ] + + +def _successful_appeal_profit(appeal_bond: int) -> int: + return appeal_bond + appeal_bond // 2 + + def calculate_local_round_fees( distribution: FeesDistribution, num_of_initial_validators: int, @@ -1238,10 +1322,8 @@ def calculate_local_round_fees( raise ValueError("MaxPriceExceeded") start_index = _validator_index(num_of_initial_validators) - if start_index + distribution["appealRounds"] * 2 >= len(VALIDATORS_PER_ROUND): - raise ValueError("InvalidNumOfValidators") - total = _calculate_fee_for_round( + taxable_work = _calculate_fee_for_round( VALIDATORS_PER_ROUND[start_index], distribution["rotations"][0] + 1, distribution["leaderTimeunitsAllocation"], @@ -1256,22 +1338,51 @@ def calculate_local_round_fees( elif offset % 2 == 1: rotations_this_round = 1 - total += _calculate_fee_for_round( - VALIDATORS_PER_ROUND[start_index + offset], + # Consensus indexes appeal/next-normal committees by absolute round + # and saturates at the published ladder. Only round zero uses the + # caller-selected initial committee. + taxable_work += _calculate_fee_for_round( + _validators_per_round_safe(offset), rotations_this_round, distribution["leaderTimeunitsAllocation"], distribution["validatorTimeunitsAllocation"], ) - if policy["genPerTimeUnit"] > 0: - total *= policy["genPerTimeUnit"] + price_cap = distribution["maxPriceGenPerTimeUnit"] + if price_cap > 0: + taxable_work *= price_cap + + appeal_profit_reserve = 0 + for appeal_ordinal in range(distribution["appealRounds"]): + next_normal_bond = _calculate_fee_for_round( + _validators_per_round_safe((appeal_ordinal + 1) * 2), + distribution["rotations"][appeal_ordinal + 1] + 1, + distribution["leaderTimeunitsAllocation"], + distribution["validatorTimeunitsAllocation"], + ) + if price_cap > 0: + next_normal_bond *= price_cap + appeal_profit_reserve += _successful_appeal_profit(next_normal_bond) + + overlay_bps = int(policy.get("timeUnitOverlayBps", 0)) + if overlay_bps < 0 or overlay_bps >= 10_000: + raise ValueError("InvalidTimeUnitOverlayBps") + overlay = ( + taxable_work * overlay_bps // (10_000 - overlay_bps) + if overlay_bps > 0 + else 0 + ) leader_rounds = sum( rotations + 1 for rotations in distribution["rotations"] ) + distribution["appealRounds"] - total += distribution["executionBudgetPerRound"] * leader_rounds - return total + return ( + taxable_work + + appeal_profit_reserve + + overlay + + distribution["executionBudgetPerRound"] * leader_rounds + ) def build_add_transaction_params_tuple( diff --git a/tests/unit/contracts/test_contract_actions.py b/tests/unit/contracts/test_contract_actions.py index b19f020..0a6d4aa 100644 --- a/tests/unit/contracts/test_contract_actions.py +++ b/tests/unit/contracts/test_contract_actions.py @@ -24,6 +24,7 @@ build_estimated_fees_distribution, calculate_local_round_fees, create_fees_distribution, + create_top_up_fees_distribution, derive_external_message_call_key, derive_internal_message_call_key, deploy_call_key, @@ -124,16 +125,18 @@ } ] -# Studio refetches its ConsensusMain ABI from the simulator RPC. Its current -# native appeal entrypoint carries only the transaction id. +# Studio refetches its decision-bound ConsensusMain ABI from the simulator RPC. STUDIO_CONSENSUS_MAIN_ABI = [ { "type": "function", "name": "submitAppeal", "stateMutability": "payable", - "inputs": [{"name": "_txId", "type": "bytes32"}], + "inputs": [ + {"name": "_txId", "type": "bytes32"}, + {"name": "_expectedDecisionId", "type": "uint256"}, + ], "outputs": [], - } + }, ] SENDER = "0x1111111111111111111111111111111111111111" @@ -149,14 +152,33 @@ def test_encode_internal_message_fee_params_uses_consensus_tuple_shape(): "appealRounds": 1, "executionBudgetPerRound": 20, "rotations": [2, 3], + "maxPriceGenPerTimeUnit": 30, + "storageFeeMaxGasPrice": 40, + "receiptFeeMaxGasPrice": 50, + } + ) + + decoded = abi_decode( + ("(uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)",), + Web3.to_bytes(hexstr=encoded), + )[0] + assert decoded == (5, 10, 1, 20, (2, 3), 30, 40, 50) + + +def test_encode_internal_message_fee_params_accepts_snake_case_price_caps(): + encoded = encode_internal_message_fee_params( + { + "max_price_gen_per_time_unit": 30, + "storage_fee_max_gas_price": 40, + "receipt_fee_max_gas_price": 50, } ) decoded = abi_decode( - ("(uint256,uint256,uint256,uint256,uint256[])",), + ("(uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)",), Web3.to_bytes(hexstr=encoded), )[0] - assert decoded == (5, 10, 1, 20, (2, 3)) + assert decoded == (0, 0, 0, 0, (0,), 30, 40, 50) def test_derive_internal_message_call_key_for_short_method_name(): @@ -219,12 +241,8 @@ def test_encode_top_up_fees_uses_consensus_tuple_shape(): function_name="topUpFees", transaction_id=TX_ID, distribution={ - "leaderTimeunitsAllocation": 100, - "validatorTimeunitsAllocation": 200, - "appealRounds": 1, "executionBudgetPerRound": 500_000, "totalMessageFees": 30, - "rotations": [0, 2], "maxPriceGenPerTimeUnit": 12, "storageFeeMaxGasPrice": 24, "receiptFeeMaxGasPrice": 36, @@ -240,7 +258,18 @@ def test_encode_top_up_fees_uses_consensus_tuple_shape(): Web3.to_bytes(hexstr=encoded[10:]), ) assert decoded_tx_id == Web3.to_bytes(hexstr=TX_ID) - assert distribution == (100, 200, 1, 500_000, 0, 30, (0, 2), 12, 24, 36) + assert distribution == (0, 0, 0, 500_000, 0, 30, (), 12, 24, 36) + + +def test_top_up_distribution_preserves_explicit_initial_schedule(): + assert create_top_up_fees_distribution({"appealRounds": 1, "rotations": [0, 2]})[ + "rotations" + ] == [0, 2] + + +def test_top_up_distribution_requires_schedule_for_nonzero_appeal_rounds(): + with pytest.raises(ValueError, match=r"rotations must contain appealRounds \+ 1"): + create_top_up_fees_distribution({"appealRounds": 1}) def test_top_up_fees_sends_consensus_call(monkeypatch): @@ -342,12 +371,6 @@ def test_appeal_transaction_auto_resolves_latest_quote(monkeypatch): } ), ) - monkeypatch.setattr( - contract_actions, - "_encode_submit_appeal_data", - Mock(return_value="0x1234"), - ) - def fake_send_consensus_call(**kwargs): captured.update(kwargs) return "0xevmtx" @@ -365,11 +388,18 @@ def fake_send_consensus_call(**kwargs): assert result == TX_ID contract_actions.get_appeal_quote.assert_called_once_with(client, TX_ID) - contract_actions._encode_submit_appeal_data.assert_called_once_with( - self=client, - transaction_id=TX_ID, - expected_decision_id=42, + selector = eth_utils.keccak( + text=f"topUpAndSubmitAppeal(bytes32,uint256,{FEES_DISTRIBUTION_ABI_TYPE})" + )[:4].hex() + assert captured["encoded_data"].startswith(f"0x{selector}") + tx_id, decision_id, distribution = abi_decode( + ("bytes32", "uint256", FEES_DISTRIBUTION_ABI_TYPE), + Web3.to_bytes(hexstr=captured["encoded_data"][10:]), ) + assert tx_id == Web3.to_bytes(hexstr=TX_ID) + assert decision_id == 42 + assert distribution[2] == 0 + assert distribution[6] == (0,) assert captured["value"] == 4_321 assert captured["operation_name"] == "Appeal" @@ -526,12 +556,29 @@ def _forbid_train_reads(monkeypatch): train_read = Mock(side_effect=AssertionError("train read on a Studio chain")) monkeypatch.setattr(contract_actions, "_consensus_data_contract", train_read) monkeypatch.setattr(contract_actions, "_get_active_decision_id", train_read) - monkeypatch.setattr(contract_actions, "get_appeal_quote", train_read) return train_read -def test_appeal_transaction_on_studio_keeps_the_native_call_shape(monkeypatch): +def _make_studio_client(): client = _make_client(STUDIO_CONSENSUS_MAIN_ABI) + client.provider.make_request = Mock( + return_value={ + "result": { + "decisionId": "42", + "bond": "4000", + "funding": "321", + "appealDeadline": "999", + } + } + ) + client.get_transaction_lifecycle = Mock( + return_value={"decision_active": True, "decision_id": 42} + ) + return client + + +def test_appeal_transaction_on_studio_binds_the_active_decision(monkeypatch): + client = _make_studio_client() train_read = _forbid_train_reads(monkeypatch) captured = {} @@ -551,16 +598,26 @@ def fake_send_consensus_call(**kwargs): value=1234, ) - selector = eth_utils.keccak(text="submitAppeal(bytes32)")[:4].hex() + selector = eth_utils.keccak( + text=f"topUpAndSubmitAppeal(bytes32,uint256,{FEES_DISTRIBUTION_ABI_TYPE})" + )[:4].hex() assert result == TX_ID assert captured["value"] == 1234 assert captured["operation_name"] == "Appeal" - assert captured["encoded_data"] == f"0x{selector}{TX_ID[2:]}" + assert captured["encoded_data"].startswith(f"0x{selector}") + tx_id, decision_id, distribution = abi_decode( + ("bytes32", "uint256", FEES_DISTRIBUTION_ABI_TYPE), + Web3.to_bytes(hexstr=captured["encoded_data"][10:]), + ) + assert tx_id == Web3.to_bytes(hexstr=TX_ID) + assert decision_id == 42 + assert distribution[2] == 0 + assert distribution[6] == (0,) train_read.assert_not_called() -def test_top_up_and_submit_appeal_on_studio_keeps_the_native_call_shape(monkeypatch): - client = _make_client(STUDIO_CONSENSUS_MAIN_ABI) +def test_top_up_and_submit_appeal_on_studio_binds_the_active_decision(monkeypatch): + client = _make_studio_client() train_read = _forbid_train_reads(monkeypatch) captured = {} @@ -582,17 +639,18 @@ def fake_send_consensus_call(**kwargs): ) selector = eth_utils.keccak( - text=f"topUpAndSubmitAppeal(bytes32,{FEES_DISTRIBUTION_ABI_TYPE})" + text=f"topUpAndSubmitAppeal(bytes32,uint256,{FEES_DISTRIBUTION_ABI_TYPE})" )[:4].hex() assert result == TX_ID assert captured["value"] == 1234 assert captured["operation_name"] == "Top up and submit appeal" assert captured["encoded_data"].startswith(f"0x{selector}") - decoded_tx_id, distribution = abi_decode( - ("bytes32", FEES_DISTRIBUTION_ABI_TYPE), + decoded_tx_id, decision_id, distribution = abi_decode( + ("bytes32", "uint256", FEES_DISTRIBUTION_ABI_TYPE), Web3.to_bytes(hexstr=captured["encoded_data"][10:]), ) assert decoded_tx_id == Web3.to_bytes(hexstr=TX_ID) + assert decision_id == 42 assert distribution[2] == 1 train_read.assert_not_called() @@ -605,70 +663,86 @@ def fake_send_consensus_call(**kwargs): contract_actions.get_min_appeal_bond, ), ) -def test_appeal_quote_reads_on_studio_report_unavailable(action, monkeypatch): - client = _make_client(STUDIO_CONSENSUS_MAIN_ABI) +def test_appeal_quote_reads_use_studio_authoritative_rpc(action, monkeypatch): + client = _make_studio_client() train_read = Mock(side_effect=AssertionError("train read on a Studio chain")) monkeypatch.setattr(contract_actions, "_consensus_data_contract", train_read) - with pytest.raises(GenLayerError, match="not available on current Studio"): - action(client, TX_ID) + result = action(client, TX_ID) + if action is contract_actions.get_appeal_quote: + assert result == { + "decision_id": 42, + "bond": 4_000, + "funding": 321, + "total": 4_321, + "appeal_deadline": 999, + } + else: + assert result == 4_321 train_read.assert_not_called() -def test_studio_appeals_require_an_explicit_value(monkeypatch): - client = _make_client(STUDIO_CONSENSUS_MAIN_ABI) +def test_studio_appeals_auto_resolve_the_authoritative_value(monkeypatch): + client = _make_studio_client() train_read = _forbid_train_reads(monkeypatch) - send_consensus_call = Mock() + send_consensus_call = Mock(return_value="0xevmtx") monkeypatch.setattr( contract_actions, "_send_consensus_call", send_consensus_call, ) - with pytest.raises(GenLayerError, match="pass value explicitly"): - contract_actions.appeal_transaction(self=client, transaction_id=TX_ID) - with pytest.raises(GenLayerError, match="pass value explicitly"): - contract_actions.top_up_and_submit_appeal( - self=client, - transaction_id=TX_ID, - distribution={"appealRounds": 1}, - ) + contract_actions.appeal_transaction(self=client, transaction_id=TX_ID) + contract_actions.top_up_and_submit_appeal( + self=client, + transaction_id=TX_ID, + distribution={"appealRounds": 1}, + ) - send_consensus_call.assert_not_called() + assert [call.kwargs["value"] for call in send_consensus_call.call_args_list] == [ + 4_321, + 4_321, + ] train_read.assert_not_called() -def test_studio_appeals_reject_the_train_decision_guard(monkeypatch): - client = _make_client(STUDIO_CONSENSUS_MAIN_ABI) +def test_studio_appeals_accept_an_explicit_decision_guard(monkeypatch): + client = _make_studio_client() train_read = _forbid_train_reads(monkeypatch) - send_consensus_call = Mock() + send_consensus_call = Mock(return_value="0xevmtx") monkeypatch.setattr( contract_actions, "_send_consensus_call", send_consensus_call, ) - with pytest.raises(GenLayerError, match="expected_decision_id is not supported"): - contract_actions.appeal_transaction( - self=client, - transaction_id=TX_ID, - value=1234, - expected_decision_id=42, - ) - with pytest.raises(GenLayerError, match="expected_decision_id is not supported"): - contract_actions.top_up_and_submit_appeal( - self=client, - transaction_id=TX_ID, - distribution={"appealRounds": 1}, - value=1234, - expected_decision_id=42, - ) + contract_actions.appeal_transaction( + self=client, + transaction_id=TX_ID, + value=1234, + expected_decision_id=42, + ) + contract_actions.top_up_and_submit_appeal( + self=client, + transaction_id=TX_ID, + distribution={"appealRounds": 1}, + value=1234, + expected_decision_id=42, + ) - send_consensus_call.assert_not_called() + assert send_consensus_call.call_count == 2 + client.provider.make_request.assert_not_called() train_read.assert_not_called() +def test_can_appeal_on_studio_uses_the_active_decision_quote(): + client = _make_studio_client() + + assert contract_actions.can_appeal(client, TX_ID) is True + client.get_transaction_lifecycle.assert_called_once_with(TX_ID) + + def test_encode_submit_appeal_still_requires_a_decision_id_on_the_train_shape(): client = _make_client(CONSENSUS_MAIN_ABI, chain_id=testnet_asimov.id) @@ -676,8 +750,8 @@ def test_encode_submit_appeal_still_requires_a_decision_id_on_the_train_shape(): contract_actions._encode_submit_appeal_data(self=client, transaction_id=TX_ID) -def test_send_consensus_call_returns_localnet_rpc_hash_without_waiting(monkeypatch): - wait_for_transaction_receipt = Mock() +def test_send_consensus_call_returns_localnet_envelope_hash_after_receipt(monkeypatch): + wait_for_transaction_receipt = Mock(return_value=SimpleNamespace(status=1)) sign_transaction = Mock(return_value=SimpleNamespace(raw_transaction=b"\x12\x34")) client = SimpleNamespace( chain=SimpleNamespace( @@ -711,7 +785,106 @@ def test_send_consensus_call_returns_localnet_rpc_hash_without_waiting(monkeypat ) assert result == TX_ID - wait_for_transaction_receipt.assert_not_called() + wait_for_transaction_receipt.assert_called_once_with(TX_ID) + + +def test_send_consensus_call_surfaces_studio_receipt_revert_reason(monkeypatch): + wait_for_transaction_receipt = Mock(return_value=SimpleNamespace(status=0)) + sign_transaction = Mock(return_value=SimpleNamespace(raw_transaction=b"\x12\x34")) + + def make_request(*, method, params): + if method == "eth_sendRawTransaction": + return {"result": TX_ID} + if method == "eth_getTransactionReceipt": + return { + "result": { + "status": "0x0", + "revertReason": "TopUpCannotExtendSchedule", + } + } + raise AssertionError(f"unexpected method {method}") + + client = SimpleNamespace( + chain=SimpleNamespace( + id=localnet.id, + consensus_main_contract={ + "address": "0x3333333333333333333333333333333333333333", + }, + ), + provider=SimpleNamespace(make_request=Mock(side_effect=make_request)), + w3=SimpleNamespace( + to_hex=Mock(return_value="0xsigned"), + eth=SimpleNamespace( + wait_for_transaction_receipt=wait_for_transaction_receipt + ), + ), + ) + account = SimpleNamespace(address=SENDER, sign_transaction=sign_transaction) + + monkeypatch.setattr( + contract_actions, + "_prepare_transaction", + Mock(return_value={"from": SENDER}), + ) + + with pytest.raises(GenLayerError, match="TopUpCannotExtendSchedule"): + contract_actions._send_consensus_call( + self=client, + encoded_data="0x1234", + sender_account=account, + value=1, + operation_name="Top up fees", + ) + + +def test_send_transaction_surfaces_studio_receipt_revert_reason(monkeypatch): + wait_for_transaction_receipt = Mock(return_value=SimpleNamespace(status=0)) + sign_transaction = Mock(return_value=SimpleNamespace(raw_transaction=b"\x12\x34")) + + def make_request(*, method, params): + if method == "eth_sendRawTransaction": + return {"result": TX_ID} + if method == "eth_getTransactionReceipt": + return { + "result": { + "status": "0x0", + "revertReason": "InsufficientFees", + } + } + raise AssertionError(f"unexpected method {method}") + + client = SimpleNamespace( + chain=SimpleNamespace( + id=localnet.id, + name="localnet", + consensus_main_contract={ + "address": "0x3333333333333333333333333333333333333333", + "abi": [], + }, + ), + provider=SimpleNamespace(make_request=Mock(side_effect=make_request)), + w3=SimpleNamespace( + to_hex=Mock(return_value="0xsigned"), + eth=SimpleNamespace( + wait_for_transaction_receipt=wait_for_transaction_receipt + ), + ), + ) + account = SimpleNamespace(address=SENDER, sign_transaction=sign_transaction) + + monkeypatch.setattr( + contract_actions, + "_prepare_transaction", + Mock(return_value={"from": SENDER}), + ) + + with pytest.raises(GenLayerError, match="InsufficientFees"): + contract_actions._send_transaction( + self=client, + encoded_data="0x1234", + sender_account=account, + value=1, + ) def test_revert_selector_formatter_names_fee_errors(): @@ -1201,6 +1374,33 @@ def test_calculate_local_round_fees_matches_consensus_initial_round(): assert calculate_local_round_fees(distribution, 5, policy) == 11_000 +def test_calculate_local_round_fees_matches_cap_overlay_appeal_reserve_and_ladder(): + distribution = create_fees_distribution( + { + "leaderTimeunitsAllocation": 100, + "validatorTimeunitsAllocation": 200, + "appealRounds": 1, + "rotations": [0, 0], + "executionBudgetPerRound": 0, + "maxPriceGenPerTimeUnit": 12, + } + ) + policy = { + "enabled": True, + "genPerTimeUnit": 10, + "storageUnitPrice": 0, + "receiptGasPrice": 0, + "executionBudgetFloor": 0, + "timeUnitOverlayBps": 1_500, + } + + # Taxable work: (5-validator round 0 + absolute rounds 1 and 2) * cap + # = (1100 + 1500 + 2300) * 12 = 58800. + # Appeal profit reserve: 1.5 * (2300 * 12) = 41400. + # Overlay: floor(58800 * 1500 / 8500) = 10376. + assert calculate_local_round_fees(distribution, 5, policy) == 110_576 + + def test_estimate_transaction_fees_uses_studio_fee_config(): client = SimpleNamespace( chain=SimpleNamespace( @@ -1325,7 +1525,7 @@ def test_estimate_transaction_fees_derives_message_bucket_from_allocations(): 3_000_000_000 + 30 * DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM ) assert estimate["distribution"]["rotations"] == [3] - assert estimate["feeValue"] == 12_001_244_080 + assert estimate["feeValue"] == 12_001_252_880 assert estimate["messageAllocations"] == message_allocations assert estimate["message_allocations"] == message_allocations