referrerpolicy=no-referrer-when-downgrade

pallet_revive/evm/
eip7702.rs

1// This file is part of Substrate.
2
3// Copyright (C) Parity Technologies (UK) Ltd.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! EIP-7702: Set EOA Account Code implementation
19//!
20//! This module implements the authorization processing for EIP-7702, which allows
21//! Externally Owned Accounts (EOAs) to temporarily set code in their account via
22//! authorization tuples attached to transactions.
23
24use crate::{
25	BalanceOf, Config, Error, ExecConfig, HoldReason, LOG_TARGET, Pallet, RuntimeCosts,
26	address::AddressMapper,
27	evm::{
28		api::{AuthorizationListEntry, recover_eth_address_from_message},
29		fees::InfoT as _,
30	},
31	metering,
32	primitives::StorageDeposit,
33	storage::AccountInfo,
34};
35use alloc::vec::Vec;
36use frame_support::{
37	storage::transactional::with_storage_layer,
38	traits::fungible::{Balanced as _, Inspect},
39	weights::Weight,
40};
41use sp_core::{Get, H160, U256};
42use sp_runtime::{
43	SaturatedConversion,
44	traits::{Saturating, Zero},
45};
46
47/// EIP-7702: Magic value for authorization signature message
48const EIP7702_MAGIC: u8 = 0x05;
49
50/// Result of processing EIP-7702 authorization tuples.
51#[derive(Default, Debug, PartialEq, Eq)]
52pub struct AuthorizationResult<Balance: sp_runtime::traits::Zero> {
53	/// Number of authorizations that created new accounts.
54	pub new_accounts: u32,
55	/// Number of authorizations that applied to existing accounts.
56	pub existing_accounts: u32,
57	/// Net deposit movement caused by authorization processing. `Charge` if more was charged than
58	/// refunded (e.g. new-account ED + delegation deposits), `Refund` if revokes outweighed new
59	/// charges (e.g. clearing delegations on existing accounts).
60	pub deposit: StorageDeposit<Balance>,
61	/// Weight to refund for authorizations that hit existing accounts.
62	pub weight_refund: Weight,
63}
64
65/// Pre-dispatch worst-case weight for processing `n` EIP-7702 authorizations.
66///
67/// Must be used as the reservation in `#[pallet::weight(...)]` and as the baseline against
68/// which the post-dispatch refund is computed, so that both expressions agree and the
69/// refund accounting balances out to the actual cost.
70///
71/// Takes a component-wise `max` of the all-new and all-existing aggregations: on at least one
72/// asset-hub runtime `process_existing_account_authorization` has a larger per-auth `proof_size`
73/// than `process_new_account_authorization` (the populated trie node carries more witness data),
74/// so neither dimension uniformly dominates.
75pub fn worst_case_authorization_weight<T: Config>(n: u32) -> Weight {
76	if n == 0 {
77		return Weight::zero();
78	}
79	let all_new = <RuntimeCosts as metering::Token<T>>::weight(&RuntimeCosts::Delegations {
80		new_accounts: n,
81		existing_accounts: 0,
82		invalid_accounts: 0,
83	});
84	let all_existing = <RuntimeCosts as metering::Token<T>>::weight(&RuntimeCosts::Delegations {
85		new_accounts: 0,
86		existing_accounts: n,
87		invalid_accounts: 0,
88	});
89	all_new.max(all_existing)
90}
91
92/// Process a list of EIP-7702 authorization tuples.
93///
94/// For new accounts the ED is drawn from the transaction fee via `FeeInfo::withdraw_txfee` and
95/// resolved into the account; the delegation deposit itself is charged via
96/// [`Pallet::charge_deposit`].
97/// The pre-dispatch weight reservation comes from [`worst_case_authorization_weight`]; the
98/// returned `weight_refund` is the gap between that baseline and the actual cost incurred.
99///
100/// Note: We process authorizations OUTSIDE the transaction context so delegation changes persist
101/// even if the call fails.
102///
103/// Returns the aggregated `AuthorizationResult` directly — every per-auth failure (spec
104/// validation step or post-validation rollback) is handled by `continue` inside the loop,
105/// so this function is structurally infallible from the caller's perspective.
106pub fn process_authorizations<T: Config>(
107	authorization_list: &[AuthorizationListEntry],
108	origin: &T::AccountId,
109	exec_config: &ExecConfig<T>,
110) -> AuthorizationResult<BalanceOf<T>> {
111	if authorization_list.is_empty() {
112		return Default::default();
113	}
114
115	let chain_id = U256::from(T::ChainId::get());
116	let ed = <T::Currency as Inspect<T::AccountId>>::minimum_balance();
117	let mut result: AuthorizationResult<BalanceOf<T>> = Default::default();
118
119	for auth in authorization_list.iter() {
120		if !auth.chain_id.is_zero() && auth.chain_id != chain_id {
121			log::debug!(target: LOG_TARGET, "Invalid chain_id in authorization: expected {chain_id:?} or 0, got {:?}", auth.chain_id);
122			continue;
123		}
124
125		let Ok(authority) = recover_authority(auth) else {
126			log::debug!(target: LOG_TARGET, "Failed to recover authority from signature");
127			continue;
128		};
129		let account_id = T::AddressMapper::to_account_id(&authority);
130
131		let current_nonce: u64 =
132			frame_system::Pallet::<T>::account_nonce(&account_id).saturated_into();
133		let Ok::<u64, _>(expected_nonce) = auth.nonce.try_into() else {
134			log::debug!(target: LOG_TARGET, "Authorization nonce too large: {:?}", auth.nonce);
135			continue;
136		};
137
138		if current_nonce != expected_nonce {
139			log::debug!(target: LOG_TARGET, "Nonce mismatch for {authority:?}: expected {expected_nonce:?}, got {current_nonce:?}");
140			continue;
141		}
142
143		if AccountInfo::<T>::is_contract(&authority) {
144			log::debug!(target: LOG_TARGET, "Account {authority:?} has non-delegation code");
145			continue;
146		}
147
148		let account_exists = frame_system::Account::<T>::contains_key(&account_id);
149
150		// Notify any active tracer about this authority before its state is mutated, so
151		// prestate-diff consumers see the pre-revocation/pre-delegation code and nonce. Without
152		// this, an authority that isn't otherwise referenced by the EVM call would either be
153		// missing from the trace entirely or have its "pre" captured post-mutation.
154		crate::tracing::if_tracing(|t| t.watch_address(&authority));
155
156		// EIP-7702 spec: "If any step above fails, immediately stop processing the tuple and
157		// continue to the next tuple." Step 8 (set code) is one such step, so wrap the whole
158		// per-auth state-changing block in a storage layer and skip the tuple on any error —
159		// ED transfer, delegation, deposit, and nonce bump all commit together or not at all.
160		let outcome = with_storage_layer(
161			|| -> Result<StorageDeposit<BalanceOf<T>>, sp_runtime::DispatchError> {
162				if !account_exists {
163					let credit = <T as Config>::FeeInfo::withdraw_txfee(ed)
164						.ok_or(Error::<T>::StorageDepositNotEnoughFunds)?;
165					<T as Config>::Currency::resolve(&account_id, credit)
166						.map_err(|_| Error::<T>::StorageDepositNotEnoughFunds)?;
167				}
168
169				// Authorizations can be relayed by anyone, so the account that paid when the
170				// delegation was set and the account submitting the next set/clear can
171				// differ. The payer field on `AccountType::DelegatedEOA` records who paid
172				// last so the refund flows back to them rather than to the current
173				// submitter (under `PGasDeposit` mis-routing also strands the
174				// `NativeDepositOf[(authority, original_payer)]` entry, because the
175				// refund-side lookup keys on the destination). `set_delegation` records
176				// `origin` as the new payer and hands back the old one.
177				let change = AccountInfo::<T>::set_delegation(
178					&authority,
179					(!auth.address.is_zero()).then_some(auth.address),
180					origin,
181				)?;
182				let (previous, current) = (change.previous, change.current);
183				let old_payer = change.previous_payer;
184
185				// `origin` is the sole payer when it set the current deposit, or when there
186				// is no recorded payer (fresh delegation / zero deposit).
187				let origin_is_sole_payer =
188					old_payer.as_ref() == Some(origin) || old_payer.is_none();
189				// `net` is what `origin` pays and what feeds its metering budget; each arm
190				// computes it and then makes exactly the payments it describes.
191				let net = if origin_is_sole_payer {
192					// Sole payer: settle the signed diff against `origin`'s own prior
193					// deposit (avoids round-tripping through `T::Deposit` twice, which
194					// under `PGasDeposit` would burn `1 - RefundPercent` of the PGAS-held
195					// portion on every revisit).
196					let net = if current >= previous {
197						StorageDeposit::Charge(current.saturating_sub(previous))
198					} else {
199						StorageDeposit::Refund(previous.saturating_sub(current))
200					};
201					match &net {
202						StorageDeposit::Charge(diff) if diff.is_zero() => {},
203						StorageDeposit::Charge(diff) => {
204							Pallet::<T>::charge_deposit(
205								HoldReason::StorageDepositReserve,
206								origin,
207								&account_id,
208								*diff,
209								exec_config,
210							)?;
211						},
212						StorageDeposit::Refund(diff) => {
213							Pallet::<T>::refund_deposit(
214								HoldReason::StorageDepositReserve,
215								&account_id,
216								exec_config.funds(origin),
217								*diff,
218							)?;
219						},
220					}
221					net
222				} else {
223					let old_payer =
224						old_payer.as_ref().expect("old_payer is Some in this branch; qed");
225					// A recorded payer implies a non-zero held deposit: `set_delegation`
226					// stores `Some(payer)` only alongside one.
227					debug_assert!(!previous.is_zero(), "recorded payer with zero deposit");
228					// Refund the recorded payer directly to their balance. Under eth-tx
229					// `exec_config.funds(old_payer)` collapses to `Funds::TxFee`, whose
230					// native arm drops the recipient and returns the deposit to the fee pot
231					// (→ the current submitter), so a relayed clear/redelegate would never
232					// reach `old_payer`. A direct `Funds::Balance` transfer honours the payer.
233					Pallet::<T>::refund_deposit(
234						HoldReason::StorageDepositReserve,
235						&account_id,
236						crate::deposit_payment::Funds::Balance(old_payer),
237						previous,
238					)?;
239					if !current.is_zero() {
240						Pallet::<T>::charge_deposit(
241							HoldReason::StorageDepositReserve,
242							origin,
243							&account_id,
244							current,
245							exec_config,
246						)?;
247					}
248					// `previous` went to a *different* account; folding it into the net
249					// would credit `origin` for money it never paid and inflate its
250					// deposit budget, so the net is the full charge.
251					StorageDeposit::Charge(current)
252				};
253
254				frame_system::Pallet::<T>::inc_account_nonce(&account_id);
255				Ok(net)
256			},
257		);
258
259		let Ok(deposit) = outcome else {
260			log::debug!(target: LOG_TARGET, "Authorization for {authority:?} failed post-validation, skipping");
261			continue;
262		};
263
264		// `account_exists` is captured pre-transaction. If the auth committed and the account
265		// didn't exist before, it was just created here — count it as new.
266		if !account_exists {
267			result.deposit = result.deposit.saturating_add(&StorageDeposit::Charge(ed));
268			result.new_accounts += 1;
269		} else {
270			result.existing_accounts += 1;
271		}
272		result.deposit = result.deposit.saturating_add(&deposit);
273	}
274
275	// Weight accounting:
276	//   worst case = N * (sig recovery + new-account creation)
277	//   actual     = sig-recovery for invalid tuples
278	//              + sig-recovery + existing-account work for existing tuples
279	//              + sig-recovery + new-account work for new tuples
280	//   refund     = worst - actual
281	// where `invalid` = tuples that applied no state change: chain-id mismatch, failed
282	// signature recovery, bad nonce, non-EOA authority, or post-validation rollback.
283	let total = authorization_list.len() as u32;
284	let invalid = total
285		.saturating_sub(result.new_accounts)
286		.saturating_sub(result.existing_accounts);
287	let worst_case_weight = worst_case_authorization_weight::<T>(total);
288	let actual_weight = <RuntimeCosts as metering::Token<T>>::weight(&RuntimeCosts::Delegations {
289		new_accounts: result.new_accounts,
290		existing_accounts: result.existing_accounts,
291		invalid_accounts: invalid,
292	});
293	result.weight_refund = worst_case_weight.saturating_sub(actual_weight);
294
295	result
296}
297
298/// Build the EIP-7702 signing message: `MAGIC || rlp([chain_id, address, nonce])`
299fn signing_message(auth: &AuthorizationListEntry) -> Vec<u8> {
300	let mut message = Vec::with_capacity(1 + 64);
301	message.push(EIP7702_MAGIC);
302	message.extend_from_slice(&auth.rlp_encode_unsigned());
303	message
304}
305
306/// Recover the authority address from an authorization signature.
307///
308/// EIP-7702 mandates `y_parity ∈ {0, 1}`. The shared `sp_io::crypto::secp256k1_ecdsa_recover`
309/// primitive accepts the legacy Bitcoin/pre-EIP-155 `v ∈ {27, 28}` convention and silently
310/// normalises it to `{0, 1}`, which would let those values pass through here as valid 7702
311/// signatures (spec deviation). Filter strictly before recovery so the per-tuple skip path in
312/// `process_authorizations` catches them.
313fn recover_authority(auth: &AuthorizationListEntry) -> Result<H160, ()> {
314	if auth.y_parity.bits() > 1 {
315		return Err(());
316	}
317	recover_eth_address_from_message(&signing_message(auth), &auth.signature())
318}
319
320/// Sign an authorization entry
321///
322/// This is a helper function for benchmarks and tests.
323#[cfg(any(feature = "runtime-benchmarks", test))]
324pub fn sign_authorization(
325	key: &k256::ecdsa::SigningKey,
326	chain_id: U256,
327	address: H160,
328	nonce: U256,
329) -> AuthorizationListEntry {
330	let unsigned = AuthorizationListEntry { chain_id, address, nonce, ..Default::default() };
331	let hash = sp_io::hashing::keccak_256(&signing_message(&unsigned));
332	let (signature, recovery_id) =
333		key.sign_prehash_recoverable(&hash).expect("signing success; qed");
334
335	let sig_bytes = signature.to_bytes();
336	AuthorizationListEntry {
337		chain_id,
338		address,
339		nonce,
340		y_parity: U256::from(recovery_id.to_byte()),
341		r: U256::from_big_endian(&sig_bytes[..32]),
342		s: U256::from_big_endian(&sig_bytes[32..64]),
343	}
344}
345
346/// Derive the Ethereum address from a signing key.
347///
348/// This is a helper function for benchmarks and tests.
349#[cfg(any(feature = "runtime-benchmarks", test))]
350pub fn eth_address(key: &k256::ecdsa::SigningKey) -> H160 {
351	let public_key = key.verifying_key();
352	let encoded = public_key.to_encoded_point(false);
353	// Skip the 0x04 prefix byte to get the uncompressed public key
354	H160::from_slice(&sp_io::hashing::keccak_256(&encoded.as_bytes()[1..])[12..])
355}