Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 7 additions & 18 deletions genlayer_py/client/genlayer_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,9 +341,8 @@ def top_up_and_submit_appeal(
) -> HexStr:
"""Deposits appeal funding and submits an appeal.

On deployed Consensus, omitted decision/value inputs are resolved from
the authoritative appeal quote. Current Studio requires an explicit
value and does not accept ``expected_decision_id``.
Omitted decision/value inputs are resolved from the authoritative
appeal quote on both Studio and deployed Consensus.
"""
return top_up_and_submit_appeal(
self=self,
Expand Down Expand Up @@ -463,9 +462,8 @@ def appeal_transaction(
):
"""Appeals a consensus transaction to trigger a new round of validation.
Returns the original transaction_id (appeals operate on the same tx).
Deployed Consensus fills missing decision/value inputs from its
authoritative quote. Current Studio requires an explicit value and does
not accept ``expected_decision_id``.
Missing decision/value inputs are filled from the authoritative quote
on both Studio and deployed Consensus.
"""
return appeal_transaction(
self=self,
Expand All @@ -492,28 +490,19 @@ def can_appeal(
transaction_id: HexStr,
expected_decision_id: Optional[int] = None,
) -> bool:
"""Checks whether the exact active decision can be appealed on a network.

This decision-bound read is not available on current Studio.
"""
"""Checks whether the exact active decision can be appealed."""
return can_appeal(
self=self,
transaction_id=transaction_id,
expected_decision_id=expected_decision_id,
)

def get_appeal_quote(self, transaction_id: HexStr) -> Dict[str, int]:
"""Returns a network's latest decision id, appeal charges, and deadline.

Current Studio has no decision-bound quote surface.
"""
"""Returns the latest decision id, appeal charges, and deadline."""
return get_appeal_quote(self=self, transaction_id=transaction_id)

def get_appeal_charge(self, transaction_id: HexStr) -> int:
"""Returns the full appeal payment (bond plus induced-work funding).

Current Studio has no decision-bound quote surface.
"""
"""Returns the full appeal payment (bond plus induced-work funding)."""
return get_appeal_charge(self=self, transaction_id=transaction_id)

def get_min_appeal_bond(self, transaction_id: HexStr) -> int:
Expand Down
179 changes: 98 additions & 81 deletions genlayer_py/contracts/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
build_estimated_fees_options_from_simulation,
calculate_local_round_fees,
create_fees_distribution,
create_top_up_fees_distribution,
encode_fee_aware_add_transaction_data,
extract_studio_fee_policy,
fees_distribution_to_abi_tuple,
Expand Down Expand Up @@ -233,41 +234,32 @@ def appeal_transaction(

Appeals emit AppealStarted/TransactionActivated events (not NewTransaction),
so we send the EVM tx directly instead of going through _send_transaction.
Deployed Consensus binds the appeal to the exact active decision. Current
Studio exposes its native ``submitAppeal(bytes32)`` entrypoint instead, so
callers must provide the value explicitly and cannot supply a decision id.
Both Studio and deployed Consensus bind the appeal to the exact active
decision and can resolve omitted decision/value inputs from the
authoritative appeal quote. The schedule-extending entry point is used for
every appeal because it accepts both pre-funded and unfunded next rounds;
``submitAppeal`` rejects an unfunded next round before collecting its quoted
funding.
"""
sender_account = account if account is not None else self.local_account
if sender_account is None:
raise GenLayerError("No account set.")
if self.chain.consensus_main_contract is None:
raise GenLayerError("Consensus main contract not configured.")
if _is_studio_chain(self):
resolved_value = _resolve_studio_appeal_value(value, expected_decision_id)
encoded_data = _encode_submit_appeal_data(
self=self,
transaction_id=transaction_id,
studio_appeal_shape=True,
)
_send_consensus_call(
self=self,
encoded_data=encoded_data,
sender_account=sender_account,
value=resolved_value,
operation_name="Appeal",
)
return transaction_id

expected_decision_id, resolved_value = _resolve_appeal_parameters(
self,
transaction_id,
expected_decision_id=expected_decision_id,
value=value,
)

encoded_data = _encode_submit_appeal_data(
encoded_data = _encode_fee_management_data(
self=self,
function_name="topUpAndSubmitAppeal",
transaction_id=transaction_id,
# Consensus derives the appeal shape from live state. This normalized
# zero schedule exists only for ABI compatibility.
distribution={},
expected_decision_id=expected_decision_id,
)

Expand All @@ -291,8 +283,7 @@ def top_up_fees(
) -> HexStr:
"""Deposits additional fee budget for an existing consensus transaction.

Returns the backend RPC hash: an EVM transaction hash on network backends,
or the target GenLayer tx id on Studio/localnet.
Returns the signed EVM envelope hash on every backend.
"""
sender_account = account if account is not None else self.local_account
encoded_data = _encode_fee_management_data(
Expand Down Expand Up @@ -321,28 +312,9 @@ def top_up_and_submit_appeal(
"""Deposits appeal fee budget and submits an appeal in one consensus call.

Returns the original GenLayer transaction id, matching appeal_transaction.
Current Studio exposes the native decision-free call shape. Deployed
Consensus uses the decision-bound train shape.
Both Studio and deployed Consensus use the decision-bound train shape.
"""
sender_account = account if account is not None else self.local_account
if _is_studio_chain(self):
resolved_value = _resolve_studio_appeal_value(value, expected_decision_id)
encoded_data = _encode_fee_management_data(
self=self,
function_name="topUpAndSubmitAppeal",
transaction_id=transaction_id,
distribution=distribution,
studio_appeal_shape=True,
)
_send_consensus_call(
self=self,
encoded_data=encoded_data,
sender_account=sender_account,
value=resolved_value,
operation_name="Top up and submit appeal",
)
return transaction_id

expected_decision_id, resolved_value = _resolve_appeal_parameters(
self,
transaction_id,
Expand Down Expand Up @@ -427,8 +399,28 @@ def can_appeal(

When no decision id is supplied, the latest active decision is read first.
The guarded on-chain call returns ``False`` if that decision changes before
it is evaluated. Current Studio does not expose this decision-bound read.
it is evaluated. Studio uses its lifecycle and appeal-quote RPCs for the
same semantics.
"""
if _is_studio_chain(self):
lifecycle = self.get_transaction_lifecycle(transaction_id)
if not lifecycle["decision_active"]:
return False
active_decision_id = lifecycle["decision_id"]
if (
expected_decision_id is not None
and expected_decision_id != active_decision_id
):
return False
try:
return get_appeal_quote(self, transaction_id)["decision_id"] == int(
active_decision_id
)
except Exception as exc:
if "CanNotAppeal" in str(exc):
return False
raise

if self.chain.appeals_contract is None:
raise GenLayerError("appeals_contract not configured for this chain")
if expected_decision_id is None:
Expand All @@ -448,10 +440,35 @@ def get_appeal_quote(

``total`` is the value to submit: the appeal bond plus induced-work
funding. Pass ``decision_id`` back to the decision-guarded appeal methods.
Current Studio exposes no authoritative decision-bound appeal quote.
"""
if _is_studio_chain(self):
raise GenLayerError(STUDIO_APPEAL_QUOTE_UNSUPPORTED)
response = self.provider.make_request(
method="gen_estimateLatestAppealCharge",
params=[{"txId": transaction_id}],
)
if not isinstance(response, dict):
raise GenLayerError(
"gen_estimateLatestAppealCharge returned an invalid response"
)
if response.get("error") is not None:
raise GenLayerError(
f"gen_estimateLatestAppealCharge failed: {response['error']}"
)
quote = response.get("result")
if not isinstance(quote, dict):
raise GenLayerError(
"gen_estimateLatestAppealCharge returned an invalid result"
)
decision_id = int(quote["decisionId"])
bond = int(quote["bond"])
funding = int(quote["funding"])
return {
"decision_id": decision_id,
"bond": bond,
"funding": funding,
"total": bond + funding,
"appeal_deadline": int(quote["appealDeadline"]),
}
contract = _consensus_data_contract(self)
decision_id, bond, funding, appeal_deadline = (
contract.functions.estimateLatestAppealCharge(
Expand Down Expand Up @@ -552,15 +569,6 @@ def _resolve_appeal_parameters(
)


STUDIO_APPEAL_QUOTE_UNSUPPORTED = (
"Decision-bound appeal quotes are not available on current Studio."
)
STUDIO_APPEAL_VALUE_UNRESOLVABLE = (
"Cannot auto-resolve the appeal payment on current Studio; pass value explicitly."
)
STUDIO_DECISION_GUARD_UNSUPPORTED = "expected_decision_id is not supported by current Studio's decision-free appeal methods."


def _is_studio_chain(self: GenLayerClient) -> bool:
"""Reports whether the client targets the studio-embedded consensus.

Expand All @@ -570,18 +578,6 @@ def _is_studio_chain(self: GenLayerClient) -> bool:
return self.chain.id == localnet.id


def _resolve_studio_appeal_value(
value: Optional[int],
expected_decision_id: Optional[int],
) -> int:
"""Require the inputs that current Studio can faithfully honor."""
if expected_decision_id is not None:
raise GenLayerError(STUDIO_DECISION_GUARD_UNSUPPORTED)
if value is None:
raise GenLayerError(STUDIO_APPEAL_VALUE_UNRESOLVABLE)
return value


def _to_bytes32(self: GenLayerClient, hex_str: HexStr) -> bytes:
"""Convert a hex string to bytes32."""
if hex_str.startswith("0x"):
Expand Down Expand Up @@ -666,9 +662,8 @@ def _encode_submit_appeal_data(
self: GenLayerClient,
transaction_id: HexStr,
expected_decision_id: Optional[int] = None,
studio_appeal_shape: bool = False,
):
"""Encode the chain's native submitAppeal entrypoint."""
"""Encode the decision-bound submitAppeal entrypoint."""
consensus_main_contract = self.w3.eth.contract(
abi=self.chain.consensus_main_contract["abi"]
)
Expand All @@ -677,11 +672,9 @@ def _encode_submit_appeal_data(
transaction_id = transaction_id[2:]
if len(transaction_id) > 64:
raise ValueError("transaction_id too long for bytes32")
arguments = [self.w3.to_bytes(hexstr=transaction_id)]
if not studio_appeal_shape:
if expected_decision_id is None:
raise ValueError("submitAppeal requires expected_decision_id")
arguments.append(expected_decision_id)
if expected_decision_id is None:
raise ValueError("submitAppeal requires expected_decision_id")
arguments = [self.w3.to_bytes(hexstr=transaction_id), expected_decision_id]
params = abi_encode(contract_fn.argument_types, arguments)
function_selector = eth_utils.keccak(text=contract_fn.signature)[:4].hex()
encoded_data = "0x" + function_selector + params.hex()
Expand All @@ -702,16 +695,19 @@ def _encode_fee_management_data(
transaction_id: HexStr,
distribution: FeesDistributionInput,
expected_decision_id: Optional[int] = None,
studio_appeal_shape: bool = False,
):
"""Encode the chain's native fee-management entrypoint."""
if function_name not in ("topUpFees", "topUpAndSubmitAppeal"):
raise ValueError(f"Unsupported fee management function: {function_name}")

tx_bytes = _to_bytes32(self, transaction_id)
fees_distribution = create_fees_distribution(distribution)
fees_distribution = (
create_top_up_fees_distribution(distribution)
if function_name == "topUpFees"
else create_fees_distribution(distribution)
)
fees_tuple = fees_distribution_to_abi_tuple(fees_distribution)
if function_name == "topUpAndSubmitAppeal" and not studio_appeal_shape:
if function_name == "topUpAndSubmitAppeal":
if expected_decision_id is None:
raise ValueError("topUpAndSubmitAppeal requires expected_decision_id")
argument_types = TOP_UP_AND_SUBMIT_APPEAL_ARGUMENT_TYPES
Expand Down Expand Up @@ -852,6 +848,9 @@ def get_current_fee_policy(self: GenLayerClient) -> FeePolicyQuote:
execution_budget_floor,
local_execution_budget_floor,
),
# Live networks quote through FeeManager.calculateRoundFees; this
# field is only consumed by Studio's local mirror.
"timeUnitOverlayBps": 0,
}

try:
Expand Down Expand Up @@ -1218,6 +1217,23 @@ def _format_rpc_error(error: Exception) -> str:
return text


def _receipt_revert_reason(self: GenLayerClient, tx_hash: HexStr) -> Optional[str]:
"""Read Studio's additive receipt reason without weakening EVM semantics."""
try:
response = self.provider.make_request(
method="eth_getTransactionReceipt", params=[tx_hash]
)
except Exception:
return None
if not isinstance(response, dict):
return None
receipt = response.get("result")
if not isinstance(receipt, dict):
return None
reason = receipt.get("revertReason") or receipt.get("error")
return reason if isinstance(reason, str) and reason.strip() else None


def _send_consensus_call(
self: GenLayerClient,
encoded_data: HexStr,
Expand Down Expand Up @@ -1249,13 +1265,12 @@ def _send_consensus_call(
raise GenLayerError(
f"{operation_name} failed: {_format_rpc_error(exc)}"
) from exc
if self.chain.id == localnet.id:
return tx_hash

tx_receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash)

if tx_receipt.status != 1:
raise GenLayerError(f"{operation_name} reverted: EVM tx {tx_hash}")
reason = _receipt_revert_reason(self, tx_hash)
suffix = f". {reason}" if reason else ""
raise GenLayerError(f"{operation_name} reverted: EVM tx {tx_hash}{suffix}")

return tx_hash

Expand Down Expand Up @@ -1298,9 +1313,11 @@ def _send_transaction(
tx_receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash)

if tx_receipt.status != 1:
reason = _receipt_revert_reason(self, tx_hash)
suffix = f" {reason}" if reason else ""
raise GenLayerError(
f"Transaction reverted: EVM tx {tx_hash} to consensus contract "
f"{self.chain.consensus_main_contract['address']} was reverted."
f"{self.chain.consensus_main_contract['address']} was reverted.{suffix}"
)

consensus_main_contract = self.w3.eth.contract(
Expand Down
3 changes: 3 additions & 0 deletions genlayer_py/transactions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
build_estimated_fees_distribution,
calculate_local_round_fees,
create_fees_distribution,
create_top_up_fees_distribution,
derive_external_message_call_key,
deploy_call_key,
derive_internal_message_call_key,
Expand All @@ -23,6 +24,7 @@ def is_successful(transaction):

return _is_successful(transaction)


__all__ = [
"CALL_KEY_DEPLOY",
"CALL_KEY_UNNAMED",
Expand All @@ -34,6 +36,7 @@ def is_successful(transaction):
"build_estimated_fees_distribution",
"calculate_local_round_fees",
"create_fees_distribution",
"create_top_up_fees_distribution",
"derive_external_message_call_key",
"deploy_call_key",
"derive_internal_message_call_key",
Expand Down
Loading
Loading