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}