From 1db0a61a1d529ff3cb2cd0f3e1eb7dbc45368be1 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 2 Jun 2026 01:29:57 +0100 Subject: [PATCH 01/49] feat: add v0.6 fee accounting --- .env.example | 10 + .github/workflows/unit-tests-pr.yml | 18 + .gitignore | 4 + backend/consensus/base.py | 614 +- backend/consensus/worker.py | 31 + backend/database_handler/accounts_manager.py | 68 +- .../transactions_processor.py | 59 +- .../database_handler/validators_registry.py | 6 +- backend/domain/types.py | 8 +- backend/node/base.py | 62 +- backend/node/genvm/base.py | 81 + backend/node/genvm/origin/base_host.py | 62 +- backend/node/genvm/origin/host_fns.py | 22 +- backend/node/genvm/origin/public_abi.py | 4 + backend/node/types.py | 49 +- backend/protocol_rpc/endpoints.py | 705 +- .../fastapi_endpoint_generator.py | 13 + backend/protocol_rpc/fees.py | 3158 +++++++++ backend/protocol_rpc/health.py | 142 +- backend/protocol_rpc/rpc_methods.py | 28 + backend/protocol_rpc/transactions_parser.py | 318 +- backend/protocol_rpc/types.py | 29 +- docker-compose.yml | 21 +- docker/Dockerfile.backend | 38 +- docker/Dockerfile.consensus-worker | 12 +- docker/entrypoint-backend.sh | 2 +- docker/entrypoint-consensus-worker.sh | 2 +- explorer/eslint.config.mjs | 5 + explorer/package.json | 5 +- explorer/scripts/test-fee-accounting.mjs | 211 + .../src/app/address/[addr]/AddressContent.tsx | 3 - explorer/src/app/address/[addr]/page.tsx | 13 +- explorer/src/app/contracts/page.tsx | 8 +- explorer/src/app/providers/page.tsx | 14 +- .../app/tx/[hash]/components/OverviewTab.tsx | 103 +- explorer/src/app/validators/page.tsx | 14 +- .../src/components/FeeAccountingPanel.tsx | 363 ++ explorer/src/components/GlobalSearch.tsx | 2 +- explorer/src/lib/feeAccounting.ts | 275 + explorer/src/lib/types.ts | 145 + frontend/package-lock.json | 47 +- frontend/package.json | 2 +- .../Simulator/ContractMethodItem.vue | 516 +- .../components/Simulator/TransactionItem.vue | 741 ++- frontend/src/hooks/useContractQueries.ts | 184 +- frontend/src/services/IJsonRpcService.ts | 9 + frontend/src/services/JsonRpcService.ts | 30 + frontend/src/types/events.ts | 4 + frontend/src/types/responses.ts | 175 + ...ContractMethodItem.fees.behavioral.test.ts | 260 + .../TransactionItem.fees.behavioral.test.ts | 270 + .../useContractQueries.behavioral.test.ts | 257 +- .../JsonRpcService.behavioral.test.ts | 25 + tests/consensus/test_payable_scenarios.py | 1719 ++++- tests/db-sqlalchemy/accounts_manager_test.py | 163 + .../test_health_orphan_detection.py | 56 + .../db-sqlalchemy/validators_registry_test.py | 38 + .../unit/consensus/test_eth_send_emission.py | 22 + .../consensus/test_payable_balance_flow.py | 15 + tests/unit/test_genvm_initial_time_units.py | 85 + tests/unit/test_node_state_proxy_metrics.py | 404 +- tests/unit/test_rpc_genvm_admission.py | 66 + tests/unit/test_run_genvm_host_state_copy.py | 4 +- tests/unit/test_studio_fees.py | 5726 +++++++++++++++++ tests/unit/test_transactions_parser.py | 252 + 65 files changed, 17317 insertions(+), 480 deletions(-) create mode 100644 backend/protocol_rpc/fees.py create mode 100644 explorer/scripts/test-fee-accounting.mjs create mode 100644 explorer/src/components/FeeAccountingPanel.tsx create mode 100644 explorer/src/lib/feeAccounting.ts create mode 100644 frontend/test/unit/components/ContractMethodItem.fees.behavioral.test.ts create mode 100644 frontend/test/unit/components/TransactionItem.fees.behavioral.test.ts create mode 100644 tests/unit/test_genvm_initial_time_units.py create mode 100644 tests/unit/test_rpc_genvm_admission.py create mode 100644 tests/unit/test_studio_fees.py diff --git a/.env.example b/.env.example index 8e2201924..a93f7d867 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,16 @@ GENVMROOT="/genvm" GENVM_LLM_DEBUG="0" 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_FIXED_PROPOSE_RECEIPT_GAS='210000' +GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS='100000' +GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES='1024' + # Ollama Server Configuration OLAMAPORT='11434' diff --git a/.github/workflows/unit-tests-pr.yml b/.github/workflows/unit-tests-pr.yml index 5b4ab9613..f2a19011a 100644 --- a/.github/workflows/unit-tests-pr.yml +++ b/.github/workflows/unit-tests-pr.yml @@ -21,6 +21,24 @@ jobs: secrets: codecov_token: ${{ secrets.CODECOV_TOKEN }} + explorer-unit-tests: + if: (github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]') + name: Explorer Unit Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: explorer + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + - run: npm ci + - run: npm run test:fee-accounting + - run: npm run lint + - run: npm run build + backend-unit-tests: if: (github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]') runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index f3eaffcd4..cd04cf23f 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,7 @@ config-overrides/ frontend/genlayer-js-0.28.2.tgz frontend/.cache-synpress/ frontend/test-results/ + +# Local tool caches +.vite/ +.claude/scheduled_tasks.lock diff --git a/backend/consensus/base.py b/backend/consensus/base.py index f6dad1fa1..6a156289d 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -9,7 +9,7 @@ import os import asyncio -from typing import Callable, List, Iterable, Literal +from typing import Any, Callable, List, Iterable, Literal import time from abc import ABC, abstractmethod import random @@ -29,6 +29,7 @@ TransactionsProcessor, TransactionStatus, ) +from backend.database_handler.models import Transactions from backend.database_handler.accounts_manager import AccountsManager from backend.database_handler.types import ConsensusData from backend.domain.types import ( @@ -52,6 +53,18 @@ EventType, EventScope, ) +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + FeeValidationError, + StudioFeePolicy, + consume_message_fees, + create_child_fee_accounting, + derive_external_message_call_key, + fill_message_fee_payload_from_allocation, + record_external_message_execution_fees, + record_reveal_message_fees, + unwind_reveal_message_fees, +) from backend.rollup.consensus_service import ConsensusService import backend.validators as validators @@ -1475,6 +1488,302 @@ async def handle( """ +def _external_message_value_total( + pending_transactions: Iterable[PendingTransaction], +) -> int: + return sum( + int(pending_transaction.value or 0) + for pending_transaction in pending_transactions + if pending_transaction.is_eth_send and int(pending_transaction.value or 0) > 0 + ) + + +def _external_message_value_for_phase( + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> int: + return sum( + int(pending_transaction.value or 0) + for pending_transaction in pending_transactions + if pending_transaction.is_eth_send + and pending_transaction.on == on + and int(pending_transaction.value or 0) > 0 + ) + + +def _apply_external_message_freeze_check( + context: TransactionContext, + leader_receipt: Receipt, +) -> None: + if leader_receipt.execution_result != ExecutionResultStatus.SUCCESS: + return + + declared_value = _external_message_value_total(leader_receipt.pending_transactions) + if declared_value <= 0: + return + + other_reserved = _external_message_pending_freeze_total(context) + balance = context.accounts_manager.get_account_balance( + context.transaction.to_address + ) + available = max(balance - other_reserved, 0) + if declared_value <= available: + return + + error_message = ( + "ExternalMessageFreezeExceeded: " + f"declaredValue={declared_value}, availableLimit={available}" + ) + leader_receipt.execution_result = ExecutionResultStatus.ERROR + leader_receipt.result = bytes([ResultCode.VM_ERROR]) + error_message.encode("utf-8") + leader_receipt.contract_state = {} + leader_receipt.contract_state_hash = None + leader_receipt.pending_transactions = [] + leader_receipt.genvm_result = { + **(leader_receipt.genvm_result or {}), + "error_code": "EXTERNAL_MESSAGE_FREEZE_EXCEEDED", + "error_description": error_message, + "external_message_freeze": { + "declaredValue": declared_value, + "availableLimit": available, + "balance": balance, + "reservedExternal": other_reserved, + }, + } + + +def _internal_message_value_for_phase( + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> int: + return sum( + int(pending_transaction.value or 0) + for pending_transaction in pending_transactions + if not pending_transaction.is_eth_send + and pending_transaction.on == on + and int(pending_transaction.value or 0) > 0 + ) + + +def _remaining_external_freeze_after_phase( + context: TransactionContext, + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> int: + pending_freeze = _external_message_pending_freeze_total(context) + if on == "finalized": + return pending_freeze + + return pending_freeze + _external_message_value_for_phase( + pending_transactions, "finalized" + ) + + +def _external_message_pending_freeze_total(context: TransactionContext) -> int: + contract_address = context.transaction.to_address + if not contract_address or not hasattr(context.transactions_processor, "session"): + return 0 + + current_created_at = ( + context.transactions_processor.session.query(Transactions.created_at) + .filter(Transactions.hash == context.transaction.hash) + .scalar() + ) + filters = [ + Transactions.to_address == contract_address, + Transactions.status == TransactionStatus.ACCEPTED, + 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) + + rows = ( + context.transactions_processor.session.query( + Transactions.hash, + Transactions.consensus_data, + ) + .filter(*filters) + .all() + ) + + total = 0 + for row in rows: + for receipt in _leader_receipts_from_consensus_data(row.consensus_data): + if ( + _receipt_execution_result(receipt) + != ExecutionResultStatus.SUCCESS.value + ): + continue + total += _external_message_value_for_phase_from_raw( + _receipt_pending_transactions(receipt), + "finalized", + ) + return total + + +def _leader_receipts_from_consensus_data(consensus_data: Any) -> list[Any]: + if isinstance(consensus_data, ConsensusData): + leader_receipt = consensus_data.leader_receipt + if isinstance(leader_receipt, list): + return leader_receipt[:1] + if leader_receipt: + return [leader_receipt] + return [] + + if not isinstance(consensus_data, dict): + return [] + + leader_receipt = consensus_data.get("leader_receipt") + if isinstance(leader_receipt, list): + return leader_receipt[:1] + if isinstance(leader_receipt, dict): + return [leader_receipt] + return [] + + +def _receipt_execution_result(receipt: Any) -> str | None: + if isinstance(receipt, Receipt): + return receipt.execution_result.value + if isinstance(receipt, dict): + return receipt.get("execution_result") + return None + + +def _receipt_pending_transactions(receipt: Any) -> Iterable[Any]: + if isinstance(receipt, Receipt): + return receipt.pending_transactions + if isinstance(receipt, dict): + return receipt.get("pending_transactions") or [] + return [] + + +def _external_message_value_for_phase_from_raw( + pending_transactions: Iterable[Any], + on: Literal["accepted", "finalized"], +) -> int: + return sum( + _pending_transaction_external_value(pending_transaction, on) + for pending_transaction in pending_transactions + ) + + +def _pending_transaction_external_value( + pending_transaction: Any, + on: Literal["accepted", "finalized"], +) -> int: + if isinstance(pending_transaction, PendingTransaction): + if not pending_transaction.is_eth_send or pending_transaction.on != on: + return 0 + return int(pending_transaction.value or 0) + + if not isinstance(pending_transaction, dict): + return 0 + + is_external = bool( + pending_transaction.get("is_eth_send") + or pending_transaction.get("isEthSend") + or pending_transaction.get("messageType") in {0, "0", "External", "external"} + ) + if not is_external: + return 0 + + 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: + return 0 + + return int(pending_transaction.get("value", 0) or 0) + + +def _pending_transaction_with_value( + pending_transaction: PendingTransaction, + value: int, +) -> PendingTransaction: + adjusted = deepcopy(pending_transaction) + adjusted.value = value + return adjusted + + +def _apply_message_value_withdrawals_for_phase( + context: TransactionContext, + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> list[PendingTransaction]: + pending_list = list(pending_transactions) + external_value = _external_message_value_for_phase(pending_list, on) + external_value_backed = True + + if external_value > 0: + external_value_backed = context.accounts_manager.debit_account_balance( + context.transaction.to_address, external_value + ) + if not external_value_backed: + from loguru import logger + + logger.error( + f"Contract external message debit failed for {context.transaction.to_address}, " + f"phase={on}, amount={external_value}, tx={context.transaction.hash}. " + f"Skipping value-bearing external child emission." + ) + + internal_value = _internal_message_value_for_phase(pending_list, on) + internal_value_backed = True + + if internal_value > 0: + frozen_after_phase = _remaining_external_freeze_after_phase( + context, pending_list, on + ) + balance_after_external = context.accounts_manager.get_account_balance( + context.transaction.to_address + ) + internal_cap = max(balance_after_external - frozen_after_phase, 0) + if internal_value > internal_cap: + internal_value_backed = False + from loguru import logger + + logger.error( + f"Contract internal message value is not backed for {context.transaction.to_address}, " + f"phase={on}, amount={internal_value}, available={internal_cap}, " + f"reserved_external={frozen_after_phase}, tx={context.transaction.hash}. " + f"Emitting internal children with value=0." + ) + else: + internal_value_backed = context.accounts_manager.debit_account_balance( + context.transaction.to_address, internal_value + ) + if not internal_value_backed: + from loguru import logger + + logger.error( + f"Contract internal message debit failed for {context.transaction.to_address}, " + f"phase={on}, amount={internal_value}, tx={context.transaction.hash}. " + f"Emitting internal children with value=0." + ) + + if external_value_backed and internal_value_backed: + return pending_list + + adjusted_pending_transactions = [] + for pending_transaction in pending_list: + 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: + continue + if not pending_transaction.is_eth_send and not internal_value_backed: + adjusted_pending_transactions.append( + _pending_transaction_with_value(pending_transaction, 0) + ) + continue + + adjusted_pending_transactions.append(pending_transaction) + + return adjusted_pending_transactions + + class PendingState(TransactionState): """ Class representing the pending state of a transaction. @@ -1651,7 +1960,7 @@ async def handle(self, context): # Credit target contract on activation (value from transaction) # Placed AFTER validator check — if no validators, tx gets canceled # and refund_tx_value must be able to refund (requires value_credited=false) - tx_value = context.transaction.value or 0 + tx_value = int(context.transaction.value or 0) if tx_value > 0: credited = context.accounts_manager.credit_tx_value_once( context.transaction.hash, @@ -2404,6 +2713,8 @@ class AcceptedState(TransactionState): async def handle(self, context): leader_receipt = context.consensus_data.leader_receipt[0] + _apply_external_message_freeze_check(context, leader_receipt) + _sync_reveal_message_fee_accounting(context, leader_receipt) accepted_contract_state = leader_receipt.contract_state execution_success = ( leader_receipt.execution_result == ExecutionResultStatus.SUCCESS @@ -2450,39 +2761,13 @@ async def handle(self, context): # Impure: triggered transaction processing (needs DB reads for nonce/accounts) # Cumulative: child emission happens on every acceptance round (including appeal re-acceptance) if execution_success: - # Balance debit for on_accepted messages BEFORE child emission - total_msg_debit = sum( - pt.value - for pt in leader_receipt.pending_transactions - if pt.on == "accepted" and pt.value > 0 - ) - debit_ok = True - if total_msg_debit > 0: - debit_ok = context.accounts_manager.debit_account_balance( - context.transaction.to_address, total_msg_debit - ) - if not debit_ok: - from loguru import logger - - logger.error( - f"Contract balance debit failed for {context.transaction.to_address}, " - f"amount={total_msg_debit}, tx={context.transaction.hash}. " - f"Skipping value-bearing child emission." - ) - - # Emit child messages — filter out value-bearing children if debit failed - if debit_ok: - pending_to_emit = leader_receipt.pending_transactions - else: - pending_to_emit = [ - pt - for pt in leader_receipt.pending_transactions - if pt.on != "accepted" or pt.value <= 0 - ] - internal_messages_data, insert_transactions_data = _get_messages_data( context, - pending_to_emit, + _apply_message_value_withdrawals_for_phase( + context, + leader_receipt.pending_transactions, + "accepted", + ), "accepted", ) @@ -2649,38 +2934,13 @@ async def handle(self, context): finalized_state=accepted_state, ) - # Balance debit BEFORE child emission - total_finalized_debit = sum( - pt.value - for pt in leader_receipt.pending_transactions - if pt.on == "finalized" and pt.value > 0 - ) - finalize_debit_ok = True - if total_finalized_debit > 0: - finalize_debit_ok = context.accounts_manager.debit_account_balance( - context.transaction.to_address, total_finalized_debit - ) - if not finalize_debit_ok: - from loguru import logger - - logger.error( - f"Contract finalization debit failed for {context.transaction.to_address}, " - f"amount={total_finalized_debit}, tx={context.transaction.hash}" - ) - - # Filter out value-bearing children if debit failed - if finalize_debit_ok: - pending_to_finalize = leader_receipt.pending_transactions - else: - pending_to_finalize = [ - pt - for pt in leader_receipt.pending_transactions - if pt.on != "finalized" or pt.value <= 0 - ] - internal_messages_data, insert_transactions_data = _get_messages_data( context, - pending_to_finalize, + _apply_message_value_withdrawals_for_phase( + context, + leader_receipt.pending_transactions, + "finalized", + ), "finalized", ) @@ -2697,6 +2957,18 @@ async def handle(self, context): await executor.execute(post_effects) + refund_recipient = ( + context.transaction.origin_address or context.transaction.from_address + ) + if refund_recipient: + context.accounts_manager.settle_tx_fee_accounting_once( + context.transaction.hash, + refund_recipient, + receipt=leader_receipt, + reason="finalized", + ) + context.accounts_manager.session.commit() + def _get_messages_data( context: TransactionContext, @@ -2705,6 +2977,12 @@ def _get_messages_data( ): insert_transactions_data = [] internal_messages_data = [] + message_fee_payloads = [] + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + reveal_recorded = bool( + parent_fee_accounting + and parent_fee_accounting.get("message_fees_recorded_at_reveal") + ) base_nonce = context.transactions_processor.get_transaction_count( context.transaction.to_address ) @@ -2753,6 +3031,47 @@ def _get_messages_data( "calldata": pending_transaction.calldata, } + if parent_fee_accounting and pending_transaction.is_eth_send: + message_fee_payloads.append( + _pending_transaction_fee_payload(pending_transaction, on) + ) + elif parent_fee_accounting: + try: + message_payload = fill_message_fee_payload_from_allocation( + parent_fee_accounting, + _pending_transaction_fee_payload(pending_transaction, on), + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + + message_fee_payloads.append(message_payload) + if int(message_payload.get("declaredBudget", 0) or 0) > 0: + try: + 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 [], + sender=context.transaction.origin_address + or context.transaction.from_address, + policy=StudioFeePolicy.from_env(), + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + data.update( + { + "fee_value": int(message_payload["declaredBudget"]), + "user_value": pending_transaction.value, + "fees_distribution": child_fees, + "message_allocations_count": len( + child_fee_accounting.get("message_allocations") or [] + ), + FEE_ACCOUNTING_KEY: child_fee_accounting, + } + ) + insert_transactions_data.append( [ pending_transaction.address, @@ -2782,9 +3101,178 @@ def _get_messages_data( } ) + if parent_fee_accounting and message_fee_payloads: + try: + if reveal_recorded: + updated_accounting = record_external_message_execution_fees( + parent_fee_accounting, + message_fee_payloads, + ) + else: + updated_accounting = consume_message_fees( + parent_fee_accounting, + message_fee_payloads, + ) + 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 + ) + return internal_messages_data, insert_transactions_data +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 + ): + _unwind_discarded_reveal_message_fee_accounting(context) + return + + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + 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 + + try: + updated_accounting = record_reveal_message_fees( + parent_fee_accounting, + message_fee_payloads, + ) + 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( + parent_fee_accounting: dict[str, Any], + pending_transactions: Iterable[Any], +) -> list[dict[str, Any]]: + message_fee_payloads = [] + for raw_pending_transaction in pending_transactions: + pending_transaction = _coerce_pending_transaction(raw_pending_transaction) + message_payload = _pending_transaction_fee_payload( + pending_transaction, + pending_transaction.on, + ) + if not pending_transaction.is_eth_send: + message_payload = fill_message_fee_payload_from_allocation( + parent_fee_accounting, + message_payload, + ) + message_fee_payloads.append(message_payload) + return message_fee_payloads + + +def _unwind_discarded_reveal_message_fee_accounting( + context: TransactionContext, +) -> None: + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + if not parent_fee_accounting: + return + + prior_receipts = _leader_receipts_from_consensus_data( + context.transaction.consensus_data + ) + if not prior_receipts: + prior_receipts = _leader_receipts_from_consensus_history( + context.transaction.consensus_history + ) + if 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 + + updated_accounting = unwind_reveal_message_fees( + parent_fee_accounting, + message_fee_payloads, + acceptance_dispatched=context.transaction.status == TransactionStatus.ACCEPTED, + ) + 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: + if isinstance(raw, PendingTransaction): + return raw + if isinstance(raw, dict): + return PendingTransaction.from_dict(raw) + raise TypeError(f"Unsupported pending transaction type: {type(raw).__name__}") + + +def _leader_receipts_from_consensus_history(consensus_history: Any) -> list[Any]: + if not isinstance(consensus_history, dict): + return [] + + consensus_results = consensus_history.get("consensus_results") + if not isinstance(consensus_results, list): + return [] + + for consensus_round in reversed(consensus_results): + if not isinstance(consensus_round, dict): + continue + leader_result = consensus_round.get("leader_result") + if isinstance(leader_result, list): + return leader_result[:1] + if isinstance(leader_result, dict): + return [leader_result] + return [] + + +def _pending_transaction_fee_payload( + pending_transaction: PendingTransaction, + on: Literal["accepted", "finalized"], +) -> dict[str, Any]: + message_type = 0 if pending_transaction.is_eth_send else 1 + call_key = pending_transaction.call_key + if message_type == 0: + call_key = derive_external_message_call_key( + call_key, + pending_transaction.calldata, + ) + return { + "messageType": message_type, + "recipient": pending_transaction.address, + "value": pending_transaction.value, + "data": pending_transaction.calldata, + "onAcceptance": on == "accepted", + "saltNonce": pending_transaction.salt_nonce, + "feeParams": pending_transaction.fee_params, + "declaredBudget": pending_transaction.declared_budget, + "allocationSubtree": pending_transaction.allocation_subtree, + "callKey": call_key, + "gasUsed": pending_transaction.gas_used, + } + + def _emit_messages( context: TransactionContext, insert_transactions_data: list, diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index 1e6808508..067f20c96 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -11,6 +11,7 @@ from backend.database_handler.models import Transactions, TransactionStatus from backend.database_handler.transactions_processor import TransactionsProcessor +from backend.database_handler.accounts_manager import AccountsManager from backend.database_handler.errors import ContractNotFoundError from backend.domain.types import Transaction from backend.node.genvm.error_codes import GenVMInternalError @@ -1297,6 +1298,12 @@ async def _handle_no_validators_retry( from backend.database_handler.accounts_manager import AccountsManager AccountsManager(session).refund_tx_value(tx_hash, tx.from_address) + if tx.from_address: + from backend.database_handler.accounts_manager import AccountsManager + + AccountsManager(session).cancel_tx_fee_accounting_once( + tx_hash, tx.from_address, "no_validators_available" + ) session.commit() # Clean up retry tracking @@ -1362,6 +1369,14 @@ async def _handle_generic_error_retry(self, tx_hash: str, error: Exception): AccountsManager(cancel_session).refund_tx_value( tx_hash, tx.from_address ) + if tx.from_address: + from backend.database_handler.accounts_manager import ( + AccountsManager, + ) + + AccountsManager(cancel_session).cancel_tx_fee_accounting_once( + tx_hash, tx.from_address, "max_generic_retries_exceeded" + ) cancel_session.commit() # Send WebSocket notification @@ -1698,6 +1713,14 @@ async def process_finalization(self, finalization_data: dict, session: Session): TransactionStatus.FINALIZED, self.msg_handler, ) + 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( + tx_hash, + refund_recipient, + reason="finalized_contract_not_found", + ) error_session.commit() logger.info( @@ -1802,6 +1825,14 @@ async def process_appeal(self, appeal_data: dict, session: Session): TransactionStatus.FINALIZED, self.msg_handler, ) + 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( + tx_hash, + refund_recipient, + reason="finalized_contract_not_found_during_appeal", + ) error_session.commit() logger.info( diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index 32149d893..338663ab7 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -3,8 +3,13 @@ from eth_account import Account from eth_utils import is_address, to_checksum_address -from .models import CurrentState +from .models import CurrentState, Transactions from backend.database_handler.errors import AccountNotFoundError +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + cancel_fee_accounting, + settle_fee_accounting, +) from sqlalchemy.orm import Session from sqlalchemy import text @@ -177,3 +182,64 @@ def refund_tx_value(self, tx_hash: str, sender_address: str) -> bool: return False # target already received funds, can't refund self.credit_account_balance(sender_address, row.value) return True + + 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() + ) + if transaction is None: + return 0 + if not isinstance(transaction.data, dict): + return 0 + data = dict(transaction.data) + accounting = data.get(FEE_ACCOUNTING_KEY) + if not accounting: + return 0 + updated, refund = cancel_fee_accounting(accounting, reason=reason) + data[FEE_ACCOUNTING_KEY] = updated + transaction.data = data + if refund > 0: + self.credit_account_balance(sender_address, refund) + return refund + + def settle_tx_fee_accounting_once( + self, + tx_hash: str, + sender_address: str, + receipt=None, + reason: str = "finalized", + ) -> int: + transaction = ( + self.session.query(Transactions).filter_by(hash=tx_hash).one_or_none() + ) + if transaction is None: + return 0 + if not isinstance(transaction.data, dict): + return 0 + data = dict(transaction.data) + accounting = data.get(FEE_ACCOUNTING_KEY) + if not accounting: + return 0 + updated, refund = settle_fee_accounting( + accounting, + receipt=receipt, + reason=reason, + actual_final_round=_infer_final_round(transaction.consensus_history), + num_of_validators=transaction.num_of_initial_validators, + ) + data[FEE_ACCOUNTING_KEY] = updated + transaction.data = data + if refund > 0: + self.credit_account_balance(sender_address, refund) + return refund + + +def _infer_final_round(consensus_history: dict | None) -> int: + if not isinstance(consensus_history, dict): + return 0 + rounds = consensus_history.get("consensus_results") + if not isinstance(rounds, list) or len(rounds) == 0: + return 0 + return max(0, len(rounds) - 1) diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index e58db974c..40e1931d9 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -19,6 +19,8 @@ from backend.consensus.utils import determine_consensus_from_votes from backend.rollup.web3_pool import Web3ConnectionPool +MAX_JSON_SAFE_INTEGER = (2**53) - 1 + class TransactionAddressFilter(Enum): ALL = "all" @@ -72,6 +74,21 @@ def __init__( # Use singleton Web3 connection pool self.web3 = Web3ConnectionPool.get() + @staticmethod + def _json_safe_numbers(value): + if isinstance(value, bool) or value is None or isinstance(value, str): + return value + if isinstance(value, int): + return str(value) if abs(value) > MAX_JSON_SAFE_INTEGER else value + if isinstance(value, list): + return [TransactionsProcessor._json_safe_numbers(item) for item in value] + if isinstance(value, dict): + return { + key: TransactionsProcessor._json_safe_numbers(item) + for key, item in value.items() + } + return value + @staticmethod def _parse_transaction_data(transaction_data: Transactions) -> dict: if transaction_data.consensus_data: @@ -90,12 +107,14 @@ def _parse_transaction_data(transaction_data: Transactions) -> dict: "hash": transaction_data.hash, "from_address": transaction_data.from_address, "to_address": transaction_data.to_address, - "data": transaction_data.data, - "value": transaction_data.value, + "data": TransactionsProcessor._json_safe_numbers(transaction_data.data), + "value": TransactionsProcessor._json_safe_numbers(transaction_data.value), "type": transaction_data.type, "status": transaction_data.status.value, "result": TransactionsProcessor._decode_base64_data(result), - "consensus_data": transaction_data.consensus_data, + "consensus_data": TransactionsProcessor._json_safe_numbers( + transaction_data.consensus_data + ), "gaslimit": transaction_data.nonce, "nonce": transaction_data.nonce, "r": transaction_data.r, @@ -900,6 +919,40 @@ def set_transaction_result( self.session.commit() + def update_transaction_data(self, transaction_hash: str, data: dict | None): + result = self.session.execute( + text( + "UPDATE transactions SET data = CAST(:data AS jsonb) WHERE hash = :hash" + ), + { + "hash": transaction_hash, + "data": json.dumps(data) if data is not None else None, + }, + ) + if result.rowcount == 0: + print( + f"[TRANSACTIONS_PROCESSOR]: Transaction {transaction_hash} not found, skipping data update" + ) + return + self.session.commit() + + def update_transaction_fee_accounting( + self, transaction_hash: str, fee_accounting: dict + ): + transaction = ( + self.session.query(Transactions) + .filter_by(hash=transaction_hash) + .one_or_none() + ) + if transaction is None: + print( + f"[TRANSACTIONS_PROCESSOR]: Transaction {transaction_hash} not found, skipping fee accounting update" + ) + return + data = dict(transaction.data or {}) + data["fee_accounting"] = fee_accounting + self.update_transaction_data(transaction_hash, data) + def get_transaction_count(self, address: str) -> int: # Normalize address to checksum format try: diff --git a/backend/database_handler/validators_registry.py b/backend/database_handler/validators_registry.py index aea670f1f..f16aa05e6 100644 --- a/backend/database_handler/validators_registry.py +++ b/backend/database_handler/validators_registry.py @@ -102,17 +102,21 @@ async def update_validator( validator.plugin_config = new_validator.llmprovider.plugin_config self.session.flush() # Ensure the validator update is persisted - return to_dict(validator, False) + result = to_dict(validator, False) + self.session.commit() + return result async def delete_validator(self, validator_address): validator = self._get_validator_or_fail(validator_address) self.session.delete(validator) self.session.flush() # Ensure the validator deletion is persisted + self.session.commit() async def delete_all_validators(self): self.session.query(Validators).delete(synchronize_session=False) self.session.flush() # Ensure all validator deletions are persisted + self.session.commit() async def batch_create_validators(self, validators: list[Validator]) -> list[dict]: """Create multiple validators in a single batch without triggering restarts per-validator.""" diff --git a/backend/domain/types.py b/backend/domain/types.py index 3fd56c88d..383261b73 100644 --- a/backend/domain/types.py +++ b/backend/domain/types.py @@ -164,6 +164,12 @@ class TransactionExecutionMode(Enum): NORMAL = "NORMAL" +def _int_from_serialized(value, default: int | None = 0) -> int | None: + if value is None or value == "": + return default + return int(value) + + @dataclass class Transaction: hash: str @@ -259,7 +265,7 @@ def from_dict(cls, input: dict) -> "Transaction": data=input.get("data"), consensus_data=ConsensusData.from_dict(input.get("consensus_data")), nonce=input.get("nonce"), - value=input.get("value"), + value=_int_from_serialized(input.get("value"), None), gaslimit=input.get("gaslimit"), r=input.get("r"), s=input.get("s"), diff --git a/backend/node/base.py b/backend/node/base.py index 20de9f5e5..cc1f0e2f9 100644 --- a/backend/node/base.py +++ b/backend/node/base.py @@ -15,6 +15,12 @@ from backend.domain.types import Validator, Transaction, TransactionType from backend.protocol_rpc.message_handler.types import LogEvent, EventType, EventScope +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + FeeValidationError, + genvm_fee_context, + genvm_message_fee_allocation, +) import backend.node.genvm.base as genvmbase import backend.node.genvm.origin.calldata as calldata from backend.database_handler.contract_snapshot import ContractSnapshot @@ -626,6 +632,7 @@ async def exec_transaction(self, transaction: Transaction) -> Receipt: assert transaction.data is not None transaction_data = transaction.data + fee_accounting = transaction_data.get(FEE_ACCOUNTING_KEY) assert transaction.from_address is not None # Override transaction timestamp @@ -650,6 +657,7 @@ async def exec_transaction(self, transaction: Transaction) -> Receipt: transaction_created_at, value=transaction.value or 0, origin_address=transaction.origin_address, + fee_accounting=fee_accounting, ) self.timing_callback("DEPLOY_END") @@ -667,6 +675,7 @@ async def exec_transaction(self, transaction: Transaction) -> Receipt: transaction_created_at, value=transaction.value or 0, origin_address=transaction.origin_address, + fee_accounting=fee_accounting, ) self.timing_callback("RUN_END") @@ -797,6 +806,7 @@ async def deploy_contract( transaction_created_at: str | None = None, value: int = 0, origin_address: str | None = None, + fee_accounting: dict | None = None, ) -> Receipt: assert self.contract_snapshot is not None @@ -814,6 +824,7 @@ async def deploy_contract( code=code_to_deploy, value=value, origin_address=origin_address, + fee_accounting=fee_accounting, ) async def run_contract( @@ -824,6 +835,7 @@ async def run_contract( transaction_created_at: str | None = None, value: int = 0, origin_address: str | None = None, + fee_accounting: dict | None = None, ) -> Receipt: return await self._run_genvm( from_address, @@ -834,6 +846,7 @@ async def run_contract( transaction_datetime=self._date_from_str(transaction_created_at), value=value, origin_address=origin_address, + fee_accounting=fee_accounting, ) async def get_contract_data( @@ -971,6 +984,7 @@ async def _run_genvm( code: bytes | None = None, value: int = 0, origin_address: str | None = None, + fee_accounting: dict | None = None, ) -> Receipt: self.timing_callback("GENVM_PREPARATION_START") @@ -1020,7 +1034,6 @@ async def _run_genvm( host_data["node_address"] = self.address logger = self.logger.with_keys({"tx_id": host_data["tx_id"]}) - message = { "is_init": is_init, "contract_address": contract_address, @@ -1038,6 +1051,13 @@ async def _run_genvm( start_time = time.time() try: + bucket_totals, gas_data = genvm_fee_context( + fee_accounting, + ) + message_fee_allocation = genvm_message_fee_allocation( + fee_accounting, + address_factory=Address, + ) result = await genvmbase.run_genvm_host( functools.partial( genvmbase.Host, @@ -1054,8 +1074,34 @@ async def _run_genvm( manager_uri=self.manager.url, timeout=timeout, code=code, + bucket_totals=bucket_totals, + gas_data=gas_data, + message_fee_allocation=message_fee_allocation, logger=logger, ) + except FeeValidationError as e: + result = genvmbase.ExecutionResult( + result=genvmbase.ExecutionError( + message=str(e), + kind=public_abi.ResultCode.USER_ERROR, + error_code=e.__class__.__name__, + raw_error={ + "fatal": False, + "causes": [str(e)], + "ctx": {"source": "studio_fee_accounting"}, + }, + description=str(e), + ), + eq_outputs={}, + pending_transactions=[], + stdout="", + stderr=str(e), + genvm_log=[], + state=snapshot_view, + processing_time=int((time.time() - start_time) * 1000), + nondet_disagree=None, + execution_stats=None, + ) except genvmbase.GenVMInternalError as e: e.is_leader = self.validator_mode == ExecutionMode.LEADER raise @@ -1082,6 +1128,17 @@ async def _run_genvm( if isinstance(result.result, genvmbase.ExecutionReturn) else ExecutionResultStatus.ERROR ) + data_fees_consumed = None + if ( + result.data_fee_bucket_totals is not None + and result.data_fees_remaining is not None + ): + data_fees_consumed = [ + max(0, int(total) - int(remaining)) + for total, remaining in zip( + result.data_fee_bucket_totals, result.data_fees_remaining + ) + ] result = Receipt( result=genvmbase.encode_result_to_bytes(result.result), @@ -1121,6 +1178,9 @@ async def _run_genvm( if isinstance(result.result, genvmbase.ExecutionError) else None ), + "data_fee_bucket_totals": result.data_fee_bucket_totals, + "data_fees_remaining": result.data_fees_remaining, + "data_fees_consumed": data_fees_consumed, }, processing_time=result.processing_time, nondet_disagree=result.nondet_disagree, diff --git a/backend/node/genvm/base.py b/backend/node/genvm/base.py index a5927e409..e6ae2abde 100644 --- a/backend/node/genvm/base.py +++ b/backend/node/genvm/base.py @@ -49,6 +49,17 @@ GenVMInternalError, ) +GENVM_GASLESS_GAS_DATA: dict[str, str] = { + "storageUnitPrice": "0", + "receiptGasPerByte": "0", + "gasPerChangedSlot": "0", + "intrinsicGas": "0", + "bootloaderOverhead": "0", + "fixedProposeReceiptGas": "0", + "fixedMessageRevealGas": "0", + "genPerTimeUnit": "0", +} + @dataclass class ExecutionError: @@ -234,6 +245,46 @@ class ExecutionResult: processing_time: int nondet_disagree: int | None execution_stats: dict | None = None + data_fee_bucket_totals: list[int] | None = None + data_fees_remaining: list[int] | None = None + + +def _emission_value(emission: dict, name: str): + snake = "".join(f"_{char.lower()}" if char.isupper() else char for char in name) + return emission.get(name, emission.get(snake)) + + +def _emission_bytes(emission: dict, name: str) -> bytes: + value = _emission_value(emission, name) + if value is None: + return b"" + if isinstance(value, bytes): + return value + if isinstance(value, str): + raw = value.removeprefix("0x") + try: + return bytes.fromhex(raw) + except ValueError: + return base64.b64decode(value) + return bytes(value) + + +def _emission_int(emission: dict, name: str) -> int: + return int(_emission_value(emission, name) or 0) + + +def _emission_hex(emission: dict, name: str) -> str: + value = _emission_value(emission, name) + if value is None: + return "0x" + ("0" * 64) + if isinstance(value, bytes): + return "0x" + value.hex().rjust(64, "0")[-64:] + return "0x" + str(value).removeprefix("0x").lower().rjust(64, "0")[-64:] + + +def _emission_list(emission: dict, name: str) -> list: + value = _emission_value(emission, name) + return value if isinstance(value, list) else [] class Host(genvmhost.IHost): @@ -355,6 +406,12 @@ def provide_result( salt_nonce=0, value=emission["value"], on=emission["on"], + fee_params=_emission_bytes(emission, "feeParams"), + declared_budget=_emission_int(emission, "declaredBudget"), + call_key=_emission_hex(emission, "callKey"), + allocation_subtree=_emission_list( + emission, "allocationSubtree" + ), ) ) case "DeployContract": @@ -366,6 +423,12 @@ def provide_result( salt_nonce=emission["salt_nonce"], value=emission["value"], on=emission["on"], + fee_params=_emission_bytes(emission, "feeParams"), + declared_budget=_emission_int(emission, "declaredBudget"), + call_key=_emission_hex(emission, "callKey"), + allocation_subtree=_emission_list( + emission, "allocationSubtree" + ), ) ) case "EthSend": @@ -378,6 +441,13 @@ def provide_result( value=emission["value"], on="finalized", is_eth_send=True, + fee_params=_emission_bytes(emission, "feeParams"), + declared_budget=_emission_int(emission, "declaredBudget"), + call_key=_emission_hex(emission, "callKey"), + allocation_subtree=_emission_list( + emission, "allocationSubtree" + ), + gas_used=_emission_int(emission, "gasUsed"), ) ) @@ -395,6 +465,7 @@ def provide_result( processing_time=0, nondet_disagree=self._nondet_disagreement, execution_stats=ctx.stats, + data_fees_remaining=res.data_fees_remaining, ) async def loop_enter(self, cancellation) -> socket.socket: @@ -499,6 +570,7 @@ def _create_timeout_result( state=state_proxy, processing_time=processing_time, nondet_disagree=None, + data_fees_remaining=[], ) @@ -527,10 +599,15 @@ async def run_genvm_host( extra_args: list[str] = [], permissions: str = "rwscn", code: bytes | None = None, + bucket_totals: list[int] | None = None, + gas_data: dict[str, str] | None = None, + message_fee_allocation: list[dict] | None = None, ) -> ExecutionResult: if logger is None: logger = genvm_logger.NoLogger() ctx = Context(logger=logger) + effective_bucket_totals = bucket_totals or [10_000_000, 10_000_000, 10_000_000] + effective_gas_data = dict(gas_data) if gas_data else dict(GENVM_GASLESS_GAS_DATA) tmpdir = Path(tempfile.mkdtemp()) try: base_delay = 5 # seconds @@ -602,6 +679,9 @@ async def run_genvm_host( host=f"unix://{sock_path}", extra_args=extra_args, code=code, + bucket_totals=effective_bucket_totals, + gas_data=effective_gas_data, + message_fee_allocation=message_fee_allocation or [], calldata=fresh_args.get( "calldata_bytes", host_args.get("calldata_bytes", b"") ), @@ -613,6 +693,7 @@ async def run_genvm_host( fresh_args.get("state_proxy", host_args.get("state_proxy")), ctx, ) + execution_result.data_fee_bucket_totals = effective_bucket_totals execution_result.processing_time = math.ceil( (time.time() - start_time) * 1000 diff --git a/backend/node/genvm/origin/base_host.py b/backend/node/genvm/origin/base_host.py index f86158aba..8f83e636e 100644 --- a/backend/node/genvm/origin/base_host.py +++ b/backend/node/genvm/origin/base_host.py @@ -25,6 +25,18 @@ ACCOUNT_ADDR_SIZE = 20 SLOT_ID_SIZE = 32 +DEFAULT_GAS_DATA: dict[str, str] = { + "storageUnitPrice": "1", + "receiptGasPerByte": "1", + "gasPerChangedSlot": "1", + "intrinsicGas": "0", + "bootloaderOverhead": "0", + "fixedProposeReceiptGas": "0", + "fixedMessageRevealGas": "0", + "genPerTimeUnit": "0", +} +DEFAULT_INITIAL_TIME_UNITS_ALLOCATION = 10 * 60 + from .logger import Logger @@ -102,11 +114,31 @@ class ResultFingerprint(typing.TypedDict): module_instances: dict[str, typing.Any] +class MessageFeeParams(typing.TypedDict): + leader_timeunits_allocation: int + validator_timeunits_allocation: int + execution_budget_per_round: int + rotations: list[int] + + +class MessageFeeAllocationNode(typing.TypedDict): + message_type: typing.Literal["InternalAccepted", "InternalFinalized", "External"] + parent_index: int | None + recipient: Address | None + call_key: bytes | None + budget: int + fee_params: MessageFeeParams + + class EthSendInner(typing.TypedDict): type: typing.Literal["EthSend"] address: Address calldata: bytes value: int + feeParams: typing.NotRequired[bytes] + declaredBudget: typing.NotRequired[int] + callKey: typing.NotRequired[bytes] + allocationSubtree: typing.NotRequired[list[dict]] class PostMessageInner(typing.TypedDict): @@ -115,6 +147,10 @@ class PostMessageInner(typing.TypedDict): calldata: gvm_calldata.Decoded value: int on: typing.Literal["finalized", "accepted"] + feeParams: typing.NotRequired[bytes] + declaredBudget: typing.NotRequired[int] + callKey: typing.NotRequired[bytes] + allocationSubtree: typing.NotRequired[list[dict]] class DeployContractInner(typing.TypedDict): @@ -124,6 +160,10 @@ class DeployContractInner(typing.TypedDict): value: int on: typing.Literal["finalized", "accepted"] salt_nonce: int + feeParams: typing.NotRequired[bytes] + declaredBudget: typing.NotRequired[int] + callKey: typing.NotRequired[bytes] + allocationSubtree: typing.NotRequired[list[dict]] class EmitEventInner(typing.TypedDict): @@ -375,6 +415,7 @@ class RunHostAndProgramRes: result_storage_changes: list[tuple[bytes, bytes]] result_emissions: list[ResultEmission] result_nondet_results: list[bytes] + data_fees_remaining: list[int] vm_error_description: str | None = None @@ -429,17 +470,20 @@ async def run_genvm( capture_output: bool = True, message: Message, host_data: str = "", + gas_data: dict[str, str] | None = None, host: str, extra_args: list[str] = [], - data_fees_limit: int = 10_000_000, - storage_page_cost: int = 1, - receipt_word_cost: int = 1, + bucket_totals: list[int] | None = None, code: bytes | None = None, calldata: bytes, leader_nondet_results: list[bytes] | None = None, + message_fee_allocation: list[MessageFeeAllocationNode] | None = None, request_extra: dict[str, gvm_calldata.Encodable] = {}, ) -> RunHostAndProgramRes: logger = ctx.logger + effective_bucket_totals = bucket_totals or [10_000_000, 10_000_000, 10_000_000] + effective_gas_data = DEFAULT_GAS_DATA if gas_data is None else gas_data + effective_message_fee_allocation = message_fee_allocation or [] perf_timeline: dict[str, typing.Any] = { "run_started_s": time.perf_counter(), @@ -475,9 +519,10 @@ async def wrap_proc_body(attempt: int): "code": code, "calldata": calldata, "leader_nondet_results": leader_nondet_results, - "data_fees_limit": data_fees_limit, - "storage_page_cost": storage_page_cost, - "receipt_word_cost": receipt_word_cost, + "bucket_totals": effective_bucket_totals, + "gas_data": effective_gas_data, + "message_fee_allocation": effective_message_fee_allocation, + "initial_time_units_allocation": DEFAULT_INITIAL_TIME_UNITS_ALLOCATION, **request_extra, } ), @@ -791,6 +836,7 @@ async def prob_died(): result_storage_changes = decoded.get("storage_changes", []) result_emissions = decoded.get("emissions", []) nondet_results = decoded.get("nondet_results", []) + data_fees_remaining = decoded.get("data_fees_remaining", []) else: execution_hash = b"" result_kind = public_abi.ResultCode.INTERNAL_ERROR @@ -799,10 +845,11 @@ async def prob_died(): result_storage_changes = [] result_emissions = [] nondet_results = [] + data_fees_remaining = [] if timeout_fired.is_set() and result_kind != public_abi.ResultCode.RETURN: result_kind = public_abi.ResultCode.VM_ERROR - result_data = public_abi.VmError.TIMEOUT.value + result_data = str(public_abi.VmError.timeout()) vm_error_description: str | None = None if result_kind == public_abi.ResultCode.VM_ERROR and isinstance( @@ -832,6 +879,7 @@ async def prob_died(): result_storage_changes=result_storage_changes, result_emissions=result_emissions, result_nondet_results=nondet_results, + data_fees_remaining=data_fees_remaining, vm_error_description=vm_error_description, execution_time=time.time() - started_at[0], ) diff --git a/backend/node/genvm/origin/host_fns.py b/backend/node/genvm/origin/host_fns.py index cd7e5dd84..281d17dd2 100644 --- a/backend/node/genvm/origin/host_fns.py +++ b/backend/node/genvm/origin/host_fns.py @@ -1,18 +1,18 @@ # This file is auto-generated. Do not edit! from enum import IntEnum +import typing class Methods(IntEnum): STORAGE_READ = 0 - STORAGE_WRITE = 1 - CONSUME_FUEL = 2 - ETH_CALL = 3 - GET_BALANCE = 4 - REMAINING_FUEL_AS_GEN = 5 - NOTIFY_NONDET_DISAGREEMENT = 6 - CONSUME_RESULT = 7 - NOTIFY_FINISHED = 8 + CONSUME_FUEL = 1 + ETH_CALL = 2 + GET_BALANCE = 3 + REMAINING_FUEL_AS_GEN = 4 + NOTIFY_NONDET_DISAGREEMENT = 5 + CONSUME_RESULT = 6 + NOTIFY_FINISHED = 7 class Errors(IntEnum): @@ -20,3 +20,9 @@ class Errors(IntEnum): ABSENT = 1 FORBIDDEN = 2 OUT_OF_STORAGE_GAS = 3 + + +CURRENT_MAJOR: typing.Final[int] = 0 + + +CURRENT_MAJOR_STR: typing.Final[str] = "v0.0.0" diff --git a/backend/node/genvm/origin/public_abi.py b/backend/node/genvm/origin/public_abi.py index 06b289e5d..f786ede5d 100644 --- a/backend/node/genvm/origin/public_abi.py +++ b/backend/node/genvm/origin/public_abi.py @@ -42,6 +42,10 @@ class VmError(StrEnum): OOM = "OOM" INVALID_CONTRACT = "invalid_contract" + @staticmethod + def timeout() -> "VmError": + return VmError.TIMEOUT + EVENT_MAX_TOPICS: typing.Final[int] = 4 diff --git a/backend/node/types.py b/backend/node/types.py index d49c83ca1..414e40d79 100644 --- a/backend/node/types.py +++ b/backend/node/types.py @@ -155,6 +155,12 @@ def from_string(cls, value: str) -> "ExecutionResultStatus": raise ValueError(f"Invalid execution result status value: {value}") +def _int_from_serialized(value, default: int = 0) -> int: + if value is None or value == "": + return default + return int(value) + + @dataclass class PendingTransaction: address: str # Address of the contract to call @@ -166,6 +172,11 @@ class PendingTransaction: is_eth_send: bool = ( False # True for EthSend (simple value transfer, no contract call) ) + fee_params: bytes = b"" + declared_budget: int = 0 + call_key: str = "0x" + ("0" * 64) + allocation_subtree: list[dict] = field(default_factory=list) + gas_used: int = 0 def is_deploy(self) -> bool: return self.code is not None @@ -177,6 +188,11 @@ def to_dict(self): "is_eth_send": True, "on": self.on, "value": self.value, + "fee_params": str(base64.b64encode(self.fee_params), encoding="ascii"), + "declared_budget": self.declared_budget, + "call_key": self.call_key, + "allocation_subtree": self.allocation_subtree, + "gas_used": self.gas_used, } elif self.code is None: return { @@ -184,6 +200,11 @@ def to_dict(self): "calldata": str(base64.b64encode(self.calldata), encoding="ascii"), "on": self.on, "value": self.value, + "fee_params": str(base64.b64encode(self.fee_params), encoding="ascii"), + "declared_budget": self.declared_budget, + "call_key": self.call_key, + "allocation_subtree": self.allocation_subtree, + "gas_used": self.gas_used, } else: return { @@ -192,6 +213,11 @@ def to_dict(self): "salt_nonce": self.salt_nonce, "on": self.on, "value": self.value, + "fee_params": str(base64.b64encode(self.fee_params), encoding="ascii"), + "declared_budget": self.declared_budget, + "call_key": self.call_key, + "allocation_subtree": self.allocation_subtree, + "gas_used": self.gas_used, } @classmethod @@ -202,27 +228,42 @@ def from_dict(cls, input: dict) -> "PendingTransaction": calldata=b"", code=None, salt_nonce=0, - value=input.get("value", 0), + value=_int_from_serialized(input.get("value"), 0), on=input.get("on", "finalized"), is_eth_send=True, + fee_params=base64.b64decode(input.get("fee_params", "")), + declared_budget=_int_from_serialized(input.get("declared_budget"), 0), + 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), ) elif "code" in input: return cls( address="0x", calldata=base64.b64decode(input["calldata"]), code=base64.b64decode(input["code"]), - salt_nonce=input.get("salt_nonce", 0), - value=input.get("value", 0), + salt_nonce=_int_from_serialized(input.get("salt_nonce"), 0), + value=_int_from_serialized(input.get("value"), 0), on=input.get("on", "finalized"), + fee_params=base64.b64decode(input.get("fee_params", "")), + declared_budget=_int_from_serialized(input.get("declared_budget"), 0), + 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), ) else: return cls( address=input["address"], calldata=base64.b64decode(input["calldata"]), - value=input.get("value", 0), + value=_int_from_serialized(input.get("value"), 0), code=None, salt_nonce=0, on=input.get("on", "finalized"), + fee_params=base64.b64decode(input.get("fee_params", "")), + declared_budget=_int_from_serialized(input.get("declared_budget"), 0), + 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), ) diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 3f6d44cab..1f25168ab 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -4,6 +4,7 @@ import time import eth_utils import logging +from contextlib import asynccontextmanager from functools import partial, wraps from typing import Any from backend.protocol_rpc.exceptions import ( @@ -38,6 +39,18 @@ ) from backend.protocol_rpc.transactions_parser import TransactionParser +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + FeeValidationError, + StudioFeePolicy, + apply_fee_top_up, + create_fee_accounting, + record_appeal_bond, + record_execution_fee_consumption, + required_fee_deposit, + studio_fee_config, + validate_transaction_fee_deposit, +) from backend.errors.errors import InvalidAddressError, InvalidTransactionError from backend.database_handler.errors import ContractNotFoundError @@ -58,14 +71,19 @@ import os import secrets as secrets_module from backend.protocol_rpc.message_handler.types import LogEvent, EventType, EventScope -from backend.protocol_rpc.types import DecodedsubmitAppealDataArgs +from backend.protocol_rpc.types import ( + DecodedRollupTransaction, + DecodedTopUpFeesDataArgs, + DecodedsubmitAppealDataArgs, +) from backend.database_handler.snapshot_manager import SnapshotManager from backend.node.base import Manager as GenVMManager import asyncio -# Limit concurrent GenVM executions on the jsonrpc path to prevent uvloop fd conflicts. -# Workers use asyncio.Semaphore(8) in consensus/base.py; gen_call had none, allowing -# unbounded concurrent GenVM socket operations that cause fd registry collisions. +# Limit concurrent GenVM executions on the jsonrpc path to prevent uvloop fd +# conflicts and DB pool exhaustion while calls hold request-scoped sessions. +# Workers use asyncio.Semaphore(8) in consensus/base.py; keep the RPC path +# bounded too. _GENVM_CONCURRENCY = int(os.environ.get("GENVM_MAX_CONCURRENT", "8")) _genvm_semaphore = asyncio.Semaphore(_GENVM_CONCURRENCY) @@ -97,13 +115,36 @@ def _check_rate_limit(address: str) -> None: ) raise JSONRPCError( code=-32005, - message=f"Rate limit exceeded: max {_RATE_LIMIT_MAX} gen_call requests per {_RATE_LIMIT_WINDOW}s per contract address", + message=f"Rate limit exceeded: max {_RATE_LIMIT_MAX} gen_call/sim_call requests per {_RATE_LIMIT_WINDOW}s per contract address", data={"address": address, "retry_after_seconds": _RATE_LIMIT_WINDOW}, ) timestamps.append(now) _address_request_log[address] = timestamps +@asynccontextmanager +async def _admit_genvm_call(method: str, to_address: str | None): + """Reject GenVM-backed RPC calls instead of queueing unlimited work.""" + if _genvm_semaphore.locked(): + _rate_limit_logger.warning( + "GenVM at capacity (%s concurrent) - rejecting %s to %s", + _GENVM_CONCURRENCY, + method, + to_address, + ) + raise JSONRPCError( + code=-32006, + message=f"Server busy: all {_GENVM_CONCURRENCY} execution slots occupied, retry later", + data={"retry_after_seconds": 2}, + ) + + await _genvm_semaphore.acquire() + try: + yield + finally: + _genvm_semaphore.release() + + # --------------------------------------------------------------------------- # Admission control on PENDING queue depth (eth_sendRawTransaction path). # @@ -204,6 +245,10 @@ def _enforce_pending_queue_caps( ) +def get_studio_fee_config() -> dict[str, Any]: + return studio_fee_config(StudioFeePolicy.from_env()) + + ####### ADMIN ACCESS CONTROL ####### def require_admin_access(func): """ @@ -922,6 +967,11 @@ def cancel_transaction( AccountsManager(session).refund_tx_value( transaction_hash, transaction.from_address ) + if transaction.from_address: + AccountsManager(session).cancel_tx_fee_accounting_once( + transaction_hash, transaction.from_address, "canceled" + ) + session.commit() # Notify frontend via WebSocket msg_handler.send_transaction_status_update(transaction_hash, "CANCELED") @@ -1153,15 +1203,17 @@ async def gen_call( genvm_manager: GenVMManager, params: dict, ) -> str: - receipt = await _execute_call_with_snapshot( - session, - accounts_manager, - msg_handler, - transactions_parser, - validators_manager, - genvm_manager, - params, - ) + to_address = params.get("to") if isinstance(params, dict) else None + async with _admit_genvm_call("gen_call", to_address): + receipt = await _execute_call_with_snapshot( + session, + accounts_manager, + msg_handler, + transactions_parser, + validators_manager, + genvm_manager, + params, + ) return eth_utils.hexadecimal.encode_hex(receipt.result[1:])[2:] @@ -1190,18 +1242,86 @@ async def sim_call( genvm_manager: GenVMManager, params: dict, ) -> dict: - receipt = await _execute_call_with_snapshot( - session, - accounts_manager, - msg_handler, - transactions_parser, - validators_manager, - genvm_manager, - params, - ) + to_address = params.get("to") if isinstance(params, dict) else None + async with _admit_genvm_call("sim_call", to_address): + receipt = await _execute_call_with_snapshot( + session, + accounts_manager, + msg_handler, + transactions_parser, + validators_manager, + genvm_manager, + params, + ) return receipt.to_dict() +async def sim_estimate_transaction_fees( + session: Session, + accounts_manager: AccountsManager, + msg_handler: IMessageHandler, + transactions_parser: TransactionParser, + validators_manager: validators.Manager, + genvm_manager: GenVMManager, + params: dict, +) -> dict: + estimate_params = _with_default_simulation_fees(params) + receipt = await sim_call( + session=session, + accounts_manager=accounts_manager, + msg_handler=msg_handler, + transactions_parser=transactions_parser, + validators_manager=validators_manager, + genvm_manager=genvm_manager, + params=estimate_params, + ) + genvm_result = receipt.get("genvm_result") or {} + fee_accounting = ( + genvm_result.get(FEE_ACCOUNTING_KEY) if isinstance(genvm_result, dict) else {} + ) or {} + return { + "scenario": _first_present(params, "scenario", "scenarioName") or "default", + "receipt": receipt, + "feeAccounting": fee_accounting, + "feeReport": fee_accounting.get("execution_fee_report") or {}, + "recommendedPreset": fee_accounting.get("recommended_fee_preset") or {}, + } + + +def _with_default_simulation_fees(params: dict) -> dict: + if not isinstance(params, dict): + return params + fees = params.get("fees") if isinstance(params.get("fees"), dict) else {} + has_fee_params = any( + key in params + for key in ( + "fees_distribution", + "feesDistribution", + "message_allocations", + "messageAllocations", + "fee_value", + "feeValue", + ) + ) or any( + key in fees + for key in ( + "distribution", + "fees_distribution", + "feesDistribution", + "message_allocations", + "messageAllocations", + "fee_value", + "feeValue", + ) + ) + if has_fee_params: + return params + + updated = dict(params) + updated["fees"] = studio_fee_config(StudioFeePolicy.from_env())["defaultFees"] + return updated + + async def _gen_call_with_validator( session: Session, accounts_manager: AccountsManager, @@ -1217,6 +1337,11 @@ async def _gen_call_with_validator( from_address = params["from"] origin_address = params.get("origin_address") call_value = int(params.get("value", "0x0"), 16) if params.get("value") else 0 + simulation_fee_accounting = _simulation_fee_accounting( + params, + sender=from_address, + user_value=call_value, + ) transaction_hash_variant = ( params["transaction_hash_variant"] if "transaction_hash_variant" in params @@ -1271,90 +1396,88 @@ async def _gen_call_with_validator( sim_config is not None and sim_config.genvm_datetime is not None ) - if _genvm_semaphore.locked(): - _rate_limit_logger.warning( - f"GenVM at capacity ({_GENVM_CONCURRENCY} concurrent) — rejecting gen_call to {to_address}" - ) - raise JSONRPCError( - code=-32006, - message=f"Server busy: all {_GENVM_CONCURRENCY} execution slots occupied, retry later", - data={"retry_after_seconds": 2}, + try: + if type == "read": + # Pre-parse timestamp override and map errors + txn_dt = None + if sim_config and override_transaction_datetime: + try: + txn_dt = sim_config.genvm_datetime_as_datetime + except ValueError as e: + raise JSONRPCError( + code=-32602, + message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", + data={}, + ) from e + decoded_data = transactions_parser.decode_method_call_data(data) + receipt = await node.get_contract_data( + from_address=from_address, + calldata=decoded_data.calldata, + state_status=state_status, + transaction_datetime=txn_dt, + origin_address=origin_address, + ) + elif type == "write": + txn_created_at = None + if sim_config and override_transaction_datetime: + try: + _ = sim_config.genvm_datetime_as_datetime # validation only + txn_created_at = sim_config.genvm_datetime + except ValueError as e: + raise JSONRPCError( + code=-32602, + message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", + data={}, + ) from e + decoded_data = transactions_parser.decode_method_send_data(data) + receipt = 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=simulation_fee_accounting, + ) + elif type == "deploy": + txn_created_at = None + if sim_config and override_transaction_datetime: + try: + _ = sim_config.genvm_datetime_as_datetime # validation only + txn_created_at = sim_config.genvm_datetime + except ValueError as e: + raise JSONRPCError( + code=-32602, + message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", + data={}, + ) from e + decoded_data = transactions_parser.decode_deployment_data(data) + receipt = 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=simulation_fee_accounting, + ) + else: + raise JSONRPCError( + code=-32602, + message=f"Invalid type '{type}': must be 'read', 'write', or 'deploy'", + ) + except ContractNotFoundError as e: + raise NotFoundError( + message=f"Contract {e.address} not found", + data={"contract_address": e.address}, + ) from e + + if simulation_fee_accounting is not None: + receipt.genvm_result = dict(receipt.genvm_result or {}) + receipt.genvm_result["fee_accounting"] = record_execution_fee_consumption( + simulation_fee_accounting, + receipt, ) - async with _genvm_semaphore: - try: - if type == "read": - # Pre-parse timestamp override and map errors - txn_dt = None - if sim_config and override_transaction_datetime: - try: - txn_dt = sim_config.genvm_datetime_as_datetime - except ValueError as e: - raise JSONRPCError( - code=-32602, - message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", - data={}, - ) from e - decoded_data = transactions_parser.decode_method_call_data(data) - receipt = await node.get_contract_data( - from_address=from_address, - calldata=decoded_data.calldata, - state_status=state_status, - transaction_datetime=txn_dt, - origin_address=origin_address, - ) - elif type == "write": - txn_created_at = None - if sim_config and override_transaction_datetime: - try: - _ = sim_config.genvm_datetime_as_datetime # validation only - txn_created_at = sim_config.genvm_datetime - except ValueError as e: - raise JSONRPCError( - code=-32602, - message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", - data={}, - ) from e - decoded_data = transactions_parser.decode_method_send_data(data) - receipt = 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, - ) - elif type == "deploy": - txn_created_at = None - if sim_config and override_transaction_datetime: - try: - _ = sim_config.genvm_datetime_as_datetime # validation only - txn_created_at = sim_config.genvm_datetime - except ValueError as e: - raise JSONRPCError( - code=-32602, - message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", - data={}, - ) from e - decoded_data = transactions_parser.decode_deployment_data(data) - receipt = 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, - ) - else: - raise JSONRPCError( - code=-32602, - message=f"Invalid type '{type}': must be 'read', 'write', or 'deploy'", - ) - except ContractNotFoundError as e: - raise NotFoundError( - message=f"Contract {e.address} not found", - data={"contract_address": e.address}, - ) from e - # Return the result of the write method if receipt.execution_result != ExecutionResultStatus.SUCCESS: raise JSONRPCError( @@ -1469,45 +1592,45 @@ async def eth_call( if not accounts_manager.is_valid_address(from_address): raise InvalidAddressError(from_address) - decoded_data = transactions_parser.decode_method_call_data(data) + async with _admit_genvm_call("eth_call", to_address): + decoded_data = transactions_parser.decode_method_call_data(data) - async with validators_manager.snapshot() as snapshot: - print(snapshot.nodes) - if len(snapshot.nodes) == 0: - raise JSONRPCError( - code=-32000, - message="No validators available to execute eth_call", - data={"reason": "no_validators"}, - ) - as_validator = snapshot.nodes[0].validator - try: - target_contract_snapshot = ContractSnapshot(to_address, session) - except ContractNotFoundError: - raise NotFoundError( - message=f"Contract {to_address} not found", - data={"contract_address": to_address}, + async with validators_manager.snapshot() as snapshot: + if len(snapshot.nodes) == 0: + raise JSONRPCError( + code=-32000, + message="No validators available to execute eth_call", + data={"reason": "no_validators"}, + ) + as_validator = snapshot.nodes[0].validator + try: + target_contract_snapshot = ContractSnapshot(to_address, session) + except ContractNotFoundError: + raise NotFoundError( + message=f"Contract {to_address} not found", + data={"contract_address": to_address}, + ) + node = Node( # Mock node just to get the data from the GenVM + contract_snapshot=target_contract_snapshot, + contract_snapshot_factory=partial(ContractSnapshot, session=session), + validator_mode=ExecutionMode.LEADER, + validator=as_validator, + leader_receipt=None, + msg_handler=msg_handler.with_client_session(get_client_session_id()), + validators_snapshot=snapshot, + manager=genvm_manager, ) - node = Node( # Mock node just to get the data from the GenVM - contract_snapshot=target_contract_snapshot, - contract_snapshot_factory=partial(ContractSnapshot, session=session), - validator_mode=ExecutionMode.LEADER, - validator=as_validator, - leader_receipt=None, - msg_handler=msg_handler.with_client_session(get_client_session_id()), - validators_snapshot=snapshot, - manager=genvm_manager, - ) - try: - receipt = await node.get_contract_data( - from_address=as_validator.address, - calldata=decoded_data.calldata, - ) - except ContractNotFoundError as e: - raise NotFoundError( - message=f"Contract {e.address} not found", - data={"contract_address": e.address}, - ) from e + try: + receipt = await node.get_contract_data( + from_address=as_validator.address, + calldata=decoded_data.calldata, + ) + except ContractNotFoundError as e: + raise NotFoundError( + message=f"Contract {e.address} not found", + data={"contract_address": e.address}, + ) from e if receipt.execution_result != ExecutionResultStatus.SUCCESS: raise JSONRPCError( @@ -1516,6 +1639,265 @@ async def eth_call( return eth_utils.hexadecimal.encode_hex(receipt.result[1:]) +def _fee_metadata(decoded_rollup_transaction: DecodedRollupTransaction) -> dict: + if ( + decoded_rollup_transaction.data is None + or isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs) + or isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs) + or not hasattr(decoded_rollup_transaction.data, "args") + or decoded_rollup_transaction.data.args is None + ): + return {} + + args = decoded_rollup_transaction.data.args + if args.fees_distribution is None and decoded_rollup_transaction.fee_value == 0: + return {} + + metadata = { + "fee_value": decoded_rollup_transaction.fee_value, + "user_value": args.user_value, + "valid_until": args.valid_until, + "salt_nonce": args.salt_nonce, + "fees_distribution": args.fees_distribution, + "message_allocations_count": args.message_allocations_count, + } + metadata[FEE_ACCOUNTING_KEY] = create_fee_accounting( + fees_distribution=args.fees_distribution, + message_allocations=args.message_allocations, + num_of_validators=args.num_of_initial_validators, + submitted_value=decoded_rollup_transaction.total_spend, + user_value=int(args.user_value or 0), + sender=decoded_rollup_transaction.from_address, + policy=StudioFeePolicy.from_env(), + ) + return metadata + + +def _validate_fee_envelope( + decoded_rollup_transaction: DecodedRollupTransaction, +) -> None: + if ( + decoded_rollup_transaction.data is None + or isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs) + or isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs) + or not hasattr(decoded_rollup_transaction.data, "args") + or decoded_rollup_transaction.data.args is None + ): + return + + args = decoded_rollup_transaction.data.args + if args.fees_distribution is None: + return + + try: + validate_transaction_fee_deposit( + fees_distribution=args.fees_distribution, + message_allocations=args.message_allocations, + 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(), + ) + except FeeValidationError as exc: + raise InvalidTransactionError(str(exc)) from exc + + +def _sandbox_debit_sender( + accounts_manager: AccountsManager, from_address: str, amount: int +) -> None: + if amount <= 0: + return + sender_balance = accounts_manager.get_account_balance(from_address) + if sender_balance < amount: + accounts_manager.credit_account_balance(from_address, amount - sender_balance) + accounts_manager.debit_account_balance(from_address, amount) + + +def _handle_top_up_fees( + *, + accounts_manager: AccountsManager, + transactions_processor: TransactionsProcessor, + decoded_rollup_transaction: DecodedRollupTransaction, +) -> 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", data={"hash": tx_id}) + + status = tx.get("status") + if status in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.UNDETERMINED.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") + + try: + updated = apply_fee_top_up( + fee_accounting, + 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(), + ) + except FeeValidationError as exc: + 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) + return tx_id + + +def _handle_appeal_or_top_up_and_submit( + *, + accounts_manager: AccountsManager, + transactions_processor: TransactionsProcessor, + msg_handler: IMessageHandler, + decoded_rollup_transaction: DecodedRollupTransaction, +) -> str: + assert isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs) + 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", data={"hash": tx_id}) + + fee_accounting = (tx.get("data") or {}).get(FEE_ACCOUNTING_KEY) + if fee_accounting is not None and decoded_rollup_transaction.total_spend > 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 ""), + fees_distribution=decoded_rollup_transaction.data.fees_distribution, + top_up_and_submit=decoded_rollup_transaction.data.top_up_and_submit, + ) + except FeeValidationError as exc: + 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) + + transactions_processor.set_transaction_appeal(tx_id, True) + msg_handler.send_message( + log_event=LogEvent( + "transaction_appeal_updated", + EventType.INFO, + EventScope.CONSENSUS, + "Set transaction appealed", + { + "hash": tx_id, + }, + ), + log_to_terminal=False, + ) + 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 + + +def _current_fee_round(consensus_history: dict | None) -> int: + if not isinstance(consensus_history, dict): + return 0 + rounds = consensus_history.get("consensus_results") + if not isinstance(rounds, list) or len(rounds) == 0: + return 0 + return max(0, len(rounds) - 1) + + +def _simulation_fee_accounting( + params: dict, + *, + sender: str, + user_value: int, +) -> dict | None: + fees = params.get("fees") if isinstance(params.get("fees"), dict) else {} + fees_distribution = _first_present( + params, + "fees_distribution", + "feesDistribution", + ) or _first_present(fees, "distribution", "fees_distribution", "feesDistribution") + message_allocations = _first_present( + params, + "message_allocations", + "messageAllocations", + ) + if message_allocations is None: + message_allocations = _first_present( + fees, + "message_allocations", + "messageAllocations", + ) + raw_fee_value = _first_present(params, "fee_value", "feeValue") + if raw_fee_value is None: + raw_fee_value = _first_present(fees, "fee_value", "feeValue") + + if fees_distribution is None and not message_allocations and raw_fee_value is None: + return None + + fees_distribution = fees_distribution or {} + message_allocations = message_allocations or [] + num_of_initial_validators = _int_param( + _first_present(params, "num_of_initial_validators", "numOfInitialValidators"), + 5, + ) + policy = StudioFeePolicy.from_env() + fee_value = _int_param(raw_fee_value, None) + if fee_value is None: + fee_value = required_fee_deposit( + fees_distribution, + num_of_initial_validators, + policy, + ) + + try: + return create_fee_accounting( + fees_distribution=fees_distribution, + message_allocations=message_allocations, + num_of_validators=num_of_initial_validators, + submitted_value=int(user_value) + int(fee_value), + user_value=int(user_value), + sender=sender, + policy=policy, + ) + except FeeValidationError as exc: + raise JSONRPCError(code=-32602, message=str(exc), data={}) from exc + + +def _first_present(source: dict | None, *keys: str): + if not isinstance(source, dict): + return None + for key in keys: + if key in source: + return source[key] + return None + + +def _int_param(value: Any, default: int | None = None) -> int | None: + if value is None: + return default + if isinstance(value, str): + return int(value, 16) if value.startswith("0x") else int(value) + return int(value) + + def send_raw_transaction( session: Session, msg_handler: IMessageHandler, @@ -1540,6 +1922,7 @@ def send_raw_transaction( from_address = decoded_rollup_transaction.from_address value = decoded_rollup_transaction.value + total_spend = getattr(decoded_rollup_transaction, "total_spend", value) if not accounts_manager.is_valid_address(from_address): raise InvalidAddressError( @@ -1557,29 +1940,27 @@ def send_raw_transaction( raise InvalidTransactionError("Transaction signature verification failed") if isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs): - tx_id = decoded_rollup_transaction.data.tx_id - tx_id_hex = "0x" + tx_id.hex() if isinstance(tx_id, bytes) else tx_id - transactions_processor.set_transaction_appeal(tx_id_hex, True) - msg_handler.send_message( - log_event=LogEvent( - "transaction_appeal_updated", - EventType.INFO, - EventScope.CONSENSUS, - "Set transaction appealed", - { - "hash": tx_id_hex, - }, - ), - log_to_terminal=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): + return _handle_top_up_fees( + accounts_manager=accounts_manager, + transactions_processor=transactions_processor, + decoded_rollup_transaction=decoded_rollup_transaction, ) - return tx_id_hex else: + _validate_fee_envelope(decoded_rollup_transaction) transaction_hash = consensus_service.generate_transaction_hash( signed_rollup_transaction ) to_address = decoded_rollup_transaction.to_address nonce = decoded_rollup_transaction.nonce value = decoded_rollup_transaction.value + total_spend = getattr(decoded_rollup_transaction, "total_spend", value) genlayer_transaction = transactions_parser.get_genlayer_transaction( decoded_rollup_transaction ) @@ -1629,6 +2010,8 @@ def send_raw_transaction( "contract_code": genlayer_transaction.data.contract_code, "calldata": genlayer_transaction.data.calldata, } + if fee_metadata := _fee_metadata(decoded_rollup_transaction): + transaction_data.update(fee_metadata) to_address = new_contract_address elif genlayer_transaction.type == TransactionType.RUN_CONTRACT: # Contract Call @@ -1645,6 +2028,8 @@ def send_raw_transaction( ) 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) @@ -1669,16 +2054,12 @@ def send_raw_transaction( # Debit sender BEFORE insert. Mint on demand if insufficient (Studio sandbox). # Skip for SEND (execute_transfer handles it) and duplicates. if ( - value > 0 + total_spend > 0 and from_address and genlayer_transaction.type != TransactionType.SEND and is_duplicate is None ): - sender_balance = accounts_manager.get_account_balance(from_address) - if sender_balance < value: - shortfall = value - sender_balance - accounts_manager.credit_account_balance(from_address, shortfall) - accounts_manager.debit_account_balance(from_address, value) + _sandbox_debit_sender(accounts_manager, from_address, total_spend) # Insert transaction into the database transactions_processor.insert_transaction( diff --git a/backend/protocol_rpc/fastapi_endpoint_generator.py b/backend/protocol_rpc/fastapi_endpoint_generator.py index d1dbdd888..5d74ab3e5 100644 --- a/backend/protocol_rpc/fastapi_endpoint_generator.py +++ b/backend/protocol_rpc/fastapi_endpoint_generator.py @@ -489,6 +489,7 @@ def register(func, method_name=None): partial(endpoints.get_finality_window_time, consensus), "sim_getFinalityWindowTime", ) + register(endpoints.get_studio_fee_config, "sim_getFeeConfig") register( partial(endpoints.get_contract, accounts_manager), "sim_getConsensusContract" ) @@ -546,6 +547,18 @@ def register(func, method_name=None): ), "sim_call", ) + register( + partial( + endpoints.sim_estimate_transaction_fees, + request_session, + accounts_manager, + msg_handler, + transactions_parser, + validators_manager, + genvm_manager, + ), + "sim_estimateTransactionFees", + ) # Ethereum-compatible endpoints register(partial(endpoints.get_balance, accounts_manager), "eth_getBalance") diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py new file mode 100644 index 000000000..079904b65 --- /dev/null +++ b/backend/protocol_rpc/fees.py @@ -0,0 +1,3158 @@ +from __future__ import annotations + +import base64 +import copy +import os +from dataclasses import dataclass, fields +from typing import Any, Callable + +import rlp +from eth_abi import decode, encode + + +VALIDATORS_PER_ROUND = ( + 5, + 7, + 11, + 13, + 23, + 25, + 47, + 49, + 95, + 97, + 191, + 193, + 383, + 385, + 767, + 769, + 1535, + 1537, +) + +MIN_RECEIPT_BYTES = 512 +PROPOSE_RECEIPT_SLOTS = 7 +MESSAGE_REVEAL_LENGTH_SLOTS = 32 +NONDET_OUTPUT_LENGTH_BYTES = 32 +NODE_ROOT_SENTINEL = (1 << 256) - 1 +CALL_KEY_WILDCARD = "0x" + ("0" * 64) +MESSAGE_TYPE_EXTERNAL = 0 +MESSAGE_TYPE_INTERNAL = 1 +FEE_ACCOUNTING_KEY = "fee_accounting" + +INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = "(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)[]" +) + +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 +DEFAULT_LEADER_TIMEUNITS_ALLOCATION = 100 +DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION = 200 +DEFAULT_PRICE_CAP_HEADROOM_BPS = 12_000 +GENVM_UNMETERED_DATA_FEE_BUCKET = (1 << 256) - 1 + + +class FeeValidationError(ValueError): + pass + + +class InvalidNumOfValidators(FeeValidationError): + pass + + +class InvalidAppealRounds(FeeValidationError): + pass + + +class InsufficientFees(FeeValidationError): + pass + + +class BudgetTooLow(FeeValidationError): + pass + + +class MaxPriceExceeded(FeeValidationError): + pass + + +class MessageAllocationsNotEqualBudget(FeeValidationError): + pass + + +class AllocationTreeMalformed(FeeValidationError): + pass + + +class AllocationLifecycleBudgetInsufficient(FeeValidationError): + pass + + +class AllocationTreeBudgetInconsistent(FeeValidationError): + pass + + +class AllocationSubtreeMismatch(FeeValidationError): + pass + + +class AllocationDuplicateKey(FeeValidationError): + pass + + +class AllocationTreeTooDeep(FeeValidationError): + pass + + +class ExternalAllocationInvalid(FeeValidationError): + pass + + +class InvalidFeeParams(FeeValidationError): + pass + + +class Mode1MessageFeesRequireGenVMPerEmissionSupport(FeeValidationError): + """GenVM must expose per-emission feeParams/declaredBudget before Mode 1 is safe.""" + + +class InvalidAppealBond(FeeValidationError): + pass + + +class MessageDeclaredBudgetInsufficient(FeeValidationError): + pass + + +class MessageFeesReportMismatch(FeeValidationError): + pass + + +class MessageBudgetExceeded(FeeValidationError): + pass + + +def _with_cap_headroom( + value: int, headroom_bps: int = DEFAULT_PRICE_CAP_HEADROOM_BPS +) -> int: + if value <= 0: + return 0 + return (value * headroom_bps + 9_999) // 10_000 + + +def _with_padding(value: int, padding_bps: int) -> int: + if value <= 0: + return 0 + return (value * int(padding_bps) + 9_999) // 10_000 + + +class MessageNoMatchingAllocation(FeeValidationError): + pass + + +class MessageEmissionPhaseMismatch(FeeValidationError): + pass + + +class MessageFeeParamsMismatch(FeeValidationError): + pass + + +class TooManyMessages(FeeValidationError): + pass + + +@dataclass(frozen=True) +class StudioFeePolicy: + gen_per_time_unit: int = 0 + storage_unit_price: int = 0 + receipt_gas_price: int = 0 + intrinsic_gas: int = 21_000 + bootloader_overhead: int = 60_000 + gas_per_changed_slot: int = 1_000 + calldata_gas_per_byte: int = 16 + fixed_propose_receipt_gas: int = 210_000 + fixed_message_reveal_gas: int = 100_000 + receipt_wrapper_bytes: int = 1_024 + extra_exec_gas: int = 210_000 + max_allocation_tree_depth: int = 5 + max_messages_per_tx: int = 0 + + @classmethod + def from_env(cls) -> "StudioFeePolicy": + return cls( + gen_per_time_unit=_env_int( + "GENLAYER_STUDIO_GEN_PER_TIME_UNIT", DEFAULT_GEN_PER_TIME_UNIT + ), + storage_unit_price=_env_int( + "GENLAYER_STUDIO_STORAGE_UNIT_PRICE", DEFAULT_STORAGE_UNIT_PRICE + ), + receipt_gas_price=_env_int( + "GENLAYER_STUDIO_RECEIPT_GAS_PRICE", DEFAULT_RECEIPT_GAS_PRICE + ), + intrinsic_gas=_env_int("GENLAYER_STUDIO_INTRINSIC_GAS", 21_000), + bootloader_overhead=_env_int("GENLAYER_STUDIO_BOOTLOADER_OVERHEAD", 60_000), + gas_per_changed_slot=_env_int( + "GENLAYER_STUDIO_GAS_PER_CHANGED_SLOT", 1_000 + ), + calldata_gas_per_byte=_env_int("GENLAYER_STUDIO_CALLDATA_GAS_PER_BYTE", 16), + fixed_propose_receipt_gas=_env_int( + "GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS", 210_000 + ), + fixed_message_reveal_gas=_env_int( + "GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS", 100_000 + ), + receipt_wrapper_bytes=_env_int( + "GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES", 1_024 + ), + extra_exec_gas=_env_int("GENLAYER_STUDIO_EXTRA_EXEC_GAS", 210_000), + max_allocation_tree_depth=_env_int( + "GENLAYER_STUDIO_MAX_ALLOCATION_TREE_DEPTH", 5 + ), + max_messages_per_tx=_env_int("GENLAYER_STUDIO_MAX_MESSAGES_PER_TX", 0), + ) + + def estimate_propose_receipt_bytes(self, eq_outputs_length: int) -> int: + return self.receipt_wrapper_bytes + max(0, int(eq_outputs_length)) + + def estimate_propose_receipt_gas(self, receipt_bytes: int) -> int: + return ( + self.fixed_propose_receipt_gas + + self.intrinsic_gas + + self.bootloader_overhead + + (max(0, int(receipt_bytes)) * self.calldata_gas_per_byte) + + (PROPOSE_RECEIPT_SLOTS * self.gas_per_changed_slot) + ) + + def estimate_message_reveal_gas( + self, + message_bytes: int, + message_count: int, + ) -> int: + return ( + self.fixed_message_reveal_gas + + self.intrinsic_gas + + self.bootloader_overhead + + (max(0, int(message_bytes)) * self.calldata_gas_per_byte) + + ( + (MESSAGE_REVEAL_LENGTH_SLOTS + max(0, int(message_count))) + * self.gas_per_changed_slot + ) + ) + + def estimate_consensus_message_reveal_gas( + self, + message_bytes: int, + message_count: int, + ) -> int: + return self.estimate_receipt_gas( + measured_exec_gas=0, + calldata_length=message_bytes, + slots_changed=message_count, + ) + + def estimate_receipt_gas( + self, + measured_exec_gas: int = 0, + calldata_length: int = MIN_RECEIPT_BYTES, + slots_changed: int = 7, + ) -> int: + measured = max(0, int(measured_exec_gas)) + if measured > 0: + measured += self.extra_exec_gas + return ( + measured + + self.intrinsic_gas + + self.bootloader_overhead + + (max(0, int(calldata_length)) * self.calldata_gas_per_byte) + + (max(0, int(slots_changed)) * self.gas_per_changed_slot) + ) + + def estimate_nondet_output_start_gas(self) -> int: + return NONDET_OUTPUT_LENGTH_BYTES * self.calldata_gas_per_byte + + def message_fee_params_budget_floor(self) -> int: + return self.minimum_execution_budget_per_round() + + def minimum_execution_budget_per_round(self) -> int: + if self.receipt_gas_price <= 0: + return 0 + fixed_bucket_gas = self.estimate_receipt_gas( + measured_exec_gas=0, + calldata_length=MIN_RECEIPT_BYTES, + slots_changed=PROPOSE_RECEIPT_SLOTS, + ) + return fixed_bucket_gas * self.receipt_gas_price + + def fee_accounting_enabled(self) -> bool: + return ( + self.gen_per_time_unit > 0 + or self.storage_unit_price > 0 + or self.receipt_gas_price > 0 + ) + + def to_snapshot(self) -> dict[str, int]: + return {field.name: int(getattr(self, field.name)) for field in fields(self)} + + @classmethod + def from_snapshot(cls, snapshot: dict[str, Any]) -> "StudioFeePolicy": + return cls(**{field.name: int(snapshot[field.name]) for field in fields(cls)}) + + +def _accounting_policy( + accounting: dict[str, Any] | None, + override: StudioFeePolicy | None = None, +) -> StudioFeePolicy: + if override is not None: + return override + snapshot = (accounting or {}).get("policy_snapshot") + if isinstance(snapshot, dict): + try: + return StudioFeePolicy.from_snapshot(snapshot) + except (KeyError, TypeError, ValueError): + pass + return StudioFeePolicy() + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or raw == "": + return default + try: + return int(raw) + except ValueError as exc: + raise ValueError(f"{name} must be an integer, got {raw!r}") from exc + + +def _int_field(fees_distribution: dict[str, Any], field: str) -> int: + return int(fees_distribution.get(field, 0) or 0) + + +def normalize_fees_distribution( + fees_distribution: dict[str, Any], +) -> dict[str, int | list[int]]: + return { + "leaderTimeunitsAllocation": _int_field( + fees_distribution, "leaderTimeunitsAllocation" + ), + "validatorTimeunitsAllocation": _int_field( + fees_distribution, "validatorTimeunitsAllocation" + ), + "appealRounds": _int_field(fees_distribution, "appealRounds"), + "executionBudgetPerRound": _int_field( + fees_distribution, "executionBudgetPerRound" + ), + "executionConsumed": _int_field(fees_distribution, "executionConsumed"), + "totalMessageFees": _int_field(fees_distribution, "totalMessageFees"), + "rotations": [ + int(rotation) for rotation in fees_distribution.get("rotations", []) + ], + "maxPriceGenPerTimeUnit": _int_field( + fees_distribution, "maxPriceGenPerTimeUnit" + ), + "storageFeeMaxGasPrice": _int_field(fees_distribution, "storageFeeMaxGasPrice"), + "receiptFeeMaxGasPrice": _int_field(fees_distribution, "receiptFeeMaxGasPrice"), + } + + +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"] + ) + + +def get_leader_rounds_through_round( + fees_distribution: dict[str, Any], final_round: int +) -> int: + fees = normalize_fees_distribution(fees_distribution) + rotations = fees["rotations"] + if not isinstance(rotations, list) or len(rotations) == 0: + raise InvalidAppealRounds("InvalidAppealRounds") + + final_round = max(0, int(final_round)) + total = int(rotations[0]) + 1 + rotations_index = 1 + for offset in range(1, min(final_round, int(fees["appealRounds"]) * 2) + 1): + if offset % 2 == 1: + total += 1 + elif rotations_index < len(rotations): + total += int(rotations[rotations_index]) + 1 + rotations_index += 1 + return total + + +def calculate_time_unit_fees_through_round( + fees_distribution: dict[str, Any], + num_of_validators: int, + final_round: int, + policy: StudioFeePolicy | None = None, +) -> int: + fees = normalize_fees_distribution(fees_distribution) + policy = policy or StudioFeePolicy() + validator_index = _validator_index(num_of_validators) + rotations = fees["rotations"] + if not isinstance(rotations, list) or len(rotations) == 0: + 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"]) + total = _calculate_fee_for_round( + VALIDATORS_PER_ROUND[validator_index], + int(rotations[0]) + 1, + leader_timeunits, + validator_timeunits, + ) + rotations_index = 1 + for offset in range(1, capped_final_round + 1): + if offset % 2 == 0 and rotations_index < len(rotations): + rotations_this_round = int(rotations[rotations_index]) + 1 + rotations_index += 1 + else: + rotations_this_round = 1 + total += _calculate_fee_for_round( + VALIDATORS_PER_ROUND[validator_index + offset], + rotations_this_round, + leader_timeunits, + validator_timeunits, + ) + + 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_round_fees( + fees_distribution: dict[str, Any], + num_of_validators: int, + round: int = 0, + policy: StudioFeePolicy | None = None, +) -> int: + fees = normalize_fees_distribution(fees_distribution) + policy = policy or StudioFeePolicy() + + if round == 0: + validator_index = _validator_index(num_of_validators) + if int(fees["appealRounds"]) != len(fees["rotations"]) - 1: + raise InvalidAppealRounds("InvalidAppealRounds") + total = _calculate_fees(fees, validator_index) + else: + if round >= len(VALIDATORS_PER_ROUND): + raise InvalidNumOfValidators("InvalidNumOfValidators") + rotations = ( + int(fees["rotations"][round - 1]) + if round - 1 < len(fees["rotations"]) + else 0 + ) + total = _calculate_fee_for_round( + VALIDATORS_PER_ROUND[round], + rotations, + int(fees["leaderTimeunitsAllocation"]), + 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") + total *= policy.gen_per_time_unit + + storage_fee_max_gas_price = int(fees["storageFeeMaxGasPrice"]) + if ( + storage_fee_max_gas_price > 0 + and policy.storage_unit_price > storage_fee_max_gas_price + ): + raise MaxPriceExceeded("MaxPriceExceeded") + + receipt_fee_max_gas_price = int(fees["receiptFeeMaxGasPrice"]) + if ( + receipt_fee_max_gas_price > 0 + and policy.receipt_gas_price > receipt_fee_max_gas_price + ): + raise MaxPriceExceeded("MaxPriceExceeded") + + if round == 0: + total += int(fees["executionBudgetPerRound"]) * get_leader_rounds(fees) + + return total + + +def required_fee_deposit( + fees_distribution: dict[str, Any], + num_of_validators: int, + policy: StudioFeePolicy | None = None, +) -> int: + fees = normalize_fees_distribution(fees_distribution) + return calculate_round_fees(fees, num_of_validators, 0, policy) + int( + fees["totalMessageFees"] + ) + + +def default_transaction_fees_for_policy( + policy: StudioFeePolicy | None = None, +) -> tuple[dict[str, int | list[int]], int]: + policy = policy or StudioFeePolicy() + 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 + else 0 + ) + distribution = _serializable_fees_distribution( + { + "leaderTimeunitsAllocation": ( + DEFAULT_LEADER_TIMEUNITS_ALLOCATION + if policy.gen_per_time_unit > 0 + else 0 + ), + "validatorTimeunitsAllocation": ( + DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION + if policy.gen_per_time_unit > 0 + 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), + } + ) + fee_value = ( + required_fee_deposit(distribution, VALIDATORS_PER_ROUND[0], policy) + if policy.fee_accounting_enabled() + else 0 + ) + return distribution, fee_value + + +def studio_fee_config(policy: StudioFeePolicy | None = None) -> dict[str, Any]: + policy = policy or StudioFeePolicy.from_env() + distribution, fee_value = default_transaction_fees_for_policy(policy) + return { + "enabled": policy.fee_accounting_enabled(), + "policy": { + "genPerTimeUnit": str(policy.gen_per_time_unit), + "storageUnitPrice": str(policy.storage_unit_price), + "receiptGasPrice": str(policy.receipt_gas_price), + "intrinsicGas": str(policy.intrinsic_gas), + "bootloaderOverhead": str(policy.bootloader_overhead), + "gasPerChangedSlot": str(policy.gas_per_changed_slot), + "calldataGasPerByte": str(policy.calldata_gas_per_byte), + "fixedProposeReceiptGas": str(policy.fixed_propose_receipt_gas), + "fixedMessageRevealGas": str(policy.fixed_message_reveal_gas), + "receiptWrapperBytes": str(policy.receipt_wrapper_bytes), + "extraExecGas": str(policy.extra_exec_gas), + "messageFeeParamsBudgetFloor": str( + policy.message_fee_params_budget_floor() + ), + "maxAllocationTreeDepth": str(policy.max_allocation_tree_depth), + "maxMessagesPerTx": str(policy.max_messages_per_tx), + }, + "capabilities": { + "messageFees": { + "mode1": { + "accounting": True, + "genvmExecution": False, + }, + "mode2": { + "accounting": True, + "genvmExecution": True, + }, + "externalFinalization": { + "accounting": True, + "genvmExecution": True, + }, + } + }, + "defaultFees": { + "distribution": { + key: ( + [str(item) for item in value] + if isinstance(value, list) + else str(value) + ) + for key, value in distribution.items() + }, + "feeValue": str(fee_value), + }, + } + + +def validate_transaction_fee_deposit( + *, + fees_distribution: dict[str, Any], + message_allocations: list[dict[str, Any]] | None = None, + num_of_validators: int, + submitted_value: int, + user_value: int, + policy: StudioFeePolicy | None = None, +) -> int: + policy = policy or StudioFeePolicy() + fees = normalize_fees_distribution(fees_distribution) + execution_budget_per_round = int(fees["executionBudgetPerRound"]) + if ( + execution_budget_per_round > 0 + and execution_budget_per_round < policy.message_fee_params_budget_floor() + ): + raise BudgetTooLow("BudgetTooLow") + + if submitted_value < user_value: + raise InsufficientFees("InsufficientFees") + + required_fee_value = required_fee_deposit(fees, num_of_validators, policy) + paid_fee_value = submitted_value - user_value + if paid_fee_value < required_fee_value: + raise InsufficientFees("InsufficientFees") + + validate_message_allocations( + message_allocations or [], + total_message_fees=int(fees["totalMessageFees"]), + policy=policy, + ) + + return required_fee_value + + +def create_fee_accounting( + *, + fees_distribution: dict[str, Any], + message_allocations: list[dict[str, Any]] | None = None, + num_of_validators: int, + submitted_value: int, + user_value: int, + sender: str | None = None, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + policy = policy or StudioFeePolicy() + required = validate_transaction_fee_deposit( + fees_distribution=fees_distribution, + message_allocations=message_allocations or [], + num_of_validators=num_of_validators, + submitted_value=submitted_value, + user_value=user_value, + policy=policy, + ) + fee_value = max(0, int(submitted_value) - int(user_value)) + return _new_fee_accounting( + fees_distribution=fees_distribution, + message_allocations=message_allocations or [], + num_of_validators=num_of_validators, + fee_value=fee_value, + required_fee_value=required, + user_value=user_value, + sender=sender, + source="submission", + policy=policy, + ) + + +def create_child_fee_accounting( + *, + message: dict[str, Any], + parent_fees_distribution: dict[str, Any] | None, + message_allocations: list[dict[str, Any]] | None = None, + sender: str | None = None, + policy: StudioFeePolicy | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + policy = policy or StudioFeePolicy() + declared_budget = int(message.get("declaredBudget", 0) or 0) + if declared_budget <= 0: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + + fee_params = decode_internal_message_fee_params(message.get("feeParams", b"")) + capless_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 + if declared_budget < child_primary: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + + parent_fees = ( + normalize_fees_distribution(parent_fees_distribution) + if parent_fees_distribution + else normalize_fees_distribution({}) + ) + child_fees = _fees_distribution_from_internal_params( + fee_params, + total_message_fees=declared_budget - child_primary, + parent_fees_distribution=parent_fees, + ) + child_message_allocations = _child_allocations_from_message_subtree( + message, + message_allocations or [], + ) + validate_message_allocations( + child_message_allocations, + total_message_fees=int(child_fees["totalMessageFees"]), + policy=policy, + ) + user_value = int(message.get("value", 0) or 0) + accounting = _new_fee_accounting( + fees_distribution=child_fees, + message_allocations=child_message_allocations, + num_of_validators=VALIDATORS_PER_ROUND[0], + fee_value=declared_budget, + required_fee_value=declared_budget, + user_value=user_value, + sender=sender, + source="internal_message", + policy=policy, + ) + return child_fees, accounting + + +def genvm_fee_context( + accounting: dict[str, Any] | None, + policy: StudioFeePolicy | None = None, +) -> tuple[list[int] | None, dict[str, str] | None]: + if not accounting: + return None, None + + policy = _accounting_policy(accounting, policy) + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + bucket_total = int(fees["executionBudgetPerRound"]) + + gas_data = { + "storageUnitPrice": str(policy.storage_unit_price), + "receiptGasPerByte": str( + policy.receipt_gas_price * policy.calldata_gas_per_byte + ), + "gasPerChangedSlot": str( + policy.receipt_gas_price * policy.gas_per_changed_slot + ), + "intrinsicGas": str(policy.receipt_gas_price * policy.intrinsic_gas), + "bootloaderOverhead": str( + policy.receipt_gas_price * policy.bootloader_overhead + ), + "fixedProposeReceiptGas": str( + policy.receipt_gas_price * policy.fixed_propose_receipt_gas + ), + "fixedMessageRevealGas": str( + policy.receipt_gas_price * policy.fixed_message_reveal_gas + ), + "genPerTimeUnit": str(policy.gen_per_time_unit), + } + message_bucket_total = int(accounting.get("message_fee_budget", 0) or 0) + if bucket_total > 0 or message_bucket_total > 0: + data_bucket_total = ( + bucket_total if bucket_total > 0 else GENVM_UNMETERED_DATA_FEE_BUCKET + ) + bucket_totals = [data_bucket_total, data_bucket_total, message_bucket_total] + else: + bucket_totals = None + return bucket_totals, gas_data + + +def genvm_message_fee_allocation( + accounting: dict[str, Any] | None, + *, + address_factory: Callable[[str], Any] | None = None, +) -> list[dict[str, Any]]: + if not accounting: + return _genvm_unmetered_message_fee_allocation() + + if not accounting.get("message_allocations"): + if int(accounting.get("message_fee_budget", 0) or 0) > 0: + raise Mode1MessageFeesRequireGenVMPerEmissionSupport( + "Mode1MessageFeesRequireGenVMPerEmissionSupport: GenVM v0.3.x " + "message emissions do not carry per-emission feeParams/" + "declaredBudget without a message allocation tree" + ) + return [] + + nodes: list[dict[str, Any]] = [] + for raw_node in accounting.get("message_allocations") or []: + node = _serializable_message_allocation(raw_node) + if int(node["parentIndex"]) != NODE_ROOT_SENTINEL: + continue + recipient = str(node["recipient"]).lower() + call_key = _normalize_call_key(node["callKey"]) + message_type = ( + "External" + if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL + else ( + "InternalAccepted" + if bool(node["onAcceptance"]) + else "InternalFinalized" + ) + ) + nodes.append( + { + "message_type": message_type, + "parent_index": ( + None + if int(node["parentIndex"]) == NODE_ROOT_SENTINEL + else int(node["parentIndex"]) + ), + "recipient": ( + None + if recipient == "" + else address_factory(recipient) if address_factory else recipient + ), + "call_key": ( + None + if call_key == CALL_KEY_WILDCARD + else bytes.fromhex(call_key.removeprefix("0x")) + ), + "budget": int(node["budget"]), + "fee_params": _genvm_message_fee_params(node), + } + ) + if nodes: + nodes.append(_genvm_external_legacy_fallback_message_fee_allocation()) + return nodes + + +def apply_fee_top_up( + accounting: dict[str, Any], + *, + fees_distribution: dict[str, Any], + amount: int, + sender: str | None = None, + num_of_validators: int = VALIDATORS_PER_ROUND[0], + perform_fee_checks: bool = True, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + policy = _accounting_policy(accounting, policy) + amount = int(amount) + incoming = normalize_fees_distribution(fees_distribution) + incoming_message_fees = int(incoming["totalMessageFees"]) + if incoming_message_fees > amount: + raise InsufficientFees("InsufficientFees") + + primary_amount = amount - incoming_message_fees + if perform_fee_checks: + required_primary = calculate_round_fees(incoming, num_of_validators, 0, policy) + if required_primary > 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"]) + < policy.message_fee_params_budget_floor() + ): + raise BudgetTooLow("BudgetTooLow") + + updated["fees_distribution"] = merged + updated["paid_fee_value"] = int(updated.get("paid_fee_value", 0)) + amount + updated["primary_fee_budget"] = ( + int(updated.get("primary_fee_budget", 0)) + primary_amount + ) + updated["message_fee_budget"] = ( + int(updated.get("message_fee_budget", 0)) + incoming_message_fees + ) + updated["execution_budget_total"] = int(merged["executionBudgetPerRound"]) * ( + get_leader_rounds(merged) + ) + updated.setdefault("top_ups", []).append( + { + "sender": sender, + "amount": amount, + "primaryAmount": primary_amount, + "messageFees": incoming_message_fees, + "feesDistribution": _serializable_fees_distribution(incoming), + } + ) + _refresh_message_fee_accounting_report_if_present(updated, policy) + return updated + + +def record_appeal_bond( + accounting: dict[str, Any], + *, + amount: int, + appealer: str | None, + current_round: int = 0, + status: str | None = None, + round: int | None = None, + fees_distribution: dict[str, Any] | None = None, + top_up_and_submit: bool = False, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + policy = _accounting_policy(updated, policy) + 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 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 + merged = normalize_fees_distribution(updated.get("fees_distribution") or {}) + merged["appealRounds"] = int(merged["appealRounds"]) + 1 + 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 + updated.setdefault("appeal_bonds", []).append( + { + "appealer": appealer, + "amount": amount, + "round": current_round if round is None else round, + "status": status, + "minimumRequired": min_required, + "topUpAndSubmit": bool(top_up_and_submit), + "feesDistributionIgnored": fees_distribution is not None + and top_up_and_submit, + } + ) + _refresh_message_fee_accounting_report_if_present(updated, policy) + return updated + + +def calculate_min_appeal_bond( + fees_distribution: dict[str, Any], + *, + current_round: int, + status: str, + 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"}: + 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, + 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 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( + fees["validatorTimeunitsAllocation"] + ) + return ( + total * policy.gen_per_time_unit if policy.gen_per_time_unit > 0 else total + ) + + return 0 + + +def fill_message_fee_payload_from_allocation( + accounting: dict[str, Any], + message: dict[str, Any], +) -> dict[str, Any]: + if int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) != MESSAGE_TYPE_INTERNAL: + return copy.deepcopy(message) + + allocations = accounting.get("message_allocations") or [] + if not allocations: + return copy.deepcopy(message) + + resolved = _resolve_allocation(allocations, message) + if resolved is None: + raise MessageNoMatchingAllocation("MessageNoMatchingAllocation") + + index, allocation = resolved + if bool(allocation["onAcceptance"]) != bool(message.get("onAcceptance", False)): + raise MessageEmissionPhaseMismatch("MessageEmissionPhaseMismatch") + + updated = copy.deepcopy(message) + 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"] + updated["callKey"] = _normalize_call_key( + 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") + updated["messageFeeMode"] = "mode2" + return updated + + +def consume_message_fees( + accounting: dict[str, Any], + messages: list[dict[str, Any]], + *, + reported_total: int | None = None, + policy: StudioFeePolicy | None = None, + reimburse_external: bool = True, +) -> dict[str, Any]: + policy = _accounting_policy(accounting, policy) + if policy.max_messages_per_tx > 0 and len(messages) > policy.max_messages_per_tx: + raise TooManyMessages("TooManyMessages") + + updated = copy.deepcopy(accounting) + recalculated_total = 0 + external_reimbursement_total = 0 + + for message in messages: + if ( + int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) + == MESSAGE_TYPE_EXTERNAL + ): + if int(message.get("declaredBudget", 0) or 0) != 0: + raise MessageDeclaredBudgetInsufficient( + "MessageDeclaredBudgetInsufficient" + ) + external_reimbursement_total += _reserve_external_execution( + updated, message, policy, reimburse=reimburse_external + ) + continue + + if ( + int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) + != MESSAGE_TYPE_INTERNAL + ): + continue + + declared_budget = int(message.get("declaredBudget", 0) or 0) + fee_params = decode_internal_message_fee_params(message.get("feeParams", b"")) + if ( + int(fee_params["executionBudgetPerRound"]) > 0 + and int(fee_params["executionBudgetPerRound"]) + < policy.message_fee_params_budget_floor() + ): + raise BudgetTooLow("BudgetTooLow") + min_required = min_message_primary_fees(fee_params, policy) + if declared_budget < min_required: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + recalculated_total += declared_budget + _consume_against_allocation(updated, message, declared_budget) + + if reported_total is not None and int(reported_total) < recalculated_total: + raise MessageFeesReportMismatch("MessageFeesReportMismatch") + + attempted = ( + int(updated.get("message_fee_consumed", 0)) + + recalculated_total + + external_reimbursement_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, + } + ) + _refresh_message_fee_accounting_report_if_present(updated, policy) + return updated + + +def record_reveal_message_fees( + accounting: dict[str, Any], + messages: list[dict[str, Any]], + *, + reported_total: int | None = None, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + updated = consume_message_fees( + accounting, + messages, + reported_total=reported_total, + policy=policy, + reimburse_external=False, + ) + updated["message_fees_recorded_at_reveal"] = True + return updated + + +def record_external_message_execution_fees( + accounting: dict[str, Any], + messages: list[dict[str, Any]], + *, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + policy = _accounting_policy(updated, policy) + reimbursement_total = 0 + remainder_total = 0 + updated_any = False + + for message in messages: + if ( + int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) + != MESSAGE_TYPE_EXTERNAL + ): + continue + + event_index = _find_unexecuted_external_message_event(updated, message) + if event_index is None: + continue + + event = updated.setdefault("external_message_events", [])[event_index] + reservation = int(event.get("reservation", 0) or 0) + gas_limit = int(event.get("gasLimit", 0) or 0) + locked_price = int(event.get("lockedGasPrice", 0) or 0) + gas_used = int(message.get("gasUsed", 0) or 0) + effective_gas = min(gas_limit, gas_used) + reimbursement = min(reservation, effective_gas * locked_price) + remainder = reservation - reimbursement + + attempted = ( + int(updated.get("message_fee_consumed", 0)) + + reimbursement_total + + reimbursement + ) + message_budget = int(updated.get("message_fee_budget", 0)) + if attempted > message_budget: + raise MessageBudgetExceeded("MessageBudgetExceeded") + + event["gasUsed"] = gas_used + event["reimbursement"] = reimbursement + event["remainder"] = remainder + event["executionRecorded"] = True + 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 + ) + updated["external_message_fee_reimbursed"] = ( + int(updated.get("external_message_fee_reimbursed", 0)) + reimbursement_total + ) + updated["external_message_fee_remainder"] = ( + int(updated.get("external_message_fee_remainder", 0)) + remainder_total + ) + updated.setdefault("message_consumption_events", []).append( + { + "consumed": reimbursement_total, + "internalConsumed": 0, + "externalReimbursed": reimbursement_total, + "remaining": max( + 0, + int(updated.get("message_fee_budget", 0)) + - int(updated.get("message_fee_consumed", 0)), + ), + } + ) + _refresh_message_fee_accounting_report_if_present(updated, policy) + + return updated + + +def refund_failed_external_message_fee( + accounting: dict[str, Any], + message: dict[str, Any], +) -> dict[str, Any]: + if int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) != MESSAGE_TYPE_EXTERNAL: + return copy.deepcopy(accounting) + + updated = copy.deepcopy(accounting) + event_index = _find_unrefunded_external_message_event(updated, message) + if event_index is None: + return updated + + event = updated.setdefault("external_message_events", [])[event_index] + reservation = int(event.get("reservation", 0) or 0) + reimbursement = int(event.get("reimbursement", 0) or 0) + remainder = int(event.get("remainder", 0) or 0) + + # Execution-level failures still spent gas. Consensus reimburses the + # executor and leaves the external execution reservation consumed; only the + # external message value leg is refunded outside this fee-accounting helper. + event["failureRefunded"] = True + updated.setdefault("external_message_refund_events", []).append( + { + "recipient": event.get("recipient"), + "callKey": event.get("callKey"), + "allocationIndex": int(event.get("allocationIndex", 0) or 0), + "reservation": reservation, + "reimbursement": reimbursement, + "remainder": remainder, + "feeRefunded": 0, + } + ) + _refresh_message_fee_accounting_report_if_present(updated) + return updated + + +def unwind_reveal_message_fees( + accounting: dict[str, Any], + messages: list[dict[str, Any]], + *, + acceptance_dispatched: bool = False, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + internal_refund = 0 + external_unreserved = 0 + external_reimbursement_rolled_back = 0 + external_remainder_rolled_back = 0 + + for message in messages: + if ( + int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) + == MESSAGE_TYPE_EXTERNAL + ): + ( + reservation, + reimbursement, + remainder, + ) = _unreserve_external_message_fee(updated, message) + external_unreserved += reservation + external_reimbursement_rolled_back += reimbursement + external_remainder_rolled_back += remainder + continue + + if ( + int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) + != MESSAGE_TYPE_INTERNAL + ): + continue + if acceptance_dispatched and bool(message.get("onAcceptance", False)): + continue + + declared_budget = int(message.get("declaredBudget", 0) or 0) + if declared_budget <= 0: + continue + internal_refund += declared_budget + _decrement_allocation_consumed(updated, message, declared_budget) + + if internal_refund > 0: + updated["message_fee_consumed"] = max( + 0, + int(updated.get("message_fee_consumed", 0)) - internal_refund, + ) + + if ( + internal_refund > 0 + or external_unreserved > 0 + or external_reimbursement_rolled_back > 0 + ): + updated.setdefault("message_fee_unwind_events", []).append( + { + "acceptanceDispatched": bool(acceptance_dispatched), + "internalRefunded": internal_refund, + "externalUnreserved": external_unreserved, + "externalReimbursementRolledBack": (external_reimbursement_rolled_back), + "externalRemainderRolledBack": external_remainder_rolled_back, + "remaining": max( + 0, + int(updated.get("message_fee_budget", 0)) + - int(updated.get("message_fee_consumed", 0)) + - int(updated.get("message_fee_refunded", 0)), + ), + } + ) + + # A re-reveal replaces or discards the previous message set. Keep the + # aggregate unwind event, but reopen receipt-based message consumption. + updated.pop("message_fees_recorded_from_receipt", None) + updated["message_consumption_events"] = [] + _refresh_message_fee_accounting_report_if_present(updated) + return updated + + +def record_execution_fee_consumption( + accounting: dict[str, Any], + receipt: Any | None, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + policy = _accounting_policy(updated, policy) + message_payloads = _receipt_message_fee_payloads(updated, receipt) + reported_message_fees_total = _receipt_reported_message_fees_total(receipt) + if ( + message_payloads + and _receipt_messages_require_fee_validation(updated, message_payloads) + and not updated.get("message_fees_recorded_from_receipt") + and not updated.get("message_consumption_events") + ): + updated = consume_message_fees( + updated, + message_payloads, + reported_total=reported_message_fees_total, + policy=policy, + ) + updated["message_fees_recorded_from_receipt"] = True + if reported_message_fees_total is not None: + updated["reported_message_fees_total"] = reported_message_fees_total + + fee_report = _receipt_fee_report(receipt, policy, message_payloads) + if fee_report is not None: + updated["execution_fee_report"] = fee_report + _attach_message_fee_accounting_report(updated) + _attach_recommended_fee_preset(updated, policy) + consumed = _receipt_data_fees_consumed(receipt) + if consumed is None: + return updated + updated["genvm_fee_consumed_buckets"] = consumed + bucket_report = _genvm_fee_bucket_report( + consumed, + execution_budget_per_round=_execution_budget_per_round(updated), + ) + execution_consumed = _chargeable_execution_fee_buckets( + consumed, + fee_report, + policy, + receipt, + ) + execution_bucket_report = _genvm_fee_bucket_report( + execution_consumed, + execution_budget_per_round=_execution_budget_per_round(updated), + ) + updated["execution_fee_consumed"] = sum(execution_consumed) + updated["execution_fee_consumed_buckets"] = execution_consumed + updated["genvm_fee_bucket_report"] = bucket_report + execution_metering_report = _execution_metering_report( + chargeable_bucket_report=execution_bucket_report, + genvm_bucket_report=bucket_report, + ) + updated["execution_fee_report"] = { + **(updated.get("execution_fee_report") or {}), + "genvmBuckets": bucket_report, + "chargeableExecution": execution_bucket_report, + "executionMetering": execution_metering_report, + } + budget_exhaustion_reason = _receipt_budget_exhaustion_reason( + receipt, + execution_bucket_report, + ) + if budget_exhaustion_reason is not None: + updated["execution_fee_report"][ + "budgetExhaustionReason" + ] = budget_exhaustion_reason + if len(consumed) > 2: + updated["genvm_message_fee_consumed"] = int(consumed[2]) + _attach_message_fee_accounting_report(updated) + _attach_recommended_fee_preset(updated, policy) + return updated + + +def settle_fee_accounting( + accounting: dict[str, Any], + *, + receipt: Any | None = None, + reason: str = "finalized", + actual_final_round: int | None = None, + num_of_validators: int | None = None, + policy: StudioFeePolicy | None = None, +) -> tuple[dict[str, Any], int]: + policy = _accounting_policy(accounting, policy) + updated = record_execution_fee_consumption(accounting, receipt, policy) + if updated.get("status") in {"settled", "canceled"}: + return updated, 0 + + 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 {} + 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( + fees_distribution, + validators, + actual_final_round, + policy, + ) + execution_budget = int( + normalize_fees_distribution(fees_distribution)["executionBudgetPerRound"] + ) * get_leader_rounds_through_round(fees_distribution, actual_final_round) + updated["actual_final_round"] = int(actual_final_round) + else: + time_unit_budget = max(0, primary_required - execution_budget) + execution_spent = min( + int(updated.get("execution_fee_consumed", 0)), execution_budget + ) + primary_spent = min(primary_budget, time_unit_budget + execution_spent) + primary_refund = max( + 0, primary_budget - primary_spent - int(updated.get("primary_fee_refunded", 0)) + ) + + message_refund = max( + 0, + int(updated.get("message_fee_budget", 0)) + - int(updated.get("message_fee_consumed", 0)) + - int(updated.get("message_fee_refunded", 0)), + ) + refund = primary_refund + message_refund + + updated["status"] = "settled" + updated["settlement_reason"] = reason + updated["primary_fee_spent"] = primary_spent + 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["total_refunded"] = int(updated.get("total_refunded", 0)) + refund + updated.setdefault("refunds", []).append( + { + "reason": reason, + "primary": primary_refund, + "message": message_refund, + "amount": refund, + } + ) + _refresh_message_fee_accounting_report_if_present(updated, policy) + return updated, refund + + +def cancel_fee_accounting( + accounting: dict[str, Any], + *, + reason: str = "canceled", +) -> tuple[dict[str, Any], int]: + updated = copy.deepcopy(accounting) + if updated.get("status") in {"settled", "canceled"}: + return updated, 0 + + primary_refund = max( + 0, + int(updated.get("primary_fee_budget", 0)) + - int(updated.get("primary_fee_spent", 0)) + - int(updated.get("primary_fee_refunded", 0)), + ) + message_refund = max( + 0, + int(updated.get("message_fee_budget", 0)) + - int(updated.get("message_fee_consumed", 0)) + - int(updated.get("message_fee_refunded", 0)), + ) + refund = primary_refund + message_refund + updated["status"] = "canceled" + updated["settlement_reason"] = reason + 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["total_refunded"] = int(updated.get("total_refunded", 0)) + refund + updated.setdefault("refunds", []).append( + { + "reason": reason, + "primary": primary_refund, + "message": message_refund, + "amount": refund, + } + ) + _refresh_message_fee_accounting_report_if_present(updated) + return updated, refund + + +def merge_fees_distribution( + current: dict[str, Any], incoming: dict[str, Any] +) -> dict[str, Any]: + current_fees = normalize_fees_distribution(current) + incoming_fees = normalize_fees_distribution(incoming) + is_initial = len(current_fees["rotations"]) == 0 + merged = dict(current_fees) + if is_initial: + merged["leaderTimeunitsAllocation"] = incoming_fees["leaderTimeunitsAllocation"] + merged["validatorTimeunitsAllocation"] = incoming_fees[ + "validatorTimeunitsAllocation" + ] + merged["appealRounds"] = incoming_fees["appealRounds"] + + merged["executionBudgetPerRound"] = int(merged["executionBudgetPerRound"]) + int( + incoming_fees["executionBudgetPerRound"] + ) + merged["totalMessageFees"] = int(merged["totalMessageFees"]) + int( + incoming_fees["totalMessageFees"] + ) + merged["rotations"] = list(merged["rotations"]) + list(incoming_fees["rotations"]) + for cap in ( + "maxPriceGenPerTimeUnit", + "storageFeeMaxGasPrice", + "receiptFeeMaxGasPrice", + ): + incoming_cap = int(incoming_fees[cap]) + if incoming_cap > 0 and ( + is_initial or (int(merged[cap]) > 0 and incoming_cap > int(merged[cap])) + ): + merged[cap] = incoming_cap + return _serializable_fees_distribution(merged) + + +def validate_message_allocations( + message_allocations: list[dict[str, Any]], + *, + total_message_fees: int, + policy: StudioFeePolicy | None = None, +) -> None: + if not message_allocations: + return + + policy = policy or StudioFeePolicy() + root_sum = 0 + root_keys: set[tuple[int, str, str]] = set() + external_keys: set[tuple[str, str]] = set() + min_required_by_index: dict[int, int] = {} + + for index, raw_node in enumerate(message_allocations): + node = _normalize_message_allocation(raw_node) + parent_index = int(node["parentIndex"]) + if parent_index != NODE_ROOT_SENTINEL and parent_index >= index: + raise AllocationTreeMalformed("AllocationTreeMalformed") + if parent_index != NODE_ROOT_SENTINEL: + parent_node = _normalize_message_allocation( + message_allocations[parent_index] + ) + if int(parent_node["messageType"]) == MESSAGE_TYPE_EXTERNAL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL: + _validate_external_allocation(node, external_keys) + root_sum += int(node["budget"]) + continue + + if int(node["messageType"]) != MESSAGE_TYPE_INTERNAL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + internal_fee_params = decode_internal_message_fee_params(node["feeParams"]) + min_primary = min_message_primary_fees(internal_fee_params, policy) + lifecycle_multiplier = ( + int(internal_fee_params["appealRounds"]) + 1 + if bool(node["onAcceptance"]) + else 1 + ) + min_required = min_primary * lifecycle_multiplier + min_required_by_index[index] = min_required + if int(node["budget"]) < min_required: + raise AllocationLifecycleBudgetInsufficient( + "AllocationLifecycleBudgetInsufficient" + ) + + execution_budget_per_round = int(internal_fee_params["executionBudgetPerRound"]) + if ( + execution_budget_per_round > 0 + and execution_budget_per_round < policy.message_fee_params_budget_floor() + ): + raise BudgetTooLow("BudgetTooLow") + + if parent_index == NODE_ROOT_SENTINEL: + key = _allocation_key(node) + if key in root_keys: + raise AllocationDuplicateKey("AllocationDuplicateKey") + root_keys.add(key) + root_sum += int(node["budget"]) + + if root_sum != total_message_fees: + raise MessageAllocationsNotEqualBudget("MessageAllocationsNotEqualBudget") + + for index, raw_node in enumerate(message_allocations): + node = _normalize_message_allocation(raw_node) + if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL: + continue + child_sum = sum( + int(_normalize_message_allocation(child)["budget"]) + for child in message_allocations[index + 1 :] + if int(_normalize_message_allocation(child)["parentIndex"]) == index + ) + if int(node["budget"]) < min_required_by_index[index] + child_sum: + raise AllocationTreeBudgetInconsistent("AllocationTreeBudgetInconsistent") + + _validate_allocation_tree_depth(message_allocations, policy) + _validate_sibling_duplicates(message_allocations) + + +def decode_internal_message_fee_params(fee_params: bytes | str) -> dict[str, Any]: + raw_fee_params = _fee_params_bytes(fee_params) + try: + decoded = decode([INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], raw_fee_params)[0] + except Exception as exc: + raise InvalidFeeParams("InvalidFeeParams") from exc + return { + "leaderTimeunitsAllocation": int(decoded[0]), + "validatorTimeunitsAllocation": int(decoded[1]), + "appealRounds": int(decoded[2]), + "executionBudgetPerRound": int(decoded[3]), + "rotations": [int(rotation) for rotation in decoded[4]], + } + + +def decode_external_message_fee_params(fee_params: bytes | str) -> dict[str, int]: + raw_fee_params = _fee_params_bytes(fee_params) + try: + decoded = decode([EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], raw_fee_params)[0] + except Exception as exc: + raise InvalidFeeParams("InvalidFeeParams") from exc + return { + "gasLimit": int(decoded[0]), + "maxGasPrice": int(decoded[1]), + } + + +def min_message_primary_fees( + internal_fee_params: dict[str, Any], + policy: StudioFeePolicy | None = None, +) -> int: + return calculate_round_fees( + { + "leaderTimeunitsAllocation": int( + internal_fee_params["leaderTimeunitsAllocation"] + ), + "validatorTimeunitsAllocation": int( + internal_fee_params["validatorTimeunitsAllocation"] + ), + "appealRounds": int(internal_fee_params["appealRounds"]), + "executionBudgetPerRound": int( + internal_fee_params["executionBudgetPerRound"] + ), + "executionConsumed": 0, + "totalMessageFees": 0, + "rotations": internal_fee_params["rotations"], + "maxPriceGenPerTimeUnit": 0, + "storageFeeMaxGasPrice": 0, + "receiptFeeMaxGasPrice": 0, + }, + VALIDATORS_PER_ROUND[0], + 0, + policy, + ) + + +def _validator_index(num_of_validators: int) -> int: + if num_of_validators > VALIDATORS_PER_ROUND[-1]: + raise InvalidNumOfValidators("InvalidNumOfValidators") + for index, validators in enumerate(VALIDATORS_PER_ROUND): + if validators >= num_of_validators: + if validators != num_of_validators: + raise InvalidNumOfValidators("InvalidNumOfValidators") + return index + raise InvalidNumOfValidators("InvalidNumOfValidators") + + +def _calculate_fees( + fees_distribution: dict[str, int | list[int]], validator_index: int +) -> int: + rotations = fees_distribution["rotations"] + if not isinstance(rotations, list) or len(rotations) == 0: + raise InvalidAppealRounds("InvalidAppealRounds") + + leader_timeunits = int(fees_distribution["leaderTimeunitsAllocation"]) + validator_timeunits = int(fees_distribution["validatorTimeunitsAllocation"]) + calculated_fees = _calculate_fee_for_round( + VALIDATORS_PER_ROUND[validator_index], + int(rotations[0]) + 1, + leader_timeunits, + validator_timeunits, + ) + + 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] + if offset % 2 == 0 and rotations_index < len(rotations): + rotations_this_round = 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, + ) + + return calculated_fees + + +def _calculate_fee_for_round( + num_of_validators: int, + rotations: int, + leader_timeunits_allocation: int, + validator_timeunits_allocation: int, +) -> int: + return rotations * ( + leader_timeunits_allocation + + (num_of_validators * validator_timeunits_allocation) + ) + + +def _normalize_message_allocation(node: dict[str, Any]) -> dict[str, Any]: + return { + "messageType": int(node.get("messageType", 0)), + "onAcceptance": bool(node.get("onAcceptance", False)), + "parentIndex": int(node.get("parentIndex", 0)), + "recipient": str(node.get("recipient", "")).lower(), + "callKey": _normalize_call_key(node.get("callKey", CALL_KEY_WILDCARD)), + "budget": int(node.get("budget", 0)), + "feeParams": node.get("feeParams", b""), + } + + +def _validate_external_allocation( + node: dict[str, Any], + external_keys: set[tuple[str, str]], +) -> None: + if int(node["parentIndex"]) != NODE_ROOT_SENTINEL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + 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") + + per_call = gas_limit * max_gas_price + budget = int(node["budget"]) + if budget == 0 or budget % per_call != 0: + raise ExternalAllocationInvalid("ExternalAllocationInvalid") + + external_key = (str(node["recipient"]).lower(), str(node["callKey"]).lower()) + if external_key in external_keys: + raise ExternalAllocationInvalid("ExternalAllocationInvalid") + external_keys.add(external_key) + + +def _validate_allocation_tree_depth( + message_allocations: list[dict[str, Any]], + policy: StudioFeePolicy, +) -> None: + depth: list[int] = [] + cap = policy.max_allocation_tree_depth or 5 + for index, raw_node in enumerate(message_allocations): + node = _normalize_message_allocation(raw_node) + if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL: + depth.append(1) + continue + parent_index = int(node["parentIndex"]) + current_depth = ( + 1 if parent_index == NODE_ROOT_SENTINEL else depth[parent_index] + 1 + ) + if current_depth > cap: + raise AllocationTreeTooDeep("AllocationTreeTooDeep") + depth.append(current_depth) + + +def _validate_sibling_duplicates(message_allocations: list[dict[str, Any]]) -> None: + sibling_keys: set[tuple[int, int, str, str]] = set() + for raw_node in message_allocations: + node = _normalize_message_allocation(raw_node) + parent_index = int(node["parentIndex"]) + if parent_index == NODE_ROOT_SENTINEL: + continue + key = (parent_index, *_allocation_key(node)) + if key in sibling_keys: + raise AllocationDuplicateKey("AllocationDuplicateKey") + sibling_keys.add(key) + + +def _allocation_key(node: dict[str, Any]) -> tuple[int, str, str]: + return ( + int(node["messageType"]), + str(node["recipient"]).lower(), + str(node["callKey"]).lower(), + ) + + +def _fee_params_bytes(fee_params: bytes | str) -> bytes: + if isinstance(fee_params, str): + return bytes.fromhex(fee_params.removeprefix("0x")) + return bytes(fee_params) + + +def _new_fee_accounting( + *, + fees_distribution: dict[str, Any], + message_allocations: list[dict[str, Any]], + num_of_validators: int, + fee_value: int, + required_fee_value: int, + user_value: int, + sender: str | None, + source: str, + policy: StudioFeePolicy, +) -> dict[str, Any]: + fees = _serializable_fees_distribution(fees_distribution) + 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) + return { + "version": 1, + "source": source, + "status": "active", + "policy_snapshot": policy.to_snapshot(), + "sender": sender, + "user_value": int(user_value), + "num_of_initial_validators": int(num_of_validators), + "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_spent": 0, + "primary_fee_refunded": 0, + "execution_budget_total": execution_budget_total, + "execution_fee_consumed": 0, + "execution_fee_consumed_buckets": [], + "genvm_fee_consumed_buckets": [], + "genvm_message_fee_consumed": 0, + "execution_fee_report": {}, + "message_fee_budget": total_message_fees, + "message_fee_consumed": 0, + "message_fee_refunded": 0, + "external_message_fee_reserved": 0, + "external_message_fee_reimbursed": 0, + "external_message_fee_remainder": 0, + "external_message_events": [], + "appeal_bonds": [], + "appeal_bonds_total": 0, + "total_refunded": 0, + "refunds": [], + "top_ups": [ + { + "sender": sender, + "amount": int(fee_value), + "primaryAmount": max(0, int(fee_value) - total_message_fees), + "messageFees": total_message_fees, + "feesDistribution": fees, + } + ], + "fees_distribution": fees, + "message_allocations": [ + _serializable_message_allocation(allocation) + for allocation in message_allocations + ], + "allocation_consumed": {}, + "message_consumption_events": [], + } + + +def _serializable_fees_distribution( + fees_distribution: dict[str, Any], +) -> dict[str, int | list[int]]: + return normalize_fees_distribution(fees_distribution) + + +def _serializable_message_allocation(node: dict[str, Any]) -> dict[str, Any]: + normalized = _normalize_message_allocation(node) + return { + "messageType": int(normalized["messageType"]), + "onAcceptance": bool(normalized["onAcceptance"]), + "parentIndex": int(normalized["parentIndex"]), + "recipient": str(normalized["recipient"]).lower(), + "callKey": _normalize_call_key(normalized["callKey"]), + "budget": int(normalized["budget"]), + "feeParams": _fee_params_hex(normalized["feeParams"]), + } + + +def _fees_distribution_from_internal_params( + fee_params: dict[str, Any], + *, + total_message_fees: int, + parent_fees_distribution: dict[str, Any], +) -> dict[str, Any]: + return { + "leaderTimeunitsAllocation": int(fee_params["leaderTimeunitsAllocation"]), + "validatorTimeunitsAllocation": int(fee_params["validatorTimeunitsAllocation"]), + "appealRounds": int(fee_params["appealRounds"]), + "executionBudgetPerRound": int(fee_params["executionBudgetPerRound"]), + "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) + ), + } + + +def _genvm_message_fee_params(node: dict[str, Any]) -> dict[str, Any]: + if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL: + return { + "leader_timeunits_allocation": 0, + "validator_timeunits_allocation": 0, + "execution_budget_per_round": 0, + "rotations": [0], + } + + decoded = decode_internal_message_fee_params(node["feeParams"]) + return { + "leader_timeunits_allocation": int(decoded["leaderTimeunitsAllocation"]), + "validator_timeunits_allocation": int(decoded["validatorTimeunitsAllocation"]), + "execution_budget_per_round": int(decoded["executionBudgetPerRound"]), + "rotations": [int(rotation) for rotation in decoded["rotations"]], + } + + +def _genvm_unmetered_message_fee_allocation() -> list[dict[str, Any]]: + fee_params = { + "leader_timeunits_allocation": 5, + "validator_timeunits_allocation": 5, + "execution_budget_per_round": 2**10, + "rotations": [4, 4, 4, 4, 4], + } + budget = 2**200 + return [ + { + "message_type": "External", + "parent_index": None, + "recipient": None, + "call_key": None, + "budget": budget, + "fee_params": fee_params, + }, + { + "message_type": "InternalFinalized", + "parent_index": None, + "recipient": None, + "call_key": None, + "budget": budget, + "fee_params": fee_params, + }, + { + "message_type": "InternalAccepted", + "parent_index": None, + "recipient": None, + "call_key": None, + "budget": budget, + "fee_params": fee_params, + }, + ] + + +def _genvm_external_legacy_fallback_message_fee_allocation() -> dict[str, Any]: + return { + "message_type": "External", + "parent_index": None, + "recipient": None, + "call_key": None, + "budget": 2**200, + "fee_params": { + "leader_timeunits_allocation": 0, + "validator_timeunits_allocation": 0, + "execution_budget_per_round": 0, + "rotations": [0], + }, + } + + +def _allocation_subtree( + message_allocations: list[dict[str, Any]], + root_index: int, +) -> list[dict[str, Any]]: + root = copy.deepcopy( + _serializable_message_allocation(message_allocations[root_index]) + ) + root["parentIndex"] = NODE_ROOT_SENTINEL + old_to_new: dict[int, int] = {root_index: 0} + subtree: list[dict[str, Any]] = [root] + for index, raw_node in enumerate(message_allocations): + if index == root_index: + continue + node = _serializable_message_allocation(raw_node) + parent_index = int(node["parentIndex"]) + if parent_index not in old_to_new: + continue + + old_to_new[index] = len(subtree) + copied = copy.deepcopy(node) + copied["parentIndex"] = old_to_new[parent_index] + subtree.append(copied) + return subtree + + +def _child_allocations_from_message_subtree( + message: dict[str, Any], + allocation_subtree: list[dict[str, Any]], +) -> list[dict[str, Any]]: + if not allocation_subtree: + return [] + + root = _serializable_message_allocation(allocation_subtree[0]) + if not _is_matched_root_allocation(message, root): + return [ + _serializable_message_allocation(allocation) + for allocation in allocation_subtree + ] + + child_allocations: list[dict[str, Any]] = [] + for raw_node in allocation_subtree[1:]: + node = _serializable_message_allocation(raw_node) + copied = copy.deepcopy(node) + parent_index = int(copied["parentIndex"]) + copied["parentIndex"] = ( + NODE_ROOT_SENTINEL if parent_index == 0 else parent_index - 1 + ) + child_allocations.append(copied) + return child_allocations + + +def _canonical_allocation_subtree( + allocation_subtree: list[dict[str, Any]], +) -> list[dict[str, Any]]: + canonical = [] + for allocation in allocation_subtree: + node = _submitted_allocation_node(allocation) + canonical.append( + { + "messageType": int(node[0]), + "onAcceptance": bool(node[1]), + "parentIndex": int(node[2]), + "recipient": str(node[3]).lower(), + "callKey": "0x" + bytes(node[4]).hex(), + "budget": int(node[5]), + "feeParams": "0x" + bytes(node[6]).hex(), + } + ) + return canonical + + +def _is_matched_root_allocation( + message: dict[str, Any], + allocation: dict[str, Any], +) -> bool: + if int(allocation["parentIndex"]) != NODE_ROOT_SENTINEL: + return False + if int(allocation["messageType"]) != int( + message.get("messageType", MESSAGE_TYPE_INTERNAL) + ): + return False + if bool(allocation["onAcceptance"]) != bool(message.get("onAcceptance", False)): + return False + if ( + str(allocation["recipient"]).lower() + != str(message.get("recipient", "")).lower() + ): + return False + if _normalize_call_key(allocation["callKey"]) != _normalize_call_key( + message.get("callKey", CALL_KEY_WILDCARD) + ): + return False + if _fee_params_hex(allocation["feeParams"]) != _fee_params_hex( + message.get("feeParams", b"") + ): + return False + return True + + +def _consume_against_allocation( + accounting: dict[str, Any], + message: dict[str, Any], + declared_budget: int, +) -> None: + allocations = accounting.get("message_allocations") or [] + if not allocations: + return + + resolved = _resolve_allocation(allocations, message) + if resolved is None: + raise MessageNoMatchingAllocation("MessageNoMatchingAllocation") + + index, allocation = resolved + if bool(allocation["onAcceptance"]) != bool(message.get("onAcceptance", False)): + raise MessageEmissionPhaseMismatch("MessageEmissionPhaseMismatch") + + if _fee_params_hex(allocation["feeParams"]) != _fee_params_hex( + message.get("feeParams", b"") + ): + raise MessageFeeParamsMismatch("MessageFeeParamsMismatch") + + key = str(index) + consumed = int(accounting.setdefault("allocation_consumed", {}).get(key, 0)) + attempted = consumed + declared_budget + if attempted > int(allocation["budget"]): + raise MessageBudgetExceeded("MessageBudgetExceeded") + accounting["allocation_consumed"][key] = attempted + + +def _reserve_external_execution( + accounting: dict[str, Any], + message: dict[str, Any], + policy: StudioFeePolicy, + *, + reimburse: bool = True, +) -> int: + if bool(message.get("onAcceptance", False)): + return 0 + + allocations = accounting.get("message_allocations") or [] + if not allocations: + return 0 + + resolved = _resolve_allocation(allocations, message) + if resolved is None: + return 0 + + index, allocation = resolved + if int(allocation["messageType"]) != MESSAGE_TYPE_EXTERNAL: + return 0 + + 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"]): + raise MessageBudgetExceeded("MessageBudgetExceeded") + accounting["allocation_consumed"][key] = attempted + + gas_used = int(message.get("gasUsed", 0) or 0) + reimbursement = min(reservation, gas_used * locked_price) + remainder = reservation - reimbursement + accounting["external_message_fee_reserved"] = ( + int(accounting.get("external_message_fee_reserved", 0)) + reservation + ) + if reimburse: + accounting["external_message_fee_reimbursed"] = ( + int(accounting.get("external_message_fee_reimbursed", 0)) + reimbursement + ) + 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 + + +def _find_unrefunded_external_message_event( + accounting: dict[str, Any], + message: dict[str, Any], +) -> int | None: + recipient = str(message.get("recipient", "")).lower() + call_key = _normalize_call_key(message.get("callKey", CALL_KEY_WILDCARD)) + for index, event in enumerate(accounting.get("external_message_events") or []): + if ( + event.get("failureRefunded") + or event.get("refunded") + 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: + continue + return index + return None + + +def _find_unexecuted_external_message_event( + accounting: dict[str, Any], + message: dict[str, Any], +) -> int | None: + recipient = str(message.get("recipient", "")).lower() + call_key = _normalize_call_key(message.get("callKey", CALL_KEY_WILDCARD)) + 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: + continue + return index + return None + + +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) + if event_index is None: + return 0, 0, 0 + + event = accounting.setdefault("external_message_events", [])[event_index] + reservation = int(event.get("reservation", 0) or 0) + reimbursement = int(event.get("reimbursement", 0) or 0) + remainder = int(event.get("remainder", 0) or 0) + allocation_index = str(event.get("allocationIndex")) + + allocation_consumed = accounting.setdefault("allocation_consumed", {}) + consumed = int(allocation_consumed.get(allocation_index, 0) or 0) + allocation_consumed[allocation_index] = max(0, consumed - reservation) + accounting["message_fee_consumed"] = max( + 0, + int(accounting.get("message_fee_consumed", 0)) - reimbursement, + ) + accounting["external_message_fee_reserved"] = max( + 0, + int(accounting.get("external_message_fee_reserved", 0)) - reservation, + ) + accounting["external_message_fee_reimbursed"] = max( + 0, + int(accounting.get("external_message_fee_reimbursed", 0)) - reimbursement, + ) + accounting["external_message_fee_remainder"] = max( + 0, + int(accounting.get("external_message_fee_remainder", 0)) - remainder, + ) + event["unreserved"] = True + return reservation, reimbursement, remainder + + +def _decrement_allocation_consumed( + accounting: dict[str, Any], + message: dict[str, Any], + amount: int, +) -> None: + resolved = _resolve_allocation(accounting.get("message_allocations") or [], message) + if resolved is None: + return + index, _ = resolved + 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)) + + +def _resolve_allocation( + allocations: list[dict[str, Any]], + message: dict[str, Any], +) -> tuple[int, dict[str, Any]] | None: + 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)) + + for wanted_call_key in (call_key, CALL_KEY_WILDCARD): + for index, raw_allocation in enumerate(allocations): + allocation = _serializable_message_allocation(raw_allocation) + if int(allocation["parentIndex"]) != NODE_ROOT_SENTINEL: + continue + if int(allocation["messageType"]) != message_type: + continue + if str(allocation["recipient"]).lower() != recipient: + continue + if _normalize_call_key(allocation["callKey"]) == wanted_call_key: + return index, allocation + return None + + +def _receipt_message_fee_payloads( + accounting: dict[str, Any], + receipt: Any | None, +) -> list[dict[str, Any]]: + if receipt is None: + return [] + if not _receipt_execution_allows_messages(receipt): + return [] + + payloads: list[dict[str, Any]] = [] + for raw in _receipt_pending_transactions(receipt): + message = _receipt_pending_transaction_fee_payload(raw) + if int(message["messageType"]) == MESSAGE_TYPE_INTERNAL and accounting.get( + "message_allocations" + ): + message = fill_message_fee_payload_from_allocation(accounting, message) + payloads.append(message) + return payloads + + +def _receipt_execution_allows_messages(receipt: Any) -> bool: + status = _receipt_value(receipt, "execution_result") + if status is None: + status = _receipt_value(receipt, "executionResult") + if hasattr(status, "value"): + status = status.value + if status is None: + return True + return str(status).replace("_", "").upper() in { + "SUCCESS", + "FINISHEDWITHRETURN", + "RETURN", + } + + +def _receipt_messages_require_fee_validation( + accounting: dict[str, Any], + messages: list[dict[str, Any]], +) -> bool: + if int(accounting.get("message_fee_budget", 0) or 0) > 0: + return True + if accounting.get("message_allocations"): + return True + return any(_message_has_fee_fields(message) for message in messages) + + +def _message_has_fee_fields(message: dict[str, Any]) -> bool: + if int(message.get("declaredBudget", 0) or 0) > 0: + return True + return _message_has_fee_params(message) + + +def _message_has_fee_params(message: dict[str, Any]) -> bool: + fee_params = message.get("feeParams", b"") + if isinstance(fee_params, str): + return fee_params not in {"", "0x"} + return bool(fee_params) + + +def _receipt_pending_transaction_fee_payload(raw: Any) -> dict[str, Any]: + message = _pending_transaction_dict(raw) + message_type = _message_type(message) + data = _bytes_field( + _message_field(message, "calldata", "data", b"") + or _message_field(message, "data", "calldata", b"") + ) + call_key = _message_field( + message, + "call_key", + "callKey", + CALL_KEY_WILDCARD, + ) + if message_type == MESSAGE_TYPE_EXTERNAL: + call_key = derive_external_message_call_key(call_key, data) + fee_params = b"" + else: + fee_params = _bytes_field( + _message_field(message, "fee_params", "feeParams", b"") + ) + return { + "messageType": message_type, + "recipient": _abi_address( + _message_field(message, "address", "recipient") + or _message_field(message, "recipient", "address") + ), + "value": int(message.get("value", 0) or 0), + "data": data, + "onAcceptance": _message_on_acceptance(message), + "saltNonce": int(_message_field(message, "salt_nonce", "saltNonce", 0) or 0), + "feeParams": fee_params, + "declaredBudget": int( + _message_field( + message, + "declared_budget", + "declaredBudget", + 0, + ) + or 0 + ), + "allocationSubtree": _message_field( + message, + "allocation_subtree", + "allocationSubtree", + [], + ), + "callKey": call_key, + "gasUsed": int(_message_field(message, "gas_used", "gasUsed", 0) or 0), + } + + +def _execution_fee_buckets(consumed: list[int]) -> list[int]: + if len(consumed) <= 2: + return consumed + return consumed[:2] + + +def _chargeable_execution_fee_buckets( + consumed: list[int], + fee_report: dict[str, Any] | None, + 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): + return [ + _bucket_value(consumed, 0), + storage_fee, + ] + + return [ + _receipt_report_chargeable_fee(fee_report), + storage_fee, + ] + + +def _chargeable_storage_fee(receipt: Any | None, consumed: list[int]) -> int: + if receipt is not None and not _receipt_execution_allows_messages(receipt): + return 0 + return _bucket_value(consumed, 1) + + +def _receipt_report_chargeable_fee(fee_report: dict[str, Any]) -> int: + proposal = fee_report.get("proposalReceipt") + proposal_fee = int(proposal.get("fee", 0) or 0) if isinstance(proposal, dict) else 0 + message_reveal = fee_report.get("messageReveal") + message_fee = ( + int(message_reveal.get("consensusAdditionalFee", 0) or 0) + if isinstance(message_reveal, dict) + else 0 + ) + return max(0, proposal_fee + message_fee) + + +def _bucket_value(consumed: list[int], index: int) -> int: + return int(consumed[index]) if len(consumed) > index else 0 + + +def _execution_budget_per_round(accounting: dict[str, Any]) -> int: + try: + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + except FeeValidationError: + return 0 + return int(fees["executionBudgetPerRound"]) + + +def _genvm_fee_bucket_report( + consumed: list[int], + *, + 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}) + report = { + "receiptAndNondetOutput": receipt_and_nondet_output, + "storage": storage, + "message": message, + "totalExecution": total_execution, + "totalWithMessage": sum(int(value) for value in consumed), + "buckets": buckets, + } + overrun = max(0, total_execution - execution_budget_per_round) + report.update( + { + "executionBudgetPerRound": execution_budget_per_round, + "executionBudgetRemaining": max( + 0, execution_budget_per_round - total_execution + ), + "executionBudgetOverrun": overrun, + "executionBudgetExceeded": overrun > 0, + } + ) + return report + + +def _execution_metering_report( + *, + chargeable_bucket_report: dict[str, Any], + genvm_bucket_report: dict[str, Any], +) -> dict[str, int]: + chargeable = int(chargeable_bucket_report.get("totalExecution", 0) or 0) + genvm_reported = int(genvm_bucket_report.get("totalExecution", 0) or 0) + return { + "chargeableExecutionFee": chargeable, + "genvmReportedExecution": genvm_reported, + "genvmDeltaFromChargeable": genvm_reported - chargeable, + } + + +def _receipt_budget_exhaustion_reason( + receipt: Any | None, + bucket_report: dict[str, Any] | None = None, +) -> str | None: + genvm_result = _receipt_genvm_result(receipt) + if isinstance(genvm_result, dict): + for key in ("budgetExhaustionReason", "budget_exhaustion_reason"): + reason = genvm_result.get(key) + if reason not in (None, "", "None"): + return str(reason) + + error_code = genvm_result.get("error_code") or genvm_result.get("errorCode") + if error_code in {"ExecutionBudgetExceeded", "MessageBudgetExceeded"}: + return str(error_code) + + if bucket_report and bucket_report.get("executionBudgetExceeded"): + return "ExecutionBudgetExceeded" + + return None + + +def _message_fee_accounting_report(accounting: dict[str, Any]) -> dict[str, int]: + budget = int(accounting.get("message_fee_budget", 0) or 0) + total_consumed = int(accounting.get("message_fee_consumed", 0) or 0) + 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) + 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 = { + "budget": budget, + "declaredConsumed": declared_consumed, + "genvmMeteredConsumed": genvm_metered_consumed, + "declaredRefunded": declared_refunded, + "remaining": max(0, budget - total_consumed - declared_refunded), + "meteringDelta": declared_consumed - genvm_metered_consumed, + } + if external_reserved or external_reimbursed or external_remainder: + report["externalReserved"] = external_reserved + report["externalReimbursed"] = external_reimbursed + report["externalRemainder"] = external_remainder + report["totalConsumed"] = total_consumed + if accounting.get("reported_message_fees_total") is not None: + report["reportedTotal"] = int(accounting["reported_message_fees_total"]) + return report + + +def _attach_message_fee_accounting_report(accounting: dict[str, Any]) -> None: + report = dict(accounting.get("execution_fee_report") or {}) + report["messageFees"] = _message_fee_accounting_report(accounting) + accounting["execution_fee_report"] = report + + +def _attach_recommended_fee_preset( + accounting: dict[str, Any], + policy: StudioFeePolicy, +) -> None: + accounting["recommended_fee_preset"] = recommended_fee_preset(accounting, policy) + + +def recommended_fee_preset( + accounting: dict[str, Any], + policy: StudioFeePolicy | None = None, + *, + padding_bps: int = DEFAULT_PRICE_CAP_HEADROOM_BPS, +) -> dict[str, Any]: + policy = _accounting_policy(accounting, policy) + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + report = accounting.get("execution_fee_report") or {} + message_report = ( + report.get("messageFees") if isinstance(report.get("messageFees"), dict) else {} + ) + message_allocations = list(accounting.get("message_allocations") or []) + num_validators = int( + accounting.get("num_of_initial_validators") or VALIDATORS_PER_ROUND[0] + ) + + observed_execution = _observed_chargeable_execution_fee(accounting, report) + recommended_execution = int(fees["executionBudgetPerRound"]) + if observed_execution > 0: + recommended_execution = max( + _with_padding(observed_execution, padding_bps), + policy.message_fee_params_budget_floor(), + ) + + declared_message = _int_report_field(message_report, "declaredConsumed") + external_reserved = int(accounting.get("external_message_fee_reserved", 0) or 0) + observed_message_budget = declared_message + external_reserved + recommended_message_budget = int(fees["totalMessageFees"]) + message_budget_mode = "current" + if message_allocations: + message_budget_mode = "allocation-preserved" + elif observed_message_budget > 0: + recommended_message_budget = _with_padding(observed_message_budget, padding_bps) + message_budget_mode = "observed" + + distribution = _serializable_fees_distribution( + { + **fees, + "rotations": _preset_rotations(fees), + "executionBudgetPerRound": recommended_execution, + "totalMessageFees": recommended_message_budget, + } + ) + fee_value = required_fee_deposit( + distribution, + num_validators, + policy, + ) + + return { + "source": "simulation", + "paddingBps": int(padding_bps), + "numOfInitialValidators": num_validators, + "distribution": distribution, + "feeValue": fee_value, + "messageAllocations": message_allocations, + "messageBudgetMode": message_budget_mode, + "observed": { + "executionFee": observed_execution, + "messageFeeBudget": observed_message_budget, + "declaredMessageFees": declared_message, + "externalMessageReserved": external_reserved, + "totalEstimatedFee": _int_report_field(report, "totalEstimatedFee"), + "totalStudioMeteredFee": _int_report_field(report, "totalStudioMeteredFee"), + }, + } + + +def _preset_rotations(fees: dict[str, Any]) -> list[int]: + appeal_rounds = int(fees.get("appealRounds", 0) or 0) + expected = appeal_rounds + 1 + rotations = [int(rotation) for rotation in fees.get("rotations", [])] + if len(rotations) >= expected: + return rotations[:expected] + return rotations + ([0] * (expected - len(rotations))) + + +def _observed_chargeable_execution_fee( + accounting: dict[str, Any], + report: dict[str, Any], +) -> int: + consumed = int(accounting.get("execution_fee_consumed", 0) or 0) + if consumed > 0: + return consumed + + chargeable = report.get("chargeableExecution") + if isinstance(chargeable, dict): + total = int(chargeable.get("totalExecution", 0) or 0) + if total > 0: + return total + + return _int_report_field(report, "totalEstimatedFee") + + +def _int_report_field(report: dict[str, Any], key: str) -> int: + try: + return int(report.get(key, 0) or 0) + except (TypeError, ValueError): + return 0 + + +def _refresh_message_fee_accounting_report_if_present( + accounting: dict[str, Any], + policy: StudioFeePolicy | None = None, +) -> None: + if accounting.get("execution_fee_report"): + policy = _accounting_policy(accounting, policy) + _attach_message_fee_accounting_report(accounting) + _attach_recommended_fee_preset(accounting, policy) + + +def _receipt_data_fees_consumed(receipt: Any | None) -> list[int] | None: + if receipt is None: + return None + genvm_result = ( + getattr(receipt, "genvm_result", None) + if not isinstance(receipt, dict) + else receipt.get("genvm_result") + ) + if not isinstance(genvm_result, dict): + return None + consumed = genvm_result.get("data_fees_consumed") + if consumed is not None: + return [int(value) for value in consumed] + totals = genvm_result.get("data_fee_bucket_totals") + remaining = genvm_result.get("data_fees_remaining") + if totals is None or remaining is None: + return None + return [max(0, int(total) - int(rest)) for total, rest in zip(totals, remaining)] + + +def _receipt_reported_message_fees_total(receipt: Any | None) -> int | None: + if receipt is None: + return None + for source in (receipt, _receipt_genvm_result(receipt) or {}): + for key in ( + "reported_message_fees_total", + "reportedMessageFeesTotal", + "message_fees_consumed", + "messageFeesConsumed", + ): + value = _receipt_value(source, key) + if value is not None: + return int(value) + return None + + +def _receipt_fee_report( + receipt: Any | None, + policy: StudioFeePolicy, + message_payloads: list[dict[str, Any]] | None = None, +) -> dict[str, Any] | None: + if receipt is None: + return None + + eq_outputs_length = _receipt_eq_blocks_outputs_length(receipt) + 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 + report: dict[str, Any] = { + "receiptGasPrice": policy.receipt_gas_price, + "proposalReceipt": { + "eqBlocksOutputsLength": eq_outputs_length, + "receiptBytes": receipt_bytes, + "estimatedGas": proposal_gas, + "fee": proposal_fee, + }, + "totalEstimatedFee": proposal_fee, + "totalStudioMeteredFee": proposal_fee, + } + + submitted_messages, message_reports = _receipt_submitted_messages_and_reports( + receipt, + message_payloads, + ) + if submitted_messages: + message_bytes = len(encode([SUBMITTED_MESSAGE_ABI_TYPE], [submitted_messages])) + message_gas = policy.estimate_message_reveal_gas( + message_bytes, + len(submitted_messages), + ) + consensus_message_gas = policy.estimate_consensus_message_reveal_gas( + message_bytes, + len(submitted_messages), + ) + message_fee = message_gas * policy.receipt_gas_price + consensus_message_fee = consensus_message_gas * policy.receipt_gas_price + report["messageReveal"] = { + "messageBytes": message_bytes, + "messageCount": len(submitted_messages), + "estimatedGas": message_gas, + "fee": message_fee, + "consensusAdditionalGas": consensus_message_gas, + "consensusAdditionalFee": consensus_message_fee, + "studioFixedOverheadGas": max(0, message_gas - consensus_message_gas), + "studioFixedOverheadFee": max(0, message_fee - consensus_message_fee), + "messages": message_reports, + } + report["totalEstimatedFee"] += consensus_message_fee + report["totalStudioMeteredFee"] += message_fee + + return 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" + ) + if explicit is not None: + return max(0, int(explicit)) + + explicit_outputs = _receipt_value(receipt, "eq_blocks_outputs") + if isinstance(explicit_outputs, str) and explicit_outputs.startswith("0x"): + return len(bytes.fromhex(explicit_outputs.removeprefix("0x"))) + + return len(_encode_eq_blocks_outputs(_receipt_eq_outputs(receipt))) + + +def _receipt_submitted_messages(receipt: Any) -> list[tuple[Any, ...]]: + submitted, _ = _receipt_submitted_messages_and_reports(receipt) + return submitted + + +def _receipt_submitted_messages_and_reports( + receipt: Any, + message_payloads: list[dict[str, Any]] | None = None, +) -> tuple[list[tuple[Any, ...]], list[dict[str, Any]]]: + submitted = [] + reports = [] + raw_messages = ( + message_payloads + if message_payloads is not None + else [ + _pending_transaction_dict(raw) + for raw in _receipt_pending_transactions(receipt) + ] + ) + for message in raw_messages: + message_type = _message_type(message) + recipient = _abi_address( + _message_field(message, "address", "recipient") + or _message_field(message, "recipient", "address") + ) + value = int(message.get("value", 0) or 0) + data = _bytes_field( + _message_field(message, "calldata", "data", b"") + or _message_field(message, "data", "calldata", b"") + ) + on_acceptance = _message_on_acceptance(message) + salt_nonce = int(_message_field(message, "salt_nonce", "saltNonce", 0) or 0) + fee_params = _bytes_field( + _message_field(message, "fee_params", "feeParams", b"") + ) + if message_type == MESSAGE_TYPE_EXTERNAL: + fee_params = b"" + declared_budget = int( + _message_field( + message, + "declared_budget", + "declaredBudget", + 0, + ) + or 0 + ) + allocation_subtree = _allocation_subtree_bytes( + _message_field( + message, + "allocation_subtree", + "allocationSubtree", + ) + ) + call_key_value = _message_field( + message, + "call_key", + "callKey", + CALL_KEY_WILDCARD, + ) + if message_type == MESSAGE_TYPE_EXTERNAL: + call_key_value = derive_external_message_call_key(call_key_value, data) + call_key = _bytes32_field(call_key_value) + submitted.append( + ( + message_type, + recipient, + value, + data, + on_acceptance, + salt_nonce, + fee_params, + declared_budget, + allocation_subtree, + call_key, + ) + ) + reports.append( + { + "messageFeeMode": _message_fee_mode( + message_type, + allocation_subtree, + message.get("messageFeeMode"), + ), + "messageType": ( + "External" if message_type == MESSAGE_TYPE_EXTERNAL else "Internal" + ), + "recipient": recipient, + "value": value, + "dataBytes": len(data), + "onAcceptance": on_acceptance, + "saltNonce": salt_nonce, + "feeParams": _fee_params_hex(fee_params), + "feeParamsDecoded": _message_fee_params_for_report( + message_type, + fee_params, + ), + "feeParamsBytes": len(fee_params), + "declaredBudget": declared_budget, + "allocationSubtree": "0x" + allocation_subtree.hex(), + "allocationSubtreeBytes": len(allocation_subtree), + "callKey": "0x" + call_key.hex(), + } + ) + return submitted, reports + + +def _message_fee_mode( + message_type: int, + allocation_subtree: bytes, + explicit: Any = None, +) -> str: + if explicit in {"mode1", "mode2", "external"}: + return str(explicit) + if message_type == MESSAGE_TYPE_EXTERNAL: + return "external" + return "mode2" if allocation_subtree else "mode1" + + +def _message_fee_params_for_report( + message_type: int, + fee_params: bytes, +) -> dict[str, Any] | None: + if not fee_params: + return None + try: + if message_type == MESSAGE_TYPE_EXTERNAL: + return decode_external_message_fee_params(fee_params) + return decode_internal_message_fee_params(fee_params) + except FeeValidationError: + return None + + +def _receipt_pending_transactions(receipt: Any) -> list[Any]: + pending = _receipt_value(receipt, "pending_transactions", []) + return pending if isinstance(pending, list) else list(pending or []) + + +def _pending_transaction_dict(pending_transaction: Any) -> dict[str, Any]: + if isinstance(pending_transaction, dict): + return pending_transaction + if hasattr(pending_transaction, "to_dict"): + return pending_transaction.to_dict() + return { + "address": getattr(pending_transaction, "address", ""), + "calldata": getattr( + pending_transaction, + "calldata", + getattr(pending_transaction, "data", b""), + ), + "code": getattr(pending_transaction, "code", b""), + "salt_nonce": getattr(pending_transaction, "salt_nonce", 0), + "on": getattr(pending_transaction, "on", "finalized"), + "value": getattr(pending_transaction, "value", 0), + "is_eth_send": getattr( + pending_transaction, + "is_eth_send", + getattr(pending_transaction, "isEthSend", False), + ), + "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), + "allocation_subtree": getattr(pending_transaction, "allocation_subtree", []), + } + + +def _message_field( + message: dict[str, Any], + snake_key: str, + camel_key: str, + default: Any = None, +) -> Any: + if snake_key in message: + return message[snake_key] + return message.get(camel_key, default) + + +def _message_type(message: dict[str, Any]) -> int: + explicit = _message_field(message, "message_type", "messageType") + if explicit is not None: + if isinstance(explicit, str) and not explicit.isdigit(): + return ( + MESSAGE_TYPE_EXTERNAL + if explicit.lower() == "external" + else MESSAGE_TYPE_INTERNAL + ) + return int(explicit) + is_eth_send = bool(_message_field(message, "is_eth_send", "isEthSend", False)) + return MESSAGE_TYPE_EXTERNAL if is_eth_send else MESSAGE_TYPE_INTERNAL + + +def _message_on_acceptance(message: dict[str, Any]) -> bool: + explicit = _message_field(message, "on_acceptance", "onAcceptance") + if explicit is not None: + return bool(explicit) + phase = str(message.get("on", "finalized")).lower() + return phase == "accepted" or phase == "acceptance" + + +def _receipt_eq_outputs(receipt: Any) -> list[bytes]: + eq_outputs = _receipt_value(receipt, "eq_outputs") + if eq_outputs is None: + eq_outputs = _receipt_value(receipt, "eqOutputs") + if isinstance(eq_outputs, dict): + + def sort_key(item: tuple[Any, Any]) -> int: + try: + return int(item[0]) + except (TypeError, ValueError): + return 0 + + return [ + _eq_output_bytes(value) + for _, value in sorted(eq_outputs.items(), key=sort_key) + ] + if isinstance(eq_outputs, list): + return [_eq_output_bytes(value) for value in eq_outputs] + return [] + + +def _eq_output_bytes(value: Any) -> bytes: + if isinstance(value, dict): + value = value.get("data", value.get("output", value.get("value", b""))) + return _bytes_field(value) + + +def _encode_eq_blocks_outputs(eq_outputs: list[bytes]) -> bytes: + return rlp.encode([*eq_outputs, b"padded"]) + + +def _receipt_genvm_result(receipt: Any) -> dict[str, Any] | None: + genvm_result = _receipt_value(receipt, "genvm_result") + return genvm_result if isinstance(genvm_result, dict) else None + + +def _receipt_value(receipt: Any, key: str, default: Any = None) -> Any: + if isinstance(receipt, dict): + return receipt.get(key, default) + return getattr(receipt, key, default) + + +def _abi_address(value: Any) -> str: + raw = str(value or "").lower() + if raw.startswith("0x"): + raw = raw[2:] + if len(raw) == 40: + try: + bytes.fromhex(raw) + return "0x" + raw + except ValueError: + pass + return "0x" + ("0" * 40) + + +def _allocation_subtree_bytes(value: Any) -> bytes: + if value is None or value == []: + return b"" + if isinstance(value, list): + nodes = [_submitted_allocation_node(node) for node in value] + return encode([MESSAGE_ALLOCATION_NODE_ABI_TYPE], [nodes]) + if isinstance(value, dict): + return encode( + [MESSAGE_ALLOCATION_NODE_ABI_TYPE], + [[_submitted_allocation_node(value)]], + ) + return _bytes_field(value) + + +def _submitted_allocation_node(node: dict[str, Any]) -> tuple[Any, ...]: + return ( + int(node.get("messageType", node.get("message_type", MESSAGE_TYPE_INTERNAL))), + bool(node.get("onAcceptance", node.get("on_acceptance", False))), + int(node.get("parentIndex", node.get("parent_index", NODE_ROOT_SENTINEL))), + _abi_address(node.get("recipient")), + _bytes32_field(node.get("callKey", node.get("call_key", CALL_KEY_WILDCARD))), + int(node.get("budget", 0) or 0), + _bytes_field(node.get("feeParams", node.get("fee_params", b""))), + ) + + +def _bytes32_field(value: Any) -> bytes: + if isinstance(value, bytes): + return value.rjust(32, b"\x00")[-32:] + raw = str(value or "").removeprefix("0x").lower() + try: + return bytes.fromhex(raw.rjust(64, "0")[-64:]) + except ValueError: + return bytes(32) + + +def _bytes_field(value: Any) -> bytes: + if value is None: + return b"" + if isinstance(value, bytes): + return value + if isinstance(value, bytearray): + return bytes(value) + if isinstance(value, str): + raw = value.removeprefix("0x") + if value.startswith("0x"): + try: + return bytes.fromhex(raw) + except ValueError: + return b"" + if raw == "": + return b"" + try: + return base64.b64decode(raw, validate=True) + except Exception: + try: + return bytes.fromhex(raw) + except ValueError: + return raw.encode("utf-8") + return bytes(value) + + +def _fee_params_hex(fee_params: bytes | str) -> str: + if isinstance(fee_params, str): + return "0x" + fee_params.removeprefix("0x").lower() + return "0x" + bytes(fee_params).hex() + + +def _normalize_call_key(call_key: bytes | str) -> str: + if isinstance(call_key, bytes): + raw = call_key.hex() + else: + raw = str(call_key).removeprefix("0x").lower() + return "0x" + raw.rjust(64, "0")[-64:] + + +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: + return normalized + + raw_calldata = _bytes_field(calldata) + if len(raw_calldata) < 4: + return CALL_KEY_WILDCARD + + return "0x" + raw_calldata[:4].hex().ljust(64, "0") diff --git a/backend/protocol_rpc/health.py b/backend/protocol_rpc/health.py index e184190d4..4539bea27 100644 --- a/backend/protocol_rpc/health.py +++ b/backend/protocol_rpc/health.py @@ -214,6 +214,7 @@ def _evaluate_permit_readiness( # Send system health metrics every 6 health checks (6 × 10s = 60s = 1 minute) METRICS_SEND_INTERVAL = 6 +_no_progress_scan_suppressed_until: float = 0.0 def get_health_check_interval() -> float: @@ -221,6 +222,11 @@ def get_health_check_interval() -> float: return float(os.getenv("HEALTH_CHECK_INTERVAL_SECONDS", "10")) +def get_no_progress_scan_error_cooldown_seconds() -> float: + """Cooldown after the expensive no-progress scan times out.""" + return float(os.getenv("HEALTH_NO_PROGRESS_SCAN_ERROR_COOLDOWN_SECONDS", "300")) + + def _update_genvm_health_cache( services: Dict[str, Any], genvm_ok: bool, @@ -328,6 +334,9 @@ async def _run_health_checks() -> None: "no_progress_check_error": consensus_health.get( "no_progress_check_error", False ), + "no_progress_scan_suppressed": consensus_health.get( + "no_progress_scan_suppressed", False + ), "active_workers": consensus_health.get("active_workers", 0), "status": consensus_status, } @@ -614,6 +623,9 @@ async def _check_consensus_health() -> Dict[str, Any]: NO_PROGRESS_QUERY_TIMEOUT_MS = int( os.environ.get("HEALTH_NO_PROGRESS_QUERY_TIMEOUT_MS", "5000") ) + NO_PROGRESS_SCAN_ERROR_COOLDOWN_SECONDS = ( + get_no_progress_scan_error_cooldown_seconds() + ) RECOVERY_STORM_MIN_RECOVERIES = int( os.environ.get("HEALTH_RECOVERY_STORM_MIN_RECOVERIES", "2") ) @@ -651,6 +663,8 @@ async def _check_consensus_health() -> Dict[str, Any]: db_manager = get_database_manager() def _query_consensus(): + global _no_progress_scan_suppressed_until + from sqlalchemy import text with db_manager.engine.connect() as conn: @@ -887,68 +901,79 @@ def _query_consensus(): seconds_since_consensus_progress = None last_progress_epoch = 0 + no_progress_scan_suppressed = False if should_scan_progress: - try: - conn.execute( - text( - f"SET LOCAL statement_timeout = {NO_PROGRESS_QUERY_TIMEOUT_MS}" + if time.time() < _no_progress_scan_suppressed_until: + no_progress_check_error = True + no_progress_scan_suppressed = True + else: + try: + conn.execute( + text( + f"SET LOCAL statement_timeout = {NO_PROGRESS_QUERY_TIMEOUT_MS}" + ) ) - ) - progress_row = conn.execute( - text( - """ - SELECT - MAX( - GREATEST( - COALESCE( - CASE - WHEN consensus_history - -> 'current_monitoring' - ->> 'ACCEPTED' - ~ '^[0-9]+(\\.[0-9]+)?$' - THEN ( - consensus_history - -> 'current_monitoring' - ->> 'ACCEPTED' - )::double precision - END, - 0 - ), - COALESCE( - CASE - WHEN consensus_history - -> 'current_monitoring' - ->> 'FINALIZED' - ~ '^[0-9]+(\\.[0-9]+)?$' - THEN ( - consensus_history - -> 'current_monitoring' - ->> 'FINALIZED' - )::double precision - END, - 0 + progress_row = conn.execute( + text( + """ + SELECT + MAX( + GREATEST( + COALESCE( + CASE + WHEN consensus_history + -> 'current_monitoring' + ->> 'ACCEPTED' + ~ '^[0-9]+(\\.[0-9]+)?$' + THEN ( + consensus_history + -> 'current_monitoring' + ->> 'ACCEPTED' + )::double precision + END, + 0 + ), + COALESCE( + CASE + WHEN consensus_history + -> 'current_monitoring' + ->> 'FINALIZED' + ~ '^[0-9]+(\\.[0-9]+)?$' + THEN ( + consensus_history + -> 'current_monitoring' + ->> 'FINALIZED' + )::double precision + END, + 0 + ) ) - ) - ) AS last_progress_epoch - FROM transactions - WHERE consensus_history IS NOT NULL - """ + ) AS last_progress_epoch + FROM transactions + WHERE consensus_history IS NOT NULL + """ + ) + ).fetchone() + _no_progress_scan_suppressed_until = 0.0 + last_progress_epoch = ( + progress_row.last_progress_epoch if progress_row else 0 ) - ).fetchone() - last_progress_epoch = ( - progress_row.last_progress_epoch if progress_row else 0 - ) - seconds_since_consensus_progress = ( - int(time.time() - last_progress_epoch) - if last_progress_epoch - else None - ) - except Exception as exc: - no_progress_check_error = True - logger.warning( - "No-progress health query skipped after timeout/error: %s", - exc, - ) + seconds_since_consensus_progress = ( + int(time.time() - last_progress_epoch) + if last_progress_epoch + else None + ) + except Exception as exc: + no_progress_check_error = True + _no_progress_scan_suppressed_until = ( + time.time() + NO_PROGRESS_SCAN_ERROR_COOLDOWN_SECONDS + ) + logger.warning( + "No-progress health query skipped after timeout/error: %s", + exc, + ) + else: + _no_progress_scan_suppressed_until = 0.0 # The progress scan is an alert-quality check, not a liveness # requirement. If it times out on a large table, surface that @@ -1005,6 +1030,7 @@ def _query_consensus(): seconds_since_consensus_progress ), "no_progress_check_error": no_progress_check_error, + "no_progress_scan_suppressed": no_progress_scan_suppressed, "no_progress_window_minutes": NO_PROGRESS_WINDOW_MINUTES, "active_workers": active_workers_count, } diff --git a/backend/protocol_rpc/rpc_methods.py b/backend/protocol_rpc/rpc_methods.py index 1c8785850..d3a2c31bb 100644 --- a/backend/protocol_rpc/rpc_methods.py +++ b/backend/protocol_rpc/rpc_methods.py @@ -169,6 +169,7 @@ async def update_validator( stake: int | None = None, provider: str | None = None, model: str | None = None, + config: dict | None = None, plugin: str | None = None, plugin_config: dict | None = None, session: Session = Depends(get_db_session), @@ -181,6 +182,7 @@ async def update_validator( stake=stake, provider=provider, model=model, + config=config, plugin=plugin, plugin_config=plugin_config, ) @@ -291,6 +293,11 @@ def get_finality_window_time( return impl.get_finality_window_time(consensus) +@rpc.method("sim_getFeeConfig", log_policy=LogPolicy.debug()) +def get_fee_config() -> dict: + return impl.get_studio_fee_config() + + @rpc.method("sim_getConsensusContract", log_policy=LogPolicy.debug()) def get_consensus_contract( contract_name: str, @@ -424,6 +431,27 @@ async def sim_call( ) +@rpc.method("sim_estimateTransactionFees") +async def sim_estimate_transaction_fees( + params: dict, + session: Session = Depends(get_db_session), + accounts_manager: AccountsManager = Depends(get_accounts_manager), + msg_handler=Depends(get_message_handler), + transactions_parser=Depends(get_transactions_parser), + validators_manager=Depends(get_validators_manager), + genvm_manager=Depends(get_genvm_manager), +) -> dict: + return await impl.sim_estimate_transaction_fees( + session=session, + accounts_manager=accounts_manager, + msg_handler=msg_handler, + transactions_parser=transactions_parser, + validators_manager=validators_manager, + genvm_manager=genvm_manager, + params=params, + ) + + # --------------------------------------------------------------------------- # Ethereum-compatible endpoints # --------------------------------------------------------------------------- diff --git a/backend/protocol_rpc/transactions_parser.py b/backend/protocol_rpc/transactions_parser.py index 3502d8d6d..541f6f053 100644 --- a/backend/protocol_rpc/transactions_parser.py +++ b/backend/protocol_rpc/transactions_parser.py @@ -22,9 +22,181 @@ DecodedGenlayerTransaction, DecodedGenlayerTransactionData, DecodedsubmitAppealDataArgs, + DecodedTopUpFeesDataArgs, ZERO_ADDRESS, ) +FEE_AWARE_ADD_TRANSACTION_ABI = { + "inputs": [ + { + "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": [ + { + "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", + }, + ], + "internalType": "struct IFeeManager.FeesDistribution", + "name": "feesDistribution", + "type": "tuple", + }, + {"internalType": "bytes", "name": "txCalldata", "type": "bytes"}, + { + "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"}, + ], + "internalType": "struct IMessages.MessageFeeAllocationNode[]", + "name": "messageAllocations", + "type": "tuple[]", + }, + ], + "internalType": "struct IConsensusMainWithFees.AddTransactionParams", + "name": "_params", + "type": "tuple", + } + ], + "name": "addTransaction", + "outputs": [], + "stateMutability": "payable", + "type": "function", +} + +FEE_AWARE_DEPLOY_SALTED_ABI = { + **FEE_AWARE_ADD_TRANSACTION_ABI, + "name": "deploySalted", +} + +FEE_AWARE_TOP_UP_FEES_ABI = { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + FEE_AWARE_ADD_TRANSACTION_ABI["inputs"][0]["components"][7] + | {"name": "_feesDistribution"}, + ], + "name": "topUpFees", + "outputs": [], + "stateMutability": "payable", + "type": "function", +} + +FEE_AWARE_TOP_UP_AND_SUBMIT_APPEAL_ABI = { + **FEE_AWARE_TOP_UP_FEES_ABI, + "name": "topUpAndSubmitAppeal", +} + +FEES_DISTRIBUTION_FIELDS = [ + "leaderTimeunitsAllocation", + "validatorTimeunitsAllocation", + "appealRounds", + "executionBudgetPerRound", + "executionConsumed", + "totalMessageFees", + "rotations", + "maxPriceGenPerTimeUnit", + "storageFeeMaxGasPrice", + "receiptFeeMaxGasPrice", +] + +ADD_TRANSACTION_PARAMS_FIELDS = [ + "sender", + "recipient", + "numOfInitialValidators", + "maxRotations", + "validUntil", + "saltNonce", + "userValue", + "feesDistribution", + "txCalldata", + "messageAllocations", +] + class Boolean: """A sedes for booleans @@ -164,6 +336,8 @@ def _to_int(value: bytes) -> int: to_address = None nonce = signed_transaction_as_dict["nonce"] value = signed_transaction_as_dict["value"] + submitted_value = int(value) + fee_value = 0 # Some decoders return `data`, others return `input` input_raw = ( signed_transaction_as_dict.get("data") @@ -192,7 +366,7 @@ def _to_int(value: bytes) -> int: for abi_entry in contract_abi: if abi_entry["type"] == "function": # Calculate function selector from ABI - function_signature = f"{abi_entry['name']}({','.join([input['type'] for input in abi_entry['inputs']])})" + function_signature = f"{abi_entry['name']}({','.join([self._canonical_abi_type(input) for input in abi_entry['inputs']])})" calculated_selector = self.web3.keccak(text=function_signature)[ :4 ].hex() @@ -200,7 +374,8 @@ def _to_int(value: bytes) -> int: if calculated_selector == function_selector: # Decode parameters using the input types from ABI input_types = [ - input["type"] for input in abi_entry["inputs"] + self._canonical_abi_type(input) + for input in abi_entry["inputs"] ] decoded_params = self.web3.codec.decode( input_types, bytes.fromhex(parameters) @@ -221,25 +396,44 @@ def _to_int(value: bytes) -> int: # Convert the decoded data into proper dataclasses if decoded_data["function"] == "addTransaction": params = decoded_data["params"] - decoded_data = DecodedRollupTransactionData( - function_name=decoded_data["function"], - args=DecodedRollupTransactionDataArgs( - sender=to_checksum_address(params["_sender"]), - recipient=to_checksum_address( - params["_recipient"] - ), - num_of_initial_validators=params[ - "_numOfInitialValidators" - ], - max_rotations=params["_maxRotations"], - data=params["_txData"], - ), + decoded_data, value, fee_value = ( + self._decode_add_transaction_data( + decoded_data["function"], params, value + ) + ) + elif decoded_data["function"] == "deploySalted": + params = decoded_data["params"] + decoded_data, value, fee_value = ( + self._decode_add_transaction_data( + decoded_data["function"], params, value + ) ) elif decoded_data["function"] == "submitAppeal": params = decoded_data["params"] decoded_data = DecodedsubmitAppealDataArgs( tx_id=params["_txId"], ) + elif decoded_data["function"] == "topUpFees": + params = decoded_data["params"] + decoded_data = DecodedTopUpFeesDataArgs( + tx_id=params["_txId"], + fees_distribution=self._fees_distribution_to_dict( + params["_feesDistribution"] + ), + ) + fee_value = int(value) + value = 0 + elif decoded_data["function"] == "topUpAndSubmitAppeal": + params = decoded_data["params"] + decoded_data = DecodedsubmitAppealDataArgs( + tx_id=params["_txId"], + fees_distribution=self._fees_distribution_to_dict( + params["_feesDistribution"] + ), + top_up_and_submit=True, + ) + fee_value = int(value) + value = 0 return DecodedRollupTransaction( from_address=sender, @@ -248,6 +442,8 @@ def _to_int(value: bytes) -> int: type=signed_transaction_as_dict.get("type", 0), nonce=nonce, value=value, + fee_value=fee_value, + submitted_value=submitted_value, ) except Exception as e: @@ -484,7 +680,97 @@ def _vrs_from(self, signed_transaction) -> tuple: def _get_contract_abi(self) -> list: # Get contract ABI from consensus service contract_data = self.consensus_service.load_contract("ConsensusMain") - return contract_data["abi"] if contract_data else [] + contract_abi = list(contract_data["abi"]) if contract_data else [] + contract_abi.extend( + [ + FEE_AWARE_ADD_TRANSACTION_ABI, + FEE_AWARE_DEPLOY_SALTED_ABI, + FEE_AWARE_TOP_UP_FEES_ABI, + FEE_AWARE_TOP_UP_AND_SUBMIT_APPEAL_ABI, + ] + ) + return contract_abi + + def _canonical_abi_type(self, abi_input: dict) -> str: + input_type = abi_input["type"] + if not input_type.startswith("tuple"): + return input_type + + suffix = input_type[5:] + component_types = ",".join( + self._canonical_abi_type(component) + for component in abi_input.get("components", []) + ) + return f"({component_types}){suffix}" + + def _decode_add_transaction_data( + self, function_name: str, params: dict, msg_value: int + ) -> tuple[DecodedRollupTransactionData, int, int]: + if "_params" in params: + add_params = dict(zip(ADD_TRANSACTION_PARAMS_FIELDS, params["_params"])) + user_value = int(add_params["userValue"]) + fee_value = max(0, int(msg_value) - user_value) + return ( + DecodedRollupTransactionData( + function_name=function_name, + args=DecodedRollupTransactionDataArgs( + sender=to_checksum_address(add_params["sender"]), + recipient=to_checksum_address(add_params["recipient"]), + num_of_initial_validators=int( + add_params["numOfInitialValidators"] + ), + max_rotations=int(add_params["maxRotations"]), + data=add_params["txCalldata"], + valid_until=int(add_params["validUntil"]), + salt_nonce=int(add_params["saltNonce"]), + user_value=user_value, + fees_distribution=self._fees_distribution_to_dict( + add_params["feesDistribution"] + ), + message_allocations=[ + self._message_allocation_to_dict(allocation) + for allocation in add_params["messageAllocations"] + ], + message_allocations_count=len(add_params["messageAllocations"]), + ), + ), + user_value, + fee_value, + ) + + return ( + DecodedRollupTransactionData( + function_name=function_name, + args=DecodedRollupTransactionDataArgs( + sender=to_checksum_address(params["_sender"]), + recipient=to_checksum_address(params["_recipient"]), + num_of_initial_validators=int(params["_numOfInitialValidators"]), + max_rotations=int(params["_maxRotations"]), + data=params["_txData"], + ), + ), + int(msg_value), + 0, + ) + + def _fees_distribution_to_dict(self, fees_distribution: tuple) -> dict: + result = dict(zip(FEES_DISTRIBUTION_FIELDS, fees_distribution)) + result["rotations"] = [int(rotation) for rotation in result["rotations"]] + for key, value in result.items(): + if key != "rotations": + result[key] = int(value) + return result + + def _message_allocation_to_dict(self, message_allocation: tuple) -> dict: + return { + "messageType": int(message_allocation[0]), + "onAcceptance": bool(message_allocation[1]), + "parentIndex": int(message_allocation[2]), + "recipient": to_checksum_address(message_allocation[3]), + "callKey": eth_utils.to_hex(message_allocation[4]), + "budget": int(message_allocation[5]), + "feeParams": bytes(message_allocation[6]), + } class DeploymentContractTransactionPayload(rlp.Serializable): diff --git a/backend/protocol_rpc/types.py b/backend/protocol_rpc/types.py index d90781081..05325c2c1 100644 --- a/backend/protocol_rpc/types.py +++ b/backend/protocol_rpc/types.py @@ -30,6 +30,14 @@ def to_json(self) -> dict[str]: @dataclass class DecodedsubmitAppealDataArgs: tx_id: str + fees_distribution: dict | None = None + top_up_and_submit: bool = False + + +@dataclass +class DecodedTopUpFeesDataArgs: + tx_id: str + fees_distribution: dict @dataclass @@ -39,6 +47,12 @@ class DecodedRollupTransactionDataArgs: num_of_initial_validators: int max_rotations: int data: str + valid_until: int | None = None + salt_nonce: int = 0 + user_value: int | None = None + fees_distribution: dict | None = None + message_allocations: list[dict] = field(default_factory=list) + message_allocations_count: int = 0 @dataclass @@ -51,10 +65,23 @@ class DecodedRollupTransactionData: class DecodedRollupTransaction: from_address: str to_address: str - data: DecodedRollupTransactionData | DecodedsubmitAppealDataArgs + data: ( + DecodedRollupTransactionData + | DecodedsubmitAppealDataArgs + | DecodedTopUpFeesDataArgs + | None + ) type: str nonce: int value: int + fee_value: int = 0 + submitted_value: int | None = None + + @property + def total_spend(self) -> int: + if self.submitted_value is not None: + return self.submitted_value + return self.value + self.fee_value @dataclass diff --git a/docker-compose.yml b/docker-compose.yml index 3e84651eb..7552fcafb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -125,18 +125,25 @@ services: - RATE_LIMIT_ANON_PER_MINUTE=${RATE_LIMIT_ANON_PER_MINUTE:-30} - RATE_LIMIT_ANON_PER_HOUR=${RATE_LIMIT_ANON_PER_HOUR:-500} - RATE_LIMIT_ANON_PER_DAY=${RATE_LIMIT_ANON_PER_DAY:-5000} + - 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_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:-} - MAX_PENDING_PER_SENDER_DEFAULT=${MAX_PENDING_PER_SENDER_DEFAULT:-} ports: - - "${RPCPORT}:${RPCPORT}" + - "${RPCHOSTPORT:-4000}:${RPCPORT:-4000}" expose: - "${RPCPORT}" volumes: - ./.env:/app/.env - ./backend:/app/backend + - ${GENVM_CACHE_DIR:-genvm_cache}:/genvm-cache # - hardhat_artifacts:/app/hardhat/artifacts # - hardhat_deployments:/app/hardhat/deployments depends_on: @@ -246,7 +253,7 @@ services: image: postgres:16-alpine command: sh -c "if [ \"$REMOTE_DATABASE\" = \"true\" ]; then echo 'Postgres disabled in hosted environment' && exec tail -f /dev/null; else exec docker-entrypoint.sh postgres; fi" ports: - - "${DBPORT}:5432" + - "${DBHOSTPORT:-5432}:5432" environment: - POSTGRES_USER=${DBUSER} - POSTGRES_PASSWORD=${DBPASSWORD} @@ -301,9 +308,16 @@ 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_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} volumes: - ./.env:/app/.env - ./backend:/app/backend + - ${GENVM_CACHE_DIR:-genvm_cache}:/genvm-cache depends_on: database-migration: condition: service_completed_successfully @@ -375,7 +389,7 @@ services: redis: image: redis:8-alpine ports: - - "6379:6379" + - "${REDISPORT:-6379}:6379" volumes: - redis_data:/data healthcheck: @@ -422,5 +436,6 @@ volumes: # hardhat_deployments: ignition_deployments: # hardhat_snapshots: + genvm_cache: postgres_data: redis_data: diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index 83bfe0c45..280816d2b 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -3,7 +3,7 @@ FROM ubuntu:24.04 AS base ARG TARGETPLATFORM ARG TARGETARCH -ARG GENVM_TAG=v0.2.16 +ARG GENVM_TAG=v0.3.0-rc1 ENV GENVM_TAG=$GENVM_TAG @@ -46,32 +46,30 @@ ENV HUGGINGFACE_HUB_CACHE /home/backend-user/.cache/huggingface ENV RUST_BACKTRACE=1 -ADD \ - https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/genvm-linux-amd64.tar.xz \ - /genvm/genvm-linux-amd64.tar.xz - -ADD \ - https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/genvm-linux-arm64.tar.xz \ - /genvm/genvm-linux-arm64.tar.xz - -ADD \ - https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/genvm-universal.tar.xz \ - /genvm/genvm-universal.tar.xz - -# Extract and prepare GenVM binaries +# Download and extract GenVM binaries. Keep downloads in the extraction layer so +# architecture tarballs do not remain in lower image layers. RUN cd /genvm \ - && if [[ "$TARGETPLATFORM" == "linux/amd64" ]] ; \ - then \ - tar -xf genvm-linux-amd64.tar.xz ; \ + && if [[ "$TARGETPLATFORM" == "linux/amd64" ]] || [[ -z "$TARGETPLATFORM" ]]; then \ + ARCH_FILE="genvm-linux-amd64.tar.xz" ; \ elif [[ "$TARGETPLATFORM" == "linux/arm64" ]] ; \ then \ - tar -xf genvm-linux-arm64.tar.xz ; \ + ARCH_FILE="genvm-linux-arm64.tar.xz" ; \ else \ echo "Sorry, $TARGETPLATFORM is not supported yet" ; exit 1 ; \ fi \ - && tar -xf genvm-universal.tar.xz \ + && curl -L --fail --retry 3 --retry-delay 2 \ + --connect-timeout 10 --max-time 300 \ + --progress-bar \ + -o "$ARCH_FILE" \ + "https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/$ARCH_FILE" \ + && curl -L --fail --retry 3 --retry-delay 2 \ + --connect-timeout 10 --max-time 300 \ + --progress-bar \ + -o "genvm-runners-all.tar.xz" \ + "https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/genvm-runners-all.tar.xz" \ + && tar -xf "$ARCH_FILE" \ + && tar -xf genvm-runners-all.tar.xz \ && rm *.tar.xz \ - && ls -R . \ && chown -R backend-user:backend-group /genvm \ && su - backend-user -c "/genvm/bin/post-install.py --precompile false" \ && find /genvm -name genvm.yaml -exec sed -i 's|cache_dir:.*|cache_dir: /genvm-cache/|' {} + \ diff --git a/docker/Dockerfile.consensus-worker b/docker/Dockerfile.consensus-worker index 337630548..0e812e38b 100644 --- a/docker/Dockerfile.consensus-worker +++ b/docker/Dockerfile.consensus-worker @@ -4,7 +4,7 @@ FROM ubuntu:24.04 AS base ARG TARGETPLATFORM ARG TARGETARCH -ARG GENVM_TAG=v0.2.16 +ARG GENVM_TAG=v0.3.0-rc1 ENV GENVM_TAG=$GENVM_TAG ARG path=/app @@ -66,13 +66,13 @@ RUN cd /genvm \ -o "$ARCH_FILE" \ "https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/$ARCH_FILE" \ && echo "✓ Downloaded $ARCH_FILE" \ - && echo "Downloading genvm-universal.tar.xz..." \ + && echo "Downloading genvm-runners-all.tar.xz..." \ && curl -L --fail --retry 3 --retry-delay 2 \ --connect-timeout 10 --max-time 300 \ --progress-bar \ - -o "genvm-universal.tar.xz" \ - "https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/genvm-universal.tar.xz" \ - && echo "✓ Downloaded genvm-universal.tar.xz" \ + -o "genvm-runners-all.tar.xz" \ + "https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/genvm-runners-all.tar.xz" \ + && echo "✓ Downloaded genvm-runners-all.tar.xz" \ && echo "Verifying downloads..." \ && ls -lah *.tar.xz \ && for f in *.tar.xz; do \ @@ -82,7 +82,7 @@ RUN cd /genvm \ done \ && echo "Extracting archives..." \ && tar -xf "$ARCH_FILE" \ - && tar -xf "genvm-universal.tar.xz" \ + && tar -xf "genvm-runners-all.tar.xz" \ && rm *.tar.xz \ && echo "Configuring GenVM manager threads..." \ && CONFIG_FILE="/genvm/config/genvm-manager.yaml" \ diff --git a/docker/entrypoint-backend.sh b/docker/entrypoint-backend.sh index eeefb54e8..1cd457791 100644 --- a/docker/entrypoint-backend.sh +++ b/docker/entrypoint-backend.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -CACHE_MARKER="/genvm-cache/pc/.precompiled" +CACHE_MARKER="/genvm-cache/pc/.precompiled-${GENVM_TAG:-unknown}-$(uname -m)" if [ -f "$CACHE_MARKER" ]; then echo "GenVM already precompiled for this host, skipping." diff --git a/docker/entrypoint-consensus-worker.sh b/docker/entrypoint-consensus-worker.sh index fb261836f..bbcc61a25 100755 --- a/docker/entrypoint-consensus-worker.sh +++ b/docker/entrypoint-consensus-worker.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -CACHE_MARKER="/genvm-cache/pc/.precompiled" +CACHE_MARKER="/genvm-cache/pc/.precompiled-${GENVM_TAG:-unknown}-$(uname -m)" if [ -f "$CACHE_MARKER" ]; then echo "GenVM ${GENVM_TAG} already precompiled for this host, skipping." diff --git a/explorer/eslint.config.mjs b/explorer/eslint.config.mjs index 05e726d1b..56b53789e 100644 --- a/explorer/eslint.config.mjs +++ b/explorer/eslint.config.mjs @@ -5,6 +5,11 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + rules: { + "react-hooks/set-state-in-effect": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/explorer/package.json b/explorer/package.json index 9e1687da6..9353ff02d 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test:fee-accounting": "node scripts/test-fee-accounting.mjs" }, "dependencies": { "@radix-ui/react-collapsible": "^1.1.12", @@ -36,7 +37,7 @@ "@types/node": "^24.0.0", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^10.0.0", + "eslint": "^9.39.4", "eslint-config-next": "16.1.6", "tailwindcss": "^4", "typescript": "^5" diff --git a/explorer/scripts/test-fee-accounting.mjs b/explorer/scripts/test-fee-accounting.mjs new file mode 100644 index 000000000..0ba5681e7 --- /dev/null +++ b/explorer/scripts/test-fee-accounting.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import Module from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const sourcePath = path.resolve(__dirname, '../src/lib/feeAccounting.ts'); +const source = fs.readFileSync(sourcePath, 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { + esModuleInterop: true, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: sourcePath, +}); + +const testModule = new Module(sourcePath); +testModule.filename = sourcePath; +testModule.paths = Module._nodeModulePaths(path.dirname(sourcePath)); +testModule._compile(compiled.outputText, sourcePath); + +const { + feeBucketRows, + feeDistributionRows, + feeMetricRows, + feeRecommendedObservedRows, + feeRecommendedPresetRows, + formatFeeAmount, + formatFeeParamsDecoded, + formatInteger, + getStudioFeeAccounting, + toBigIntAmount, +} = testModule.exports; + +function rowMap(rows) { + return Object.fromEntries(rows.map((row) => [row.label, row.value])); +} + +const WEI_PER_GEN = '1000000000000000000'; +const accounting = { + status: 'active', + paid_fee_value: '120000000000000000', + required_fee_value: '110000000000000000', + primary_fee_budget: '100000000000000000', + primary_fee_spent: '90000000000000000', + primary_fee_refunded: '10000000000000000', + execution_budget_total: '100000000000000000', + execution_fee_consumed: '90000000000000000', + genvm_message_fee_consumed: '1234', + message_fee_budget: '55000000000000000', + message_fee_consumed: '55000000000000000', + message_fee_refunded: '0', + external_message_fee_reserved: '700', + external_message_fee_reimbursed: '420', + external_message_fee_remainder: '280', + appeal_bonds_total: '1400000000000000000', + total_refunded: '10000000000000000', + fees_distribution: { + leaderTimeunitsAllocation: '100', + validatorTimeunitsAllocation: '200', + appealRounds: '1', + executionBudgetPerRound: '50000000000000000', + executionConsumed: '90000000000000000', + totalMessageFees: '55000000000000000', + rotations: ['0', '2'], + maxPriceGenPerTimeUnit: '1000000000000000', + storageFeeMaxGasPrice: '1', + receiptFeeMaxGasPrice: '1', + }, + recommended_fee_preset: { + feeValue: '132000000000000000', + paddingBps: '12000', + numOfInitialValidators: '5', + messageBudgetMode: 'allocation-preserved', + messageAllocations: [{ messageType: 1, budget: '55000000000000000' }], + distribution: { + leaderTimeunitsAllocation: '120', + validatorTimeunitsAllocation: '240', + appealRounds: '2', + executionBudgetPerRound: '60000000000000000', + executionConsumed: '0', + totalMessageFees: '55000000000000000', + rotations: ['0', '1', '1'], + maxPriceGenPerTimeUnit: '1000000000000000', + storageFeeMaxGasPrice: '1', + receiptFeeMaxGasPrice: '1', + }, + observed: { + executionFee: '90000000000000000', + messageFeeBudget: '55000000000000000', + declaredMessageFees: '55000000000000000', + externalMessageReserved: '700', + totalEstimatedFee: '145000000000000000', + totalStudioMeteredFee: '145000000000000000', + }, + }, +}; + +assert.equal(toBigIntAmount('42'), 42n); +assert.equal(toBigIntAmount(42.9), 42n); +assert.equal(toBigIntAmount('not-a-number'), null); +assert.equal(formatInteger('1000000'), '1,000,000'); +assert.equal(formatFeeAmount('999'), '999 wei'); +assert.equal( + formatFeeAmount('1000000000000000'), + '0.001 GEN (1,000,000,000,000,000 wei)', +); +assert.equal( + formatFeeAmount(WEI_PER_GEN), + '1 GEN (1,000,000,000,000,000,000 wei)', +); +assert.equal(formatFeeParamsDecoded(null), '-'); +assert.equal(formatFeeParamsDecoded({}), '-'); +assert.equal( + formatFeeParamsDecoded({ + leaderTimeunitsAllocation: 5, + validatorTimeunitsAllocation: 10, + appealRounds: 0, + executionBudgetPerRound: 0, + rotations: [0, 1], + }), + 'Leader 5, Validator 10, Appeals 0, Exec budget 0 wei, Rotations 0 / 1', +); +assert.equal( + formatFeeParamsDecoded({ + gasLimit: '21000', + maxGasPrice: '1000000000000000', + }), + 'Gas limit 21,000, Max gas price 0.001 GEN (1,000,000,000,000,000 wei)', +); +assert.equal(formatFeeParamsDecoded({ zeta: 'x', alpha: 3 }), 'alpha 3, zeta x'); + +assert.deepEqual( + getStudioFeeAccounting({ + data: { fee_accounting: accounting }, + consensus_data: { fee_accounting: { status: 'ignored' } }, + }), + accounting, +); +assert.deepEqual( + getStudioFeeAccounting({ + data: {}, + consensus_data: { fee_accounting: accounting }, + }), + accounting, +); +assert.deepEqual( + getStudioFeeAccounting({ + data: {}, + consensus_data: { + leader_receipt: [{ genvm_result: { fee_accounting: accounting } }], + }, + }), + accounting, +); +assert.equal(getStudioFeeAccounting({ data: {}, consensus_data: {} }), null); + +const metrics = rowMap(feeMetricRows(accounting)); +assert.equal(metrics['Paid fee'], '0.120 GEN (120,000,000,000,000,000 wei)'); +assert.equal(metrics['Message budget'], '0.055 GEN (55,000,000,000,000,000 wei)'); +assert.equal(metrics['GenVM message meter'], '1,234 wei'); +assert.equal(metrics['External reimbursed'], '420 wei'); +assert.equal(metrics['Appeal bonds'], '1.400 GEN (1,400,000,000,000,000,000 wei)'); + +const distribution = rowMap(feeDistributionRows(accounting)); +assert.equal(distribution['Leader time units'], '100'); +assert.equal(distribution.Rotations, '0 / 2'); +assert.equal( + distribution['Execution budget per round'], + '0.050 GEN (50,000,000,000,000,000 wei)', +); +assert.equal(distribution['Max price per time unit'], '0.001 GEN (1,000,000,000,000,000 wei)'); + +const recommended = rowMap(feeRecommendedPresetRows(accounting)); +assert.equal(recommended['Fee value'], '0.132 GEN (132,000,000,000,000,000 wei)'); +assert.equal(recommended.Padding, '12,000 bps'); +assert.equal(recommended.Validators, '5'); +assert.equal(recommended['Message budget mode'], 'allocation-preserved'); +assert.equal(recommended['Message allocations'], '1'); + +const observed = rowMap(feeRecommendedObservedRows(accounting)); +assert.equal(observed['Execution fee'], '0.090 GEN (90,000,000,000,000,000 wei)'); +assert.equal(observed['External reserved'], '700 wei'); +assert.equal(observed['Studio metered fee'], '0.145 GEN (145,000,000,000,000,000 wei)'); + +const zeroBudgetBuckets = rowMap( + feeBucketRows({ + receiptAndNondetOutput: '1', + storage: '0', + message: '0', + totalExecution: '1', + totalWithMessage: '1', + executionBudgetPerRound: '0', + executionBudgetRemaining: '0', + executionBudgetOverrun: '1', + executionBudgetExceeded: true, + }), +); +assert.equal(zeroBudgetBuckets['Receipt/nondet used'], '1 wei'); +assert.equal(zeroBudgetBuckets['Execution budget'], '0 wei'); +assert.equal(zeroBudgetBuckets['Budget remaining'], '0 wei'); +assert.equal(zeroBudgetBuckets['Budget overrun'], '1 wei'); +assert.equal(zeroBudgetBuckets['Budget exceeded'], 'true'); +assert.equal(zeroBudgetBuckets['Message meter'], '0 wei'); + +console.log('feeAccounting helper tests passed'); diff --git a/explorer/src/app/address/[addr]/AddressContent.tsx b/explorer/src/app/address/[addr]/AddressContent.tsx index d10a49ea1..06f05c311 100644 --- a/explorer/src/app/address/[addr]/AddressContent.tsx +++ b/explorer/src/app/address/[addr]/AddressContent.tsx @@ -7,8 +7,6 @@ import { Transaction, Validator, CurrentState } from '@/lib/types'; import { AddressTransactionTable } from '@/components/AddressTransactionTable'; import { CopyButton } from '@/components/CopyButton'; import { AddressDisplay } from '@/components/AddressDisplay'; -import { CodeBlock } from '@/components/CodeBlock'; -import { JsonViewer } from '@/components/JsonViewer'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; @@ -302,4 +300,3 @@ function AddressHeader({ title, address, backHref, icon, iconBg }: { ); } - diff --git a/explorer/src/app/address/[addr]/page.tsx b/explorer/src/app/address/[addr]/page.tsx index e1b71547c..b4200f1e7 100644 --- a/explorer/src/app/address/[addr]/page.tsx +++ b/explorer/src/app/address/[addr]/page.tsx @@ -7,13 +7,18 @@ import { ArrowLeft } from 'lucide-react'; export default async function AddressPage({ params }: { params: Promise<{ addr: string }> }) { const { addr } = await params; + let data: AddressInfo | null = null; + let error: unknown = null; try { - const data = await fetchBackend( + data = await fetchBackend( `/address/${encodeURIComponent(addr)}`, ); - return ; } catch (err) { + error = err; + } + + if (error || !data) { return (
); } + + return ; } diff --git a/explorer/src/app/contracts/page.tsx b/explorer/src/app/contracts/page.tsx index 44cf6f6fd..00002e4aa 100644 --- a/explorer/src/app/contracts/page.tsx +++ b/explorer/src/app/contracts/page.tsx @@ -68,7 +68,7 @@ function StateContent() { } }; - const SortIcon = ({ column }: { column: string }) => { + const renderSortIcon = (column: string) => { if (sortBy !== column) return ; return sortOrder === 'asc' ? @@ -103,17 +103,17 @@ function StateContent() { Balance diff --git a/explorer/src/app/providers/page.tsx b/explorer/src/app/providers/page.tsx index dbc3a7555..61dd4d405 100644 --- a/explorer/src/app/providers/page.tsx +++ b/explorer/src/app/providers/page.tsx @@ -4,19 +4,27 @@ import { ProvidersContent } from './ProvidersContent'; import { Card, CardContent } from '@/components/ui/card'; export default async function ProvidersPage() { + let data: { providers: LLMProvider[] } | null = null; + let error: unknown = null; + try { - const data = await fetchBackend<{ providers: LLMProvider[] }>('/providers'); - return ; + data = await fetchBackend<{ providers: LLMProvider[] }>('/providers'); } catch (err) { + error = err; + } + + if (error || !data) { return (

Error loading providers

- {err instanceof Error ? err.message : 'Unknown error'} + {error instanceof Error ? error.message : 'Unknown error'}

); } + + return ; } diff --git a/explorer/src/app/tx/[hash]/components/OverviewTab.tsx b/explorer/src/app/tx/[hash]/components/OverviewTab.tsx index dc9a467ab..4b369a1ff 100644 --- a/explorer/src/app/tx/[hash]/components/OverviewTab.tsx +++ b/explorer/src/app/tx/[hash]/components/OverviewTab.tsx @@ -9,12 +9,16 @@ import { ConsensusJourney } from '@/components/ConsensusJourney'; import { InfoRow } from '@/components/InfoRow'; import { Badge } from '@/components/ui/badge'; import { JsonViewer } from '@/components/JsonViewer'; -import { getExecutionResult, getConsensusRoundResult } from '@/lib/transactionUtils'; +import { + getExecutionResult, + getConsensusRoundResult, +} from '@/lib/transactionUtils'; import { ConsensusResultBadge } from '@/components/ConsensusResultBadge'; import { resultStatusLabel, type DecodedResult } from '@/lib/resultDecoder'; import { InputDataPanel } from '@/components/InputDataPanel'; import { DataDecodePanel } from '@/components/DataDecodePanel'; import { formatGenValue } from '@/lib/formatters'; +import { FeeAccountingPanel } from '@/components/FeeAccountingPanel'; interface OverviewTabProps { transaction: Transaction; @@ -100,13 +104,17 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { const eqOutputs = execResult?.eqOutputs; const dataObj = - tx.data && typeof tx.data === 'object' ? (tx.data as Record) : null; + tx.data && typeof tx.data === 'object' + ? (tx.data as Record) + : null; const calldataB64 = (tx.type === 1 || tx.type === 2) && dataObj ? (dataObj.calldata as string | undefined) : undefined; const contractCodeB64 = - tx.type === 1 && dataObj ? (dataObj.contract_code as string | undefined) : undefined; + tx.type === 1 && dataObj + ? (dataObj.contract_code as string | undefined) + : undefined; return (
@@ -117,7 +125,10 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { label="From" value={ tx.from_address ? ( - + {tx.from_address} ) : ( @@ -131,7 +142,10 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { label="To" value={ tx.to_address ? ( - + {tx.to_address} ) : ( @@ -142,8 +156,14 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { copyText={tx.to_address || undefined} /> - - + + Leader Only + + Leader Only + ) : tx.execution_mode === 'LEADER_SELF_VALIDATOR' ? ( - Leader + Self Validator + + Leader + Self Validator + ) : ( - Normal + + Normal + ) } /> - - + + : '-'} + value={ + consensusRound ? ( + + ) : ( + '-' + ) + } /> {tx.worker_id && } + + {contractCodeB64 && dataObj && (
-

Input Data

+

+ Input Data +

{/* Deploy: show both constructor calldata and contract source */}
@@ -178,7 +220,9 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { {calldataB64 && !contractCodeB64 && (
-

Input Data

+

+ Input Data +

)} @@ -187,16 +231,22 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { {(executionResult || genvmResult || decodedResult) && ( <>
-

GenVM Execution

+

+ GenVM Execution +

{executionResult && ( SUCCESS + + SUCCESS + ) : ( - {executionResult} + + {executionResult} + ) } /> @@ -213,7 +263,11 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { {genvmResult?.stdout !== undefined && ( (empty)} + value={ + genvmResult.stdout || ( + (empty) + ) + } copyable={!!genvmResult.stdout} copyText={genvmResult.stdout} /> @@ -247,19 +301,24 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { {Object.entries(eqOutputs).map(([key, decoded]) => (
- {key} + + {key} +
{decoded.payload != null && (
{typeof decoded.payload === 'object' && decoded.payload !== null && - 'readable' in (decoded.payload as Record) ? ( + 'readable' in + (decoded.payload as Record) ? ( {(decoded.payload as { readable: string }).readable} ) : typeof decoded.payload === 'string' ? ( - {decoded.payload} + + {decoded.payload} + ) : ( )} diff --git a/explorer/src/app/validators/page.tsx b/explorer/src/app/validators/page.tsx index a797d4f09..bd333a58b 100644 --- a/explorer/src/app/validators/page.tsx +++ b/explorer/src/app/validators/page.tsx @@ -4,19 +4,27 @@ import { ValidatorsContent } from './ValidatorsContent'; import { Card, CardContent } from '@/components/ui/card'; export default async function ValidatorsPage() { + let data: { validators: Validator[] } | null = null; + let error: unknown = null; + try { - const data = await fetchBackend<{ validators: Validator[] }>('/validators'); - return ; + data = await fetchBackend<{ validators: Validator[] }>('/validators'); } catch (err) { + error = err; + } + + if (error || !data) { return (

Error loading validators

- {err instanceof Error ? err.message : 'Unknown error'} + {error instanceof Error ? error.message : 'Unknown error'}

); } + + return ; } diff --git a/explorer/src/components/FeeAccountingPanel.tsx b/explorer/src/components/FeeAccountingPanel.tsx new file mode 100644 index 000000000..ee6940bc4 --- /dev/null +++ b/explorer/src/components/FeeAccountingPanel.tsx @@ -0,0 +1,363 @@ +'use client'; + +import type { Transaction } from '@/lib/types'; +import { + feeBucketRows, + feeDistributionRows, + feeMetricRows, + feeRecommendedObservedRows, + feeRecommendedPresetRows, + formatFeeAmount, + formatFeeParamsDecoded, + formatInteger, + getStudioFeeAccounting, +} from '@/lib/feeAccounting'; +import { truncateAddress, truncateHash } from '@/lib/formatters'; + +interface FeeAccountingPanelProps { + transaction: Transaction; +} + +export function FeeAccountingPanel({ transaction }: FeeAccountingPanelProps) { + const accounting = getStudioFeeAccounting(transaction); + if (!accounting) return null; + + const report = accounting.execution_fee_report; + const messages = report?.messageReveal?.messages ?? []; + const genvmBuckets = report?.genvmBuckets ?? accounting.genvm_fee_bucket_report; + const messageFees = report?.messageFees; + const executionMetering = report?.executionMetering; + const metricRows = feeMetricRows(accounting); + const distributionRows = feeDistributionRows(accounting); + const recommendedRows = feeRecommendedPresetRows(accounting); + const observedRows = feeRecommendedObservedRows(accounting); + const chargeableBucketRows = feeBucketRows(report?.chargeableExecution); + const genvmBucketRows = feeBucketRows(genvmBuckets); + + return ( +
+

Fees

+ +
+ {metricRows.map((row) => ( +
+
{row.label}
+
+ {row.value} +
+
+ ))} +
+ + {distributionRows.length > 0 && ( +
+ {distributionRows.map((row) => ( +
+
+ {row.label} +
+
+ {row.value} +
+
+ ))} +
+ )} + + {recommendedRows.length > 0 && ( +
+
+
+ Recommended Preset +
+ {recommendedRows.map((row) => ( + + ))} +
+ + {observedRows.length > 0 && ( +
+
+ Observed Usage +
+ {observedRows.map((row) => ( + + ))} +
+ )} +
+ )} + + {report && ( +
+ {report.proposalReceipt && ( +
+
+ Proposal Receipt +
+ + + +
+ )} + + {report.messageReveal && ( +
+
+ Message Reveal +
+ + + + + + + + +
+ )} + +
+
+ Execution Report +
+ + + {report.totalStudioMeteredFee !== undefined && ( + + )} + {report.budgetExhaustionReason && ( + + )} + {messageFees && ( + <> + + + {messageFees.externalReserved !== undefined && ( + + )} + {messageFees.externalReimbursed !== undefined && ( + + )} + {messageFees.externalRemainder !== undefined && ( + + )} + {messageFees.totalConsumed !== undefined && ( + + )} + {messageFees.reportedTotal !== undefined && ( + + )} + + + + + )} + {executionMetering && ( + <> + + + + + )} + {chargeableBucketRows.length > 0 && ( + <> + Chargeable Buckets + {chargeableBucketRows.map((row) => ( + + ))} + + )} + {genvmBucketRows.length > 0 && ( + <> + GenVM Raw Buckets + {genvmBucketRows.map((row) => ( + + ))} + + )} +
+
+ )} + + {messages.length > 0 && ( +
+ + + + + + + + + + + + + + + + + {messages.map((message, index) => ( + + + + + + + + + + + + + ))} + +
TypeModeRecipientValueDataFee ParamsDeclared BudgetAllocationOnCall Key
{message.messageType}{message.messageFeeMode ?? '-'} + {truncateAddress(message.recipient)} + + {formatFeeAmount(message.value)} + + {formatInteger(message.dataBytes)} B + + {formatInteger(message.feeParamsBytes)} B + {message.feeParams && message.feeParams !== '0x' && ( + + {truncateHash(message.feeParams)} + + )} + {formatFeeParamsDecoded(message.feeParamsDecoded) !== '-' && ( + + {formatFeeParamsDecoded(message.feeParamsDecoded)} + + )} + + {formatFeeAmount(message.declaredBudget)} + + {formatInteger(message.allocationSubtreeBytes)} B + {message.allocationSubtree && + message.allocationSubtree !== '0x' && ( + + {truncateHash(message.allocationSubtree)} + + )} + + {message.onAcceptance ? 'accepted' : 'finalized'} + + {truncateHash(message.callKey)} +
+
+ )} +
+ ); +} + +function ReportRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function SectionLabel({ children }: { children: string }) { + return ( +
+ {children} +
+ ); +} diff --git a/explorer/src/components/GlobalSearch.tsx b/explorer/src/components/GlobalSearch.tsx index 65cbd55c6..43e5839ad 100644 --- a/explorer/src/components/GlobalSearch.tsx +++ b/explorer/src/components/GlobalSearch.tsx @@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { StatusBadge } from '@/components/StatusBadge'; import { Badge } from '@/components/ui/badge'; import { truncateHash, truncateAddress } from '@/lib/formatters'; -import type { Transaction, CurrentState, Validator, TransactionStatus } from '@/lib/types'; +import type { Transaction, CurrentState, Validator } from '@/lib/types'; interface SearchResults { transactions: Transaction[]; diff --git a/explorer/src/lib/feeAccounting.ts b/explorer/src/lib/feeAccounting.ts new file mode 100644 index 000000000..462d2aaad --- /dev/null +++ b/explorer/src/lib/feeAccounting.ts @@ -0,0 +1,275 @@ +import type { StudioFeeAccounting, StudioGenvmFeeBucketReport, Transaction } from './types'; + +export type FeeAccountingRow = { + label: string; + value: string; +}; + +const amountDistributionLabels = new Set([ + 'Execution budget per round', + 'Message fee budget', + 'Max price per time unit', + 'Storage gas price', + 'Receipt gas price', +]); + +const recommendedPresetFeeLabels = new Set([ + 'Fee value', + 'Execution budget per round', + 'Message fee budget', + 'Max price per time unit', + 'Storage gas price', + 'Receipt gas price', +]); + +const feeParamsDecodedLabels: Record = { + leaderTimeunitsAllocation: 'Leader', + validatorTimeunitsAllocation: 'Validator', + appealRounds: 'Appeals', + executionBudgetPerRound: 'Exec budget', + rotations: 'Rotations', + gasLimit: 'Gas limit', + maxGasPrice: 'Max gas price', +}; + +const feeParamsDecodedOrder = Object.keys(feeParamsDecodedLabels); +const feeParamsDecodedFeeKeys = new Set(['executionBudgetPerRound', 'maxGasPrice']); +const feeParamsDecodedIntegerKeys = new Set([ + 'leaderTimeunitsAllocation', + 'validatorTimeunitsAllocation', + 'appealRounds', + 'gasLimit', + 'rotations', +]); + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +function isNonEmptyRecord(value: unknown): value is Record { + const record = asRecord(value); + return Boolean(record && Object.keys(record).length > 0); +} + +export function getStudioFeeAccounting(tx: Transaction): StudioFeeAccounting | null { + const data = asRecord(tx.data); + const consensusData = asRecord(tx.consensus_data); + const leaderReceipts = consensusData?.leader_receipt; + const leaderReceipt = Array.isArray(leaderReceipts) ? asRecord(leaderReceipts[0]) : null; + const genvmResult = asRecord(leaderReceipt?.genvm_result); + const candidates = [ + data?.fee_accounting, + consensusData?.fee_accounting, + genvmResult?.fee_accounting, + ]; + const found = candidates.find(isNonEmptyRecord); + return found ? (found as StudioFeeAccounting) : null; +} + +export function toBigIntAmount(value: unknown): bigint | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'bigint') return value; + if (typeof value === 'number') { + return Number.isFinite(value) ? BigInt(Math.trunc(value)) : null; + } + if (typeof value === 'string') { + try { + return BigInt(value.trim()); + } catch { + return null; + } + } + return null; +} + +export function formatInteger(value: unknown): string { + const amount = toBigIntAmount(value); + return amount === null ? '-' : amount.toLocaleString(); +} + +function formatGenFromWei(wei: bigint): string { + const zero = BigInt(0); + const weiPerGen = BigInt('1000000000000000000'); + const negative = wei < zero; + const absWei = negative ? -wei : wei; + const whole = absWei / weiPerGen; + const remainder = absWei % weiPerGen; + const sign = negative ? '-' : ''; + + if (remainder === zero) return `${sign}${whole.toLocaleString()}`; + + const fraction = remainder.toString().padStart(18, '0'); + const trimmed = fraction.replace(/0+$/, ''); + const decimals = Math.min(6, Math.max(3, trimmed.length)); + return `${sign}${whole.toLocaleString()}.${fraction.slice(0, decimals)}`; +} + +export function formatFeeAmount(value: unknown): string { + const amount = toBigIntAmount(value); + if (amount === null) return '-'; + + const raw = `${amount.toLocaleString()} wei`; + const zero = BigInt(0); + const absAmount = amount < zero ? -amount : amount; + if (absAmount < BigInt('1000000000000')) return raw; + + return `${formatGenFromWei(amount)} GEN (${raw})`; +} + +function formatFeeParamsDecodedValue(key: string, value: unknown): string { + if (Array.isArray(value)) { + return value + .map((item) => formatFeeParamsDecodedValue(key, item)) + .join(' / '); + } + + if (feeParamsDecodedFeeKeys.has(key)) return formatFeeAmount(value); + if (feeParamsDecodedIntegerKeys.has(key)) return formatInteger(value); + return String(value); +} + +export function formatFeeParamsDecoded(value: unknown): string { + const record = asRecord(value); + if (!record || Object.keys(record).length === 0) return '-'; + + const orderedKeys = [ + ...feeParamsDecodedOrder.filter((key) => key in record), + ...Object.keys(record) + .filter((key) => !(key in feeParamsDecodedLabels)) + .sort(), + ]; + + const rows = orderedKeys + .filter((key) => record[key] !== undefined && record[key] !== null) + .map((key) => { + const label = feeParamsDecodedLabels[key] ?? key; + return `${label} ${formatFeeParamsDecodedValue(key, record[key])}`; + }); + + return rows.length > 0 ? rows.join(', ') : '-'; +} + +export function feeMetricRows(accounting: StudioFeeAccounting): FeeAccountingRow[] { + return [ + ['Paid fee', accounting.paid_fee_value], + ['Required fee', accounting.required_fee_value], + ['Primary budget', accounting.primary_fee_budget], + ['Primary spent', accounting.primary_fee_spent], + ['Primary refunded', accounting.primary_fee_refunded], + ['Execution budget', accounting.execution_budget_total], + ['Execution consumed', accounting.execution_fee_consumed], + ['GenVM message meter', accounting.genvm_message_fee_consumed], + ['Message budget', accounting.message_fee_budget], + ['Declared message spent', accounting.message_fee_consumed], + ['Declared message refunded', accounting.message_fee_refunded], + ['External reserved', accounting.external_message_fee_reserved], + ['External reimbursed', accounting.external_message_fee_reimbursed], + ['External remainder', accounting.external_message_fee_remainder], + ['Appeal bonds', accounting.appeal_bonds_total], + ['Total refunded', accounting.total_refunded], + ] + .filter(([, value]) => value !== undefined && value !== null) + .map(([label, value]) => ({ + label: String(label), + value: formatFeeAmount(value), + })); +} + +export function feeDistributionRows(accounting: StudioFeeAccounting): FeeAccountingRow[] { + const distribution = accounting.fees_distribution; + if (!distribution) return []; + return [ + ['Leader time units', distribution.leaderTimeunitsAllocation], + ['Validator time units', distribution.validatorTimeunitsAllocation], + ['Appeal rounds', distribution.appealRounds], + ['Rotations', distribution.rotations?.join(' / ')], + ['Execution budget per round', distribution.executionBudgetPerRound], + ['Message fee budget', distribution.totalMessageFees], + ['Max price per time unit', distribution.maxPriceGenPerTimeUnit], + ['Storage gas price', distribution.storageFeeMaxGasPrice], + ['Receipt gas price', distribution.receiptFeeMaxGasPrice], + ] + .filter(([, value]) => value !== undefined && value !== null) + .map(([label, value]) => ({ + label: String(label), + value: amountDistributionLabels.has(String(label)) + ? formatFeeAmount(value) + : String(value), + })); +} + +export function feeRecommendedPresetRows( + accounting: StudioFeeAccounting, +): FeeAccountingRow[] { + const preset = accounting.recommended_fee_preset; + const distribution = preset?.distribution; + if (!preset || !distribution) return []; + + return [ + ['Fee value', preset.feeValue], + ['Padding', preset.paddingBps ? `${formatInteger(preset.paddingBps)} bps` : null], + ['Validators', preset.numOfInitialValidators], + ['Leader time units', distribution.leaderTimeunitsAllocation], + ['Validator time units', distribution.validatorTimeunitsAllocation], + ['Appeal rounds', distribution.appealRounds], + ['Rotations', distribution.rotations?.join(' / ')], + ['Execution budget per round', distribution.executionBudgetPerRound], + ['Message fee budget', distribution.totalMessageFees], + ['Max price per time unit', distribution.maxPriceGenPerTimeUnit], + ['Storage gas price', distribution.storageFeeMaxGasPrice], + ['Receipt gas price', distribution.receiptFeeMaxGasPrice], + ['Message budget mode', preset.messageBudgetMode], + ['Message allocations', preset.messageAllocations?.length], + ] + .filter(([, value]) => value !== undefined && value !== null) + .map(([label, value]) => ({ + label: String(label), + value: recommendedPresetFeeLabels.has(String(label)) + ? formatFeeAmount(value) + : String(value), + })); +} + +export function feeRecommendedObservedRows( + accounting: StudioFeeAccounting, +): FeeAccountingRow[] { + const observed = accounting.recommended_fee_preset?.observed; + if (!observed) return []; + + return [ + ['Execution fee', observed.executionFee], + ['Message fee budget', observed.messageFeeBudget], + ['Declared message fees', observed.declaredMessageFees], + ['External reserved', observed.externalMessageReserved], + ['Estimated fee', observed.totalEstimatedFee], + ['Studio metered fee', observed.totalStudioMeteredFee], + ] + .filter(([, value]) => value !== undefined && value !== null) + .map(([label, value]) => ({ + label: String(label), + value: formatFeeAmount(value), + })); +} + +export function feeBucketRows( + bucketReport: StudioGenvmFeeBucketReport | null | undefined, +): FeeAccountingRow[] { + if (!bucketReport) return []; + + return [ + ['Receipt/nondet used', bucketReport.receiptAndNondetOutput, 'fee'], + ['Storage used', bucketReport.storage, 'fee'], + ['Total execution', bucketReport.totalExecution, 'fee'], + ['Execution budget', bucketReport.executionBudgetPerRound, 'fee'], + ['Budget remaining', bucketReport.executionBudgetRemaining, 'fee'], + ['Budget overrun', bucketReport.executionBudgetOverrun, 'fee'], + ['Budget exceeded', bucketReport.executionBudgetExceeded, 'boolean'], + ['Message meter', bucketReport.message, 'fee'], + ['Total with message', bucketReport.totalWithMessage, 'fee'], + ] + .filter(([, value]) => value !== undefined && value !== null) + .map(([label, value, kind]) => ({ + label: String(label), + value: kind === 'boolean' ? String(value) : formatFeeAmount(value), + })); +} diff --git a/explorer/src/lib/types.ts b/explorer/src/lib/types.ts index 01f7e5600..4bd330394 100644 --- a/explorer/src/lib/types.ts +++ b/explorer/src/lib/types.ts @@ -52,6 +52,151 @@ export interface Transaction { worker_id: string | null; } +export interface StudioFeesDistribution { + leaderTimeunitsAllocation?: string | number; + validatorTimeunitsAllocation?: string | number; + appealRounds?: string | number; + executionBudgetPerRound?: string | number; + executionConsumed?: string | number; + totalMessageFees?: string | number; + rotations?: Array; + maxPriceGenPerTimeUnit?: string | number; + storageFeeMaxGasPrice?: string | number; + receiptFeeMaxGasPrice?: string | number; +} + +export interface StudioExecutionFeeReportMessage { + messageFeeMode?: 'mode1' | 'mode2' | 'external'; + messageType: string; + recipient: string; + value: string | number; + dataBytes: string | number; + onAcceptance: boolean; + saltNonce: string | number; + feeParams?: string; + feeParamsDecoded?: Record> | null; + feeParamsBytes: string | number; + declaredBudget: string | number; + allocationSubtree?: string; + allocationSubtreeBytes: string | number; + callKey: string; +} + +export interface StudioGenvmFeeBucket { + index?: string | number; + name?: string; + consumed?: string | number; +} + +export interface StudioGenvmFeeBucketReport { + receiptAndNondetOutput?: string | number; + storage?: string | number; + message?: string | number; + totalExecution?: string | number; + totalWithMessage?: string | number; + executionBudgetPerRound?: string | number; + executionBudgetRemaining?: string | number; + executionBudgetOverrun?: string | number; + executionBudgetExceeded?: boolean; + buckets?: StudioGenvmFeeBucket[]; +} + +export interface StudioExecutionFeeReport { + receiptGasPrice?: string | number; + budgetExhaustionReason?: string | null; + proposalReceipt?: { + eqBlocksOutputsLength?: string | number; + receiptBytes?: string | number; + estimatedGas?: string | number; + fee?: string | number; + }; + messageReveal?: { + messageBytes?: string | number; + messageCount?: string | number; + estimatedGas?: string | number; + fee?: string | number; + consensusAdditionalGas?: string | number; + consensusAdditionalFee?: string | number; + studioFixedOverheadGas?: string | number; + studioFixedOverheadFee?: string | number; + messages?: StudioExecutionFeeReportMessage[]; + }; + genvmBuckets?: StudioGenvmFeeBucketReport; + chargeableExecution?: StudioGenvmFeeBucketReport; + executionMetering?: { + chargeableExecutionFee?: string | number; + genvmReportedExecution?: string | number; + genvmDeltaFromChargeable?: string | number; + }; + messageFees?: { + budget?: string | number; + declaredConsumed?: string | number; + genvmMeteredConsumed?: string | number; + externalReserved?: string | number; + externalReimbursed?: string | number; + externalRemainder?: string | number; + totalConsumed?: string | number; + declaredRefunded?: string | number; + remaining?: string | number; + meteringDelta?: string | number; + reportedTotal?: string | number; + }; + totalEstimatedFee?: string | number; + totalStudioMeteredFee?: string | number; +} + +export interface StudioRecommendedFeePreset { + source?: string; + paddingBps?: string | number; + numOfInitialValidators?: string | number; + distribution?: StudioFeesDistribution; + feeValue?: string | number; + messageAllocations?: unknown[]; + messageBudgetMode?: 'current' | 'observed' | 'allocation-preserved' | string; + observed?: { + executionFee?: string | number; + messageFeeBudget?: string | number; + declaredMessageFees?: string | number; + externalMessageReserved?: string | number; + totalEstimatedFee?: string | number; + totalStudioMeteredFee?: string | number; + }; +} + +export interface StudioFeeAccounting { + version?: string | number; + source?: string; + status?: string; + paid_fee_value?: string | number; + required_fee_value?: string | number; + primary_fee_required?: string | number; + primary_fee_budget?: string | number; + primary_fee_spent?: string | number; + primary_fee_refunded?: string | number; + execution_budget_total?: string | number; + execution_fee_consumed?: string | number; + execution_fee_consumed_buckets?: Array; + genvm_fee_consumed_buckets?: Array; + genvm_fee_bucket_report?: StudioGenvmFeeBucketReport; + genvm_message_fee_consumed?: string | number; + execution_fee_report?: StudioExecutionFeeReport; + recommended_fee_preset?: StudioRecommendedFeePreset; + message_fee_budget?: string | number; + message_fee_consumed?: string | number; + message_fee_refunded?: string | number; + external_message_fee_reserved?: string | number; + external_message_fee_reimbursed?: string | number; + external_message_fee_remainder?: string | number; + appeal_bonds_total?: string | number; + total_refunded?: string | number; + fees_distribution?: StudioFeesDistribution; + message_allocations?: unknown[]; + allocation_consumed?: Record; + message_consumption_events?: unknown[]; + refunds?: unknown[]; + top_ups?: unknown[]; +} + export interface ConsensusHistoryEntry { // Legacy format leader?: ValidatorVote; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f387ec1e8..cf5ec947b 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.1", + "genlayer-js": "^1.1.8", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", @@ -5869,9 +5869,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "license": "MIT", "optional": true, "peer": true, @@ -8598,21 +8598,6 @@ "node": ">=0.2.0" } }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/builtin-status-codes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", @@ -11157,9 +11142,9 @@ } }, "node_modules/genlayer-js": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-1.1.1.tgz", - "integrity": "sha512-DNKfr/E0eDigBHZ6dUx3ViCm67UJzsA9qTEmsBBPP12EnNwslo8xBobjKG2SLEni0kWVDRo8aJGeg2TBgeUy7w==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-1.1.8.tgz", + "integrity": "sha512-qlqh8oqR9Ad7FVbIdqIrHfsMPLLJ24ZRHUZ2LGMpw6DX5ySjrEWdV1X93bVIHO44cu9CLGdx8m2ubkPv78/RLg==", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", @@ -12013,9 +11998,9 @@ } }, "node_modules/html-encoding-sniffer/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "dev": true, "license": "MIT", "optional": true, @@ -13153,9 +13138,9 @@ } }, "node_modules/jsdom/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "dev": true, "license": "MIT", "optional": true, @@ -20081,9 +20066,9 @@ } }, "node_modules/whatwg-url/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "dev": true, "license": "MIT", "optional": true, diff --git a/frontend/package.json b/frontend/package.json index 59cddd934..e114bc534 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.1", + "genlayer-js": "^1.1.8", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", diff --git a/frontend/src/components/Simulator/ContractMethodItem.vue b/frontend/src/components/Simulator/ContractMethodItem.vue index 09d536d8b..109b42b0a 100644 --- a/frontend/src/components/Simulator/ContractMethodItem.vue +++ b/frontend/src/components/Simulator/ContractMethodItem.vue @@ -2,17 +2,27 @@ import type { ContractMethod } from 'genlayer-js/types'; import { abi } from 'genlayer-js'; import { TransactionHashVariant } from 'genlayer-js/types'; -import { ref } from 'vue'; +import { computed, ref } from 'vue'; import { Collapse } from 'vue-collapsed'; import { notify } from '@kyvg/vue3-notification'; import { ChevronDownIcon } from '@heroicons/vue/16/solid'; import { useEventTracking, useContractQueries } from '@/hooks'; import { unfoldArgsData, type ArgData } from './ContractParams'; import ContractParams from './ContractParams.vue'; -import type { ExecutionMode, ReadStateMode } from '@/types'; +import type { + ExecutionMode, + ReadStateMode, + StudioExecutionFeeReportMessage, + StudioFeeEstimateResult, +} from '@/types'; -const { callWriteMethod, callReadMethod, simulateWriteMethod, contract } = - useContractQueries(); +const { + callWriteMethod, + callReadMethod, + simulateWriteMethod, + estimateWriteMethodFees, + contract, +} = useContractQueries(); const { trackEvent } = useEventTracking(); const props = defineProps<{ @@ -27,12 +37,27 @@ const props = defineProps<{ const isExpanded = ref(false); const isCalling = ref(false); +const isEstimatingFees = ref(false); const responseMessage = ref(''); const responseMessageAccepted = ref(''); const responseMessageFinalized = ref(''); +const feeEstimateMessage = ref(''); +const feeEstimateResult = ref(null); const calldataArguments = ref({ args: [], kwargs: {} }); const payableValue = ref(''); +const WEI_PER_GEN = BigInt('1000000000000000000'); + +type FeeEstimateRow = { + label: string; + value: string; +}; + +function payableValueWei(): bigint | undefined { + return props.method.payable && payableValue.value + ? BigInt(payableValue.value) * WEI_PER_GEN + : undefined; +} const formatResponseIfNeeded = (response: string): string => { if (!response) { @@ -62,6 +87,299 @@ const formatResponseIfNeeded = (response: string): string => { return response; }; +const formatIntegerLike = ( + value: string | number | bigint | boolean | null | undefined, +): string => { + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + const raw = String(value); + return /^-?\d+$/.test(raw) ? BigInt(raw).toLocaleString('en-US') : raw; +}; + +const formatFeeAmount = ( + value: string | number | bigint | null | undefined, +): string => { + const formatted = formatIntegerLike(value); + return formatted ? `${formatted} wei` : ''; +}; + +const formatRotations = (rotations: unknown): string => { + if (!Array.isArray(rotations)) { + return ''; + } + return rotations.map((rotation) => formatIntegerLike(rotation)).join(', '); +}; + +const feeParamsDecodedLabels: Record = { + leaderTimeunitsAllocation: 'Leader', + validatorTimeunitsAllocation: 'Validator', + appealRounds: 'Appeals', + executionBudgetPerRound: 'Exec budget', + rotations: 'Rotations', + gasLimit: 'Gas limit', + maxGasPrice: 'Max gas price', +}; + +const feeParamsDecodedOrder = Object.keys(feeParamsDecodedLabels); +const feeParamsDecodedFeeKeys = new Set([ + 'executionBudgetPerRound', + 'maxGasPrice', +]); +const feeParamsDecodedIntegerKeys = new Set([ + 'leaderTimeunitsAllocation', + 'validatorTimeunitsAllocation', + 'appealRounds', + 'gasLimit', + 'rotations', +]); + +const formatFeeParamsDecodedValue = (key: string, value: unknown): string => { + if (Array.isArray(value)) { + return value + .map((item) => formatFeeParamsDecodedValue(key, item)) + .join(' / '); + } + + if (feeParamsDecodedFeeKeys.has(key)) { + return formatFeeAmount(value as string | number | bigint); + } + if (feeParamsDecodedIntegerKeys.has(key)) { + return formatIntegerLike(value as string | number | bigint); + } + return String(value); +}; + +const formatFeeParamsDecoded = (value: unknown): string => { + if (!value || typeof value !== 'object') { + return ''; + } + + const record = value as Record; + const keys = Object.keys(record); + if (keys.length === 0) { + return ''; + } + + const orderedKeys = [ + ...feeParamsDecodedOrder.filter((key) => key in record), + ...keys.filter((key) => !(key in feeParamsDecodedLabels)).sort(), + ]; + return orderedKeys + .filter((key) => record[key] !== undefined && record[key] !== null) + .map((key) => { + const label = feeParamsDecodedLabels[key] ?? key; + return `${label} ${formatFeeParamsDecodedValue(key, record[key])}`; + }) + .join(', '); +}; + +const shortHex = (value: string | undefined, start = 8, end = 6): string => { + if (!value) { + return ''; + } + if (value.length <= start + end) { + return value; + } + return `${value.slice(0, start)}...${value.slice(-end)}`; +}; + +const addFeeEstimateRow = ( + rows: FeeEstimateRow[], + label: string, + value: string, +) => { + if (value !== '') { + rows.push({ label, value }); + } +}; + +const feeEstimateRows = computed(() => { + const result = feeEstimateResult.value; + if (!result) { + return []; + } + + const preset = result.recommendedPreset; + const distribution = preset?.distribution; + const observed = preset?.observed; + const report = result.feeReport; + const messageFees = report?.messageFees; + const metering = report?.executionMetering; + const chargeable = report?.chargeableExecution; + const proposalReceipt = report?.proposalReceipt; + const messageReveal = report?.messageReveal; + const rows: FeeEstimateRow[] = []; + + addFeeEstimateRow(rows, 'Scenario', result.scenario ?? ''); + addFeeEstimateRow( + rows, + 'Recommended fee value', + formatFeeAmount(preset?.feeValue), + ); + addFeeEstimateRow( + rows, + 'Execution budget / round', + formatFeeAmount(distribution?.executionBudgetPerRound), + ); + addFeeEstimateRow( + rows, + 'Leader time units', + formatIntegerLike(distribution?.leaderTimeunitsAllocation), + ); + addFeeEstimateRow( + rows, + 'Validator time units', + formatIntegerLike(distribution?.validatorTimeunitsAllocation), + ); + addFeeEstimateRow( + rows, + 'Message fee budget', + formatFeeAmount(distribution?.totalMessageFees), + ); + addFeeEstimateRow( + rows, + 'Appeal rounds', + formatIntegerLike(distribution?.appealRounds), + ); + addFeeEstimateRow( + rows, + 'Rotations', + formatRotations(distribution?.rotations), + ); + addFeeEstimateRow( + rows, + 'Max GEN / time unit', + formatFeeAmount(distribution?.maxPriceGenPerTimeUnit), + ); + addFeeEstimateRow( + rows, + 'Storage gas price', + formatFeeAmount(distribution?.storageFeeMaxGasPrice), + ); + addFeeEstimateRow( + rows, + 'Receipt gas price', + formatFeeAmount(distribution?.receiptFeeMaxGasPrice), + ); + addFeeEstimateRow( + rows, + 'Proposal receipt bytes', + formatIntegerLike(proposalReceipt?.receiptBytes), + ); + addFeeEstimateRow( + rows, + 'Proposal receipt gas', + formatIntegerLike(proposalReceipt?.estimatedGas), + ); + addFeeEstimateRow( + rows, + 'Message count', + formatIntegerLike(messageReveal?.messageCount), + ); + addFeeEstimateRow( + rows, + 'Message bytes', + formatIntegerLike(messageReveal?.messageBytes), + ); + addFeeEstimateRow( + rows, + 'Message reveal gas', + formatIntegerLike(messageReveal?.estimatedGas), + ); + addFeeEstimateRow( + rows, + 'Padding', + preset?.paddingBps !== undefined + ? `${formatIntegerLike(preset.paddingBps)} bps` + : '', + ); + addFeeEstimateRow( + rows, + 'Message budget mode', + preset?.messageBudgetMode ?? '', + ); + addFeeEstimateRow( + rows, + 'Observed execution', + formatFeeAmount(observed?.executionFee), + ); + addFeeEstimateRow( + rows, + 'Observed message budget', + formatFeeAmount(observed?.messageFeeBudget), + ); + addFeeEstimateRow( + rows, + 'Observed external reserve', + formatFeeAmount(observed?.externalMessageReserved), + ); + addFeeEstimateRow( + rows, + 'Total estimated fee', + formatFeeAmount(report?.totalEstimatedFee), + ); + addFeeEstimateRow( + rows, + 'Chargeable execution', + formatFeeAmount(metering?.chargeableExecutionFee), + ); + addFeeEstimateRow( + rows, + 'Chargeable storage', + formatFeeAmount(chargeable?.storage), + ); + addFeeEstimateRow( + rows, + 'Chargeable receipt/non-det', + formatFeeAmount(chargeable?.receiptAndNondetOutput), + ); + addFeeEstimateRow( + rows, + 'Chargeable message', + formatFeeAmount(chargeable?.message), + ); + addFeeEstimateRow( + rows, + 'GenVM raw execution', + formatFeeAmount(metering?.genvmReportedExecution), + ); + addFeeEstimateRow( + rows, + 'Message fees spent', + formatFeeAmount(messageFees?.declaredConsumed), + ); + addFeeEstimateRow( + rows, + 'External message reserved', + formatFeeAmount(messageFees?.externalReserved), + ); + addFeeEstimateRow( + rows, + 'External message reimbursed', + formatFeeAmount(messageFees?.externalReimbursed), + ); + addFeeEstimateRow( + rows, + 'External message remainder', + formatFeeAmount(messageFees?.externalRemainder), + ); + addFeeEstimateRow( + rows, + 'Message fees remaining', + formatFeeAmount(messageFees?.remaining), + ); + + return rows; +}); + +const feeEstimateMessages = computed(() => { + return feeEstimateResult.value?.feeReport?.messageReveal?.messages ?? []; +}); + const handleCallReadMethod = async () => { responseMessage.value = ''; isCalling.value = true; @@ -107,11 +425,7 @@ const handleCallWriteMethod = async () => { responseMessageAccepted.value = ''; responseMessageFinalized.value = ''; - const WEI_PER_GEN = BigInt('1000000000000000000'); - const simValue = - props.method.payable && payableValue.value - ? BigInt(payableValue.value) * WEI_PER_GEN - : undefined; + const simValue = payableValueWei(); const result = await simulateWriteMethod({ method: props.name, args: unfoldArgsData({ @@ -137,11 +451,7 @@ const handleCallWriteMethod = async () => { } else { // Real transaction mode // User inputs GEN, convert to wei (1 GEN = 10^18 wei) - const WEI_PER_GEN = BigInt('1000000000000000000'); - const txValue = - props.method.payable && payableValue.value - ? BigInt(payableValue.value) * WEI_PER_GEN - : BigInt(0); + const txValue = payableValueWei() ?? BigInt(0); await callWriteMethod({ method: props.name, executionMode: props.executionMode, @@ -173,6 +483,52 @@ const handleCallWriteMethod = async () => { isCalling.value = false; } }; + +const handleEstimateFees = async () => { + isEstimatingFees.value = true; + feeEstimateMessage.value = ''; + feeEstimateResult.value = null; + + try { + const result = await estimateWriteMethodFees({ + method: props.name, + args: unfoldArgsData({ + args: calldataArguments.value.args, + kwargs: calldataArguments.value.kwargs, + }), + value: payableValueWei(), + }); + + feeEstimateResult.value = result; + feeEstimateMessage.value = JSON.stringify( + { + scenario: result.scenario, + feeReport: result.feeReport, + recommendedPreset: result.recommendedPreset, + }, + null, + 2, + ); + + notify({ + text: 'Fee estimate completed', + type: 'success', + }); + + trackEvent('estimated_write_method_fees', { + contract_name: contract.value?.name || '', + method_name: props.name, + }); + } catch (error) { + notify({ + title: 'Error', + text: (error as Error)?.message || 'Error estimating transaction fees', + type: 'error', + }); + } finally { + isEstimatingFees.value = false; + } +};