diff --git a/EIPS/eip-8141.md b/EIPS/eip-8141.md index e1b8afc28dd11f..0aaab89ae59760 100644 --- a/EIPS/eip-8141.md +++ b/EIPS/eip-8141.md @@ -793,11 +793,11 @@ A node MUST drop a frame transaction from the public mempool if it contains an ` #### Canonical Paymaster Exception -The generic validation trace and opcode rules below apply to all frames in the validation prefix except a `pay` frame whose target runtime code exactly matches the canonical paymaster implementation. The canonical paymaster implementation is explicitly designed to be safe for public mempool use and is therefore admitted by code match, successful `APPROVE(APPROVE_PAYMENT)`, and the paymaster accounting rules in this section, rather than by requiring it to satisfy each generic validation rule individually. +The generic validation trace and opcode rules below apply to all frames in the validation prefix except a `pay` frame whose target's runtime code hash equals the canonical paymaster code hash of the active fork. The canonical paymaster implementation is explicitly designed to be safe for public mempool use and is therefore admitted by that code-hash match, successful `APPROVE(APPROVE_PAYMENT)`, and the paymaster accounting rules in this section, rather than by requiring it to satisfy each generic validation rule individually. #### Direct Evaluation of Protocol-Defined Frames -Three frame species in the validation prefix have fully protocol-defined semantics, leaving no deployed code whose behavior a node would need to discover by execution: a frame whose resolved target has the empty code hash (default code), an expiry verifier frame whose runtime code at `EXPIRY_VERIFIER` matches the canonical expiry verifier code, and a `pay` frame admitted by canonical paymaster code match per the previous section. +Three frame species in the validation prefix have fully protocol-defined semantics, leaving no deployed code whose behavior a node would need to discover by execution: a frame whose resolved target has the empty code hash (default code), an expiry verifier frame whose runtime code at `EXPIRY_VERIFIER` matches the canonical expiry verifier code, and a `pay` frame whose target's runtime code hash equals the canonical paymaster code hash of the active fork, per the previous section. When every frame in the validation prefix is one of these, direct evaluation of the protocol-defined semantics is equivalent to simulation, and a node MAY use it to satisfy the validation requirements below. Direct evaluation MUST apply the same limits as simulation: signature validation and the evaluated frames' work count against `MAX_VERIFY_GAS`, and the paymaster accounting rules in this section apply unchanged. @@ -865,11 +865,94 @@ We address this conflict in two ways: ##### Canonical paymaster -The canonical paymaster is not a singleton deployment. Many instances may be deployed. For public mempool purposes, a paymaster instance is considered canonical if and only if the runtime code at the `pay` frame target exactly matches the canonical paymaster implementation. +The canonical paymaster is not a singleton: many instances may be deployed, one per sponsor. An instance is canonical for public mempool purposes if and only if its runtime code hash equals the active fork's pinned canonical paymaster code hash (listed in this section). A new version is introduced by pinning a new hash; versions unpinned at a fork boundary demote to non-canonical. -The canonical paymaster in this draft authorizes with a single secp256k1 signer via `ecrecover`, does not support contract-signature schemes, and may change in later specifications, in which case a new canonical implementation version would be required. +**Storage layout.** The layout is normative per canonical version. Nodes read these slots directly for admission and revalidation; no EVM execution is required to compute solvency. -Because the canonical paymaster implementation is explicitly standardized to be safe for public mempool use, nodes do not need to apply the generic validation trace and opcode rules to that `pay` frame. Instead, they identify it by runtime code match and apply the paymaster-specific accounting and revalidation rules in this section. +| Slot | Content | +|------|---------| +| `0` | `signer` - the address whose signature authorizes payments | +| `1` | `pending_withdrawal_amount` - `0` when no withdrawal is pending | +| `2` | `pending_unlock_time` - timestamp at which the pending action matures; `0` when none | +| `3` | `pending_new_signer` - `0` when no rotation is pending | + +At most one pending action exists at a time: a withdrawal (slot 1 non-zero) or a signer rotation (slot 3 non-zero); slot 2 is the shared maturity clock. `pending_withdrawal_amount(paymaster)` in the reservation formula below is slot 1. + +**Deployment.** Recognition is on the *runtime* code hash, so every instance shares identical runtime bytes; the per-instance `signer` is written to slot `0` by initialization code that then returns the canonical runtime (the standard constructor-writes-storage pattern) and is not itself part of recognition. An instance is fully described by its runtime code hash and slot `0`. An instance whose slot `0` is zero authorizes nothing - no protocol-verified signature resolves to the zero address - so a mis-deployment is inert, not open. + +**Payment authorization.** The sponsor's authorization is a protocol-verified signature entry (any scheme except `ARBITRARY`) at index 1, naming the stored `signer` with empty `msg` - a signature over `compute_sig_hash(tx)`. That hash covers the chain id, the sender and nonce, and every frame including the `pay` target, so one authorization is valid for exactly one transaction, instance, and chain, and a fee change requires a fresh one. Since `compute_sig_hash` elides only empty-`msg` signature bytes, the authorization commits to the index-1 signer without circularity. + +`ARBITRARY` entries are excluded - structurally checked but not cryptographically verified; every other scheme is accepted the moment the protocol verifies it, so the paymaster names no curve and inherits signature-scheme agility, including post-quantum schemes, from the signature list with no new canonical version. Because verification happens before any frame executes, the validation path performs no recovery: it checks that index 1 names the stored signer, then calls `APPROVE(APPROVE_PAYMENT)`, which itself enforces balance sufficiency, the `sender_approved` precondition, nonce increment, and the maximum-cost charge - so a canonical `pay` frame can only follow an approving frame. + +**Treasury rules.** Deposits are unrestricted: any plain value transfer is accepted. Ether leaves an instance in exactly two ways: the protocol's `APPROVE` charge, and a matured withdrawal. Signer rotation shares the withdrawal timelock, because payments are an exit the timelock cannot guard: an instantly rotated key could drain the balance through valid payments. `DELAY` is `86400` seconds in this version. + +**Calldata interface.** The `pay` frame passes empty `data`. Administrative operations are invoked with `op (1 byte) || argument (32 bytes)`, mirroring the expiry verifier's raw-calldata convention: + +| `op` | Operation | Argument | Authorization | +|------|-----------|----------|---------------| +| `0x01` | initiate withdrawal | amount (non-zero) | authorized by `signer`; no action pending | +| `0x02` | initiate rotation | new signer (non-zero) | authorized by `signer`; no action pending | +| `0x03` | cancel pending action | - | authorized by `signer` | +| `0x04` | finalize matured action | - | anyone, once `block.timestamp >= slot 2` | + +`authorized()` accepts the direct caller or a signer-signed index-1 entry (the same check as the payment path): the caller route serves a plain secp256k1 signer, the signature route serves any protocol-verified scheme whose derived address cannot originate calls, and because that signature covers the canonical transaction hash it binds the whole transaction and cannot be replayed. Finalizing a withdrawal sends the amount to `signer`, reverting if the balance is insufficient (the sponsor cancels and re-initiates a smaller amount) or `signer` cannot receive it; finalizing a rotation writes slot 3 into slot 0. The withdrawal path clears its pending slots (1 and 2) before the external transfer; rotation has no external call, so its slot ordering is unobservable. + +**Runtime behavior** (normative): + +```python +if CALLVALUE > 0: + if CALLDATASIZE == 0: STOP # deposit + REVERT # admin ops are non-payable + +if CALLDATASIZE == 0: # validation path (pay frame) + if SIGPARAM(scheme, 1) == ARBITRARY: REVERT + if SIGPARAM(resolved_signer, 1) != SLOAD(0): REVERT + if SIGPARAM(msg, 1) != 0: REVERT # must sign the canonical sig hash + APPROVE(APPROVE_PAYMENT) + +def authorized(): # caller, or signer-signed frame-tx entry + if CALLER == SLOAD(0): return true + return SIGPARAM(scheme, 1) != ARBITRARY \ + and SIGPARAM(resolved_signer, 1) == SLOAD(0) \ + and SIGPARAM(msg, 1) == 0 # halts in a non-frame context: correct rejection + +op = CALLDATA[0] +if op == 0x01: # initiate withdrawal + require authorized() and SLOAD(2) == 0 + amount = CALLDATA[1:33]; require amount != 0 + SSTORE(1, amount); SSTORE(2, TIMESTAMP + 86400); STOP +if op == 0x02: # initiate rotation + require authorized() and SLOAD(2) == 0 + new_signer = CALLDATA[1:33]; require new_signer != 0 + SSTORE(3, new_signer); SSTORE(2, TIMESTAMP + 86400); STOP +if op == 0x03: # cancel + require authorized() + SSTORE(1, 0); SSTORE(2, 0); SSTORE(3, 0); STOP +if op == 0x04: # finalize + require SLOAD(2) != 0 and TIMESTAMP >= SLOAD(2) + amount = SLOAD(1) + if amount != 0: + SSTORE(1, 0); SSTORE(2, 0) + require CALL(signer, amount) # state cleared before the send + else: + SSTORE(0, SLOAD(3)); SSTORE(3, 0); SSTORE(2, 0) + STOP +REVERT +``` + +The assembled runtime bytecode (355 bytes) is: + +```text +0x3461002e57366100355760016001b41561005a575f6001b45f54141561005a5760026001b461005a5760015f5faa5b3661005a57005b5f3560f81c8060011461005e57806002146100a557806003146100ec57600414610123575b5f5ffd5b50335f54146100875760016001b41561005a575f6001b45f54141561005a5760026001b461005a575b60025461005a57600135801561005a57600155426201518001600255005b50335f54146100ce5760016001b41561005a575f6001b45f54141561005a5760026001b461005a575b60025461005a57600135801561005a57600355426201518001600255005b50335f54146101155760016001b41561005a575f6001b45f54141561005a5760026001b461005a575b5f6001555f6002555f600355005b600254801561005a57421061005a576001548015610153575f6001555f6002555f5f5f5f845f545af11561005a57005b506003545f555f6003555f60025500 +``` + +Its per-fork `keccak256` code hash is `0xda42f0d11838c4c0c3129b8b8e93e9718127ad6b315e517e1088125707c4d45c`. An annotated assembly listing is given in the [canonical paymaster assets file](../assets/eip-8141/canonical-paymaster.md). + +**Gas.** The validation path is dominated by a single cold `SLOAD` of slot 0, plus a small fixed overhead, so a canonical `pay` frame needs a `gas_limit` of at most 15,000 on any current schedule; nodes MAY reject canonical `pay` frames with a higher `gas_limit` as non-canonical usage. + +**Tracked dependencies.** For revalidation purposes, the canonical paymaster's tracked state is its balance, its code, and storage slots 0 through 3. A finalized rotation changes slot 0 and therefore invalidates pending transactions authorized by the previous signer. + +Because the canonical paymaster implementation is explicitly standardized to be safe for public mempool use, nodes do not need to apply the generic validation trace and opcode rules to that `pay` frame. Instead, they identify it by its code hash and apply the paymaster-specific accounting and revalidation rules in this section. A transaction using a paymaster is eligible for public mempool propagation only if the `pay` frame targets a canonical paymaster instance and the node can reserve the maximum transaction cost against that paymaster. @@ -906,7 +989,7 @@ available_paymaster_balance = state.balance(paymaster) - reserved_pending_cost(p 1. A transaction is received over the wire and the node decides whether to accept or reject it. 2. The node validates all protocol-validated signatures and structurally checks all `ARBITRARY` signatures. If any signature is malformed or invalid, reject. 3. The node analyzes the frame structure and determines the validation prefix. If the prefix is not one of the recognized prefixes, reject. -4. The node simulates the validation prefix and enforces the structural and trace rules above, except that a `pay` frame whose target runtime code exactly matches the canonical paymaster implementation is handled via the canonical paymaster exception and the paymaster-specific rules below. +4. The node simulates the validation prefix and enforces the structural and trace rules above, except that a `pay` frame whose target's runtime code hash equals the canonical paymaster code hash of the active fork is handled via the canonical paymaster exception and the paymaster-specific rules below. 5. The node records the sender storage slots read during validation. Calls into helper contracts do not create additional mutable-state dependencies unless they cause disallowed storage access under the trace rules above. 6. If a canonical paymaster instance is used, the node verifies paymaster solvency using the reservation rule above. 7. A node should keep at most one pending frame transaction per sender in the public mempool. A new transaction from the same sender MAY replace the existing one only if it uses the same nonce and satisfies the replacement rules below. @@ -918,13 +1001,13 @@ Pending frame transactions are identified by `(sender, nonce)`. The sender's non A replacement must be valid under all rules in this section and should only be accepted and propagated if it increases both `max_fee_per_gas` and `max_priority_fee_per_gas` by at least a node-configured minimum increment (10% is the conventional default). A blob-carrying frame transaction additionally follows the blob pool's replacement conventions for `max_fee_per_blob_gas`. A replacement may name a different payer than the transaction it replaces. The node should release the reservation or exposure held for the old payer and shift it to the new payer atomically with the replacement. -Reservation accounting applies to every payer, not only canonical paymasters. A node must not hold pending frame transactions whose summed maximum costs exceed the payer's balance, tracked per payer exactly as `reserved_pending_cost` is tracked for canonical paymaster instances. For canonical paymaster instances this is the reservation rule above. For any other payer it bounds aggregate exposure so that a single balance change cannot invalidate an unbounded set of pending transactions. The `MAX_PENDING_TXS_USING_NON_CANONICAL_PAYMASTER` cap continues to apply to `pay` frames whose target carries code that is not the canonical paymaster implementation; a `pay` frame whose target has the empty code hash (a default-code sponsor) is not a paymaster and is governed by the per-payer exposure rule alone. +Reservation accounting applies to every payer, not only canonical paymasters. A node must not hold pending frame transactions whose summed maximum costs exceed the payer's balance, tracked per payer exactly as `reserved_pending_cost` is tracked for canonical paymaster instances. For canonical paymaster instances this is the reservation rule above. For any other payer it bounds aggregate exposure so that a single balance change cannot invalidate an unbounded set of pending transactions. The `MAX_PENDING_TXS_USING_NON_CANONICAL_PAYMASTER` cap continues to apply to `pay` frames whose target's runtime code hash does not equal the canonical paymaster code hash of the active fork; a `pay` frame whose target has the empty code hash (a default-code sponsor) is not a paymaster and is governed by the per-payer exposure rule alone. When local resource limits are reached, a node should evict in this order: first transactions already invalid against the current head, then transactions with the nearest expiry deadline, then transactions with the lowest effective priority fee. Evicted and replaced transactions must not be re-propagated. Re-admission requires receiving the transaction again. On any eviction or replacement the corresponding payer reservation or exposure is released. #### Revalidation -When a new canonical block is accepted, the node removes any included frame transactions from the public mempool, updates paymaster reservations accordingly, and identifies the remaining pending transactions whose tracked dependencies were touched by the block. This includes at least transactions for the same sender, transactions whose recorded sender storage slots changed, transactions that reference a canonical paymaster instance whose balance, code, or delayed-withdrawal state changed, and transactions whose payer's balance or code changed. The node then re-simulates the validation prefix of only those affected transactions against the new head and evicts any transaction that no longer satisfies the public mempool rules. +When a new canonical block is accepted, the node removes any included frame transactions from the public mempool, updates paymaster reservations accordingly, and identifies the remaining pending transactions whose tracked dependencies were touched by the block. This includes at least transactions for the same sender, transactions whose recorded sender storage slots changed, transactions that reference a canonical paymaster instance whose balance, code, signer, or delayed-withdrawal state changed - equivalently, any of its tracked storage slots (0 through 3) or its balance or code - and transactions whose payer's balance or code changed. A finalized signer rotation changes slot 0 and so invalidates transactions authorized by the previous signer; this trigger is what evicts them. The node then re-simulates the validation prefix of only those affected transactions against the new head and evicts any transaction that no longer satisfies the public mempool rules. #### Transaction origination @@ -1228,6 +1311,10 @@ For deployment of the sender account in the first frame, the mempool enforces de In general, it can be assumed that handling of frame transactions imposes similar restrictions as EIP-7702 on mempool relay, i.e. only a single transaction can be pending for an account that uses frame transactions. +### Canonical Paymaster Key Compromise + +The canonical paymaster protects the mempool from the sponsor; it does not protect the sponsor from signer-key compromise. Payments are not timelocked, so an attacker holding the signer key can drain the balance immediately through self-sponsored transactions - the timelock guards only withdrawals and rotations, not the payment exit. Rotation is no remedy under compromise: the replacement is authorized by the same key, matures only after `DELAY`, and the attacker can `cancel` any competing rotation the owner initiates, so a single compromised key means total loss with no in-contract recovery. This is the deliberate cost of a minimal, single-signer canonical implementation; multi-key control, spend limits, and social recovery are the province of non-canonical wallet code layered above, not of the canonical mempool-safe tier. A separate, bounded effect: anyone may finalize a matured action, so a sponsor's own pending rotation, once mature, may be finalized by a third party, invalidating pending transactions authorized by the outgoing key. + ### Execution Approval Authorizes All Subsequent Sender Frames `sender_approved` is a single transaction-scoped flag. Once a frame grants `APPROVE_EXECUTION` (or `APPROVE_EXECUTION_AND_PAYMENT`), every subsequent `SENDER` frame executes with `caller` set to `tx.sender`, not only the frame the approving code inspected. The approval is not scoped to a particular frame or call target. diff --git a/assets/eip-8141/CanonicalPaymaster.sol b/assets/eip-8141/CanonicalPaymaster.sol deleted file mode 100644 index bccca466351aaf..00000000000000 --- a/assets/eip-8141/CanonicalPaymaster.sol +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-License-Identifier: CC0-1.0 -pragma solidity ^0.8.24; - -/// @notice Minimal canonical paymaster for EIP-8141-style frame transactions. -/// @dev VERIFY frame calldata is expected to be exactly: -/// r (32 bytes) || s (32 bytes) || v (1 byte) -/// The signature is checked against TXPARAM(0x08), i.e. the canonical tx sig hash. -/// On success, the contract calls APPROVE with scope=0x1 and offset/length=0 to approve payment. -/// This implementation supports only a single secp256k1 signer recovered via ecrecover. -/// ERC-1271 and other contract-signature schemes are not supported. -contract CanonicalPaymaster { - uint256 public constant WITHDRAWAL_DELAY = 12 hours; - - // secp256k1n / 2 - uint256 private constant SECP256K1N_DIV_2 = - 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0; - - // Stored in contract storage instead of immutable so the deployed runtime code - // is identical across all instances and can be recognized canonically by code match. - // This is the authorized secp256k1 signer address, not a generic contract-signature authority. - address public owner; - - address payable public pendingWithdrawalTo; - uint256 public pendingWithdrawalAmount; - uint256 public pendingWithdrawalReadyAt; - - error NotOwner(); - error ZeroAddress(); - error InvalidSignature(); - error NoPendingWithdrawal(); - error WithdrawalNotReady(); - error TransferFailed(); - - event WithdrawalRequested(address indexed to, uint256 amount, uint256 readyAt); - event WithdrawalExecuted(address indexed to, uint256 amount); - - constructor(address owner_) payable { - if (owner_ == address(0)) revert ZeroAddress(); - owner = owner_; - } - - receive() external payable {} - - /// @dev Raw paymaster validation entrypoint. - /// Use as the target of the PAY/VERIFY frame. - fallback() external payable { - if (msg.data.length != 65) revert InvalidSignature(); - - bytes32 r; - bytes32 s; - uint8 v; - - assembly { - r := calldataload(0x00) - s := calldataload(0x20) - v := byte(0, calldataload(0x40)) - } - - if (uint256(s) > SECP256K1N_DIV_2) revert InvalidSignature(); - if (v != 27 && v != 28) revert InvalidSignature(); - - if (ecrecover(_txSigHash(), v, r, s) != owner) { - revert InvalidSignature(); - } - - _approvePayer(); - } - - function requestWithdrawal(address payable to, uint256 amount) external { - if (msg.sender != owner) revert NotOwner(); - if (to == address(0)) revert ZeroAddress(); - - pendingWithdrawalTo = to; - pendingWithdrawalAmount = amount; - pendingWithdrawalReadyAt = block.timestamp + WITHDRAWAL_DELAY; - - emit WithdrawalRequested(to, amount, pendingWithdrawalReadyAt); - } - - function executeWithdrawal() external { - if (msg.sender != owner) revert NotOwner(); - - address payable to = pendingWithdrawalTo; - uint256 amount = pendingWithdrawalAmount; - uint256 readyAt = pendingWithdrawalReadyAt; - - if (readyAt == 0) revert NoPendingWithdrawal(); - if (block.timestamp < readyAt) revert WithdrawalNotReady(); - - delete pendingWithdrawalTo; - delete pendingWithdrawalAmount; - delete pendingWithdrawalReadyAt; - - (bool ok, ) = to.call{value: amount}(""); - if (!ok) revert TransferFailed(); - - emit WithdrawalExecuted(to, amount); - } - - function _txSigHash() internal returns (bytes32 sigHash) { - assembly { - // TXPARAM(0x08) -> canonical frame transaction signature hash - sigHash := verbatim_0i_1o(hex"6008b0") - } - } - - function _approvePayer() internal { - assembly { - // APPROVE(scope=0x1, length=0, offset=0) - // Push order: scope, length, offset - verbatim_0i_0o(hex"600160006000aa") - } - } -} diff --git a/assets/eip-8141/canonical-paymaster.md b/assets/eip-8141/canonical-paymaster.md new file mode 100644 index 00000000000000..65861e4e9da0f9 --- /dev/null +++ b/assets/eip-8141/canonical-paymaster.md @@ -0,0 +1,305 @@ +# Canonical paymaster reference artifacts + +## Annotated assembly + +Storage layout: `slot0 = signer`, `slot1 = pending_withdrawal_amount`, +`slot2 = pending_unlock_time`, `slot3 = pending_new_signer`. `DELAY = 86400`. +Jump targets are assembled as fixed-width `push2