diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e26aeddaf57..6f4318d8c89 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -80,18 +80,27 @@ jobs: - label: pre-shanghai from_fork: Frontier until_fork: Paris + fill_paths: tests - label: shanghai-cancun from_fork: Shanghai until_fork: Cancun + fill_paths: tests - label: prague from_fork: Prague until_fork: Prague + fill_paths: tests - label: osaka from_fork: Osaka until_fork: Osaka - - label: amsterdam - from_fork: Amsterdam - until_fork: Amsterdam + fill_paths: tests + # TODO: The EIP-8141 frame transaction tests fill under the + # ``Bogota`` pseudo-fork (Amsterdam spec + EIP-8141). Re-add an + # ``amsterdam`` entry with ``fill_paths: tests`` as the rest of + # the suite is brought up to date in subsequent PRs. + - label: bogota + from_fork: Bogota + until_fork: Bogota + fill_paths: tests/amsterdam/eip8141_frame_transactions steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-uv @@ -103,6 +112,7 @@ jobs: -m "not slow and not derived_test" env: PYTEST_XDIST_AUTO_NUM_WORKERS: auto + FILL_PATHS: ${{ matrix.fill_paths }} - name: Upload coverage reports to Codecov uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: @@ -132,9 +142,13 @@ jobs: with: python-version: "pypy3.11" - uses: ./.github/actions/setup-env-pypy + # TODO: Stop short of Amsterdam while only the EIP-8141 frame + # transaction tests are filled for that fork. Drop FILL_UNTIL as the + # test run is expanded in subsequent PRs. - name: Run fill-pypy tests run: just fill-pypy env: + FILL_UNTIL: Osaka PYPY_GC_MAX: "2G" PYPY_GC_MIN: "1G" @@ -146,9 +160,13 @@ jobs: - uses: ./.github/actions/setup-uv with: python-version: "3.14" + # TODO: Stop short of Amsterdam while only the EIP-8141 frame + # transaction tests are filled for that fork. Drop FILL_UNTIL as the + # test run is expanded in subsequent PRs. - name: Fill and run json-loader tests run: just json-loader env: + FILL_UNTIL: Osaka PYTEST_XDIST_AUTO_NUM_WORKERS: auto - name: Upload coverage reports to Codecov uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 diff --git a/Justfile b/Justfile index 01467ef46fe..62d6543402d 100644 --- a/Justfile +++ b/Justfile @@ -16,8 +16,18 @@ xdist_workers := env("PYTEST_XDIST_AUTO_NUM_WORKERS", "6") # `-n auto` mode, does not warn on non-numeric values such as "auto". export PYTEST_XDIST_AUTO_NUM_WORKERS := "" evm_bin := env("EVM_BIN", "evm") +# Tests for the ``Bogota`` pseudo-fork (e.g. EIP-8141) are outside the +# default fill range; fill them with `just fill --from Bogota --until +# Bogota`. Fold ``Bogota`` into the range once it is a real fork. latest_fork := "Amsterdam" +# Test paths filled by `just fill`, overridable so CI can narrow the run. +fill_paths := env("FILL_PATHS", "tests") + +# Last fork filled by the integration test recipes, overridable so CI can +# stop short of the fork under development. +fill_until := env("FILL_UNTIL", latest_fork) + # Use the faster sys.monitoring coverage core (default on 3.14, opt-in below). export COVERAGE_CORE := "sysmon" @@ -141,7 +151,7 @@ fill *args: (_tmp-logs "fill") --until "{{ latest_fork }}" \ --durations=50 \ "$@" \ - tests + {{ fill_paths }} # Callers append the feature params, fork range and output; last flag wins. # Fill fixtures with the flags shared by all fixture releases @@ -174,7 +184,7 @@ fill-pypy *args: (_tmp-logs "fill-pypy") --basetemp="{{ output_dir }}/fill-pypy/tmp" \ --log-to "{{ output_dir }}/fill-pypy/logs" \ --clean \ - --until "{{ latest_fork }}" \ + --until "{{ fill_until }}" \ --ignore=tests/ported_static \ "$@" \ tests @@ -184,7 +194,7 @@ fill-pypy *args: (_tmp-logs "fill-pypy") json-loader *args: (_tmp "json-loader") uv run fill \ -m "eels_base_coverage and not derived_test" \ - --until "{{ latest_fork }}" \ + --until "{{ fill_until }}" \ -n {{ xdist_workers }} --dist=loadgroup \ --skip-index \ --clean \ diff --git a/packages/testing/src/execution_testing/__init__.py b/packages/testing/src/execution_testing/__init__.py index 0b47af7a9be..e3b51a9b39f 100644 --- a/packages/testing/src/execution_testing/__init__.py +++ b/packages/testing/src/execution_testing/__init__.py @@ -71,6 +71,9 @@ DepositRequest, Environment, FeeSystemContractRequest, + Frame, + FrameReceipt, + FrameSignature, NetworkWrappedTransaction, Removable, Requests, @@ -176,6 +179,9 @@ "EIPChecklist", "EngineAPIError", "Environment", + "Frame", + "FrameReceipt", + "FrameSignature", "EOA", "FeeSystemContractRequest", "FixedIterationsBytecode", diff --git a/packages/testing/src/execution_testing/client_clis/clis/execution_specs.py b/packages/testing/src/execution_testing/client_clis/clis/execution_specs.py index 4d5d7d81865..508df2d7ec2 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/execution_specs.py +++ b/packages/testing/src/execution_testing/client_clis/clis/execution_specs.py @@ -262,6 +262,15 @@ class ExecutionSpecsExceptionMapper(ExceptionMapper): "Block access list exceeds gas limit" ), TransactionException.LOG_MISMATCH: "LogMismatchError", + TransactionException.TYPE_6_INVALID_FRAME_FORMAT: ( + "InvalidFrameError" + ), + TransactionException.TYPE_6_INVALID_SIGNATURE: ( + "InvalidSignatureError" + ), + TransactionException.TYPE_6_INVALID_FRAME_EXECUTION: ( + "FrameTransactionExecutionError" + ), } mapping_regex: ClassVar[Dict[ExceptionBase, str]] = { # Temporary solution for issue #1981. diff --git a/packages/testing/src/execution_testing/client_clis/clis/geth.py b/packages/testing/src/execution_testing/client_clis/clis/geth.py index e133c839455..3d369522fab 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/geth.py +++ b/packages/testing/src/execution_testing/client_clis/clis/geth.py @@ -97,6 +97,15 @@ class GethExceptionMapper(ExceptionMapper): TransactionException.TYPE_4_TX_PRE_FORK: ( "transaction type not supported" ), + TransactionException.TYPE_6_INVALID_FRAME_FORMAT: ( + "invalid frame tx format" + ), + TransactionException.TYPE_6_INVALID_SIGNATURE: ( + "invalid frame tx signature" + ), + TransactionException.TYPE_6_INVALID_FRAME_EXECUTION: ( + "invalid frame execution" + ), TransactionException.INITCODE_SIZE_EXCEEDED: ( "max initcode size exceeded" ), diff --git a/packages/testing/src/execution_testing/exceptions/exceptions/transaction.py b/packages/testing/src/execution_testing/exceptions/exceptions/transaction.py index 286a71e188d..1934846ab8e 100644 --- a/packages/testing/src/execution_testing/exceptions/exceptions/transaction.py +++ b/packages/testing/src/execution_testing/exceptions/exceptions/transaction.py @@ -194,5 +194,22 @@ class TransactionException(ExceptionBase): """ TYPE_4_TX_PRE_FORK = auto() """Transaction type 4 included before activation fork.""" + TYPE_6_INVALID_FRAME_FORMAT = auto() + """ + Transaction is type 6, but violates a static frame transaction + constraint (frame count, mode, flags, value, signature entry + structure, expiry verifier frame shape, blob fields, etc.). + """ + TYPE_6_INVALID_SIGNATURE = auto() + """ + Transaction is type 6, but a signature entry failed protocol + validation. + """ + TYPE_6_INVALID_FRAME_EXECUTION = auto() + """ + Transaction is type 6, but frame execution invalidated it (a SENDER + frame ran before execution approval, a VERIFY frame reverted, or no + frame approved gas payment). + """ LOG_MISMATCH = auto() """Transaction receipt logs do not match expected logs.""" diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index 3a840769c06..f6ac919cf45 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -73,6 +73,8 @@ from .common import ( FixtureAuthorizationTuple, FixtureBlobSchedule, + FixtureFrame, + FixtureFrameSignature, FixtureTransactionReceipt, ) @@ -692,6 +694,8 @@ class FixtureTransaction( authorization_list: List[FixtureAuthorizationTuple] | None = None initcodes: List[Bytes] | None = None + frames: List[FixtureFrame] | None = None + signatures: List[FixtureFrameSignature] | None = None @classmethod def from_transaction(cls, tx: Transaction) -> Self: diff --git a/packages/testing/src/execution_testing/fixtures/common.py b/packages/testing/src/execution_testing/fixtures/common.py index e56cb5a8b4c..37b0e242000 100644 --- a/packages/testing/src/execution_testing/fixtures/common.py +++ b/packages/testing/src/execution_testing/fixtures/common.py @@ -27,6 +27,8 @@ ) from execution_testing.test_types.transaction_types import ( AuthorizationTupleGeneric, + FrameGeneric, + FrameSignatureGeneric, Transaction, ) @@ -104,6 +106,22 @@ def sign(self) -> None: return +class FixtureFrame(FrameGeneric[ZeroPaddedHexNumber]): + """Fixture variant of the EIP-8141 Frame type.""" + + # Allow extra fields: FixtureFrame is constructed from Frame via + # model_dump(), which may include extra fields. + model_config = CamelModel.model_config | {"extra": "ignore"} + + +class FixtureFrameSignature(FrameSignatureGeneric[ZeroPaddedHexNumber]): + """Fixture variant of the EIP-8141 signature entry type.""" + + # Allow extra fields: FixtureFrameSignature is constructed from + # FrameSignature via model_dump(), which may include extra fields. + model_config = CamelModel.model_config | {"extra": "ignore"} + + class FixtureTransactionLog(CamelModel, RLPSerializable): """Fixture variant of the TransactionLog type.""" @@ -126,6 +144,22 @@ class FixtureReceiptDelegation(ReceiptDelegation): nonce: ZeroPaddedHexNumber +class FixtureFrameReceipt(CamelModel, RLPSerializable): + """Fixture variant of the EIP-8141 FrameReceipt type.""" + + model_config = CamelModel.model_config | {"extra": "ignore"} + + status: ZeroPaddedHexNumber + gas_used: ZeroPaddedHexNumber + logs: List[FixtureTransactionLog] + + rlp_fields: ClassVar[List[str]] = [ + "status", + "gas_used", + "logs", + ] + + class FixtureTransactionReceipt(CamelModel, RLPSerializable): """Fixture variant of the TransactionReceipt type.""" @@ -137,6 +171,9 @@ class FixtureTransactionReceipt(CamelModel, RLPSerializable): post_state: Hash | None = None status: bool | None = None + payer: Address | None = None + frame_receipts: List[FixtureFrameReceipt] | None = None + rlp_fields: ClassVar[List[str]] = [ "post_state", "status", @@ -146,6 +183,15 @@ class FixtureTransactionReceipt(CamelModel, RLPSerializable): ] rlp_exclude_none: ClassVar[bool] = True + def get_rlp_fields(self) -> List[str]: + """ + Return the RLP field list, using the EIP-8141 frame receipt + payload for frame transactions. + """ + if self.payer is not None: + return ["cumulative_gas_used", "payer", "frame_receipts"] + return self.rlp_fields + @model_validator(mode="before") @classmethod def _drop_computed_fields(cls, data: Any) -> Any: diff --git a/packages/testing/src/execution_testing/fixtures/state.py b/packages/testing/src/execution_testing/fixtures/state.py index 9e29a6e9a4d..73dd9b114b8 100644 --- a/packages/testing/src/execution_testing/fixtures/state.py +++ b/packages/testing/src/execution_testing/fixtures/state.py @@ -25,6 +25,8 @@ from .common import ( FixtureAuthorizationTuple, FixtureBlobSchedule, + FixtureFrame, + FixtureFrameSignature, FixtureTransactionReceipt, ) @@ -58,6 +60,8 @@ class FixtureTransaction(TransactionFixtureConverter): access_lists: List[List[AccessList] | None] | None = None authorization_list: List[FixtureAuthorizationTuple] | None = None initcodes: List[Bytes] | None = None + frames: List[FixtureFrame] | None = None + signatures: List[FixtureFrameSignature] | None = None max_fee_per_blob_gas: ZeroPaddedHexNumber | None = None blob_versioned_hashes: Sequence[Hash] | None = None sender: Address | None = None diff --git a/packages/testing/src/execution_testing/forks/__init__.py b/packages/testing/src/execution_testing/forks/__init__.py index cd333c559e8..0560c81b3bb 100644 --- a/packages/testing/src/execution_testing/forks/__init__.py +++ b/packages/testing/src/execution_testing/forks/__init__.py @@ -10,6 +10,7 @@ Amsterdam, ArrowGlacier, Berlin, + Bogota, Byzantium, Cancun, Constantinople, @@ -93,6 +94,7 @@ "ArrowGlacier", "Berlin", "BerlinToLondonAt5", + "Bogota", "Byzantium", "Constantinople", "ConstantinopleFix", diff --git a/packages/testing/src/execution_testing/forks/forks/eips/bogota/eip_8141.py b/packages/testing/src/execution_testing/forks/forks/eips/bogota/eip_8141.py new file mode 100644 index 00000000000..2198c4d185a --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/bogota/eip_8141.py @@ -0,0 +1,50 @@ +""" +EIP-8141: Frame Transaction. + +Add a new transaction type constructed from a series of frames, +abstractly defining validity conditions and gas payment. + +https://eips.ethereum.org/EIPS/eip-8141 +""" + +from typing import List, Mapping + +from ....base_fork import BaseFork + +EXPIRY_VERIFIER_ADDRESS = 0x0000000000000000000000000000000000008141 +EXPIRY_VERIFIER_BYTECODE = bytes.fromhex( + "60083614600a575f5ffd5b5f3560c01c4211601657005b5f5ffd" +) + + +class EIP8141(BaseFork): + """EIP-8141 class.""" + + @classmethod + def tx_types(cls) -> List[int]: + """Frame transactions (type 6) are introduced.""" + return super(EIP8141, cls).tx_types() + [6] + + @classmethod + def pre_allocation(cls) -> Mapping: + """Pre-allocate the expiry verifier contract.""" + return { + EXPIRY_VERIFIER_ADDRESS: { + # EIP-8141 installs only the runtime code at + # activation; the nonce stays zero. + "nonce": 0, + "code": EXPIRY_VERIFIER_BYTECODE, + } + } | super(EIP8141, cls).pre_allocation() # type: ignore + + @classmethod + def pre_allocation_blockchain(cls) -> Mapping: + """Pre-allocate the expiry verifier contract.""" + return { + EXPIRY_VERIFIER_ADDRESS: { + # EIP-8141 installs only the runtime code at + # activation; the nonce stays zero. + "nonce": 0, + "code": EXPIRY_VERIFIER_BYTECODE, + } + } | super(EIP8141, cls).pre_allocation_blockchain() # type: ignore diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 8d1bb82e5d0..026212fa16d 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1649,3 +1649,18 @@ class Amsterdam( # live on mainnet. pass + + +class Bogota( + eips.EIP8141, + Amsterdam, + deployed=False, +): + """ + Pseudo Bogota fork. + For testing purposes only. Labels fixtures for features slated for the + fork after Amsterdam while the specs repository has no dedicated Bogota + fork module yet; execution uses the Amsterdam spec module. + """ + + pass diff --git a/packages/testing/src/execution_testing/specs/helpers.py b/packages/testing/src/execution_testing/specs/helpers.py index a176cd1c59b..83800e8f6f3 100644 --- a/packages/testing/src/execution_testing/specs/helpers.py +++ b/packages/testing/src/execution_testing/specs/helpers.py @@ -340,6 +340,100 @@ def verify_transaction_receipt( # TODO: Add more fields as needed +def verify_frame_transaction_receipt( + transaction_index: int, + expected_receipt: TransactionReceipt | None, + actual_receipt: TransactionReceipt | None, +) -> None: + """ + Verify the frame-transaction-specific fields of the actual receipt + against the expected one: the `payer` and the per-frame receipt + entries defined by [EIP-8141]. + + Only called for frame transactions, on top of the generic + [`verify_transaction_receipt`][vtr] checks. If the expected receipt + is None, validation is skipped. Only non-None values in the + expected receipt are verified; within an expected frame receipt + entry, only its non-None fields are verified. + + [vtr]: ref:execution_testing.specs.helpers.verify_transaction_receipt + [EIP-8141]: https://eips.ethereum.org/EIPS/eip-8141 + """ + if expected_receipt is None: + return + assert actual_receipt is not None + if ( + expected_receipt.payer is not None + and actual_receipt.payer != expected_receipt.payer + ): + raise TransactionReceiptMismatchError( + index=transaction_index, + field_name="payer", + expected_value=expected_receipt.payer, + actual_value=actual_receipt.payer, + ) + + if expected_receipt.frame_receipts is None: + return + actual_frame_receipts = actual_receipt.frame_receipts + if actual_frame_receipts is None: + raise TransactionReceiptMismatchError( + index=transaction_index, + field_name="frame_receipts", + expected_value=expected_receipt.frame_receipts, + actual_value=None, + ) + if len(expected_receipt.frame_receipts) != len(actual_frame_receipts): + raise TransactionReceiptMismatchError( + index=transaction_index, + field_name="frame_receipt_count", + expected_value=len(expected_receipt.frame_receipts), + actual_value=len(actual_frame_receipts), + ) + for frame_idx, (expected_frame, actual_frame) in enumerate( + zip( + expected_receipt.frame_receipts, + actual_frame_receipts, + strict=True, + ) + ): + if ( + expected_frame.status is not None + and actual_frame.status != expected_frame.status + ): + raise TransactionReceiptMismatchError( + index=transaction_index, + field_name=f"frame_receipts[{frame_idx}].status", + expected_value=expected_frame.status, + actual_value=actual_frame.status, + ) + if ( + expected_frame.gas_used is not None + and actual_frame.gas_used != expected_frame.gas_used + ): + raise TransactionReceiptMismatchError( + index=transaction_index, + field_name=f"frame_receipts[{frame_idx}].gas_used", + expected_value=expected_frame.gas_used, + actual_value=actual_frame.gas_used, + ) + if expected_frame.logs is not None: + actual_frame_logs = actual_frame.logs or [] + if len(expected_frame.logs) != len(actual_frame_logs): + raise TransactionReceiptMismatchError( + index=transaction_index, + field_name=f"frame_receipts[{frame_idx}].log_count", + expected_value=len(expected_frame.logs), + actual_value=len(actual_frame_logs), + ) + for log_idx, (expected_log, actual_log) in enumerate( + zip(expected_frame.logs, actual_frame_logs, strict=True) + ): + verify_log( + transaction_index, log_idx, expected_log, actual_log + ) + + def verify_transactions( *, txs: List[Transaction], @@ -369,6 +463,10 @@ def verify_transactions( verify_transaction_receipt( i, tx.expected_receipt, result.receipts[receipt_index] ) + if tx.frames is not None: + verify_frame_transaction_receipt( + i, tx.expected_receipt, result.receipts[receipt_index] + ) receipt_index += 1 return list(rejected_txs.keys()) diff --git a/packages/testing/src/execution_testing/test_types/__init__.py b/packages/testing/src/execution_testing/test_types/__init__.py index ffb82b59196..871eca48c67 100644 --- a/packages/testing/src/execution_testing/test_types/__init__.py +++ b/packages/testing/src/execution_testing/test_types/__init__.py @@ -33,7 +33,7 @@ eoa_from_hash, ) from .phase_manager import TestPhase, TestPhaseManager -from .receipt_types import TransactionLog, TransactionReceipt +from .receipt_types import FrameReceipt, TransactionLog, TransactionReceipt from .request_types import ( BuilderDepositRequest, BuilderExitRequest, @@ -53,6 +53,8 @@ ) from .transaction_types import ( AuthorizationTuple, + Frame, + FrameSignature, NetworkWrappedTransaction, Transaction, TransactionDefaults, @@ -86,6 +88,9 @@ "Environment", "EnvironmentDefaults", "EOA", + "Frame", + "FrameReceipt", + "FrameSignature", "FeeSystemContractRequest", "NetworkWrappedTransaction", "Removable", diff --git a/packages/testing/src/execution_testing/test_types/receipt_types.py b/packages/testing/src/execution_testing/test_types/receipt_types.py index 55e94498350..143e6e063bf 100644 --- a/packages/testing/src/execution_testing/test_types/receipt_types.py +++ b/packages/testing/src/execution_testing/test_types/receipt_types.py @@ -37,6 +37,17 @@ class ReceiptDelegation(CamelModel): target: Address +class FrameReceipt(CamelModel): + """ + Per-frame receipt of an + [EIP-8141](https://eips.ethereum.org/EIPS/eip-8141) frame transaction. + """ + + status: HexNumber | None = None + gas_used: HexNumber | None = None + logs: List[TransactionLog] | None = None + + class TransactionReceipt(CamelModel): """Transaction receipt.""" @@ -86,3 +97,5 @@ def strip_extra_fields(cls, data: Any) -> Any: blob_gas_used: HexNumber | None = None blob_gas_price: HexNumber | None = None delegations: List[ReceiptDelegation] | None = None + payer: Address | None = None + frame_receipts: List[FrameReceipt] | None = None diff --git a/packages/testing/src/execution_testing/test_types/transaction_types.py b/packages/testing/src/execution_testing/test_types/transaction_types.py index 00fd02ee8bb..019d25d0b31 100644 --- a/packages/testing/src/execution_testing/test_types/transaction_types.py +++ b/packages/testing/src/execution_testing/test_types/transaction_types.py @@ -55,6 +55,7 @@ class TransactionType(IntEnum): BASE_FEE = 2 BLOB_TRANSACTION = 3 SET_CODE = 4 + FRAME = 6 @dataclass @@ -193,6 +194,78 @@ def sign(self: "AuthorizationTuple") -> None: pass +class FrameGeneric(CamelModel, Generic[NumberBoundTypeVar], RLPSerializable): + """ + Frame within an [EIP-8141](https://eips.ethereum.org/EIPS/eip-8141) + frame transaction. + """ + + mode: NumberBoundTypeVar = Field(0) # type: ignore + flags: NumberBoundTypeVar = Field(0) # type: ignore + target: Address | None = None + gas_limit: NumberBoundTypeVar = Field(0) # type: ignore + value: NumberBoundTypeVar = Field(0) # type: ignore + data: Bytes = Field(Bytes(b"")) + + rlp_fields: ClassVar[List[str]] = [ + "mode", + "flags", + "target", + "gas_limit", + "value", + "data", + ] + + +class Frame(FrameGeneric[HexNumber]): + """Frame within an EIP-8141 frame transaction (test authoring).""" + + pass + + +class FrameSignatureGeneric( + CamelModel, Generic[NumberBoundTypeVar], RLPSerializable +): + """ + Signature entry within an + [EIP-8141](https://eips.ethereum.org/EIPS/eip-8141) frame transaction. + """ + + scheme: NumberBoundTypeVar = Field(0) # type: ignore + signer: Bytes = Field(Bytes(b"")) + msg: Bytes = Field(Bytes(b"")) + signature: Bytes = Field(Bytes(b"")) + + rlp_fields: ClassVar[List[str]] = [ + "scheme", + "signer", + "msg", + "signature", + ] + + +class FrameSignature(FrameSignatureGeneric[HexNumber]): + """ + Signature entry within an EIP-8141 frame transaction (test authoring). + + When `secret_key` is set (or the entry's `signer` matches the + transaction sender), the raw `signature` bytes are filled in + automatically when the transaction is signed: entries with an + explicit 32-byte `msg` sign that digest, while entries with an empty + `msg` sign the canonical transaction signature hash. + """ + + secret_key: Hash | None = Field(None, exclude=True) + + def signed_over(self, digest: bytes, key: Hash) -> None: + """Fill the raw signature bytes by signing `digest` with `key`.""" + signature_bytes = PrivateKey(key).sign_recoverable(digest) + # EIP-8141 secp256k1 encoding: v (1 byte) || r || s, v in {0, 1}. + self.signature = Bytes( + bytes([signature_bytes[64]]) + signature_bytes[0:64] + ) + + class TransactionGeneric(BaseModel, Generic[NumberBoundTypeVar]): """ Generic transaction type used as a parent for Transaction and @@ -339,6 +412,9 @@ def treat_none_gas_limit_as_unset(cls, data: Any) -> Any: initcodes: List[Bytes] | None = None + frames: List[Frame] | None = None + signatures: List[FrameSignature] | None = None + secret_key: Hash | None = None error: List[TransactionException] | TransactionException | None = Field( None, exclude=True @@ -425,7 +501,9 @@ def model_post_init(self, __context: Any) -> None: if "ty" not in self.model_fields_set: # Try to deduce transaction type from included fields - if self.initcodes is not None: + if self.frames is not None: + self.ty = HexNumber(6) + elif self.initcodes is not None: self.ty = HexNumber(6) elif self.authorization_list is not None: self.ty = HexNumber(4) @@ -444,10 +522,21 @@ def model_post_init(self, __context: Any) -> None: else: self.ty = HexNumber(0) + if self.ty == 6 and self.frames is None and self.initcodes is None: + # Type 6 is the EIP-8141 frame transaction; an explicit + # `initcodes` list selects the EIP-7873 shape instead. + self.frames = [] + if "v" in self.model_fields_set and self.secret_key is not None: raise Transaction.InvalidSignaturePrivateKeyError() - if "v" not in self.model_fields_set and self.secret_key is None: + if self.frames is not None: + # EIP-8141: Frame transactions carry an explicit sender and + # a signature list instead of a single v/r/s signature. + assert self.sender is not None, ( + "frame transactions require an explicit sender" + ) + elif "v" not in self.model_fields_set and self.secret_key is None: if self.sender is not None: self.secret_key = self.sender.key else: @@ -488,7 +577,13 @@ def model_post_init(self, __context: Any) -> None: if self.ty == 3 and self.max_fee_per_blob_gas is None: self.max_fee_per_blob_gas = HexNumber(1) self.model_fields_set.remove("max_fee_per_blob_gas") - if self.ty != 3: + if self.frames is not None: + # EIP-8141: Frame transactions always carry blob fields. + if self.blob_versioned_hashes is None: + self.blob_versioned_hashes = [] + if self.max_fee_per_blob_gas is None: + self.max_fee_per_blob_gas = HexNumber(0) + elif self.ty != 3: assert self.blob_versioned_hashes is None, ( "blob_versioned_hashes must be None" ) @@ -503,10 +598,10 @@ def model_post_init(self, __context: Any) -> None: "authorization_list must be None" ) - if self.ty == 6 and self.initcodes is None: - self.initcodes = [] if self.ty != 6: assert self.initcodes is None, "initcodes must be None" + assert self.frames is None, "frames must be None" + assert self.signatures is None, "signatures must be None" if "nonce" not in self.model_fields_set and self.sender is not None: self.nonce = HexNumber(self.sender.get_nonce()) @@ -538,8 +633,87 @@ def signature_bytes(self) -> Bytes: + bytes([v]) ) + @property + def signing_signatures(self) -> List[FrameSignature]: + """ + Return the signature entries as included in the canonical frame + transaction signature hash: entries with an empty `msg` have + their raw `signature` bytes elided. + """ + assert self.signatures is not None + return [ + sig.model_copy(update={"signature": Bytes(b"")}) + if len(sig.msg) == 0 + else sig + for sig in self.signatures + ] + + def _sign_frame_signatures(self) -> None: + """ + Fill in the raw signature bytes of the frame transaction's + signature entries. + + A missing signature list defaults to a single secp256k1 entry + from the sender over the canonical signature hash. Entries with + an explicit 32-byte `msg` are signed first since their raw bytes + are committed to by the canonical hash. + """ + assert self.frames is not None + if self.signatures is None: + assert self.sender is not None + if getattr(self.sender, "key", None) is not None: + # EOA sender: default to a single secp256k1 entry over + # the canonical signature hash, as consumed by the + # default code. + self.signatures = [ + FrameSignature( + scheme=HexNumber(1), + signer=Bytes(self.sender), + msg=Bytes(b""), + ) + ] + else: + # Contract senders authorize via their code; no + # protocol-validated signature is required. + self.signatures = [] + + def resolve_key(sig: FrameSignature) -> Hash | None: + if sig.secret_key is not None: + return sig.secret_key + if ( + self.sender is not None + and Bytes(self.sender) == sig.signer + and self.sender.key is not None + ): + return self.sender.key + return None + + # Explicit-digest entries first: their raw bytes are part of the + # canonical signature hash signed by empty-msg entries. + for sig in self.signatures: + if sig.scheme != 1 or len(sig.signature) > 0: + continue + if len(sig.msg) != 32: + continue + key = resolve_key(sig) + if key is not None: + sig.signed_over(bytes(sig.msg), key) + + sig_hash = self.rlp_signing_bytes().keccak256() + for sig in self.signatures: + if sig.scheme != 1 or len(sig.signature) > 0: + continue + if len(sig.msg) != 0: + continue + key = resolve_key(sig) + if key is not None: + sig.signed_over(sig_hash, key) + def sign(self: "Transaction") -> None: """Signs the authorization tuple with a private key.""" + if self.frames is not None: + self._sign_frame_signatures() + return signature_bytes: bytes | None = None rlp_signing_bytes = self.rlp_signing_bytes() if ( @@ -700,6 +874,12 @@ def with_signature_and_sender( """Return signed version of the transaction using the private key.""" updated_values: Dict[str, Any] = {} + if self.frames is not None: + # EIP-8141: The sender is explicit; fill in the signature + # entries instead of v/r/s. + self._sign_frame_signatures() + return self + if ( "v" in self.model_fields_set or "r" in self.model_fields_set @@ -768,7 +948,20 @@ def get_rlp_signing_fields(self) -> List[str]: depending on the transaction type. """ field_list: List[str] - if self.ty == 6: + if self.ty == 6 and self.frames is not None: + # EIP-8141: https://eips.ethereum.org/EIPS/eip-8141 + field_list = [ + "chain_id", + "nonce", + "sender", + "frames", + "signing_signatures", + "max_priority_fee_per_gas", + "max_fee_per_gas", + "max_fee_per_blob_gas", + "blob_versioned_hashes", + ] + elif self.ty == 6: # EIP-7873: https://eips.ethereum.org/EIPS/eip-7873 field_list = [ "chain_id", @@ -866,6 +1059,13 @@ def get_rlp_fields(self) -> List[str]: depending on the transaction type. """ fields = self.get_rlp_signing_fields() + if self.ty == 6 and self.frames is not None: + # EIP-8141: The transaction is not wrapped in a signature; + # the full encoding carries the raw signature entries. + return [ + "signatures" if field == "signing_signatures" else field + for field in fields + ] if self.ty == 0 and self.protected: fields = fields[:-3] return fields + ["v", "r", "s"] diff --git a/packages/testing/src/execution_testing/vm/opcodes.py b/packages/testing/src/execution_testing/vm/opcodes.py index 7402e55dcca..aa57c5122f8 100644 --- a/packages/testing/src/execution_testing/vm/opcodes.py +++ b/packages/testing/src/execution_testing/vm/opcodes.py @@ -5957,6 +5957,205 @@ class Opcodes(Opcode, Enum): Source: [evm.codes/#FF](https://www.evm.codes/#FF) """ + # EIP-8141 Frame Transaction Opcodes + + APPROVE = Opcode( + 0xAA, + popped_stack_items=3, + pushed_stack_items=0, + kwargs=["offset", "size", "scope"], + terminating=True, + ) + """ + APPROVE(offset, size, scope) + ---- + + Description + ---- + Exit the current call frame successfully like RETURN while updating + the transaction-scoped approval context of an EIP-8141 frame + transaction according to `scope` (bitmask: 1=payment, 2=execution, + 3=both). + + Inputs + ---- + - offset: byte offset in memory of the return data + - size: byte size of the return data + - scope: requested approval scope + + Outputs + ---- + None (terminates the current context) + + Fork + ---- + Amsterdam + + Gas: 0 (plus memory expansion) + """ + + TXPARAM = Opcode( + 0xB0, + popped_stack_items=1, + pushed_stack_items=1, + kwargs=["param"], + ) + """ + TXPARAM(param) + ---- + + Description + ---- + Push transaction-scoped information of the executing EIP-8141 frame + transaction (type, nonce, sender, fees, max cost, signature hash, + frame count, current frame index, signature count). + + Inputs + ---- + - param: parameter selector (0x00-0x0B) + + Outputs + ---- + - value: the requested transaction parameter + + Fork + ---- + Amsterdam + + Gas: 2 + """ + + FRAMEDATALOAD = Opcode( + 0xB1, + popped_stack_items=2, + pushed_stack_items=1, + kwargs=["offset", "frame_index"], + ) + """ + FRAMEDATALOAD(offset, frame_index) + ---- + + Description + ---- + Load one 32-byte word from the chosen frame's data with + CALLDATALOAD semantics (EIP-8141). + + Inputs + ---- + - offset: byte offset in the frame data + - frame_index: index of the frame + + Outputs + ---- + - value: 32-byte word from the frame data + + Fork + ---- + Amsterdam + + Gas: 3 + """ + + FRAMEDATACOPY = Opcode( + 0xB2, + popped_stack_items=4, + pushed_stack_items=0, + kwargs=["dest_offset", "offset", "size", "frame_index"], + ) + """ + FRAMEDATACOPY(dest_offset, offset, size, frame_index) + ---- + + Description + ---- + Copy the chosen frame's data into memory with CALLDATACOPY + semantics (EIP-8141). + + Inputs + ---- + - dest_offset: byte offset in memory to copy to + - offset: byte offset in the frame data to copy from + - size: number of bytes to copy + - frame_index: index of the frame + + Outputs + ---- + None + + Fork + ---- + Amsterdam + + Gas: 3 + 3 * ceil(size / 32) (plus memory expansion) + """ + + FRAMEPARAM = Opcode( + 0xB3, + popped_stack_items=2, + pushed_stack_items=1, + kwargs=["frame_index", "param"], + ) + """ + FRAMEPARAM(frame_index, param) + ---- + + Description + ---- + Push frame-scoped information of the chosen frame of the executing + EIP-8141 frame transaction (resolved target, gas limit, mode, + flags, data length, status, allowed approval scope, atomic batch + bit, value). + + Inputs + ---- + - frame_index: index of the frame + - param: parameter selector (0x00-0x08) + + Outputs + ---- + - value: the requested frame parameter + + Fork + ---- + Amsterdam + + Gas: 2 + """ + + SIGPARAM = Opcode( + 0xB4, + popped_stack_items=2, + pushed_stack_items=1, + kwargs=["signature_index", "param"], + ) + """ + SIGPARAM(signature_index, param) + ---- + + Description + ---- + Push signature-scoped metadata of the chosen signature entry of the + executing EIP-8141 frame transaction (effective signer, scheme, + msg, signature length). With `param=0x04` (arity handled manually + by the caller), copies an ARBITRARY entry's raw signature bytes to + memory with CALLDATACOPY semantics. + + Inputs + ---- + - signature_index: index of the signature entry + - param: parameter selector (0x00-0x04) + + Outputs + ---- + - value: the requested signature parameter + + Fork + ---- + Amsterdam + + Gas: 2 (params 0x00-0x03) + """ + _push_opcodes_byte_list: List[Opcode] = [ Opcodes.PUSH1, diff --git a/pyproject.toml b/pyproject.toml index 23a1eff6527..0f5d2830379 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,6 +174,7 @@ packages = [ "ethereum.forks.bpo5.vm.precompiled_contracts", "ethereum.forks.bpo5.vm.precompiled_contracts.bls12_381", "ethereum.forks.amsterdam", + "ethereum.forks.amsterdam.transactions", "ethereum.forks.amsterdam.utils", "ethereum.forks.amsterdam.vm", "ethereum.forks.amsterdam.vm.instructions", @@ -308,6 +309,11 @@ omit = [ "*/ethereum/forks/*_glacier/*", "*/ethereum/forks/dao_fork/*", "*/ethereum/forks/bpo*/*", + # TODO: Only the EIP-8141 frame transaction tests fill for Amsterdam + # (under the ``Bogota`` pseudo-fork), so the coverage-gated jobs stop + # short of it (``FILL_UNTIL: Osaka``). Remove this entry as the test + # run is expanded to Amsterdam in subsequent PRs. + "*/ethereum/forks/amsterdam/*", ] [tool.coverage.report] diff --git a/src/ethereum/forks/amsterdam/blocks.py b/src/ethereum/forks/amsterdam/blocks.py index 68732a167d4..cc5a5fd34e1 100644 --- a/src/ethereum/forks/amsterdam/blocks.py +++ b/src/ethereum/forks/amsterdam/blocks.py @@ -29,6 +29,7 @@ SetCodeTransaction, Transaction, ) +from .transactions.frame_transaction import FrameStatus, FrameTransaction @final @@ -390,7 +391,65 @@ class Receipt: """ -def encode_receipt(tx: Transaction, receipt: Receipt) -> Bytes | Receipt: +@final +@slotted_freezable +@dataclass +class FrameReceipt: + """ + Result of a single frame's execution, included in the frame + transaction's receipt. + """ + + status: FrameStatus + """ + Outcome of the frame's execution. + """ + + gas_used: Uint + """ + Gas used by the frame, not reduced by refunds. + """ + + logs: Tuple[Log, ...] + """ + A tuple of logs generated by this frame. Emptied when the frame's + atomic batch was unrolled. + """ + + +@final +@slotted_freezable +@dataclass +class FrameTransactionReceipt: + """ + Result of a frame transaction's execution. Frame transaction + receipts are included in the receipts trie. + + Unlike `Receipt`, there is no transaction-level status and no + bloom filter: outcomes are reported per frame, and the logs of all + frames contribute to the block's log bloom in frame order. + """ + + cumulative_gas_used: Uint + """ + Total gas used in the block up to and including this transaction. + This is the gas used after refunds, paid by the payer. + """ + + payer: Address + """ + The account that paid for the transaction's gas. + """ + + frame_receipts: Tuple[FrameReceipt, ...] + """ + A tuple of per-frame receipts, in frame order. + """ + + +def encode_receipt( + tx: Transaction, receipt: Receipt | FrameTransactionReceipt +) -> Bytes | Receipt: r""" Encodes a transaction receipt based on the transaction type. @@ -399,6 +458,7 @@ def encode_receipt(tx: Transaction, receipt: Receipt) -> Bytes | Receipt: - FeeMarketTransaction receipts are prefixed with `b"\x02"`. - BlobTransaction receipts are prefixed with `b"\x03"`. - SetCodeTransaction receipts are prefixed with `b"\x04"`. + - FrameTransaction receipts are prefixed with `b"\x06"`. - LegacyTransaction receipts are returned as is. """ if isinstance(tx, AccessListTransaction): @@ -409,11 +469,16 @@ def encode_receipt(tx: Transaction, receipt: Receipt) -> Bytes | Receipt: return b"\x03" + rlp.encode(receipt) elif isinstance(tx, SetCodeTransaction): return b"\x04" + rlp.encode(receipt) + elif isinstance(tx, FrameTransaction): + return b"\x06" + rlp.encode(receipt) else: + assert isinstance(receipt, Receipt) return receipt -def decode_receipt(receipt: Bytes | Receipt) -> Receipt: +def decode_receipt( + receipt: Bytes | Receipt, +) -> Receipt | FrameTransactionReceipt: r""" Decodes a receipt from its serialized form. @@ -426,9 +491,13 @@ def decode_receipt(receipt: Bytes | Receipt) -> Receipt: receipts. - Receipts prefixed with `b"\x04"` are decoded as SetCodeTransaction receipts. + - Receipts prefixed with `b"\x06"` are decoded as FrameTransaction + receipts. - LegacyTransaction receipts are returned as is. """ if isinstance(receipt, Bytes): + if receipt[0] == 6: + return rlp.decode_to(FrameTransactionReceipt, receipt[1:]) assert receipt[0] in (1, 2, 3, 4) return rlp.decode_to(Receipt, receipt[1:]) else: diff --git a/src/ethereum/forks/amsterdam/exceptions.py b/src/ethereum/forks/amsterdam/exceptions.py index 6ef5651cfa8..7692dc96366 100644 --- a/src/ethereum/forks/amsterdam/exceptions.py +++ b/src/ethereum/forks/amsterdam/exceptions.py @@ -111,6 +111,13 @@ class NoBlobDataError(InvalidTransaction): """ +class InvalidMaxFeePerBlobGas(InvalidTransaction): + """ + The transaction carries no blobs but has a nonzero + `max_fee_per_blob_gas`. + """ + + class BlobCountExceededError(InvalidTransaction): """ The transaction has more blobs than the limit. @@ -145,6 +152,54 @@ class TransactionGasLimitExceededError(InvalidTransaction): """ +class FrameCountError(InvalidTransaction): + """ + The transaction has either too many or two few [`Frame`]s to be valid. + + [`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame + """ # noqa: E501 + + maximum: Final[Uint] + """ + Any more than this number of [`Frame`]s invalidates a transaction. + + [`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame + """ # noqa: E501 + + actual: Final[Uint] + """ + Number of [`Frame`]s actually included in the transaction. + + [`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame + """ # noqa: E501 + + def __init__(self, actual: Uint, maximum: Uint) -> None: + message = ( + f"transaction must contain between 1 and {maximum} frames, " + f"inclusive (got {actual})" + ) + + super().__init__(message) + self.maximum = maximum + self.actual = actual + + +class InvalidFrameError(InvalidTransaction): + """ + A [`Frame`] did not pass validation. + + [`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame + """ # noqa: E501 + + +class FrameTransactionExecutionError(InvalidTransaction): + """ + A frame transaction violated a validity rule that is only checkable + during execution: a `VERIFY` frame reverted, a `SENDER` frame ran + before execution approval, or no frame approved gas payment. + """ + + class BlockAccessListGasLimitExceededError(InvalidBlock): """ The block access list exceeds the gas limit constraint. diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index da6a5929f0b..7b1a647debe 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -28,8 +28,19 @@ ) from ethereum.forks.bpo5.blocks import Header as PreviousHeader from ethereum.merkle_patricia_trie import root, trie_set -from ethereum.state import EMPTY_CODE_HASH, Address, BlockDiff -from ethereum.state_mpt import State, apply_changes_to_state +from ethereum.state import ( + EMPTY_ACCOUNT, + EMPTY_CODE_HASH, + Account, + Address, + BlockDiff, +) +from ethereum.state_mpt import ( + State, + apply_changes_to_state, + set_account, + store_code, +) from . import vm from .block_access_lists import ( @@ -42,6 +53,7 @@ from .bloom import logs_bloom from .exceptions import WrongChainIdError from .fork_types import Authorization, BlockAccessIndex +from .frame_processing import process_frame_transaction from .requests import ( BUILDER_DEPOSIT_REQUEST_TYPE, BUILDER_EXIT_REQUEST_TYPE, @@ -79,6 +91,11 @@ recover_sender, validate_transaction, ) +from .transactions.frame_transaction import ( + EXPIRY_VERIFIER, + EXPIRY_VERIFIER_CODE, + FrameTransaction, +) from .utils.address import compute_contract_address from .utils.hexadecimal import hex_to_address from .vm.eoa_delegation import is_valid_delegation @@ -166,24 +183,35 @@ class BlockChain: def apply_fork(old: BlockChain) -> BlockChain: """ - Transforms the state from the previous hard fork (`old`) into the block - chain object for this hard fork and returns it. - - When forks need to implement an irregular state transition, this function - is used to handle the irregularity. See the :ref:`DAO Fork ` for - an example. - - Parameters - ---------- - old : - Previous block chain object. - - Returns - ------- - new : `BlockChain` - Upgraded block chain object for this hard fork. - - """ + Transform the state from the previous hard fork (`old`) into the + block chain object for this hard fork and return it. + + As required by [EIP-8141], the runtime code of the expiry verifier + contract ([`EXPIRY_VERIFIER_CODE`][evc]) is installed at + [`EXPIRY_VERIFIER`][ev] when this fork activates. Only the code is + installed: the account's other fields are left untouched, so a + previously nonexistent account keeps a zero nonce and any balance + the account held before the fork is preserved. + + [EIP-8141]: https://eips.ethereum.org/EIPS/eip-8141 + [ev]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.EXPIRY_VERIFIER + [evc]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.EXPIRY_VERIFIER_CODE + """ # noqa: E501 + state = old.state + existing_account = state.get_account_optional(EXPIRY_VERIFIER) + if existing_account is None: + existing_account = EMPTY_ACCOUNT + + code_hash = store_code(state, EXPIRY_VERIFIER_CODE) + set_account( + state, + EXPIRY_VERIFIER, + Account( + nonce=existing_account.nonce, + balance=existing_account.balance, + code_hash=code_hash, + ), + ) return old @@ -536,6 +564,8 @@ def check_transaction( limit. """ + assert not isinstance(tx, FrameTransaction) + sender = recover_sender(tx) intrinsic = validate_transaction(tx, sender) tx_state = TransactionState(parent=block_env.state) @@ -606,10 +636,6 @@ def check_transaction( return vm.TransactionEnvironment( origin=sender, - recipient=recipient, - is_create=is_create, - data=tx.data, - value=tx.value, gas_limit=tx.gas, effective_gas_price=effective_gas_price, execution_gas_grant=allocation.execution_gas, @@ -623,6 +649,13 @@ def check_transaction( authorizations=authorizations, index_in_block=index, tx_hash=get_transaction_hash(encode_transaction(tx)), + top_level_context=vm.TopLevelContext( + recipient=recipient, + is_create=is_create, + data=tx.data, + value=tx.value, + ), + frame_context=None, ) @@ -750,10 +783,6 @@ def process_unchecked_system_transaction( tx_env = vm.TransactionEnvironment( origin=SYSTEM_ADDRESS, - recipient=target_address, - is_create=False, - data=data, - value=U256(0), gas_limit=SYSTEM_TRANSACTION_GAS, effective_gas_price=block_env.base_fee_per_gas, execution_gas_grant=SYSTEM_TRANSACTION_GAS, @@ -770,6 +799,13 @@ def process_unchecked_system_transaction( authorizations=(), index_in_block=None, tx_hash=None, + top_level_context=vm.TopLevelContext( + recipient=target_address, + is_create=False, + data=data, + value=U256(0), + ), + frame_context=None, ) system_tx_output = process_top_level(block_env, tx_env) @@ -1049,6 +1085,9 @@ def process_transaction( actual=tx_chain_id, ) + if isinstance(tx, FrameTransaction): + return process_frame_transaction(block_env, block_output, tx, index) + tx_env = check_transaction(block_env, block_output, tx, index) update_sender_state(block_env, tx_env, tx) diff --git a/src/ethereum/forks/amsterdam/frame_processing.py b/src/ethereum/forks/amsterdam/frame_processing.py new file mode 100644 index 00000000000..7624c895bae --- /dev/null +++ b/src/ethereum/forks/amsterdam/frame_processing.py @@ -0,0 +1,317 @@ +""" +Frame transaction processing. + +The block-level flow for [EIP-8141] frame transactions, separate from +the regular flow in `fork.py` from admission onwards: a frame +transaction has no single top-level call to dispatch — it executes a +list of frames — and no upfront sender payment: the sender's nonce +increment and the collection of the transaction's maximum cost are +effects of the `APPROVE` instruction, during execution. + +[EIP-8141]: https://eips.ethereum.org/EIPS/eip-8141 +""" + +from typing import Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U256, Uint + +from ethereum.merkle_patricia_trie import trie_set +from ethereum.state import Address + +from . import vm +from .blocks import ( + FrameReceipt, + FrameTransactionReceipt, + Receipt, + encode_receipt, +) +from .state_tracker import ( + TransactionState, + clear_account_preserving_balance, + create_ether, + get_account, + incorporate_tx_into_block, +) +from .transactions import ( + calculate_effective_gas_price, + check_nonce, + encode_transaction, + get_transaction_hash, +) +from .transactions.frame_transaction import ( + FrameTransaction, + validate_frame_transaction, +) +from .vm.frame_interpreter import process_frames +from .vm.gas import ( + TransactionGasSettlement, + calculate_blob_gas_price, + calculate_total_blob_gas, + check_block_gas_capacity, + check_max_fee_per_blob_gas, + settle_transaction_gas, +) + + +def check_frame_transaction( + block_env: vm.BlockEnvironment, + block_output: vm.BlockOutput, + tx: FrameTransaction, + index: Uint, +) -> vm.TransactionEnvironment: + """ + Admit a raw frame transaction and build its execution environment. + + Statically validate the transaction and check that it is includable + in the block, in that order, so that a transaction invalid in + several ways reports the earliest failure. + + Unlike the regular flow, the sender needs no recovery — it is an + explicit field, authenticated by the signature entries during + static validation — and no balance or EOA check: payment is + collected from the payer during execution, when a frame `APPROVE`s + it. + + Parameters + ---------- + block_env : + The block scoped environment. + block_output : + The block output for the current block. + tx : + The frame transaction. + index : + The index of the current transaction. + + Returns + ------- + tx_env : + The environment for executing the transaction. + + Raises + ------ + InvalidBlock : + If the transaction is not includable. + InvalidSignatureError : + If a signature entry is cryptographically invalid. + InvalidFrameError : + If the frames or signature entries violate a structural + constraint. + TransactionGasLimitExceededError : + If the derived gas limit exceeds the maximum allowed for a + transaction. + GasUsedExceedsLimitError : + If the gas used by the transaction exceeds the block's gas limit. + NonceMismatchError : + If the nonce of the transaction is not equal to the sender's nonce. + InsufficientMaxFeePerGasError : + If the maximum fee per gas is insufficient for the transaction. + InsufficientMaxFeePerBlobGasError : + If the maximum fee per blob gas is insufficient for the transaction. + BlobGasLimitExceededError : + If the blob gas used by the transaction exceeds the block's blob gas + limit. + + """ + validation = validate_frame_transaction(tx) + tx_state = TransactionState(parent=block_env.state) + + check_block_gas_capacity( + block_env, + block_output, + validation.max_gas, + calculate_total_blob_gas(tx), + ) + + sender_account = get_account(tx_state, tx.sender) + + effective_gas_price = calculate_effective_gas_price( + tx, block_env.base_fee_per_gas + ) + + check_max_fee_per_blob_gas( + tx.blob_versioned_hashes, + tx.max_fee_per_blob_gas, + block_env.excess_blob_gas, + ) + + check_nonce(tx, sender_account.nonce) + + # A state gas reservoir holds only gas above `TX_MAX_GAS_LIMIT`, + # and the derived `max_gas` never exceeds that cap: a frame + # transaction's reservoir is always empty, and state gas spills + # from execution gas instead. + execution_gas_grant = validation.standard_gas_limit - Uint( + validation.intrinsic.execution + ) + + max_cost = validation.max_gas * tx.max_fee_per_gas + Uint( + calculate_total_blob_gas(tx) + ) * calculate_blob_gas_price(block_env.excess_blob_gas) + + return vm.TransactionEnvironment( + origin=tx.sender, + gas_limit=validation.max_gas, + effective_gas_price=effective_gas_price, + execution_gas_grant=execution_gas_grant, + state_gas_reservoir=Uint(0), + calldata_floor=validation.intrinsic.calldata_floor, + access_list_addresses=set(), + access_list_storage_keys=set(), + accounts_with_paid_writes={tx.sender}, + state=tx_state, + blob_versioned_hashes=tx.blob_versioned_hashes, + authorizations=(), + index_in_block=index, + tx_hash=get_transaction_hash(encode_transaction(tx)), + top_level_context=None, + frame_context=vm.FrameContext( + tx=tx, + signature_hash=validation.signature_hash, + resolved_signers=validation.resolved_signers, + standard_gas_limit=validation.standard_gas_limit, + max_cost=max_cost, + current_frame_index=Uint(0), + frame_receipts=[], + payer=None, + sender_approved=False, + ), + ) + + +def disburse_frame_gas_fees( + block_env: vm.BlockEnvironment, + tx_env: vm.TransactionEnvironment, + settlement: TransactionGasSettlement, +) -> None: + """ + Refund the payer's unspent escrow and pay the priority fee. + + At `APPROVE` the payer escrowed the transaction's maximum cost, + priced at the maximum fee per gas; the refund is that escrow less + the charged fee — the gas used priced at the effective gas price, + plus the blob gas fee, which appears in both terms and cancels. + Refunding the unused gas at the effective gas price, as the + regular flow does, would under-refund the escrowed difference + between the two prices. + + The coinbase's priority fee on the gas used is unchanged from the + regular flow. + """ + frame_context = tx_env.frame_context + assert frame_context is not None + # `process_frames` invalidates the transaction unless a frame + # approved payment. + payer = frame_context.payer + assert payer is not None + + blob_gas_fee = Uint( + calculate_total_blob_gas(frame_context.tx) + ) * calculate_blob_gas_price(block_env.excess_blob_gas) + charged_fee = ( + settlement.gas_used * tx_env.effective_gas_price + blob_gas_fee + ) + payer_refund = frame_context.max_cost - charged_fee + + priority_fee_per_gas = ( + tx_env.effective_gas_price - block_env.base_fee_per_gas + ) + transaction_fee = settlement.gas_used * priority_fee_per_gas + + create_ether(tx_env.state, payer, U256(payer_refund)) + create_ether(tx_env.state, block_env.coinbase, U256(transaction_fee)) + + +def make_frame_receipt( + tx: FrameTransaction, + payer: Address, + cumulative_gas_used: Uint, + frame_receipts: Tuple[FrameReceipt, ...], +) -> Bytes | Receipt: + """ + Make the receipt for a frame transaction that was executed. + + Unlike a regular receipt there is no transaction-level status and + no bloom filter: outcomes are reported per frame, and the frames' + logs reach the block's log bloom through the block accumulator. + """ + receipt = FrameTransactionReceipt( + cumulative_gas_used=cumulative_gas_used, + payer=payer, + frame_receipts=frame_receipts, + ) + + return encode_receipt(tx, receipt) + + +def process_frame_transaction( + block_env: vm.BlockEnvironment, + block_output: vm.BlockOutput, + tx: FrameTransaction, + index: Uint, +) -> None: + """ + Execute a frame transaction against the provided environment. + + Admit the transaction, execute its frames in order, settle the gas, + disburse the fees, and write the frame transaction receipt. + """ + tx_env = check_frame_transaction(block_env, block_output, tx, index) + + tx_output = process_frames(block_env, tx_env) + + frame_context = tx_env.frame_context + assert frame_context is not None + # `process_frames` invalidates the transaction unless a frame + # approved payment. + payer = frame_context.payer + assert payer is not None + + # Settlement anchors on the standard gas limit; the floor headroom + # above it — nonzero only when the calldata floor exceeds the + # standard gas limit — is never executable and counts as unused + # gas, so the floor can bind without an underflow in the gas + # accounting. + floor_headroom = tx_env.gas_limit - frame_context.standard_gas_limit + settlement = settle_transaction_gas( + tx_env.gas_limit, + tx_env.calldata_floor, + tx_output.gas_left + floor_headroom, + tx_output.state_gas_left, + tx_output.refund_counter, + tx_output.state_gas_used, + ) + + disburse_frame_gas_fees(block_env, tx_env, settlement) + + block_output.block_gas_used += settlement.execution_gas_used + block_output.block_state_gas_used += settlement.state_gas_used + block_output.blob_gas_used += calculate_total_blob_gas(tx) + + block_output.cumulative_gas_used += settlement.gas_used + receipt = make_frame_receipt( + tx, + payer, + block_output.cumulative_gas_used, + tuple(frame_context.frame_receipts), + ) + + receipt_key = rlp.encode(Uint(index)) + block_output.receipt_keys += (receipt_key,) + + trie_set( + block_output.receipts_trie, + receipt_key, + receipt, + ) + + block_output.block_logs += tx_output.logs + + for address in tx_output.accounts_to_delete: + clear_account_preserving_balance(tx_env.state, address) + + incorporate_tx_into_block( + tx_env.state, block_env.block_access_list_builder + ) diff --git a/src/ethereum/forks/amsterdam/requests.py b/src/ethereum/forks/amsterdam/requests.py index fdfab016599..70f73d8e997 100644 --- a/src/ethereum/forks/amsterdam/requests.py +++ b/src/ethereum/forks/amsterdam/requests.py @@ -42,7 +42,7 @@ from ethereum.merkle_patricia_trie import trie_get from ethereum.utils.hexadecimal import hex_to_bytes32 -from .blocks import decode_receipt +from .blocks import FrameTransactionReceipt, decode_receipt from .utils.hexadecimal import hex_to_address from .vm import BlockOutput @@ -292,7 +292,17 @@ def parse_deposit_requests(block_output: BlockOutput) -> Bytes: receipt = trie_get(block_output.receipts_trie, key) assert receipt is not None decoded_receipt = decode_receipt(receipt) - for log in decoded_receipt.logs: + if isinstance(decoded_receipt, FrameTransactionReceipt): + # A frame transaction's logs are its frames' logs, in + # frame order. + logs = tuple( + log + for frame_receipt in decoded_receipt.frame_receipts + for log in frame_receipt.logs + ) + else: + logs = decoded_receipt.logs + for log in logs: if log.address == DEPOSIT_CONTRACT_ADDRESS: if ( len(log.topics) > 0 diff --git a/src/ethereum/forks/amsterdam/transactions.py b/src/ethereum/forks/amsterdam/transactions/__init__.py similarity index 96% rename from src/ethereum/forks/amsterdam/transactions.py rename to src/ethereum/forks/amsterdam/transactions/__init__.py index 693064688da..140c4a3e910 100644 --- a/src/ethereum/forks/amsterdam/transactions.py +++ b/src/ethereum/forks/amsterdam/transactions/__init__.py @@ -23,7 +23,7 @@ ) from ethereum.state import Address -from .exceptions import ( +from ..exceptions import ( BlobCountExceededError, EmptyAuthorizationListError, InitCodeTooLargeError, @@ -34,7 +34,8 @@ TransactionTypeContractCreationError, TransactionTypeError, ) -from .fork_types import Authorization, ExecutionGas, VersionedHash +from ..fork_types import Authorization, ExecutionGas, VersionedHash +from .frame_transaction import FrameTransaction @final @@ -495,6 +496,7 @@ class SetCodeTransaction: | FeeMarketTransaction | BlobTransaction | SetCodeTransaction + | FrameTransaction ) """ Union type representing any valid transaction type. @@ -519,7 +521,10 @@ class SetCodeTransaction: FeeMarketCapableTransaction = ( - FeeMarketTransaction | BlobTransaction | SetCodeTransaction + FeeMarketTransaction + | BlobTransaction + | SetCodeTransaction + | FrameTransaction ) """ Transaction types that include the [EIP-1559]-style fee structure. @@ -531,6 +536,17 @@ class SetCodeTransaction: """ +BlobCapableTransaction = BlobTransaction | FrameTransaction +""" +Transaction types that include the [EIP-4844]-style blobs. + +See [`BlobTransaction`][fmt] for more details. + +[EIP-4844]: https://eips.ethereum.org/EIPS/eip-4844 +[fmt]: ref:ethereum.forks.amsterdam.transactions.BlobTransaction +""" + + def encode_transaction(tx: Transaction) -> LegacyTransaction | Bytes: """ Encode a transaction into its RLP or typed transaction format. @@ -549,6 +565,8 @@ def encode_transaction(tx: Transaction) -> LegacyTransaction | Bytes: return b"\x03" + rlp.encode(tx) elif isinstance(tx, SetCodeTransaction): return b"\x04" + rlp.encode(tx) + elif isinstance(tx, FrameTransaction): + return b"\x06" + rlp.encode(tx) else: raise Exception(f"Unable to encode transaction of type {type(tx)}") @@ -574,6 +592,8 @@ def decode_transaction(tx: LegacyTransaction | Bytes) -> Transaction: return rlp.decode_to(BlobTransaction, tx[1:]) elif tx[0] == 4: return rlp.decode_to(SetCodeTransaction, tx[1:]) + elif tx[0] == 6: + return rlp.decode_to(FrameTransaction, tx[1:]) elif tx[0] >= 0xC0: assert tx[0] <= 0xFE return rlp.decode_to(LegacyTransaction, tx) @@ -615,7 +635,9 @@ def validate_transaction(tx: Transaction, sender: Address) -> IntrinsicGasCost: [EIP-2681]: https://eips.ethereum.org/EIPS/eip-2681 [EIP-7623]: https://eips.ethereum.org/EIPS/eip-7623 """ - from .vm.interpreter import MAX_INIT_CODE_SIZE + from ..vm.interpreter import MAX_INIT_CODE_SIZE + + assert not isinstance(tx, FrameTransaction) if U256(tx.nonce) >= U256(U64.MAX_VALUE): raise NonceOverflowError("Nonce too high") @@ -657,6 +679,7 @@ def validate_transaction(tx: Transaction, sender: Address) -> IntrinsicGasCost: raise InsufficientTransactionGasError("Insufficient intrinsic gas") if intrinsic.calldata_floor > tx.gas: raise InsufficientTransactionGasError("Insufficient calldata floor") + if intrinsic.execution > TX_MAX_GAS_LIMIT: raise InsufficientTransactionGasError( "Intrinsic execution gas exceeds TX_MAX_GAS_LIMIT" @@ -708,7 +731,11 @@ def calculate_intrinsic_cost( execution-gas portion of items 1 to 3 above rather than `TX_BASE` alone, so it never undercuts the transaction's own intrinsic base. """ - from .vm.gas import GasCosts, init_code_cost + from ..vm.gas import GasCosts, init_code_cost + + # Frame transactions never reach this function; their intrinsic cost + # is calculated by `calculate_frame_transaction_intrinsic_cost`. + assert not isinstance(tx, FrameTransaction) tokens_in_calldata = count_tokens_in_data(tx.data) @@ -869,6 +896,8 @@ def recover_sender(tx: Transaction) -> Address: the address of the sender of the transaction. It raises an `InvalidSignatureError` if the signature values (r, s, v) are invalid. """ + assert not isinstance(tx, FrameTransaction) + r, s = tx.r, tx.s if U256(0) >= r or r >= SECP256K1N: raise InvalidSignatureError("bad r") diff --git a/src/ethereum/forks/amsterdam/transactions/frame_transaction.py b/src/ethereum/forks/amsterdam/transactions/frame_transaction.py new file mode 100644 index 00000000000..34326336997 --- /dev/null +++ b/src/ethereum/forks/amsterdam/transactions/frame_transaction.py @@ -0,0 +1,776 @@ +""" +Frame transactions, the transaction type introduced in [EIP-8141]. + +A frame transaction expresses validity conditions, gas payment, and +execution as an explicit list of [`Frame`]s, each a unit of execution +with its own mode, target, and gas limit. Signatures are carried +alongside the frames in a list of [`FrameSignature`] entries that the +protocol validates before any frame executes. + +[EIP-8141]: https://eips.ethereum.org/EIPS/eip-8141 +[`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame +[`FrameSignature`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignature +""" # noqa: E501 + +from dataclasses import dataclass, replace +from enum import STRICT +from typing import TYPE_CHECKING, Final, Optional, Tuple, assert_never, final + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes0, Bytes32 +from ethereum_types.enum import UintEnum, UintFlag +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U64, U256, Uint, ulen + +from ethereum.crypto.elliptic_curve import ( + SECP256K1N, + SECP256R1N, + secp256k1_recover, + secp256r1_verify, +) +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.exceptions import InvalidSignatureError, NonceOverflowError +from ethereum.state import Address + +from ..exceptions import ( + BlobCountExceededError, + FrameCountError, + InvalidBlobVersionedHashError, + InvalidFrameError, + InvalidMaxFeePerBlobGas, + PriorityFeeGreaterThanMaxFeeError, + TransactionGasLimitExceededError, +) +from ..fork_types import ExecutionGas, VersionedHash + +if TYPE_CHECKING: + from . import IntrinsicGasCost + +MAX_FRAMES_PER_TX: Final[Uint] = Uint(64) +""" +Maximum number of [`Frame`]s allowed per [`FrameTransaction`][ftx]. + +[`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame +[ftx]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameTransaction +""" # noqa: E501 + +EXPIRY_VERIFIER: Final[Address] = Address( + bytes.fromhex("0000000000000000000000000000000000008141") +) +""" +Address of the expiry verifier contract. + +A [`VERIFY`][v] frame targeting this address is an _expiry verifier frame_: +its data holds an unsigned big-endian expiry timestamp, and the frame +reverts unless the block timestamp is at or before that expiry. Such frames +are subject to additional validity constraints, checked in +[`validate_frame_transaction`][vft]. + +[v]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameMode.VERIFY +[vft]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.validate_frame_transaction +""" # noqa: E501 + +EXPIRY_VERIFIER_CODE: Final[Bytes] = Bytes( + bytes.fromhex("60083614600a575f5ffd5b5f3560c01c4211601657005b5f5ffd") +) +""" +Runtime code of the expiry verifier contract, installed at +[`EXPIRY_VERIFIER`][ev] when the fork activates (see [`apply_fork`][af]). + +The code reverts unless called with exactly [`EXPIRY_DATA_LENGTH`][edl] +bytes of calldata holding an unsigned big-endian expiry timestamp at or +after the current block timestamp. + +[ev]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.EXPIRY_VERIFIER +[af]: ref:ethereum.forks.amsterdam.fork.apply_fork +[edl]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.EXPIRY_DATA_LENGTH +""" # noqa: E501 + +EXPIRY_DATA_LENGTH: Final[int] = 8 +""" +Exact length, in bytes, of an expiry verifier frame's data: an unsigned +big-endian expiry timestamp. +""" + + +@final +class FrameMode(UintEnum, boundary=STRICT): + """ + Indicates the purpose of a [`Frame`]. + + The strict boundary rejects values other than the modes defined here as + the enum is constructed — notably while decoding a transaction — so a + frame with an undefined mode never decodes and no separate validity + check is required. + + [`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame + """ # noqa: E501 + + DEFAULT = Uint(0) + """ + Execute frame as [`FRAME_ENTRY_POINT`][fep]. + + [fep]: ref:ethereum.forks.amsterdam.vm.FRAME_ENTRY_POINT + """ + + VERIFY = Uint(1) + """ + Identify frame as transaction validation. + """ + + SENDER = Uint(2) + """ + Execute frame as [`sender`][s]. + + [s]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameTransaction.sender + """ # noqa: E501 + + +@final +class FrameFlag(UintFlag, boundary=STRICT): + """ + Frame or mode features. + + Each member represents a single bit, and any combination of the bits + defined here is a valid set of flags. The strict boundary rejects values + with any other bit set as the flag is constructed — notably while + decoding a transaction — so a frame carrying a reserved flag bit never + decodes and no separate validity check is required. + """ + + APPROVE_PAYMENT = Uint(1) + """ + [`Frame`] has permission to approve payment. + + [`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame + """ # noqa: E501 + + APPROVE_EXECUTION = Uint(2) + """ + [`Frame`] has permission to approve execution. + + [`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame + """ # noqa: E501 + + ATOMIC_BATCH = Uint(4) + """ + [`Frame`] belongs to an atomic batch. + + All frames within an atomic batch either all succeed or are all reverted. + + [`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame + """ # noqa: E501 + + +APPROVE_SCOPE_MASK: Final[FrameFlag] = ( + FrameFlag.APPROVE_PAYMENT | FrameFlag.APPROVE_EXECUTION +) +""" +The flag bits holding a frame's allowed approval scope. +""" + + +@final +class FrameStatus(UintEnum): + """ + Outcome of a completed frame, as reported in the frame + transaction's receipt and exposed by the `FRAMEPARAM` opcode. + + Statuses exist only for completed frames — reading the status of + the current or a future frame is an exceptional halt — so there is + no pending member. A frame that executed inside a later-unrolled + atomic batch keeps its execution status; `SKIPPED` marks only + frames that never ran. + """ + + FAILURE = Uint(0) + """ + The frame executed and reverted or exceptionally halted. + """ + + SUCCESS = Uint(1) + """ + The frame executed and completed successfully. + """ + + SKIPPED = Uint(2) + """ + The frame never executed because an earlier frame of its atomic + batch failed. + """ + + +@final +@slotted_freezable +@dataclass +class Frame: + """ + Unit of execution defined in a [`FrameTransaction`][ft]. + + [ft]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameTransaction + """ # noqa: E501 + + mode: FrameMode + """ + Purpose of this frame. + + Specifies the specific execution semantics this frame will execute with. + """ + + flags: FrameFlag + """ + Enable optional frame or mode features. + """ + + to: Bytes0 | Address + """ + Destination or target account for the frame. + """ + + gas: U64 + """ + Maximum amount of gas that can be used by this frame. + """ + + value: U256 + """ + Amount of ether (in wei) to transfer from the [`sender`][s] as part of the + frame execution. + + [s]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameTransaction.sender + """ # noqa: E501 + + data: Bytes + """ + The data payload of the frame, which can be used to call functions on + contracts. + """ + + +@final +class FrameSignatureScheme(UintEnum, boundary=STRICT): + """ + Algorithm used to authenticate [`FrameSignature`][fs]s. + + The strict boundary rejects values other than the schemes defined here + as the enum is constructed — notably while decoding a transaction — so + a signature using a reserved scheme never decodes and no separate + validity check is required. + + [fs]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignature + """ # noqa: E501 + + ARBITRARY = Uint(0) + """ + Arbitrary bytes that the protocol does not cryptographically validate. + """ + + SECP256K1 = Uint(1) + """ + ECDSA signature over the secp256k1 curve, as used by other transaction + types. + """ + + P256 = Uint(2) + """ + Signature over the NIST P-256 (secp256r1) curve. + """ + + +@final +@slotted_freezable +@dataclass +class FrameSignature: + """ + A signature provided to [`VERIFY`][v] frames. + + [v]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameMode.VERIFY + """ # noqa: E501 + + scheme: FrameSignatureScheme + """ + Algorithm used to construct the signature. + """ + + signer: Bytes + """ + Scheme-dependent signer metadata. + + For [`SECP256K1`] and [`P256`], this is a 20-byte address. + + [`SECP256K1`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignatureScheme.SECP256K1 + [`P256`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignatureScheme.P256 + """ # noqa: E501 + + message: Bytes0 | Bytes32 + """ + Either empty, indicating the canonical transaction signature hash, or an + explicit 32-byte digest. + """ + + signature: Bytes + """ + Raw signature bytes, to be interpreted according to [`scheme`]. + + [`scheme`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignature.scheme + """ # noqa: E501 + + +@final +@slotted_freezable +@dataclass +class FrameTransaction: + """ + Transaction type constructed from a series of frames, abstractly defining + validity conditions and gas payment. Introduced in [EIP-8141]. + + [EIP-8141]: https://eips.ethereum.org/EIPS/eip-8141 + """ + + chain_id: U64 + """ + The ID of the chain on which this transaction is executed. + """ + + nonce: U256 + """ + A scalar value equal to the number of transactions sent by the + [`sender`][s]. + + [s]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameTransaction.sender + """ # noqa: E501 + + sender: Address + """ + Address of the account intended to be the sender of the transaction. + """ + + frames: Tuple[Frame, ...] + """ + List of frames to execute. + """ + + signatures: Tuple[FrameSignature, ...] + """ + Validated signatures available to the transaction. + + The `signatures` list contains signatures that may be referenced by + [`VERIFY`][v] frames and by ordinary EVM execution. Every signature in the + list must validate successfully before any [`Frame`] is executed. If any + signature is malformed or invalid, the whole transaction is invalid. + + [v]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameMode.VERIFY + [`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame + """ # noqa: E501 + + max_priority_fee_per_gas: Uint + """ + The maximum priority fee per gas that the sender is willing to pay. + """ + + max_fee_per_gas: Uint + """ + The maximum fee per gas that the sender is willing to pay, including the + base fee and priority fee. + """ + + max_fee_per_blob_gas: U256 + """ + The maximum fee per blob gas that the sender is willing to pay. + """ + + blob_versioned_hashes: Tuple[VersionedHash, ...] + """ + A tuple of objects that represent the versioned hashes of the blobs + included in the transaction. + """ + + +def resolve_frame_target(tx: FrameTransaction, frame: Frame) -> Address: + """ + Resolve the account a frame executes at: an empty `to` resolves to + the transaction's sender in every mode. + """ + if isinstance(frame.to, Bytes0): + return tx.sender + return frame.to + + +def compute_frame_signature_hash(tx: FrameTransaction) -> Hash32: + """ + Compute the canonical signature hash of a frame transaction. + + The raw `signature` bytes of every entry with an empty `msg` are + elided before hashing, since a signature over the canonical hash + cannot commit to its own bytes. + """ + elided_signatures = [] + for signature in tx.signatures: + if len(signature.message) == 0: + elided_signatures.append(replace(signature, signature=Bytes(b""))) + else: + elided_signatures.append(signature) + + elided_tx = replace(tx, signatures=tuple(elided_signatures)) + return keccak256(b"\x06" + rlp.encode(elided_tx)) + + +def validate_signature( + frame_signature: FrameSignature, sender: Address, sig_hash: Hash32 +) -> Optional[Address]: + """ + Validate a single [`FrameSignature`] entry and return the signer + it resolved to. + + The entry's `message` selects what the signature authorizes: empty + means the canonical signature hash `sig_hash` (see + [`compute_frame_signature_hash`][csh]), while a 32-byte value is an + explicit digest. The all-zero digest is invalid, reserving the zero + stack value as the EVM-visible representation of the canonical-hash + case. + + An empty `signer` resolves to `sender`. For the protocol-validated + schemes ([`SECP256K1`][k1] and [`P256`][p256]) the raw signature + bytes must be canonical — one unique encoding per signature, with + low-`s` — and must authenticate the resolved signer. The protocol + does not cryptographically validate [`ARBITRARY`][arb] entries and + assigns them no resolved signer — `None` is returned — so their + `signer` must be empty. + + [`FrameSignature`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignature + [csh]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.compute_frame_signature_hash + [k1]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignatureScheme.SECP256K1 + [p256]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignatureScheme.P256 + [arb]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignatureScheme.ARBITRARY + """ # noqa: E501 + signature_scheme = frame_signature.scheme + signer = frame_signature.signer + signature = frame_signature.signature + + if len(frame_signature.message) == 0: + message = sig_hash + elif len(frame_signature.message) == 32: + if frame_signature.message == b"\0" * 32: + raise InvalidFrameError( + "frame signature message cannot be all zeros" + ) + message = Hash32(frame_signature.message) + else: + raise InvalidFrameError("Invalid signature message length") + + if len(signer) not in [0, Address.LENGTH]: + raise InvalidFrameError("invalid frame signer length") + resolved_signer: Bytes + if len(signer) == 0: + resolved_signer = sender + else: + resolved_signer = signer + + match signature_scheme: + case FrameSignatureScheme.SECP256K1: + if len(signature) != 65: + raise InvalidSignatureError( + "SECP256K1 signature must be 65 bytes" + ) + + v = U256(signature[0]) + r = U256.from_be_bytes(signature[1:33]) + s = U256.from_be_bytes(signature[33:65]) + if v not in (U256(0), U256(1)): + raise InvalidSignatureError("bad v in secp256k1 scheme") + if U256(0) >= r or r >= SECP256K1N: + raise InvalidSignatureError("bad r in secp256k1 scheme") + if U256(0) >= s or s > SECP256K1N // U256(2): + raise InvalidSignatureError("bad s in secp256k1 scheme") + + public_key = secp256k1_recover(r, s, v, message) + + if resolved_signer != keccak256(public_key)[12:]: + raise InvalidFrameError( + "signer does not match in secp256k1 scheme" + ) + + return Address(resolved_signer) + + case FrameSignatureScheme.P256: + if len(signature) != 128: + raise InvalidSignatureError("P256 signature must be 128 bytes") + + r = U256.from_be_bytes(signature[0:32]) + s = U256.from_be_bytes(signature[32:64]) + qx = U256.from_be_bytes(signature[64:96]) + qy = U256.from_be_bytes(signature[96:128]) + + if U256(0) >= r or r >= SECP256R1N: + raise InvalidSignatureError("bad r in p256 scheme") + if U256(0) >= s or s > SECP256R1N // U256(2): + raise InvalidSignatureError("bad s in p256 scheme") + if resolved_signer != keccak256(signature[64:128])[12:]: + raise InvalidFrameError("signer does not match in p256 scheme") + try: + secp256r1_verify(r, s, qx, qy, message) + except ValueError as e: + raise InvalidSignatureError("invalid p256 public key") from e + + return Address(resolved_signer) + + case FrameSignatureScheme.ARBITRARY: + if len(signer) != 0: + raise InvalidFrameError( + "signer length should be zero for arbitrary schemes" + ) + return None + case _ as unreachable: + assert_never(unreachable) + + +@final +@dataclass +class FrameTransactionValidation: + """ + Everything the static validation of a frame transaction + establishes: its intrinsic gas cost, its two gas anchors, and the + signature artifacts retained for execution. + """ + + intrinsic: "IntrinsicGasCost" + """ + The transaction's intrinsic gas cost. + """ + + standard_gas_limit: Uint + """ + Settlement anchor: the intrinsic execution gas cost plus the sum + of the frames' gas limits. + """ + + max_gas: Uint + """ + Inclusion anchor: the larger of `standard_gas_limit` and the + calldata floor. + """ + + signature_hash: Hash32 + """ + The transaction's canonical signature hash. + """ + + resolved_signers: Tuple[Optional[Address], ...] + """ + The signer each signature entry resolved to; `None` for + `ARBITRARY` entries, to which the protocol assigns no signer. + """ + + +def validate_frame_transaction( + tx: FrameTransaction, +) -> FrameTransactionValidation: + """ + Check the statically determinable validity constraints of a frame + transaction and derive its gas anchors. + + Constraints on individual fields — frame modes and flags, signature + schemes, and field lengths — are enforced by their types while the + transaction is decoded, so only the constraints that span several + fields are checked here. + + A frame transaction has no gas limit field; its two gas anchors are + derived instead, and the inclusion-facing `max_gas` must not exceed + the per-transaction gas cap of [EIP-7825]. + + [EIP-7825]: https://eips.ethereum.org/EIPS/eip-7825 + """ + from . import ( + BLOB_COUNT_LIMIT, + TX_MAX_GAS_LIMIT, + VERSIONED_HASH_VERSION_KZG, + ) + + if U256(tx.nonce) >= U256(U64.MAX_VALUE): + raise NonceOverflowError("Nonce too high") + + if tx.max_fee_per_gas < tx.max_priority_fee_per_gas: + raise PriorityFeeGreaterThanMaxFeeError( + "priority fee greater than max fee" + ) + + blob_count = len(tx.blob_versioned_hashes) + if blob_count == 0 and tx.max_fee_per_blob_gas != U256(0): + raise InvalidMaxFeePerBlobGas( + "max fee per blob gas must be zero without blobs" + ) + if blob_count > BLOB_COUNT_LIMIT: + raise BlobCountExceededError( + f"Tx has {blob_count} blobs. Max allowed: {BLOB_COUNT_LIMIT}" + ) + for blob_versioned_hash in tx.blob_versioned_hashes: + if blob_versioned_hash[0:1] != VERSIONED_HASH_VERSION_KZG: + raise InvalidBlobVersionedHashError("invalid blob versioned hash") + + frame_count = ulen(tx.frames) + if frame_count < Uint(1) or frame_count > MAX_FRAMES_PER_TX: + raise FrameCountError(actual=frame_count, maximum=MAX_FRAMES_PER_TX) + + signature_hash = compute_frame_signature_hash(tx) + resolved_signers = tuple( + validate_signature(signature, tx.sender, signature_hash) + for signature in tx.signatures + ) + + has_expiry_verifier_frame = False + total_frame_gas = Uint(0) + for index, frame in enumerate(tx.frames): + total_frame_gas += Uint(frame.gas) + if total_frame_gas > Uint(U64.MAX_VALUE): + raise InvalidFrameError("total frame gas overflows") + + if frame.mode != FrameMode.SENDER and frame.value != U256(0): + raise InvalidFrameError("only sender frames can transfer value") + + if FrameFlag.APPROVE_EXECUTION in frame.flags: + if isinstance(frame.to, Address) and frame.to != tx.sender: + raise InvalidFrameError( + "approve execution frame must target sender" + ) + + if FrameFlag.ATOMIC_BATCH in frame.flags: + if frame.mode == FrameMode.VERIFY: + raise InvalidFrameError( + "atomic batches cannot contain verify frames" + ) + if index + 1 >= len(tx.frames): + raise InvalidFrameError("last frame cannot have atomic flag") + if tx.frames[index + 1].mode == FrameMode.VERIFY: + raise InvalidFrameError( + "atomic batches cannot contain verify frames" + ) + + if frame.mode == FrameMode.VERIFY and frame.to == EXPIRY_VERIFIER: + if has_expiry_verifier_frame: + raise InvalidFrameError("multiple expiry verifier frames") + has_expiry_verifier_frame = True + if frame.flags != FrameFlag(0): + raise InvalidFrameError("expiry verifier frame with flags") + if frame.value != U256(0): + raise InvalidFrameError("expiry verifier frame with value") + if len(frame.data) != EXPIRY_DATA_LENGTH: + raise InvalidFrameError( + "expiry verifier frame data must be an expiry timestamp" + ) + + intrinsic = calculate_frame_transaction_intrinsic_cost(tx) + standard_gas_limit = calculate_frame_transaction_gas_limit( + tx, intrinsic.execution + ) + max_gas = max(standard_gas_limit, Uint(intrinsic.calldata_floor)) + if max_gas > TX_MAX_GAS_LIMIT: + raise TransactionGasLimitExceededError( + "Derived gas limit exceeds TX_MAX_GAS_LIMIT" + ) + + return FrameTransactionValidation( + intrinsic=intrinsic, + standard_gas_limit=standard_gas_limit, + max_gas=max_gas, + signature_hash=signature_hash, + resolved_signers=resolved_signers, + ) + + +def signature_verification_gas(signature: FrameSignature) -> Uint: + """ + Return the gas charged for validating a single signature entry. + """ + from ..vm.gas import GasCosts + + match signature.scheme: + case FrameSignatureScheme.SECP256K1: + return GasCosts.FRAME_SIGNATURE_SCHEME_SECP256K1 + case FrameSignatureScheme.P256: + return GasCosts.FRAME_SIGNATURE_SCHEME_P256 + case FrameSignatureScheme.ARBITRARY: + return GasCosts.FRAME_SIGNATURE_SCHEME_ARBITRARY + case _ as unreachable: + assert_never(unreachable) + + +def calculate_frame_transaction_intrinsic_cost( + tx: FrameTransaction, +) -> "IntrinsicGasCost": + """ + Calculate the gas that is charged to the payer of a frame transaction + before execution is started. + + The intrinsic cost is the base cost, the per-frame cost, the calldata + cost of the byte fields priced as calldata — the `data` of each frame + and the `signer`, `message`, and `signature` bytes of each signature + entry — and the signature verification cost. Unlike other transaction + types, there is no recipient or value component: target access and + value transfer are paid during frame execution from each frame's own + gas limit. + + The calldata floor of [EIP-7623] counts every charged byte uniformly + per [EIP-7976] and is anchored on the costs the transaction always + pays regardless of execution — the base cost, the per-frame cost, and + the signature verification cost — so it never undercuts the + transaction's own intrinsic base. + + [EIP-7623]: https://eips.ethereum.org/EIPS/eip-7623 + [EIP-7976]: https://eips.ethereum.org/EIPS/eip-7976 + """ + from ..vm.gas import GasCosts + from . import IntrinsicGasCost, count_tokens_in_data + + tokens = Uint(0) + data_length = Uint(0) + for frame in tx.frames: + tokens += count_tokens_in_data(frame.data) + data_length += ulen(frame.data) + + signature_gas = Uint(0) + for signature in tx.signatures: + signature_gas += signature_verification_gas(signature) + for data in ( + signature.signer, + signature.message, + signature.signature, + ): + tokens += count_tokens_in_data(data) + data_length += ulen(data) + + # EIP-7976 floor tokens: all charged bytes count uniformly. + floor_tokens = data_length * GasCosts.TX_DATA_TOKEN_STANDARD + + base_execution_gas = ( + GasCosts.TX_FRAME_INTRINSIC + + ulen(tx.frames) * GasCosts.TX_PER_FRAME + + signature_gas + ) + + return IntrinsicGasCost( + execution=ExecutionGas( + base_execution_gas + tokens * GasCosts.TX_DATA_TOKEN_STANDARD + ), + calldata_floor=ExecutionGas( + base_execution_gas + floor_tokens * GasCosts.TX_DATA_TOKEN_FLOOR + ), + ) + + +def calculate_frame_transaction_gas_limit( + tx: FrameTransaction, intrinsic_execution_gas: ExecutionGas +) -> Uint: + """ + Calculate the total gas limit of a frame transaction. + + Frame transactions have no gas limit field. Their gas limit is + derived instead: the sum of the transaction's intrinsic execution + gas cost, as returned by + `calculate_frame_transaction_intrinsic_cost`, + and the gas limits of all frames. + """ + total_frame_gas = Uint(0) + for frame in tx.frames: + total_frame_gas += Uint(frame.gas) + + return Uint(intrinsic_execution_gas) + total_frame_gas diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 4ebc1ba2640..761bcc98add 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -12,10 +12,11 @@ `.fork_types.Account`. """ -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import List, Optional, Set, Tuple, final from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.frozen import slotted_freezable from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32, keccak256 @@ -25,10 +26,22 @@ from ethereum.utils.byte import left_pad_zero_bytes from ..block_access_lists import BlockAccessList, BlockAccessListBuilder -from ..blocks import Log, Receipt, Withdrawal +from ..blocks import FrameReceipt, Log, Receipt, Withdrawal from ..fork_types import Authorization, VersionedHash -from ..state_tracker import BlockState, TransactionState +from ..state_tracker import ( + BlockState, + TransactionState, + get_account, + increment_nonce, + set_account_balance, +) from ..transactions import LegacyTransaction +from ..transactions.frame_transaction import ( + APPROVE_SCOPE_MASK, + FrameFlag, + FrameTransaction, + resolve_frame_target, +) from .gas import GasMeter __all__ = ("Environment", "Evm") @@ -36,6 +49,9 @@ SYSTEM_ADDRESS = Address( bytes.fromhex("fffffffffffffffffffffffffffffffffffffffe") ) +FRAME_ENTRY_POINT = Address( + bytes.fromhex("00000000000000000000000000000000000000aa") +) CALL_SUCCESS = U256(1) @@ -115,18 +131,115 @@ class BlockOutput: @final +@slotted_freezable @dataclass -class TransactionEnvironment: +class TopLevelContext: """ - Items that are used while processing a transaction. + The single top-level call or creation a non-frame transaction + describes. + + Unlike `FrameContext`, this is a frozen one-shot descriptor: + consumed once when the transaction's top-level frame is built; + instructions never read it. """ - origin: Address - # For a creation, the address the contract deploys to. recipient: Address + """ + The address the transaction calls; for a creation, the address the + contract deploys to. + """ + is_create: bool + """ + Whether the transaction is a contract creation. + """ + data: Bytes + """ + The transaction's data payload: call data for a call, init code + for a creation. + """ + value: U256 + """ + The amount of ether (in wei) sent with the transaction. + """ + + +@final +@dataclass +class FrameContext: + """ + Frame-transaction state, alive for the whole transaction and + visible at every call depth through the transaction environment. + """ + + tx: FrameTransaction + """ + The frame transaction being executed. + """ + + signature_hash: Hash32 + """ + The transaction's canonical signature hash. + """ + + resolved_signers: Tuple[Optional[Address], ...] + """ + The signer each signature entry resolved to; `None` for + `ARBITRARY` entries, to which the protocol assigns no signer. + """ + + standard_gas_limit: Uint + """ + Settlement anchor: the transaction's intrinsic cost plus the sum + of the frames' gas limits. The environment's `gas_limit` carries + the inclusion-facing `max_gas` instead. + """ + + max_cost: Uint + """ + The maximum cost of the transaction: `max_gas` priced at the fee + cap, plus the blob fee. Collected from the payer when a frame + approves payment. + """ + + current_frame_index: Uint + """ + Index of the frame currently executing, advanced by the frame + loop. + """ + + frame_receipts: List[FrameReceipt] + """ + Receipts of the completed frames, growing as frames complete. + """ + + payer: Optional[Address] + """ + The account that approved paying for the transaction's gas, once + one has. + """ + + sender_approved: bool + """ + Whether the sender has approved future frames executing on its + behalf. + """ + + +@final +@dataclass +class TransactionEnvironment: + """ + Items that are used while processing a transaction. + + Fields shared by every transaction type, plus exactly one of the + two type-specific contexts: `top_level_context` for a regular or + system transaction, `frame_context` for a frame transaction. + """ + + origin: Address gas_limit: Uint effective_gas_price: Uint execution_gas_grant: Uint @@ -141,6 +254,125 @@ class TransactionEnvironment: index_in_block: Optional[Uint] tx_hash: Optional[Hash32] + top_level_context: Optional[TopLevelContext] + """ + Present iff the transaction describes a single top-level call or + creation. Exactly one of this and `frame_context` is set; both + flow entries assert it. + """ + + frame_context: Optional[FrameContext] + """ + Present iff this is a frame transaction. The frame-only opcodes + exceptionally halt when this is `None`. + """ + + +def copy_frame_context( + tx_env: TransactionEnvironment, +) -> Optional[FrameContext]: + """ + Copy a frame transaction's context, to be restored on failure. + + Paired with every transaction-state snapshot taken while a frame + executes: `APPROVE`'s state-side effects (the sender's nonce + increment and the payment escrow) are transaction-state writes + that roll back with the state, so the context fields recording the + approval must roll back in the same motion. Return `None` for + other transaction types, whose environments carry no frame + context. + """ + frame_context = tx_env.frame_context + if frame_context is None: + return None + return replace( + frame_context, frame_receipts=list(frame_context.frame_receipts) + ) + + +def restore_frame_context( + tx_env: TransactionEnvironment, + snapshot: Optional[FrameContext], +) -> None: + """ + Restore the mutable fields of a frame transaction's context from a + copy taken by `copy_frame_context`; a no-op for other transaction + types. + """ + if snapshot is None: + return + frame_context = tx_env.frame_context + assert frame_context is not None + frame_context.current_frame_index = snapshot.current_frame_index + frame_context.frame_receipts = snapshot.frame_receipts + frame_context.payer = snapshot.payer + frame_context.sender_approved = snapshot.sender_approved + + +def attempt_approval(tx_env: TransactionEnvironment, scope: FrameFlag) -> bool: + """ + Attempt an `APPROVE` of `scope` on behalf of the executing frame's + resolved target, applying its effects on success. + + The scope must be non-empty and within the frame's allowed + approval flags. Approving execution requires that execution is not + already approved and that the frame's resolved target is the + transaction's sender. Approving payment requires that no payer is + set, that execution is approved (by this same scope or earlier), + and that the resolved target can cover the transaction's maximum + cost; it increments the sender's nonce and collects the maximum + cost from the resolved target, which becomes the payer. + + Return whether the approval was granted; a refusal reverts the + requesting frame. + """ + frame_context = tx_env.frame_context + assert frame_context is not None + tx = frame_context.tx + frame = tx.frames[int(frame_context.current_frame_index)] + resolved_target = resolve_frame_target(tx, frame) + + allowed_scope = frame.flags & APPROVE_SCOPE_MASK + # An empty scope, or one beyond the frame's allowed flags. + if not scope or scope & ~allowed_scope: + return False + + approves_execution = FrameFlag.APPROVE_EXECUTION in scope + approves_payment = FrameFlag.APPROVE_PAYMENT in scope + + if approves_execution: + # Execution is already approved. + if frame_context.sender_approved: + return False + # Only the sender may approve execution on its own behalf. + if resolved_target != tx.sender: + return False + + if approves_payment: + # Payment is already approved. + if frame_context.payer is not None: + return False + # Payment approval requires execution approval first. + if not (frame_context.sender_approved or approves_execution): + return False + payer_balance = get_account(tx_env.state, resolved_target).balance + # The payer cannot cover the transaction's maximum cost. + if Uint(payer_balance) < frame_context.max_cost: + return False + + if approves_execution: + frame_context.sender_approved = True + if approves_payment: + increment_nonce(tx_env.state, tx.sender) + set_account_balance( + tx_env.state, + resolved_target, + U256(Uint(payer_balance) - frame_context.max_cost), + ) + frame_context.payer = resolved_target + + return True + @final @dataclass diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index b25f4e9fcfb..1ff4674f42f 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -278,7 +278,8 @@ def set_delegation( transaction. """ - assert not tx_env.is_create + top_level_context = tx_env.top_level_context + assert top_level_context is not None and not top_level_context.is_create tx_state = tx_env.state # Authorities a delegation was set for earlier in this transaction. accessed_authorities: Set[Address] = set() diff --git a/src/ethereum/forks/amsterdam/vm/frame_interpreter.py b/src/ethereum/forks/amsterdam/vm/frame_interpreter.py new file mode 100644 index 00000000000..854a3d2c426 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/frame_interpreter.py @@ -0,0 +1,621 @@ +""" +Execute the frames of an [EIP-8141] frame transaction. + +Where a regular transaction describes a single top-level call, a frame +transaction describes a list of frames that `process_frames` executes +in order, each as its own top-level call with a fresh gas meter +holding the frame's gas limit. The frames share the transaction's +state, a `FrameJournal` of the effects accrued across frames, and +the approval context that the `APPROVE` instruction advances. + +[EIP-8141]: https://eips.ethereum.org/EIPS/eip-8141 +""" + +from dataclasses import dataclass, replace +from typing import Optional, Set, Tuple, final + +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U256, Uint + +from ethereum.state import EMPTY_CODE_HASH, Address + +from ..blocks import FrameReceipt, Log +from ..exceptions import FrameTransactionExecutionError +from ..state_tracker import ( + TransactionState, + copy_tx_state, + get_account, + get_code, + restore_tx_state, +) +from ..transactions.frame_transaction import ( + APPROVE_SCOPE_MASK, + Frame, + FrameFlag, + FrameMode, + FrameSignatureScheme, + FrameStatus, + resolve_frame_target, +) +from . import ( + FRAME_ENTRY_POINT, + BlockEnvironment, + Evm, + TransactionEnvironment, + attempt_approval, +) +from .eoa_delegation import resolve_delegated_code_address +from .exceptions import ExceptionalHalt +from .gas import ( + GasCosts, + GasMeter, + charge_gas_from_meter, + forfeit_remaining_gas, + restore_state_gas, + tx_state_gas_used, +) +from .interpreter import ( + TransactionOutput, + charge_value_transfer_to_non_alive_account, + process_call, +) +from .precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS +from .runtime import get_valid_jump_destinations + + +@final +@dataclass +class FrameJournal: + """ + Effects accrued across the frames of a frame transaction. + + Each finished frame's contributions are incorporated here: the + warm journal that successful frames feed, and the quantities that + settle the transaction once the last frame has run. When an + atomic batch opens, a copy of the journal joins the batch's + rollback point; unrolling the batch resumes from that copy — + except the unused gas, which never rolls back. + """ + + warm_addresses: Set[Address] + """ + Addresses left warm for later frames by successful frames. + """ + + warm_storage_keys: Set[Tuple[Address, Bytes32]] + """ + Storage keys left warm for later frames by successful frames. + """ + + unused_gas: Uint + """ + Gas the frames so far did not consume. Not available to later + frames; it accumulates for settlement. + """ + + refund_counter: int + """ + Refunds accrued by successful frames. + """ + + state_gas_used: int + """ + Net state gas consumed by successful frames. + """ + + accounts_to_delete: Set[Address] + """ + Accounts scheduled for deletion by successful frames. + """ + + +def copy_frame_journal(journal: FrameJournal) -> FrameJournal: + """ + Return an independent copy of the journal, safe to keep as a + rollback point while the original continues to accrue. + """ + return FrameJournal( + warm_addresses=set(journal.warm_addresses), + warm_storage_keys=set(journal.warm_storage_keys), + unused_gas=journal.unused_gas, + refund_counter=journal.refund_counter, + state_gas_used=journal.state_gas_used, + accounts_to_delete=set(journal.accounts_to_delete), + ) + + +@final +@dataclass +class AtomicBatch: + """ + Rollback point captured when an atomic batch opens. + + A frame carrying the atomic batch flag opens a batch that runs up + to and including the next frame without the flag. When a batch + frame fails, `unroll_atomic_batch` restores everything captured + here. + """ + + first_frame_index: Uint + """ + Index of the frame that opened the batch. + """ + + state_snapshot: TransactionState + """ + Copy of the transaction state taken before the batch began. + """ + + payer: Optional[Address] + """ + The context's payer before the batch began. + """ + + sender_approved: bool + """ + The context's execution approval before the batch began. + """ + + journal: FrameJournal + """ + Copy of the frame journal taken before the batch began. + """ + + +@final +@dataclass +class FrameOutcome: + """ + Reduced outcome of a single frame. + + A finished frame's EVM is read once and immediately reduced to + this record: the consensus receipt entry, plus the quantities the + frame contributes to the transaction's settlement. + """ + + receipt: FrameReceipt + """ + The frame's receipt entry. + """ + + gas_left: Uint + """ + Gas the frame did not consume. Not available to later frames; it + accumulates for settlement. + """ + + refund_counter: int + """ + Refunds the frame accrued; zero unless the frame succeeded. + """ + + state_gas_used: int + """ + Net state gas the frame consumed; zero unless the frame succeeded. + """ + + accounts_to_delete: Set[Address] + """ + Accounts the frame scheduled for deletion; empty unless the frame + succeeded. + """ + + +def incorporate_frame_outcome( + journal: FrameJournal, outcome: FrameOutcome +) -> None: + """ + Incorporate a finished frame's settlement quantities into the + journal. + + The frame's warm accesses are not carried on the outcome: + `execute_frame` commits them into the journal only when the frame + succeeds. + """ + journal.unused_gas += outcome.gas_left + journal.refund_counter += outcome.refund_counter + journal.state_gas_used += outcome.state_gas_used + journal.accounts_to_delete |= outcome.accounts_to_delete + + +def unroll_atomic_batch( + tx_env: TransactionEnvironment, + batch: AtomicBatch, + journal: FrameJournal, +) -> FrameJournal: + """ + Unroll a failed atomic batch. + + The transaction state and the approval fields are restored to the + condition immediately before the batch began, and the receipts of + the executed batch frames keep their status and gas with their + logs emptied. Return the batch's journal copy for the frame loop + to continue from — carrying over the live journal's unused gas, + because the gas the batch frames consumed remains charged. + """ + frame_context = tx_env.frame_context + assert frame_context is not None + + restore_tx_state(tx_env.state, batch.state_snapshot) + frame_context.payer = batch.payer + frame_context.sender_approved = batch.sender_approved + + receipts = frame_context.frame_receipts + for index in range(int(batch.first_frame_index), len(receipts)): + receipts[index] = replace(receipts[index], logs=()) + + restored = batch.journal + restored.unused_gas = journal.unused_gas + return restored + + +def create_evm_from_frame( + block_env: BlockEnvironment, + tx_env: TransactionEnvironment, + frame: Frame, + resolved_target: Address, + gas_meter: GasMeter, + warm_addresses: Set[Address], + warm_storage_keys: Set[Tuple[Address, Bytes32]], +) -> Evm: + """ + Build a frame's top-level EVM. + + The frame starts warm with the coinbase, the precompiles, and the + journal shared across frames — not its caller, and not its target: + the target's warm or cold access is charged here, within the + frame's own gas limit, as are the state gas for a value transfer + reviving a dead account and the access for resolving an EIP-7702 + delegation. A charge exceeding the frame's gas raises instead of + building the EVM, failing the frame. + """ + ## Warm up the access sets + accessed_addresses: Set[Address] = set(warm_addresses) + accessed_addresses.add(block_env.coinbase) + accessed_addresses.update(PRE_COMPILED_CONTRACTS.keys()) + accessed_storage_keys = set(warm_storage_keys) + + ## Resolve dispatch and charge its state-dependent costs + if resolved_target in accessed_addresses: + charge_gas_from_meter(gas_meter, GasCosts.WARM_ACCESS) + else: + charge_gas_from_meter(gas_meter, GasCosts.COLD_ACCOUNT_ACCESS) + accessed_addresses.add(resolved_target) + + charge_value_transfer_to_non_alive_account( + tx_env.state, gas_meter, resolved_target, frame.value + ) + + code_address, disable_precompiles = resolve_delegated_code_address( + tx_env.state, gas_meter, accessed_addresses, resolved_target + ) + + code = get_code( + tx_env.state, + get_account(tx_env.state, code_address).code_hash, + ) + + ## Build the frame + return Evm( + # Context + block_env=block_env, + tx_env=tx_env, + parent_evm=None, + depth=Uint(0), + # Call Parameters + caller=tx_env.origin, + current_target=resolved_target, + value=frame.value, + call_data=frame.data, + should_transfer_value=True, + is_static=frame.mode == FrameMode.VERIFY, + disable_precompiles=disable_precompiles, + # Code + code_address=code_address, + code=code, + valid_jump_destinations=get_valid_jump_destinations(code), + # Machine State + gas_meter=gas_meter, + pc=Uint(0), + stack=[], + memory=bytearray(), + return_data=b"", + # Accrued Effects + logs=(), + accounts_to_delete=set(), + accessed_addresses=accessed_addresses, + accessed_storage_keys=accessed_storage_keys, + # Outcome + running=True, + output=b"", + error=None, + ) + + +def execute_default_verify_code( + tx_env: TransactionEnvironment, frame: Frame +) -> FrameReceipt: + """ + Execute the protocol default code of a `VERIFY` frame whose + resolved target has no code. It consumes no gas. + + The default code approves the scope allowed by the frame's flags, + provided the transaction carries an authorizing secp256k1 + signature entry over the canonical signature hash whose resolved + signer is the frame's resolved target: the entry at index 0 for + frames allowed to approve execution, or at index 1 for + payment-only frames. Anything else reverts the frame — which, for + a `VERIFY` frame, invalidates the transaction. + """ + frame_context = tx_env.frame_context + assert frame_context is not None + tx = frame_context.tx + resolved_target = resolve_frame_target(tx, frame) + + failure = FrameReceipt( + status=FrameStatus.FAILURE, gas_used=Uint(0), logs=() + ) + + allowed_scope = frame.flags & APPROVE_SCOPE_MASK + # The frame is not allowed to approve anything. + if not allowed_scope: + return failure + + if FrameFlag.APPROVE_EXECUTION in allowed_scope: + signature_index = 0 + else: + signature_index = 1 + + # There is no signature entry at the authorizing index. + if len(tx.signatures) <= signature_index: + return failure + signature = tx.signatures[signature_index] + + # Only a protocol-validated secp256k1 signature authorizes. + if signature.scheme != FrameSignatureScheme.SECP256K1: + return failure + # The signature must cover the canonical signature hash. + if len(signature.message) != 0: + return failure + # The signature must come from the frame's resolved target. + if frame_context.resolved_signers[signature_index] != resolved_target: + return failure + + if not attempt_approval(tx_env, allowed_scope): + return failure + + return FrameReceipt(status=FrameStatus.SUCCESS, gas_used=Uint(0), logs=()) + + +def execute_frame( + block_env: BlockEnvironment, + tx_env: TransactionEnvironment, + frame: Frame, + journal: FrameJournal, +) -> FrameOutcome: + """ + Run a single frame as a top-level call and reduce its outcome. + + A `VERIFY` frame whose resolved target has no code runs the + protocol default code instead of an EVM. As with an ordinary + `CALL`, a caller that cannot cover the transferred value reverts + the frame before it executes, consuming no gas. + + On success the frame's accesses are committed back to the + journal's warm sets; a failed frame's accesses are discarded with + its EVM, so nothing it touched stays warm. + """ + frame_context = tx_env.frame_context + assert frame_context is not None + tx = frame_context.tx + tx_state = tx_env.state + resolved_target = resolve_frame_target(tx, frame) + + target_account = get_account(tx_state, resolved_target) + if ( + frame.mode == FrameMode.VERIFY + and target_account.code_hash == EMPTY_CODE_HASH + ): + return FrameOutcome( + receipt=execute_default_verify_code(tx_env, frame), + gas_left=Uint(frame.gas), + refund_counter=0, + state_gas_used=0, + accounts_to_delete=set(), + ) + + if frame.value != U256(0): + caller_balance = get_account(tx_state, tx_env.origin).balance + if caller_balance < frame.value: + return FrameOutcome( + receipt=FrameReceipt( + status=FrameStatus.FAILURE, gas_used=Uint(0), logs=() + ), + gas_left=Uint(frame.gas), + refund_counter=0, + state_gas_used=0, + accounts_to_delete=set(), + ) + + gas_meter = GasMeter( + gas_left=Uint(frame.gas), + state_gas_left=Uint(0), + state_gas_baseline=Uint(0), + ) + + try: + evm = create_evm_from_frame( + block_env, + tx_env, + frame, + resolved_target, + gas_meter, + journal.warm_addresses, + journal.warm_storage_keys, + ) + except ExceptionalHalt: + # The frame's entry charges exceeded its own gas limit. + restore_state_gas(gas_meter) + forfeit_remaining_gas(gas_meter) + return FrameOutcome( + receipt=FrameReceipt( + status=FrameStatus.FAILURE, + gas_used=Uint(frame.gas), + logs=(), + ), + gas_left=Uint(0), + refund_counter=0, + state_gas_used=0, + accounts_to_delete=set(), + ) + + process_call(evm) + + gas_used = Uint(frame.gas) - gas_meter.gas_left + if evm.error is None: + journal.warm_addresses.update(evm.accessed_addresses) + journal.warm_storage_keys.update(evm.accessed_storage_keys) + receipt = FrameReceipt( + status=FrameStatus.SUCCESS, gas_used=gas_used, logs=evm.logs + ) + accounts_to_delete = set(evm.accounts_to_delete) + else: + receipt = FrameReceipt( + status=FrameStatus.FAILURE, gas_used=gas_used, logs=() + ) + accounts_to_delete = set() + + return FrameOutcome( + receipt=receipt, + gas_left=gas_meter.gas_left, + refund_counter=gas_meter.refund_counter, + state_gas_used=tx_state_gas_used(gas_meter, Uint(0)), + accounts_to_delete=accounts_to_delete, + ) + + +def process_frames( + block_env: BlockEnvironment, + tx_env: TransactionEnvironment, +) -> TransactionOutput: + """ + Execute the frames of a frame transaction in order. + + Each frame runs with a fresh gas meter holding its own gas limit; + unused gas is not available to later frames and accumulates for + settlement. Between frames the transient storage is discarded and + the environment's origin is rebound to the caller of the frame + about to run. + + A failing frame of an atomic batch unrolls the batch, and the + remaining batch frames are skipped — their allotted gas counts as + unused. + + Unlike `process_top_level`, this flow can invalidate the whole + transaction: a `VERIFY` frame reverting, a `SENDER` frame before + execution approval, or no frame having approved payment by the end + raises `FrameTransactionExecutionError`. + """ + frame_context = tx_env.frame_context + assert frame_context is not None + assert tx_env.top_level_context is None + + tx = frame_context.tx + tx_state = tx_env.state + + journal = FrameJournal( + warm_addresses={tx.sender}, + warm_storage_keys=set(), + unused_gas=Uint(0), + refund_counter=0, + state_gas_used=0, + accounts_to_delete=set(), + ) + + open_batch: Optional[AtomicBatch] = None + skip_batch = False + + for index, frame in enumerate(tx.frames): + frame_context.current_frame_index = Uint(index) + has_batch_flag = FrameFlag.ATOMIC_BATCH in frame.flags + + if has_batch_flag and open_batch is None: + open_batch = AtomicBatch( + first_frame_index=Uint(index), + state_snapshot=copy_tx_state(tx_state), + payer=frame_context.payer, + sender_approved=frame_context.sender_approved, + journal=copy_frame_journal(journal), + ) + + if skip_batch: + # A frame of a failed atomic batch never executes; its + # allotted gas counts as unused. + frame_context.frame_receipts.append( + FrameReceipt( + status=FrameStatus.SKIPPED, gas_used=Uint(0), logs=() + ) + ) + journal.unused_gas += Uint(frame.gas) + if not has_batch_flag: + open_batch = None + skip_batch = False + continue + + if ( + frame.mode == FrameMode.SENDER + and not frame_context.sender_approved + ): + raise FrameTransactionExecutionError( + "SENDER frame before execution approval" + ) + + # Transient storage is discarded between frames. + tx_state.transient_storage.clear() + + # The ORIGIN opcode returns the frame's caller at every call + # depth. + if frame.mode == FrameMode.SENDER: + tx_env.origin = tx.sender + else: + tx_env.origin = FRAME_ENTRY_POINT + + outcome = execute_frame(block_env, tx_env, frame, journal) + receipt = outcome.receipt + + if ( + frame.mode == FrameMode.VERIFY + and receipt.status == FrameStatus.FAILURE + ): + raise FrameTransactionExecutionError("VERIFY frame reverted") + + incorporate_frame_outcome(journal, outcome) + frame_context.frame_receipts.append(receipt) + + terminates_batch = open_batch is not None and not has_batch_flag + if receipt.status == FrameStatus.FAILURE and open_batch is not None: + journal = unroll_atomic_batch(tx_env, open_batch, journal) + if terminates_batch: + open_batch = None + else: + skip_batch = True + elif terminates_batch: + open_batch = None + + if frame_context.payer is None: + raise FrameTransactionExecutionError("no frame approved gas payment") + + logs: Tuple[Log, ...] = () + for receipt in frame_context.frame_receipts: + logs += receipt.logs + + return TransactionOutput( + gas_left=journal.unused_gas, + refund_counter=U256(journal.refund_counter), + logs=logs, + accounts_to_delete=journal.accounts_to_delete, + error=None, + return_data=Bytes(b""), + state_gas_left=Uint(0), + state_gas_used=journal.state_gas_used, + ) diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index 6a07ef0e361..c28007d8cc8 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -29,7 +29,7 @@ from ..fork_types import StateGas, StateGasPerByte, VersionedHash from ..transactions import ( TX_MAX_GAS_LIMIT, - BlobTransaction, + BlobCapableTransaction, IntrinsicGasCost, Transaction, ) @@ -157,6 +157,50 @@ class GasCosts: + Uint(2) * WARM_ACCESS ) + TX_FRAME_INTRINSIC: Final[Uint] = Uint(15000) + """ + Base gas cost for [`FrameTransaction`][ftx]s. + + [ftx]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameTransaction + """ # noqa: E501 + + TX_PER_FRAME: Final[Uint] = Uint(475) + """ + Additional per-[`Frame`] gas cost for [`FrameTransaction`][ftx]s. + + [ftx]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameTransaction + [`Frame`]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.Frame + """ # noqa: E501 + + # Frames + FRAME_SIGNATURE_SCHEME_SECP256K1: Final[Uint] = Uint(2800) + """ + Cost for verifying a [`SECP256K1`][s] signature in a + [`FrameTransaction`][ftx]. + + [s]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignatureScheme.SECP256K1 + [ftx]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameTransaction + """ # noqa: E501 + + FRAME_SIGNATURE_SCHEME_P256: Final[Uint] = Uint(6700) + """ + Cost for verifying a [`P256`][s] signature in a [`FrameTransaction`][ftx]. + + [s]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignatureScheme.P256 + [ftx]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameTransaction + """ # noqa: E501 + + FRAME_SIGNATURE_SCHEME_ARBITRARY: Final[Uint] = Uint(100) + """ + Cost charged for an [`ARBITRARY`][s] signature entry in a + [`FrameTransaction`][ftx]. The protocol does not cryptographically + validate these entries; the charge covers making the bytes available + for introspection. + + [s]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameSignatureScheme.ARBITRARY + [ftx]: ref:ethereum.forks.amsterdam.transactions.frame_transaction.FrameTransaction + """ # noqa: E501 + # Block LIMIT_ADJUSTMENT_FACTOR: Final[Uint] = Uint(1024) LIMIT_MINIMUM: Final[Uint] = Uint(5000) @@ -223,8 +267,14 @@ class GasCosts: OPCODE_EXCHANGE: Final[Uint] = VERY_LOW OPCODE_TLOAD: Final[Uint] = Uint(100) OPCODE_TSTORE: Final[Uint] = Uint(100) + OPCODE_TXPARAM: Final[Uint] = BASE + OPCODE_FRAMEDATALOAD: Final[Uint] = VERY_LOW + OPCODE_FRAMEPARAM: Final[Uint] = BASE + OPCODE_SIGPARAM: Final[Uint] = BASE # Dynamic Opcode Components + OPCODE_FRAMEDATACOPY_BASE: Final[Uint] = VERY_LOW + OPCODE_SIGPARAM_COPY_BASE: Final[Uint] = VERY_LOW OPCODE_RETURNDATACOPY_BASE: Final[Uint] = VERY_LOW OPCODE_RETURNDATACOPY_PER_WORD: Final[Uint] = Uint(3) OPCODE_CALLDATACOPY_BASE: Final[Uint] = VERY_LOW @@ -900,7 +950,7 @@ def calculate_total_blob_gas(tx: Transaction) -> U64: The total blob gas for the transaction. """ - if isinstance(tx, BlobTransaction): + if isinstance(tx, BlobCapableTransaction): return GasCosts.PER_BLOB * U64(len(tx.blob_versioned_hashes)) else: return U64(0) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/__init__.py b/src/ethereum/forks/amsterdam/vm/instructions/__init__.py index 06295ec86f1..2fdc69864fa 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/__init__.py @@ -21,6 +21,7 @@ from . import comparison as comparison_instructions from . import control_flow as control_flow_instructions from . import environment as environment_instructions +from . import frame as frame_instructions from . import keccak as keccak_instructions from . import log as log_instructions from . import memory as memory_instructions @@ -208,6 +209,14 @@ class Ops(enum.Enum): LOG3 = 0xA3 LOG4 = 0xA4 + # Frame Transaction Operations + APPROVE = 0xAA + TXPARAM = 0xB0 + FRAMEDATALOAD = 0xB1 + FRAMEDATACOPY = 0xB2 + FRAMEPARAM = 0xB3 + SIGPARAM = 0xB4 + # System Operations CREATE = 0xF0 CALL = 0xF1 @@ -365,6 +374,12 @@ class Ops(enum.Enum): Ops.LOG2: log_instructions.log2, Ops.LOG3: log_instructions.log3, Ops.LOG4: log_instructions.log4, + Ops.APPROVE: frame_instructions.approve, + Ops.TXPARAM: frame_instructions.txparam, + Ops.FRAMEDATALOAD: frame_instructions.framedataload, + Ops.FRAMEDATACOPY: frame_instructions.framedatacopy, + Ops.FRAMEPARAM: frame_instructions.frameparam, + Ops.SIGPARAM: frame_instructions.sigparam, Ops.CREATE: system_instructions.create, Ops.RETURN: system_instructions.return_, Ops.CALL: system_instructions.call, diff --git a/src/ethereum/forks/amsterdam/vm/instructions/frame.py b/src/ethereum/forks/amsterdam/vm/instructions/frame.py new file mode 100644 index 00000000000..da55ef0d455 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/frame.py @@ -0,0 +1,343 @@ +""" +Implementations of the EVM instructions defined only during the +execution of an [EIP-8141] frame transaction. Executing any of them in +the context of any other transaction type results in an exceptional +halt. + +[EIP-8141]: https://eips.ethereum.org/EIPS/eip-8141 +""" + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U256, Uint + +from ethereum.utils.numeric import ceil32 + +from ...transactions.frame_transaction import ( + APPROVE_SCOPE_MASK, + FrameFlag, + FrameSignatureScheme, + resolve_frame_target, +) +from ...vm.memory import buffer_read, memory_read_bytes, memory_write +from .. import Evm, FrameContext, attempt_approval +from ..exceptions import InvalidParameter, Revert +from ..gas import GasCosts, calculate_gas_extend_memory, charge_gas +from ..stack import pop, push + + +def frame_transaction_context(evm: Evm) -> FrameContext: + """ + Return the executing frame transaction's context, or exceptionally + halt when the current transaction is not a frame transaction. + """ + frame_context = evm.tx_env.frame_context + if frame_context is None: + raise InvalidParameter("not a frame transaction") + return frame_context + + +def approve(evm: Evm) -> None: + """ + Exit the current call frame successfully, updating the + transaction-scoped approval context based on the scope operand. + + The memory region designated by the offset and length operands + becomes the frame's return data, following `RETURN` semantics — + only the memory expansion is charged. A refused approval — an + `ADDRESS` other than the frame's resolved target, a scope outside + the frame's allowed flags, or a failed precondition — reverts the + frame instead. The approval's writes deliberately bypass the + `VERIFY` static restriction: only `APPROVE` may mutate state + there. + """ + # STACK + offset = pop(evm.stack) + length = pop(evm.stack) + scope = pop(evm.stack) + + # GAS + extend_memory = calculate_gas_extend_memory(evm.memory, [(offset, length)]) + charge_gas(evm, GasCosts.ZERO + extend_memory.cost) + + # OPERATION + frame_context = frame_transaction_context(evm) + tx = frame_context.tx + frame = tx.frames[int(frame_context.current_frame_index)] + resolved_target = resolve_frame_target(tx, frame) + + evm.memory += b"\x00" * extend_memory.expand_by + + # Only the frame's resolved target may approve. + if evm.current_target != resolved_target: + raise Revert + # A scope with bits beyond the approval mask is never allowed. + if scope & ~U256(APPROVE_SCOPE_MASK) != U256(0): + raise Revert + if not attempt_approval(evm.tx_env, FrameFlag(Uint(scope))): + raise Revert + + evm.output = Bytes(memory_read_bytes(evm.memory, offset, length)) + evm.running = False + + # PROGRAM COUNTER + pass + + +def txparam(evm: Evm) -> None: + """ + Push transaction-scoped information of the executing frame + transaction onto the stack, selected by the parameter operand. + """ + # STACK + param = pop(evm.stack) + + # GAS + charge_gas(evm, GasCosts.OPCODE_TXPARAM) + + # OPERATION + frame_context = frame_transaction_context(evm) + tx = frame_context.tx + + if param == U256(0x00): + # The frame transaction's type identifier. + value = U256(0x06) + elif param == U256(0x01): + value = U256(tx.nonce) + elif param == U256(0x02): + value = U256.from_be_bytes(tx.sender) + elif param == U256(0x03): + value = U256(tx.max_priority_fee_per_gas) + elif param == U256(0x04): + value = U256(tx.max_fee_per_gas) + elif param == U256(0x05): + value = tx.max_fee_per_blob_gas + elif param == U256(0x06): + value = U256(frame_context.max_cost) + elif param == U256(0x07): + value = U256(len(tx.blob_versioned_hashes)) + elif param == U256(0x08): + value = U256.from_be_bytes(frame_context.signature_hash) + elif param == U256(0x09): + value = U256(len(tx.frames)) + elif param == U256(0x0A): + value = U256(frame_context.current_frame_index) + elif param == U256(0x0B): + value = U256(len(tx.signatures)) + else: + raise InvalidParameter("undefined TXPARAM parameter") + + push(evm.stack, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def framedataload(evm: Evm) -> None: + """ + Push a word (32 bytes) of the chosen frame's data onto the stack. + + The operation semantics match `CALLDATALOAD`: bytes beyond the end + of the frame's data read as zeroes. + """ + # STACK + offset = pop(evm.stack) + frame_index = pop(evm.stack) + + # GAS + charge_gas(evm, GasCosts.OPCODE_FRAMEDATALOAD) + + # OPERATION + frame_context = frame_transaction_context(evm) + frames = frame_context.tx.frames + if frame_index >= U256(len(frames)): + raise InvalidParameter("frame index out of bounds") + data = frames[int(frame_index)].data + + value = buffer_read(data, offset, U256(32)) + push(evm.stack, U256.from_be_bytes(value)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def framedatacopy(evm: Evm) -> None: + """ + Copy a portion of the chosen frame's data to memory. + + The operation semantics and gas match `CALLDATACOPY`: bytes beyond + the end of the frame's data are copied as zeroes, and the memory + is expanded as needed. + """ + # STACK + memory_offset = pop(evm.stack) + data_offset = pop(evm.stack) + length = pop(evm.stack) + frame_index = pop(evm.stack) + + # GAS + words = ceil32(Uint(length)) // Uint(32) + copy_gas_cost = GasCosts.OPCODE_COPY_PER_WORD * words + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_offset, length)] + ) + charge_gas( + evm, + GasCosts.OPCODE_FRAMEDATACOPY_BASE + + copy_gas_cost + + extend_memory.cost, + ) + + # OPERATION + frame_context = frame_transaction_context(evm) + frames = frame_context.tx.frames + if frame_index >= U256(len(frames)): + raise InvalidParameter("frame index out of bounds") + data = frames[int(frame_index)].data + + evm.memory += b"\x00" * extend_memory.expand_by + value = buffer_read(data, data_offset, length) + memory_write(evm.memory, memory_offset, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def frameparam(evm: Evm) -> None: + """ + Push frame-scoped information of the chosen frame onto the stack, + selected by the parameter operand. + + The status of a frame exists only once the frame has completed: + requesting it for the current or a subsequent frame results in an + exceptional halt. + """ + # STACK + frame_index = pop(evm.stack) + param = pop(evm.stack) + + # GAS + charge_gas(evm, GasCosts.OPCODE_FRAMEPARAM) + + # OPERATION + frame_context = frame_transaction_context(evm) + tx = frame_context.tx + if frame_index >= U256(len(tx.frames)): + raise InvalidParameter("frame index out of bounds") + frame = tx.frames[int(frame_index)] + + if param == U256(0x00): + value = U256.from_be_bytes(resolve_frame_target(tx, frame)) + elif param == U256(0x01): + value = U256(frame.gas) + elif param == U256(0x02): + value = U256(frame.mode) + elif param == U256(0x03): + value = U256(frame.flags) + elif param == U256(0x04): + value = U256(len(frame.data)) + elif param == U256(0x05): + if frame_index >= U256(frame_context.current_frame_index): + raise InvalidParameter( + "status of the current or a subsequent frame" + ) + receipt = frame_context.frame_receipts[int(frame_index)] + value = U256(receipt.status) + elif param == U256(0x06): + value = U256(frame.flags & APPROVE_SCOPE_MASK) + elif param == U256(0x07): + if FrameFlag.ATOMIC_BATCH in frame.flags: + value = U256(1) + else: + value = U256(0) + elif param == U256(0x08): + value = frame.value + else: + raise InvalidParameter("undefined FRAMEPARAM parameter") + + push(evm.stack, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def sigparam(evm: Evm) -> None: + """ + Access signature-scoped metadata of the chosen signature entry. + + The raw signature bytes of protocol-validated schemes are + intentionally not accessible: the copy operation is defined only + for `ARBITRARY` entries, whose bytes the protocol does not + validate, and the resolved signer only for protocol-validated + entries, to which the protocol assigns one. + """ + # STACK + signature_index = pop(evm.stack) + param = pop(evm.stack) + + if param == U256(0x04): + # STACK (copy operation) + memory_offset = pop(evm.stack) + data_offset = pop(evm.stack) + length = pop(evm.stack) + + # GAS + words = ceil32(Uint(length)) // Uint(32) + copy_gas_cost = GasCosts.OPCODE_COPY_PER_WORD * words + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_offset, length)] + ) + charge_gas( + evm, + GasCosts.OPCODE_SIGPARAM_COPY_BASE + + copy_gas_cost + + extend_memory.cost, + ) + + # OPERATION + frame_context = frame_transaction_context(evm) + signatures = frame_context.tx.signatures + if signature_index >= U256(len(signatures)): + raise InvalidParameter("signature index out of bounds") + signature = signatures[int(signature_index)] + if signature.scheme != FrameSignatureScheme.ARBITRARY: + raise InvalidParameter( + "signature bytes of a protocol-validated scheme" + ) + + evm.memory += b"\x00" * extend_memory.expand_by + signature_bytes = buffer_read(signature.signature, data_offset, length) + memory_write(evm.memory, memory_offset, signature_bytes) + else: + # GAS + charge_gas(evm, GasCosts.OPCODE_SIGPARAM) + + # OPERATION + frame_context = frame_transaction_context(evm) + signatures = frame_context.tx.signatures + if signature_index >= U256(len(signatures)): + raise InvalidParameter("signature index out of bounds") + signature = signatures[int(signature_index)] + + if param == U256(0x00): + resolved_signer = frame_context.resolved_signers[ + int(signature_index) + ] + if resolved_signer is None: + raise InvalidParameter("resolved signer of an ARBITRARY entry") + value = U256.from_be_bytes(resolved_signer) + elif param == U256(0x01): + value = U256(signature.scheme) + elif param == U256(0x02): + if len(signature.message) == 0: + value = U256(0) + else: + value = U256.from_be_bytes(signature.message) + elif param == U256(0x03): + value = U256(len(signature.signature)) + else: + raise InvalidParameter("undefined SIGPARAM parameter") + + push(evm.stack, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 4275887632b..e79352ad2b6 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -64,8 +64,11 @@ from . import ( BlockEnvironment, Evm, + TopLevelContext, TransactionEnvironment, + copy_frame_context, emit_transfer_log, + restore_frame_context, ) from .eoa_delegation import resolve_delegated_code_address, set_delegation from .exceptions import ( @@ -137,6 +140,7 @@ def charge_value_transfer_to_non_alive_account( def create_evm( block_env: BlockEnvironment, tx_env: TransactionEnvironment, + top_level_context: TopLevelContext, gas_meter: GasMeter, ) -> Evm: """ @@ -149,11 +153,11 @@ def create_evm( the caller to roll back the state and gas the preparation charged and settle the transaction without dispatching. """ - current_target = tx_env.recipient - if tx_env.is_create: + current_target = top_level_context.recipient + if top_level_context.is_create: call_data = Bytes(b"") else: - call_data = tx_env.data + call_data = top_level_context.data code_address: Optional[Address] = None disable_precompiles = False @@ -174,7 +178,7 @@ def create_evm( accessed_addresses.add(current_target) ## Resolve dispatch and charge its state-dependent costs - if tx_env.is_create: + if top_level_context.is_create: if not account_deployable(tx_env.state, current_target): raise AddressCollision() @@ -184,14 +188,17 @@ def create_evm( ): charge_state_gas_from_meter(gas_meter, StateGasCosts.NEW_ACCOUNT) - code = tx_env.data + code = top_level_context.data else: charge_value_transfer_to_non_alive_account( - tx_env.state, gas_meter, current_target, tx_env.value + tx_env.state, gas_meter, current_target, top_level_context.value ) code_address, disable_precompiles = resolve_delegated_code_address( - tx_env.state, gas_meter, accessed_addresses, tx_env.recipient + tx_env.state, + gas_meter, + accessed_addresses, + top_level_context.recipient, ) code = get_code( @@ -209,7 +216,7 @@ def create_evm( # Call Parameters caller=tx_env.origin, current_target=current_target, - value=tx_env.value, + value=top_level_context.value, call_data=call_data, should_transfer_value=True, is_static=False, @@ -262,6 +269,10 @@ def process_top_level( The settled output of the top-level execution. """ + top_level_context = tx_env.top_level_context + assert top_level_context is not None + assert tx_env.frame_context is None + gas_meter = GasMeter( gas_left=tx_env.execution_gas_grant, state_gas_left=tx_env.state_gas_reservoir, @@ -270,7 +281,7 @@ def process_top_level( prep_snapshot = copy_tx_state(tx_env.state) try: - evm = create_evm(block_env, tx_env, gas_meter) + evm = create_evm(block_env, tx_env, top_level_context, gas_meter) except ExceptionalHalt as halt: # The rollback also reverts any applied delegations, so their # state gas commit is undone with it: roll state gas back to @@ -291,7 +302,7 @@ def process_top_level( ), ) - if tx_env.is_create: + if top_level_context.is_create: process_create(evm) else: process_call(evm) @@ -343,6 +354,7 @@ def process_create(evm: Evm) -> Evm: tx_state = evm.tx_env.state # take snapshot of state before processing the message snapshot = copy_tx_state(tx_state) + frame_context_snapshot = copy_frame_context(evm.tx_env) # If the address where the account is being created has storage, it is # destroyed. This can only happen in the following highly unlikely @@ -383,6 +395,7 @@ def process_create(evm: Evm) -> Evm: charge_state_gas(evm, code_deposit_state_gas) except ExceptionalHalt as error: restore_tx_state(tx_state, snapshot) + restore_frame_context(evm.tx_env, frame_context_snapshot) # A create frame never applies authorizations, so its # baseline is still the frame's entry reservoir. restore_state_gas(evm.gas_meter) @@ -393,6 +406,7 @@ def process_create(evm: Evm) -> Evm: set_code(tx_state, evm.current_target, contract_code) else: restore_tx_state(tx_state, snapshot) + restore_frame_context(evm.tx_env, frame_context_snapshot) return evm @@ -416,6 +430,7 @@ def process_call(evm: Evm) -> Evm: raise StackDepthLimitError("Stack depth limit reached") snapshot = copy_tx_state(tx_state) + frame_context_snapshot = copy_frame_context(evm.tx_env) # Execute message code and handle errors try: @@ -470,4 +485,5 @@ def process_call(evm: Evm) -> Evm: if evm.error: restore_tx_state(tx_state, snapshot) + restore_frame_context(evm.tx_env, frame_context_snapshot) return evm diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index eebd406f7c7..66c7012192c 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -34,6 +34,7 @@ def tx_types(self) -> list[int]: (2, "FeeMarketTransaction"), (3, "BlobTransaction"), (4, "SetCodeTransaction"), + (6, "FrameTransaction"), ): if hasattr(transactions, attribute): tx_types.append(tx_type) @@ -280,6 +281,38 @@ def SetCodeTransaction(self) -> Any: """Set code transaction class of the fork.""" return self._module("transactions").SetCodeTransaction + @property + def FrameTransaction(self) -> Any: + """Frame transaction class of the fork.""" + return self._module("transactions").FrameTransaction + + @property + def Frame(self) -> Any: + """Frame class of the fork.""" + return self._module("transactions.frame_transaction").Frame + + @property + def FrameMode(self) -> Any: + """Frame mode enum of the fork.""" + return self._module("transactions.frame_transaction").FrameMode + + @property + def FrameFlag(self) -> Any: + """Frame flag enum of the fork.""" + return self._module("transactions.frame_transaction").FrameFlag + + @property + def FrameSignature(self) -> Any: + """Frame signature class of the fork.""" + return self._module("transactions.frame_transaction").FrameSignature + + @property + def FrameSignatureScheme(self) -> Any: + """Frame signature scheme enum of the fork.""" + return self._module( + "transactions.frame_transaction" + ).FrameSignatureScheme + @property def Withdrawal(self) -> Any: """Withdrawal class of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/loaders/transaction_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/transaction_loader.py index da882c94d99..010f563a065 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/transaction_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/transaction_loader.py @@ -127,6 +127,59 @@ def json_to_blob_versioned_hashes(self) -> List[Bytes32]: for blob_hash in self.raw.get("blobVersionedHashes") ] + def json_to_sender(self) -> Any: + """Get the explicit sender address of a frame transaction.""" + return self.fork.hex_to_address(self.raw.get("sender")) + + def json_to_frames(self) -> Any: + """Get the frames of a frame transaction.""" + frames = [] + for frame_data in self.raw.get("frames", []): + target_raw = frame_data.get("target") + if target_raw is None or target_raw in ("", "0x"): + to: Any = Bytes0(b"") + else: + to = self.fork.hex_to_address(target_raw) + frames.append( + self.fork.Frame( + mode=self.fork.FrameMode( + parse_hex_or_int(frame_data.get("mode", 0), Uint) + ), + flags=self.fork.FrameFlag( + parse_hex_or_int(frame_data.get("flags", 0), Uint) + ), + to=to, + gas=parse_hex_or_int(frame_data.get("gasLimit", 0), U64), + value=parse_hex_or_int(frame_data.get("value", 0), U256), + data=hex_to_bytes(frame_data.get("data", "0x")), + ) + ) + return tuple(frames) + + def json_to_signatures(self) -> Any: + """Get the signature entries of a frame transaction.""" + signatures = [] + for sig_data in self.raw.get("signatures", []): + msg = hex_to_bytes(sig_data.get("msg", "0x")) + message: Any + if len(msg) == 0: + message = Bytes0(b"") + elif len(msg) == 32: + message = Bytes32(msg) + else: + message = msg + signatures.append( + self.fork.FrameSignature( + scheme=self.fork.FrameSignatureScheme( + parse_hex_or_int(sig_data.get("scheme", 0), Uint) + ), + signer=hex_to_bytes(sig_data.get("signer", "0x")), + message=message, + signature=hex_to_bytes(sig_data.get("signature", "0x")), + ) + ) + return tuple(signatures) + def json_to_v(self) -> U256: """Get the v value of the transaction.""" return hex_to_u256( @@ -177,7 +230,12 @@ def read(self) -> Any: """Convert json transaction data to a transaction object.""" if "type" in self.raw: tx_type = parse_hex_or_int(self.raw.get("type"), Uint) - if tx_type == Uint(4): + if tx_type == Uint(6): + if not self.fork.supports_tx_type(6): + raise self.unsupported_tx_type(6) + tx_cls = self.fork.FrameTransaction + tx_byte_prefix = b"\x06" + elif tx_type == Uint(4): if not self.fork.supports_tx_type(4): raise self.unsupported_tx_type(4) tx_cls = self.fork.SetCodeTransaction @@ -203,7 +261,14 @@ def read(self) -> Any: else: raise ValueError(f"Unknown transaction type: {tx_type}") else: - if "authorizationList" in self.raw: + if "frames" in self.raw: + # Checked before the blob fields: frame transactions + # always carry `maxFeePerBlobGas`. + if not self.fork.supports_tx_type(6): + raise self.unsupported_tx_type(6) + tx_cls = self.fork.FrameTransaction + tx_byte_prefix = b"\x06" + elif "authorizationList" in self.raw: if not self.fork.supports_tx_type(4): raise self.unsupported_tx_type(4) tx_cls = self.fork.SetCodeTransaction diff --git a/src/ethereum_spec_tools/evm_tools/t8n/result.py b/src/ethereum_spec_tools/evm_tools/t8n/result.py index 5bab2af3b75..6edbe08fdb0 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/result.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/result.py @@ -28,6 +28,7 @@ def get_receipts_from_output(t8n: "T8N", block_output: Any) -> List[Any]: # imports ``ExecutionSpecsTransitionTool`` — top-level import would # cycle back into ``t8n``. from execution_testing.test_types.receipt_types import ( + FrameReceipt, TransactionLog, TransactionReceipt, ) @@ -46,6 +47,51 @@ def get_receipts_from_output(t8n: "T8N", block_output: Any) -> List[Any]: else: decoded_receipt = receipt + if hasattr(decoded_receipt, "frame_receipts"): + # EIP-8141 frame transaction receipt: no transaction-level + # status and no consensus bloom — the logs are reported per + # frame, and the bloom is derived from their concatenation + # in frame order. + all_logs = [ + log + for frame_receipt in decoded_receipt.frame_receipts + for log in frame_receipt.logs + ] + receipts.append( + TransactionReceipt( + transaction_hash=tx_hash, + cumulative_gas_used=int( + decoded_receipt.cumulative_gas_used + ), + bloom=t8n.fork.logs_bloom(tuple(all_logs)), + logs=[ + TransactionLog( + address=log.address, + topics=list(log.topics), + data=log.data, + ) + for log in all_logs + ], + payer=decoded_receipt.payer, + frame_receipts=[ + FrameReceipt( + status=int(frame_receipt.status), + gas_used=int(frame_receipt.gas_used), + logs=[ + TransactionLog( + address=log.address, + topics=list(log.topics), + data=log.data, + ) + for log in frame_receipt.logs + ], + ) + for frame_receipt in decoded_receipt.frame_receipts + ], + ) + ) + continue + receipt_kwargs: Dict[str, Any] = { "transaction_hash": tx_hash, "cumulative_gas_used": int(decoded_receipt.cumulative_gas_used), diff --git a/src/ethereum_spec_tools/evm_tools/utils.py b/src/ethereum_spec_tools/evm_tools/utils.py index 7483c38b34f..f916a68c2e1 100644 --- a/src/ethereum_spec_tools/evm_tools/utils.py +++ b/src/ethereum_spec_tools/evm_tools/utils.py @@ -27,6 +27,9 @@ "BPO4": { "fork_blocks": [("osaka", 0)], }, + "Bogota": { + "fork_blocks": [("amsterdam", 0)], + }, "FrontierToHomesteadAt5": { "fork_blocks": [("frontier", 0), ("homestead", 5)], }, @@ -136,10 +139,16 @@ def find_fork( # * ``DAOFork`` would snake-case to ``d_a_o_fork``. # * ``ConstantinopleFix`` is a testing-side distinction that the spec # folds into the ``constantinople`` module. +# * ``Bogota`` is a testing-side pseudo-fork that executes with the +# ``amsterdam`` spec module until the spec repository grows a +# dedicated Bogota fork module. +# TODO: Remove the ``Bogota`` alias (and its ``EXCEPTION_MAPS`` entry) +# once a dedicated ``bogota`` fork module exists in the spec. _SPEC_SHORT_NAME_OVERRIDES: Dict[str, str] = { "Merge": "paris", "DAOFork": "dao_fork", "ConstantinopleFix": "constantinople", + "Bogota": "amsterdam", } diff --git a/tests/amsterdam/eip8141_frame_transactions/__init__.py b/tests/amsterdam/eip8141_frame_transactions/__init__.py new file mode 100644 index 00000000000..5a44c078a7a --- /dev/null +++ b/tests/amsterdam/eip8141_frame_transactions/__init__.py @@ -0,0 +1 @@ +"""Tests for EIP-8141 frame transactions.""" diff --git a/tests/amsterdam/eip8141_frame_transactions/helpers.py b/tests/amsterdam/eip8141_frame_transactions/helpers.py new file mode 100644 index 00000000000..e71d651192e --- /dev/null +++ b/tests/amsterdam/eip8141_frame_transactions/helpers.py @@ -0,0 +1,19 @@ +"""Helpers for EIP-8141 frame transaction tests.""" + +from execution_testing import Bytecode, Op + +from .spec import Spec + + +def approve_bytecode( + scope: int = Spec.APPROVE_EXECUTION_AND_PAYMENT, +) -> Bytecode: + """ + Return bytecode that calls `APPROVE` with the given scope and no + return data. + + `APPROVE` succeeds only when the executing account is the frame's + resolved target, so this code is meant to be deployed at the account + a `VERIFY` frame targets. + """ + return Op.APPROVE(0, 0, scope) diff --git a/tests/amsterdam/eip8141_frame_transactions/spec.py b/tests/amsterdam/eip8141_frame_transactions/spec.py new file mode 100644 index 00000000000..ea3721deded --- /dev/null +++ b/tests/amsterdam/eip8141_frame_transactions/spec.py @@ -0,0 +1,91 @@ +"""Defines EIP-8141 specification constants and types.""" + +from dataclasses import dataclass + +from execution_testing import Address + + +@dataclass(frozen=True) +class ReferenceSpec: + """Defines the reference spec version and git path.""" + + git_path: str + version: str + + +ref_spec_8141 = ReferenceSpec( + "EIPS/eip-8141.md", "4a9ad32cf2c851d16ab889d643342074ea3fab95" +) + + +@dataclass(frozen=True) +class Spec: + """ + Parameters from the EIP-8141 specification as defined at + https://eips.ethereum.org/EIPS/eip-8141. + """ + + FRAME_TX_TYPE = 0x06 + FRAME_TX_INTRINSIC_COST = 15_000 + FRAME_TX_PER_FRAME_COST = 475 + ENTRY_POINT = Address(0xAA) + EXPIRY_VERIFIER = Address(0x8141) + EXPIRY_VERIFIER_CODE = bytes.fromhex( + "60083614600a575f5ffd5b5f3560c01c4211601657005b5f5ffd" + ) + EXPIRY_DATA_LENGTH = 8 + MAX_FRAMES = 64 + + # Frame modes + MODE_DEFAULT = 0 + MODE_VERIFY = 1 + MODE_SENDER = 2 + + # Frame flags + APPROVE_NONE = 0x0 + APPROVE_PAYMENT = 0x1 + APPROVE_EXECUTION = 0x2 + APPROVE_EXECUTION_AND_PAYMENT = 0x3 + ATOMIC_BATCH_FLAG = 0x4 + + # Signature schemes + SCHEME_ARBITRARY = 0x0 + SCHEME_SECP256K1 = 0x1 + SCHEME_P256 = 0x2 + + # Frame receipt statuses + STATUS_FAILURE = 0 + STATUS_SUCCESS = 1 + STATUS_SKIPPED = 2 + + # TXPARAM selectors + TXPARAM_TYPE = 0x00 + TXPARAM_NONCE = 0x01 + TXPARAM_SENDER = 0x02 + TXPARAM_MAX_PRIORITY_FEE = 0x03 + TXPARAM_MAX_FEE = 0x04 + TXPARAM_MAX_BLOB_FEE = 0x05 + TXPARAM_MAX_COST = 0x06 + TXPARAM_BLOB_COUNT = 0x07 + TXPARAM_SIG_HASH = 0x08 + TXPARAM_FRAME_COUNT = 0x09 + TXPARAM_FRAME_INDEX = 0x0A + TXPARAM_SIGNATURE_COUNT = 0x0B + + # FRAMEPARAM selectors + FRAMEPARAM_TARGET = 0x00 + FRAMEPARAM_GAS_LIMIT = 0x01 + FRAMEPARAM_MODE = 0x02 + FRAMEPARAM_FLAGS = 0x03 + FRAMEPARAM_DATA_LENGTH = 0x04 + FRAMEPARAM_STATUS = 0x05 + FRAMEPARAM_ALLOWED_SCOPE = 0x06 + FRAMEPARAM_ATOMIC_BATCH = 0x07 + FRAMEPARAM_VALUE = 0x08 + + # SIGPARAM selectors + SIGPARAM_RESOLVED_SIGNER = 0x00 + SIGPARAM_SCHEME = 0x01 + SIGPARAM_MSG = 0x02 + SIGPARAM_SIGNATURE_LENGTH = 0x03 + SIGPARAM_COPY = 0x04 diff --git a/tests/amsterdam/eip8141_frame_transactions/test_expiry_verifier.py b/tests/amsterdam/eip8141_frame_transactions/test_expiry_verifier.py new file mode 100644 index 00000000000..a04e74c1878 --- /dev/null +++ b/tests/amsterdam/eip8141_frame_transactions/test_expiry_verifier.py @@ -0,0 +1,125 @@ +""" +Tests for the expiry verifier frames of +[EIP-8141: Frame Transaction](https://eips.ethereum.org/EIPS/eip-8141). + +A `VERIFY` frame targeting the expiry verifier predeploy carries an +unsigned big-endian expiry timestamp in its data. The predeploy's code +returns successfully while the block timestamp is at or before that +expiry, and reverts once the block timestamp exceeds it — invalidating +the whole transaction. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Bytes, + Environment, + Frame, + FrameReceipt, + Op, + StateTestFiller, + Transaction, + TransactionException, + TransactionReceipt, +) + +from .spec import Spec, ref_spec_8141 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8141.git_path +REFERENCE_SPEC_VERSION = ref_spec_8141.version + +pytestmark = pytest.mark.valid_from("Bogota") + +SLOT_EXECUTED = 0x01 +"""Storage slot used by target contracts to record execution.""" + +BLOCK_TIMESTAMP = 1_000 +"""Timestamp of the block executing the frame transaction.""" + + +@pytest.mark.parametrize( + "expiry,error", + [ + pytest.param(BLOCK_TIMESTAMP + 1, None, id="future_expiry"), + pytest.param(BLOCK_TIMESTAMP, None, id="expiry_at_block_timestamp"), + pytest.param( + BLOCK_TIMESTAMP - 1, + TransactionException.TYPE_6_INVALID_FRAME_EXECUTION, + id="expired", + marks=pytest.mark.exception_test, + ), + ], +) +def test_expiry_verifier_frame( + state_test: StateTestFiller, + pre: Alloc, + expiry: int, + error: TransactionException | None, +) -> None: + """ + Execute a frame transaction carrying an expiry verifier frame. + + While the block timestamp is at or before the expiry — including + exactly at it — the frame succeeds and the transaction executes. + Once the block timestamp exceeds the expiry, the predeploy + reverts, which for a `VERIFY` frame invalidates the whole + transaction. + """ + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + + expected_receipt = None + if error is None: + expected_receipt = TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=Spec.STATUS_SUCCESS), + FrameReceipt(status=Spec.STATUS_SUCCESS), + FrameReceipt(status=Spec.STATUS_SUCCESS), + ], + ) + + tx = Transaction( + sender=sender, + frames=[ + Frame( + mode=Spec.MODE_VERIFY, + flags=Spec.APPROVE_EXECUTION_AND_PAYMENT, + gas_limit=100_000, + ), + Frame( + mode=Spec.MODE_VERIFY, + flags=Spec.APPROVE_NONE, + target=Spec.EXPIRY_VERIFIER, + gas_limit=100_000, + data=Bytes(expiry.to_bytes(Spec.EXPIRY_DATA_LENGTH, "big")), + ), + Frame( + mode=Spec.MODE_SENDER, + target=target, + gas_limit=200_000, + ), + ], + error=error, + expected_receipt=expected_receipt, + ) + + state_test( + env=Environment(timestamp=BLOCK_TIMESTAMP), + pre=pre, + tx=tx, + post={ + # The predeploy is injected into the genesis allocation by + # the testing framework; pin its account here so a missing + # predeploy fails loudly instead of silently exercising the + # default verify code. + Spec.EXPIRY_VERIFIER: Account( + nonce=0, + code=Spec.EXPIRY_VERIFIER_CODE, + ), + target: Account( + storage={SLOT_EXECUTED: 0 if error else 1}, + ), + }, + ) diff --git a/tests/amsterdam/eip8141_frame_transactions/test_frame_transactions.py b/tests/amsterdam/eip8141_frame_transactions/test_frame_transactions.py new file mode 100644 index 00000000000..06befde5d47 --- /dev/null +++ b/tests/amsterdam/eip8141_frame_transactions/test_frame_transactions.py @@ -0,0 +1,367 @@ +""" +Broad-stroke end-to-end tests for +[EIP-8141: Frame Transaction](https://eips.ethereum.org/EIPS/eip-8141). + +These tests cover the core flows of the frame transaction: default-code +validation and payment, contract senders approving via `APPROVE`, +third-party payers, atomic batches, transaction introspection, and the +basic invalid-transaction cases. Exhaustive edge-case coverage is left +for follow-up work. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Bytes, + Frame, + FrameReceipt, + FrameSignature, + Op, + StateTestFiller, + Transaction, + TransactionException, + TransactionReceipt, +) + +from .helpers import approve_bytecode +from .spec import Spec, ref_spec_8141 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8141.git_path +REFERENCE_SPEC_VERSION = ref_spec_8141.version + +# EIP-8141 is slated for the fork after Amsterdam, so fixtures are +# labeled with the pseudo `Bogota` fork (Amsterdam + EIP-8141), even +# though the spec prototypes the EIP inside the Amsterdam fork module. +# Fill these tests with `--fork Bogota`. +pytestmark = pytest.mark.valid_from("Bogota") + +SLOT_EXECUTED = 0x01 +"""Storage slot used by target contracts to record execution.""" + + +def test_transfer_with_default_code( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Transfer ETH from an EOA sender using the default code: a `VERIFY` + frame authorizes execution and payment against the sender's + signature entry, and a `SENDER` frame carries the value. + """ + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=1) + transfer_value = 10**17 + + tx = Transaction( + sender=sender, + frames=[ + Frame( + mode=Spec.MODE_VERIFY, + flags=Spec.APPROVE_EXECUTION_AND_PAYMENT, + gas_limit=100_000, + ), + Frame( + mode=Spec.MODE_SENDER, + target=recipient, + gas_limit=100_000, + value=transfer_value, + ), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=Spec.STATUS_SUCCESS, logs=[]), + FrameReceipt(status=Spec.STATUS_SUCCESS), + ], + ), + ) + + state_test( + pre=pre, + tx=tx, + post={ + sender: Account(nonce=1), + recipient: Account(balance=1 + transfer_value), + }, + ) + + +def test_contract_sender_approves( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Send a frame transaction from a contract account whose code calls + `APPROVE` to authorize execution and payment, then executes a + `SENDER` frame calling another contract. + """ + sender = pre.deploy_contract( + code=approve_bytecode(Spec.APPROVE_EXECUTION_AND_PAYMENT), + balance=10**18, + ) + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + + tx = Transaction( + sender=sender, + nonce=1, + frames=[ + Frame( + mode=Spec.MODE_VERIFY, + flags=Spec.APPROVE_EXECUTION_AND_PAYMENT, + gas_limit=100_000, + ), + Frame( + mode=Spec.MODE_SENDER, + target=target, + gas_limit=200_000, + ), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=Spec.STATUS_SUCCESS), + FrameReceipt(status=Spec.STATUS_SUCCESS), + ], + ), + ) + + state_test( + pre=pre, + tx=tx, + post={ + sender: Account(nonce=2), + target: Account(storage={SLOT_EXECUTED: 1}), + }, + ) + + +def test_eoa_paymaster( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Sponsor a frame transaction's fees from a second EOA via the + default code: the sender approves only execution, the payer + approves only payment, and the sender's balance is untouched. + """ + sender_balance = 10**18 + sender = pre.fund_eoa(amount=sender_balance) + payer = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + + tx = Transaction( + sender=sender, + frames=[ + Frame( + mode=Spec.MODE_VERIFY, + flags=Spec.APPROVE_EXECUTION, + gas_limit=100_000, + ), + Frame( + mode=Spec.MODE_VERIFY, + flags=Spec.APPROVE_PAYMENT, + target=payer, + gas_limit=100_000, + ), + Frame( + mode=Spec.MODE_SENDER, + target=target, + gas_limit=200_000, + ), + ], + signatures=[ + FrameSignature( + scheme=Spec.SCHEME_SECP256K1, + signer=Bytes(sender), + ), + FrameSignature( + scheme=Spec.SCHEME_SECP256K1, + signer=Bytes(payer), + secret_key=payer.key, + ), + ], + expected_receipt=TransactionReceipt( + payer=payer, + frame_receipts=[ + FrameReceipt(status=Spec.STATUS_SUCCESS), + FrameReceipt(status=Spec.STATUS_SUCCESS), + FrameReceipt(status=Spec.STATUS_SUCCESS), + ], + ), + ) + + state_test( + pre=pre, + tx=tx, + post={ + sender: Account(nonce=1, balance=sender_balance), + payer: Account(nonce=0), + target: Account(storage={SLOT_EXECUTED: 1}), + }, + ) + + +@pytest.mark.parametrize( + "revert_position", + [ + pytest.param("last", id="unrolls_executed_frames"), + pytest.param("first", id="skips_remaining_frames"), + ], +) +def test_atomic_batch_rollback( + state_test: StateTestFiller, + pre: Alloc, + revert_position: str, +) -> None: + """ + Roll back an atomic batch containing a reverting frame. + + When the batch terminator reverts, the state changes of the + already executed batch frame are unrolled; its frame receipt + retains the execution status and gas used, with empty logs. When + the first batch frame reverts, the remaining batch frame is + skipped with status `0x2` and no gas consumed. In both cases the + storage write is discarded. + """ + sender = pre.fund_eoa() + target = pre.deploy_contract( + code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.LOG0(0, 0) + Op.STOP + ) + reverter = pre.deploy_contract(code=Op.REVERT(0, 0)) + + store_frame_flags = ( + Spec.ATOMIC_BATCH_FLAG if revert_position == "last" else 0 + ) + revert_frame_flags = ( + Spec.ATOMIC_BATCH_FLAG if revert_position == "first" else 0 + ) + store_frame = Frame( + mode=Spec.MODE_SENDER, + flags=store_frame_flags, + target=target, + gas_limit=200_000, + ) + revert_frame = Frame( + mode=Spec.MODE_SENDER, + flags=revert_frame_flags, + target=reverter, + gas_limit=100_000, + ) + if revert_position == "last": + batch = [store_frame, revert_frame] + expected_frame_receipts = [ + FrameReceipt(status=Spec.STATUS_SUCCESS), + # The unrolled frame retains its execution status and gas + # used, but its logs are discarded with its state changes. + FrameReceipt(status=Spec.STATUS_SUCCESS, logs=[]), + FrameReceipt(status=Spec.STATUS_FAILURE), + ] + else: + batch = [revert_frame, store_frame] + expected_frame_receipts = [ + FrameReceipt(status=Spec.STATUS_SUCCESS), + FrameReceipt(status=Spec.STATUS_FAILURE), + FrameReceipt(status=Spec.STATUS_SKIPPED, gas_used=0), + ] + + tx = Transaction( + sender=sender, + frames=[ + Frame( + mode=Spec.MODE_VERIFY, + flags=Spec.APPROVE_EXECUTION_AND_PAYMENT, + gas_limit=100_000, + ), + *batch, + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=expected_frame_receipts, + ), + ) + + state_test( + pre=pre, + tx=tx, + post={ + sender: Account(nonce=1), + target: Account(storage={SLOT_EXECUTED: 0}), + }, + ) + + +@pytest.mark.exception_test +def test_sender_frame_before_approval( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Reject a frame transaction whose `SENDER` frame runs before any + frame has approved execution. + """ + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + + tx = Transaction( + sender=sender, + frames=[ + Frame( + mode=Spec.MODE_SENDER, + target=target, + gas_limit=200_000, + ), + Frame( + mode=Spec.MODE_VERIFY, + flags=Spec.APPROVE_EXECUTION_AND_PAYMENT, + gas_limit=100_000, + ), + ], + error=TransactionException.TYPE_6_INVALID_FRAME_EXECUTION, + ) + + state_test( + pre=pre, + tx=tx, + post={ + target: Account(storage={SLOT_EXECUTED: 0}), + }, + ) + + +@pytest.mark.exception_test +def test_verify_frame_reverts( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Reject a frame transaction with a reverting `VERIFY` frame: the + sender contract allows no approval scope, so the default code + reverts. + """ + sender = pre.fund_eoa() + reverter = pre.deploy_contract(code=Op.REVERT(0, 0)) + + tx = Transaction( + sender=sender, + frames=[ + Frame( + mode=Spec.MODE_VERIFY, + flags=Spec.APPROVE_EXECUTION_AND_PAYMENT, + gas_limit=100_000, + ), + Frame( + mode=Spec.MODE_VERIFY, + flags=Spec.APPROVE_NONE, + target=reverter, + gas_limit=100_000, + ), + ], + error=TransactionException.TYPE_6_INVALID_FRAME_EXECUTION, + ) + + state_test( + pre=pre, + tx=tx, + post={}, + ) diff --git a/tests/amsterdam/eip8141_frame_transactions/test_introspection.py b/tests/amsterdam/eip8141_frame_transactions/test_introspection.py new file mode 100644 index 00000000000..49a90abd1cd --- /dev/null +++ b/tests/amsterdam/eip8141_frame_transactions/test_introspection.py @@ -0,0 +1,722 @@ +""" +Tests for the introspection instructions of +[EIP-8141: Frame Transaction](https://eips.ethereum.org/EIPS/eip-8141). + +`TXPARAM`, `FRAMEDATALOAD`, `FRAMEDATACOPY`, `FRAMEPARAM` and `SIGPARAM` +are exercised from a `DEFAULT` frame that stores what it reads, so the +post state pins the value each selector returns. +""" + +from typing import List + +import pytest +from execution_testing import ( + EOA, + Account, + Address, + Alloc, + Bytecode, + Bytes, + Frame, + FrameSignature, + Op, + StateTestFiller, + Transaction, +) + +from .spec import Spec, ref_spec_8141 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8141.git_path +REFERENCE_SPEC_VERSION = ref_spec_8141.version + +pytestmark = pytest.mark.valid_from("Bogota") + +SLOT_RESULT = 0x01 +"""Storage slot the probe contract writes what it read into.""" + +PROBE_FRAME_DATA = Bytes(bytes(range(1, 41))) +"""Data of the probe frame: 40 bytes, so a word read is truncated.""" + +# A fresh SSTORE costs STATE_BYTES_PER_STORAGE_SET * COST_PER_STATE_BYTE +# of state gas under EIP-8037, and a frame transaction holds no state +# gas reservoir, so a probe writing two slots needs room for both. +PROBE_FRAME_GAS = 500_000 + +MAX_PRIORITY_FEE = 7 +MAX_FEE = 1_000_000_000 + +ARBITRARY_WITNESS = Bytes(b"\xab" * 5) + + +def verify_frame() -> Frame: + """ + Return the `VERIFY` frame that approves execution and payment + against the sender's default code. + """ + return Frame( + mode=Spec.MODE_VERIFY, + flags=Spec.APPROVE_EXECUTION_AND_PAYMENT, + gas_limit=100_000, + ) + + +def probe_transaction( + sender: EOA, + probe: Address, + signatures: List[FrameSignature] | None = None, +) -> Transaction: + """ + Return a two frame transaction: a `VERIFY` frame approving through + the sender's default code, followed by a `DEFAULT` frame calling + the probe contract at index 1. + """ + return Transaction( + sender=sender, + max_priority_fee_per_gas=MAX_PRIORITY_FEE, + max_fee_per_gas=MAX_FEE, + frames=[ + verify_frame(), + Frame( + mode=Spec.MODE_DEFAULT, + target=probe, + gas_limit=PROBE_FRAME_GAS, + data=PROBE_FRAME_DATA, + ), + ], + signatures=signatures, + ) + + +@pytest.mark.parametrize( + "param,expected", + [ + pytest.param(Spec.TXPARAM_TYPE, Spec.FRAME_TX_TYPE, id="type"), + pytest.param(Spec.TXPARAM_NONCE, 0, id="nonce"), + pytest.param( + Spec.TXPARAM_MAX_PRIORITY_FEE, MAX_PRIORITY_FEE, id="priority_fee" + ), + pytest.param(Spec.TXPARAM_MAX_FEE, MAX_FEE, id="max_fee"), + pytest.param(Spec.TXPARAM_MAX_BLOB_FEE, 0, id="max_blob_fee"), + pytest.param(Spec.TXPARAM_BLOB_COUNT, 0, id="blob_count"), + pytest.param(Spec.TXPARAM_FRAME_COUNT, 2, id="frame_count"), + pytest.param(Spec.TXPARAM_FRAME_INDEX, 1, id="frame_index"), + pytest.param(Spec.TXPARAM_SIGNATURE_COUNT, 1, id="signature_count"), + ], +) +def test_txparam( + state_test: StateTestFiller, + pre: Alloc, + param: int, + expected: int, +) -> None: + """Read transaction scoped information through `TXPARAM`.""" + sender = pre.fund_eoa() + probe = pre.deploy_contract( + code=Op.SSTORE(SLOT_RESULT, Op.TXPARAM(param)) + Op.STOP + ) + + state_test( + pre=pre, + tx=probe_transaction(sender, probe), + post={probe: Account(storage={SLOT_RESULT: expected})}, + ) + + +def test_txparam_max_cost( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Check `TXPARAM`'s max cost selector against the payer's escrow. + + The maximum cost is charged to the payer in full when payment is + approved and the surplus is only refunded at settlement, so during + frame execution the sender's balance is short of its funding by + exactly the value the selector reports. + """ + sender_funds = 10**18 + sender = pre.fund_eoa(amount=sender_funds) + probe = pre.deploy_contract( + code=Op.SSTORE( + SLOT_RESULT, + Op.EQ( + Op.TXPARAM(Spec.TXPARAM_MAX_COST), + Op.SUB( + sender_funds, + Op.BALANCE(Op.TXPARAM(Spec.TXPARAM_SENDER)), + ), + ), + ) + + Op.STOP + ) + + state_test( + pre=pre, + tx=probe_transaction(sender, probe), + post={probe: Account(storage={SLOT_RESULT: 1})}, + ) + + +def test_txparam_sender_and_sig_hash( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Read the sender and the canonical signature hash through `TXPARAM`. + + The canonical hash covers the raw signature bytes of the entry + signing it, so its value is not known when the test is authored and + the probe can only pin that it reads back nonzero. The `msg` + readback of an explicit-digest entry, by contrast, is pinned to the + exact digest the entry was verified over. + """ + explicit_msg = Bytes(b"\x5a" * 32) + sender = pre.fund_eoa() + probe = pre.deploy_contract( + code=Op.SSTORE(SLOT_RESULT, Op.TXPARAM(Spec.TXPARAM_SENDER)) + + Op.SSTORE( + SLOT_RESULT + 1, + Op.ISZERO(Op.TXPARAM(Spec.TXPARAM_SIG_HASH)), + ) + + Op.SSTORE(SLOT_RESULT + 2, Op.SIGPARAM(1, Spec.SIGPARAM_MSG)) + + Op.STOP + ) + + state_test( + pre=pre, + tx=probe_transaction( + sender, + probe, + signatures=[ + FrameSignature( + scheme=Spec.SCHEME_SECP256K1, signer=Bytes(sender) + ), + FrameSignature( + scheme=Spec.SCHEME_SECP256K1, + signer=Bytes(sender), + msg=explicit_msg, + ), + ], + ), + post={ + probe: Account( + storage={ + SLOT_RESULT: sender, + SLOT_RESULT + 1: 0, + SLOT_RESULT + 2: int.from_bytes(explicit_msg, "big"), + } + ) + }, + ) + + +@pytest.mark.parametrize( + "frame_index,param,expected", + [ + pytest.param(0, Spec.FRAMEPARAM_MODE, Spec.MODE_VERIFY, id="mode"), + pytest.param( + 0, + Spec.FRAMEPARAM_FLAGS, + Spec.APPROVE_EXECUTION_AND_PAYMENT, + id="flags", + ), + pytest.param( + 0, + Spec.FRAMEPARAM_ALLOWED_SCOPE, + Spec.APPROVE_EXECUTION_AND_PAYMENT, + id="allowed_scope", + ), + pytest.param(0, Spec.FRAMEPARAM_DATA_LENGTH, 0, id="empty_data"), + pytest.param(0, Spec.FRAMEPARAM_GAS_LIMIT, 100_000, id="gas_limit"), + pytest.param(0, Spec.FRAMEPARAM_ATOMIC_BATCH, 0, id="atomic_batch"), + pytest.param( + 0, + Spec.FRAMEPARAM_STATUS, + Spec.STATUS_SUCCESS, + id="status_of_earlier_frame", + ), + pytest.param( + 1, + Spec.FRAMEPARAM_DATA_LENGTH, + len(PROBE_FRAME_DATA), + id="data_length", + ), + pytest.param(1, Spec.FRAMEPARAM_VALUE, 0, id="value"), + pytest.param( + 1, Spec.FRAMEPARAM_GAS_LIMIT, PROBE_FRAME_GAS, id="own_gas_limit" + ), + ], +) +def test_frameparam( + state_test: StateTestFiller, + pre: Alloc, + frame_index: int, + param: int, + expected: int, +) -> None: + """Read frame scoped information through `FRAMEPARAM`.""" + sender = pre.fund_eoa() + probe = pre.deploy_contract( + code=Op.SSTORE(SLOT_RESULT, Op.FRAMEPARAM(frame_index, param)) + + Op.STOP + ) + + state_test( + pre=pre, + tx=probe_transaction(sender, probe), + post={probe: Account(storage={SLOT_RESULT: expected})}, + ) + + +def test_frameparam_resolved_target( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + `FRAMEPARAM` reports the resolved target, so a frame with an empty + target reads back as the transaction sender. + """ + sender = pre.fund_eoa() + probe = pre.deploy_contract( + code=Op.SSTORE(SLOT_RESULT, Op.FRAMEPARAM(0, Spec.FRAMEPARAM_TARGET)) + + Op.SSTORE(SLOT_RESULT + 1, Op.FRAMEPARAM(1, Spec.FRAMEPARAM_TARGET)) + + Op.STOP + ) + + state_test( + pre=pre, + tx=probe_transaction(sender, probe), + post={ + probe: Account( + storage={SLOT_RESULT: sender, SLOT_RESULT + 1: probe} + ) + }, + ) + + +def test_frameparam_atomic_batch_set( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Read the atomic batch flag of a frame that carries it back as one + through `FRAMEPARAM`, both via the dedicated selector and the raw + flags. + + A flagged frame cannot be the transaction's last, so a `DEFAULT` + frame targeting the sender trails the batch. + """ + sender = pre.fund_eoa() + probe = pre.deploy_contract( + code=Op.SSTORE( + SLOT_RESULT, Op.FRAMEPARAM(1, Spec.FRAMEPARAM_ATOMIC_BATCH) + ) + + Op.SSTORE(SLOT_RESULT + 1, Op.FRAMEPARAM(1, Spec.FRAMEPARAM_FLAGS)) + + Op.STOP + ) + + tx = Transaction( + sender=sender, + max_priority_fee_per_gas=MAX_PRIORITY_FEE, + max_fee_per_gas=MAX_FEE, + frames=[ + verify_frame(), + Frame( + mode=Spec.MODE_DEFAULT, + flags=Spec.ATOMIC_BATCH_FLAG, + target=probe, + gas_limit=PROBE_FRAME_GAS, + ), + Frame( + mode=Spec.MODE_DEFAULT, + gas_limit=100_000, + ), + ], + ) + + state_test( + pre=pre, + tx=tx, + post={ + probe: Account( + storage={ + SLOT_RESULT: 1, + SLOT_RESULT + 1: Spec.ATOMIC_BATCH_FLAG, + } + ) + }, + ) + + +@pytest.mark.parametrize( + "frame_index,param", + [ + pytest.param(1, Spec.FRAMEPARAM_STATUS, id="status_of_current_frame"), + pytest.param(2, Spec.FRAMEPARAM_MODE, id="frame_index_out_of_bounds"), + pytest.param(0, 0x09, id="undefined_param"), + ], +) +def test_frameparam_halts( + state_test: StateTestFiller, + pre: Alloc, + frame_index: int, + param: int, +) -> None: + """ + `FRAMEPARAM` halts exceptionally on the status of the current + frame, an out of bounds frame index, and an undefined selector. + The halt fails the frame without invalidating the transaction, + because the frame does not run in `VERIFY` mode. + + The probe writes a marker before the halting read, so a selector + that returned zero instead of halting would leave the marker + behind. + """ + sender = pre.fund_eoa() + probe = pre.deploy_contract( + code=Op.SSTORE(SLOT_RESULT, 0xFF) + + Op.POP(Op.FRAMEPARAM(frame_index, param)) + + Op.STOP + ) + + state_test( + pre=pre, + tx=probe_transaction(sender, probe), + post={probe: Account(storage={SLOT_RESULT: 0})}, + ) + + +@pytest.mark.parametrize( + "halting_read", + [ + pytest.param( + Op.POP(Op.TXPARAM(0x0C)), + id="txparam_undefined_param", + ), + pytest.param( + Op.POP(Op.SIGPARAM(0, 0x05)), + id="sigparam_undefined_param", + ), + pytest.param( + Op.POP(Op.SIGPARAM(1, Spec.SIGPARAM_SCHEME)), + id="sigparam_signature_index_out_of_bounds", + ), + pytest.param( + Op.POP(Op.FRAMEDATALOAD(0, 2)), + id="framedataload_frame_index_out_of_bounds", + ), + pytest.param( + Op.FRAMEDATACOPY(0, 0, 32, 2), + id="framedatacopy_frame_index_out_of_bounds", + ), + ], +) +def test_introspection_halts( + state_test: StateTestFiller, + pre: Alloc, + halting_read: Bytecode, +) -> None: + """ + `TXPARAM`, `SIGPARAM`, `FRAMEDATALOAD` and `FRAMEDATACOPY` halt + exceptionally on undefined selectors and out of bounds indices. + The halt fails the frame without invalidating the transaction, + because the frame does not run in `VERIFY` mode. + + The probe transaction carries two frames and one signature entry, + so frame index 2 and signature index 1 are the first indices out + of bounds. The probe writes a marker before the halting read, so + a read that returned a value instead of halting would leave the + marker behind. + """ + sender = pre.fund_eoa() + probe = pre.deploy_contract( + code=Op.SSTORE(SLOT_RESULT, 0xFF) + halting_read + Op.STOP + ) + + state_test( + pre=pre, + tx=probe_transaction(sender, probe), + post={probe: Account(storage={SLOT_RESULT: 0})}, + ) + + +@pytest.mark.parametrize( + "frame_index,offset,expected", + [ + pytest.param( + 1, + 0, + int.from_bytes(PROBE_FRAME_DATA[0:32], "big"), + id="first_word", + ), + pytest.param( + 1, + 32, + int.from_bytes(PROBE_FRAME_DATA[32:].ljust(32, b"\x00"), "big"), + id="tail_zero_padded", + ), + pytest.param(1, 64, 0, id="past_the_end"), + pytest.param(0, 0, 0, id="empty_frame_data"), + ], +) +def test_framedataload( + state_test: StateTestFiller, + pre: Alloc, + frame_index: int, + offset: int, + expected: int, +) -> None: + """ + Read a word of a frame's data through `FRAMEDATALOAD`, including + reads that run past the end and read as zeroes. + """ + sender = pre.fund_eoa() + probe = pre.deploy_contract( + code=Op.SSTORE(SLOT_RESULT, Op.FRAMEDATALOAD(offset, frame_index)) + + Op.STOP + ) + + state_test( + pre=pre, + tx=probe_transaction(sender, probe), + post={probe: Account(storage={SLOT_RESULT: expected})}, + ) + + +def test_framedatacopy( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Copy a frame's data into memory through `FRAMEDATACOPY`, including + a copy that straddles the end of the data and is zero filled. + """ + sender = pre.fund_eoa() + probe = pre.deploy_contract( + code=Op.FRAMEDATACOPY(0, 0, 32, 1) + + Op.SSTORE(SLOT_RESULT, Op.MLOAD(0)) + + Op.FRAMEDATACOPY(32, 32, 32, 1) + + Op.SSTORE(SLOT_RESULT + 1, Op.MLOAD(32)) + + Op.STOP + ) + + state_test( + pre=pre, + tx=probe_transaction(sender, probe), + post={ + probe: Account( + storage={ + SLOT_RESULT: int.from_bytes(PROBE_FRAME_DATA[0:32], "big"), + SLOT_RESULT + 1: int.from_bytes( + PROBE_FRAME_DATA[32:].ljust(32, b"\x00"), "big" + ), + } + ) + }, + ) + + +@pytest.mark.parametrize( + "signature_index,param,expected", + [ + pytest.param( + 0, + Spec.SIGPARAM_SCHEME, + Spec.SCHEME_SECP256K1, + id="secp256k1_scheme", + ), + pytest.param( + 0, Spec.SIGPARAM_SIGNATURE_LENGTH, 65, id="secp256k1_length" + ), + pytest.param(0, Spec.SIGPARAM_MSG, 0, id="canonical_hash_msg"), + pytest.param( + 1, + Spec.SIGPARAM_SCHEME, + Spec.SCHEME_ARBITRARY, + id="arbitrary_scheme", + ), + pytest.param( + 1, + Spec.SIGPARAM_SIGNATURE_LENGTH, + len(ARBITRARY_WITNESS), + id="arbitrary_length", + ), + ], +) +def test_sigparam( + state_test: StateTestFiller, + pre: Alloc, + signature_index: int, + param: int, + expected: int, +) -> None: + """Read signature scoped metadata through `SIGPARAM`.""" + sender = pre.fund_eoa() + probe = pre.deploy_contract( + code=Op.SSTORE(SLOT_RESULT, Op.SIGPARAM(signature_index, param)) + + Op.STOP + ) + + state_test( + pre=pre, + tx=probe_transaction( + sender, + probe, + signatures=[ + FrameSignature( + scheme=Spec.SCHEME_SECP256K1, signer=Bytes(sender) + ), + FrameSignature( + scheme=Spec.SCHEME_ARBITRARY, signature=ARBITRARY_WITNESS + ), + ], + ), + post={probe: Account(storage={SLOT_RESULT: expected})}, + ) + + +def test_sigparam_resolved_signer( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Read the resolved signer of a protocol-validated entry through + `SIGPARAM`, and check the same read against an `ARBITRARY` entry + halts the frame instead: the protocol assigns no signer to bytes + it does not validate. + + The refused probe writes a marker before the halting read, so a + read that returned a value instead of halting would leave the + marker behind. + """ + sender = pre.fund_eoa() + resolved = pre.deploy_contract( + code=Op.SSTORE( + SLOT_RESULT, Op.SIGPARAM(0, Spec.SIGPARAM_RESOLVED_SIGNER) + ) + + Op.STOP + ) + refused = pre.deploy_contract( + code=Op.SSTORE(SLOT_RESULT, 0xFF) + + Op.POP(Op.SIGPARAM(1, Spec.SIGPARAM_RESOLVED_SIGNER)) + + Op.STOP + ) + + tx = Transaction( + sender=sender, + max_priority_fee_per_gas=MAX_PRIORITY_FEE, + max_fee_per_gas=MAX_FEE, + frames=[ + verify_frame(), + Frame( + mode=Spec.MODE_DEFAULT, + target=resolved, + gas_limit=PROBE_FRAME_GAS, + ), + Frame( + mode=Spec.MODE_DEFAULT, + target=refused, + gas_limit=PROBE_FRAME_GAS, + ), + ], + signatures=[ + FrameSignature(scheme=Spec.SCHEME_SECP256K1, signer=Bytes(sender)), + FrameSignature( + scheme=Spec.SCHEME_ARBITRARY, signature=ARBITRARY_WITNESS + ), + ], + ) + + state_test( + pre=pre, + tx=tx, + post={ + resolved: Account(storage={SLOT_RESULT: sender}), + refused: Account(storage={SLOT_RESULT: 0}), + }, + ) + + +def sigparam_copy( + signature_index: int, + param: int, + mem_offset: int, + data_offset: int, + length: int, +) -> Bytecode: + """ + Return bytecode for `SIGPARAM`'s copy operation, whose five stack + operands the opcode helper does not model. + """ + return ( + Op.PUSH1(length) + + Op.PUSH1(data_offset) + + Op.PUSH1(mem_offset) + + Op.PUSH1(param) + + Op.PUSH1(signature_index) + + Op.SIGPARAM + ) + + +def test_sigparam_copy_arbitrary( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Copy an `ARBITRARY` entry's raw signature bytes into memory, and + check that the same copy against a protocol validated entry halts + the frame instead. + """ + sender = pre.fund_eoa() + copied = pre.deploy_contract( + code=sigparam_copy(1, Spec.SIGPARAM_COPY, 0, 0, 32) + + Op.SSTORE(SLOT_RESULT, Op.MLOAD(0)) + + Op.STOP + ) + refused = pre.deploy_contract( + code=sigparam_copy(0, Spec.SIGPARAM_COPY, 0, 0, 32) + + Op.SSTORE(SLOT_RESULT, 1) + + Op.STOP + ) + + signatures = [ + FrameSignature(scheme=Spec.SCHEME_SECP256K1, signer=Bytes(sender)), + FrameSignature( + scheme=Spec.SCHEME_ARBITRARY, signature=ARBITRARY_WITNESS + ), + ] + + tx = Transaction( + sender=sender, + max_priority_fee_per_gas=MAX_PRIORITY_FEE, + max_fee_per_gas=MAX_FEE, + frames=[ + verify_frame(), + Frame( + mode=Spec.MODE_DEFAULT, + target=copied, + gas_limit=PROBE_FRAME_GAS, + ), + Frame( + mode=Spec.MODE_DEFAULT, + target=refused, + gas_limit=PROBE_FRAME_GAS, + ), + ], + signatures=signatures, + ) + + state_test( + pre=pre, + tx=tx, + post={ + copied: Account( + storage={ + SLOT_RESULT: int.from_bytes( + bytes(ARBITRARY_WITNESS).ljust(32, b"\x00"), "big" + ) + } + ), + refused: Account(storage={SLOT_RESULT: 0}), + }, + ) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index b68fbccedab..d3870bd9b74 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -11,6 +11,9 @@ from ethereum.ethash import * from ethereum.fork_criteria import Unscheduled +from ethereum.forks.amsterdam.transactions.frame_transaction import ( + FrameMode, +) from ethereum.trace import EvmTracer from ethereum.utils.hexadecimal import hex_to_bytes256 from ethereum_optimized.state_db import State @@ -47,6 +50,10 @@ # src/ethereum/fork_criteria.py Unscheduled +# src/ethereum/forks/amsterdam/transactions/frame_transaction.py - +# constructed while decoding transactions, never compared explicitly +FrameMode.DEFAULT + # src/ethereum/ethash.py ethash.generate_dataset @@ -116,6 +123,9 @@ TransactionLoad.json_to_max_priority_fee_per_gas TransactionLoad.json_to_max_fee_per_blob_gas TransactionLoad.json_to_blob_versioned_hashes +TransactionLoad.json_to_sender +TransactionLoad.json_to_frames +TransactionLoad.json_to_signatures TransactionLoad.json_to_v TransactionLoad.json_to_y_parity TransactionLoad.json_to_r