referrerpolicy=no-referrer-when-downgrade

pallet_revive/
address.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//! Functions that deal contract addresses.
19
20use crate::{Config, Error, HoldReason, OriginalAccount, ensure};
21use alloc::vec::Vec;
22use core::marker::PhantomData;
23use frame_support::traits::{
24	OnKilledAccount, OnNewAccount, fungible::MutateHold, tokens::Precision,
25};
26use sp_core::{Get, H160};
27use sp_io::hashing::keccak_256;
28use sp_runtime::{AccountId32, DispatchResult, Saturating};
29
30/// Map between the native chain account id `T` and an Ethereum [`H160`].
31///
32/// This trait exists only to emulate specialization for different concrete
33/// native account ids. **Not** to make the mapping user configurable. Hence
34/// the trait is `Sealed` and depending on your runtime configuration you need
35/// to pick either [`AccountId32Mapper`] or [`H160Mapper`]. Picking the wrong
36/// one will result in a compilation error. No footguns here.
37///
38/// Please note that we assume that the native account is at least 20 bytes and
39/// only implement this type for a `T` where this is the case. Luckily, this is the
40/// case for all existing runtimes as of right now. Reasoning is that this will allow
41/// us to reverse an address -> account_id mapping by just stripping the prefix.
42///
43/// We require the mapping to be reversible. Since we are potentially dealing with types of
44/// different sizes one direction of the mapping is necessarily lossy. This requires the mapping to
45/// make use of the [`OriginalAccount`] storage item to reverse the mapping.
46pub trait AddressMapper<T: Config>: private::Sealed {
47	/// Convert an account id to an ethereum address.
48	fn to_address(account_id: &T::AccountId) -> H160;
49
50	/// Convert an ethereum address to a native account id.
51	fn to_account_id(address: &H160) -> T::AccountId;
52
53	/// Same as [`Self::to_account_id`] but always returns the fallback account.
54	///
55	/// This skips the query into [`OriginalAccount`] and always returns the stateless
56	/// fallback account. This is useful when we know for a fact that the `address`
57	/// in question is originally a `H160`. This is usually only the case when we
58	/// generated a new contract address.
59	fn to_fallback_account_id(address: &H160) -> T::AccountId;
60
61	/// Create a stateful mapping for `account_id`
62	///
63	/// This will enable `to_account_id` to map back to the original
64	/// `account_id` instead of the fallback account id.
65	fn map(account_id: &T::AccountId) -> DispatchResult;
66
67	/// Map an account id without taking any deposit, without verifying that the
68	/// account exists.
69	///
70	/// The caller must guarantee that `account_id` exists, or is in the process
71	/// of being created (e.g. from inside `OnNewAccount`). Calling this with an
72	/// arbitrary `AccountId` permanently writes an unbacked `OriginalAccount`
73	/// entry.
74	fn map_no_deposit_unchecked(account_id: &T::AccountId) -> DispatchResult {
75		Self::map(account_id)
76	}
77
78	/// Remove the mapping in order to reclaim the deposit.
79	///
80	/// There is no reason why one would unmap their `account_id` except
81	/// for reclaiming the deposit.
82	fn unmap(account_id: &T::AccountId) -> DispatchResult;
83
84	/// Returns true if the `account_id` is usable as an origin.
85	///
86	/// This means either the `account_id` doesn't require a stateful mapping
87	/// or a stateful mapping exists.
88	fn is_mapped(account_id: &T::AccountId) -> bool;
89
90	/// Returns true if the account is derived from an eth (secp256k1) key.
91	///
92	/// These accounts don't need a stateful mapping and never hold a mapping deposit.
93	fn is_eth_derived(account_id: &T::AccountId) -> bool;
94}
95
96mod private {
97	pub trait Sealed {}
98	impl<T> Sealed for super::AccountId32Mapper<T> {}
99	impl<T> Sealed for super::H160Mapper<T> {}
100	impl<T> Sealed for super::TestAccountMapper<T> {}
101}
102
103/// The mapper to be used if the account id is `AccountId32`.
104///
105/// It converts between addresses by either hash then truncate the last 12 bytes or
106/// suffixing them. To recover the original account id of a hashed and truncated account id we use
107/// [`OriginalAccount`] and will fall back to all `0xEE` if account was found. This means contracts
108/// and plain wallets controlled by an `secp256k1` always have a `0xEE` suffixed account.
109pub struct AccountId32Mapper<T>(PhantomData<T>);
110
111/// The mapper to be used if the account id is `H160`.
112///
113/// It just trivially returns its inputs and doesn't make use of any state.
114#[allow(dead_code)]
115pub struct H160Mapper<T>(PhantomData<T>);
116
117/// An account mapper that can be used for testing u64 account ids.
118pub struct TestAccountMapper<T>(PhantomData<T>);
119
120impl<T> AddressMapper<T> for AccountId32Mapper<T>
121where
122	T: Config<AccountId = AccountId32>,
123{
124	fn to_address(account_id: &AccountId32) -> H160 {
125		let account_bytes: &[u8; 32] = account_id.as_ref();
126		if Self::is_eth_derived(account_id) {
127			// this was originally an eth address
128			// we just strip the 0xEE suffix to get the original address
129			H160::from_slice(&account_bytes[..20])
130		} else {
131			// this is an (ed|sr)25510 derived address
132			// avoid truncating the public key by hashing it first
133			let account_hash = keccak_256(account_bytes);
134			H160::from_slice(&account_hash[12..])
135		}
136	}
137
138	fn to_account_id(address: &H160) -> AccountId32 {
139		<OriginalAccount<T>>::get(address).unwrap_or_else(|| Self::to_fallback_account_id(address))
140	}
141
142	fn to_fallback_account_id(address: &H160) -> AccountId32 {
143		let mut account_id = AccountId32::new([0xEE; 32]);
144		let account_bytes: &mut [u8; 32] = account_id.as_mut();
145		account_bytes[..20].copy_from_slice(address.as_bytes());
146		account_id
147	}
148
149	fn map(account_id: &T::AccountId) -> DispatchResult {
150		ensure!(!Self::is_mapped(account_id), <Error<T>>::AccountAlreadyMapped);
151
152		// each mapping entry stores the address (20 bytes) and the account id (32 bytes)
153		let deposit = T::DepositPerByte::get()
154			.saturating_mul(52u32.into())
155			.saturating_add(T::DepositPerItem::get());
156		T::Currency::hold(&HoldReason::AddressMapping.into(), account_id, deposit)?;
157
158		<OriginalAccount<T>>::insert(Self::to_address(account_id), account_id);
159		Ok(())
160	}
161
162	fn map_no_deposit_unchecked(account_id: &T::AccountId) -> DispatchResult {
163		ensure!(!Self::is_mapped(account_id), <Error<T>>::AccountAlreadyMapped);
164		<OriginalAccount<T>>::insert(Self::to_address(account_id), account_id);
165		Ok(())
166	}
167
168	fn unmap(account_id: &T::AccountId) -> DispatchResult {
169		// will do nothing if address is not mapped so no check required
170		<OriginalAccount<T>>::remove(Self::to_address(account_id));
171		T::Currency::release_all(
172			&HoldReason::AddressMapping.into(),
173			account_id,
174			Precision::BestEffort,
175		)?;
176		Ok(())
177	}
178
179	fn is_mapped(account_id: &T::AccountId) -> bool {
180		Self::is_eth_derived(account_id) ||
181			<OriginalAccount<T>>::contains_key(Self::to_address(account_id))
182	}
183
184	/// This is a stateless check that just compares the last 12 bytes. Please note that it is
185	/// theoretically possible to create an ed25519 keypair that passes this filter. However,
186	/// this can't be used for an attack. It also won't happen by accident since everybody is
187	/// using sr25519 where this is not a valid public key.
188	fn is_eth_derived(account_id: &T::AccountId) -> bool {
189		let account_bytes: &[u8; 32] = account_id.as_ref();
190		&account_bytes[20..] == &[0xEE; 12]
191	}
192}
193
194impl<T> AddressMapper<T> for TestAccountMapper<T>
195where
196	T: Config<AccountId = u64>,
197{
198	fn to_address(account_id: &T::AccountId) -> H160 {
199		let mut bytes = [0u8; 20];
200		bytes[12..].copy_from_slice(&account_id.to_be_bytes());
201		H160::from(bytes)
202	}
203
204	fn to_account_id(address: &H160) -> T::AccountId {
205		Self::to_fallback_account_id(address)
206	}
207
208	fn to_fallback_account_id(address: &H160) -> T::AccountId {
209		u64::from_be_bytes(address.as_ref()[12..].try_into().unwrap())
210	}
211
212	fn map(_account_id: &T::AccountId) -> DispatchResult {
213		Ok(())
214	}
215
216	fn unmap(_account_id: &T::AccountId) -> DispatchResult {
217		Ok(())
218	}
219
220	fn is_mapped(_account_id: &T::AccountId) -> bool {
221		true
222	}
223
224	fn is_eth_derived(_account_id: &T::AccountId) -> bool {
225		false
226	}
227}
228
229impl<T> AddressMapper<T> for H160Mapper<T>
230where
231	T: Config,
232	crate::AccountIdOf<T>: AsRef<[u8; 20]> + From<H160>,
233{
234	fn to_address(account_id: &T::AccountId) -> H160 {
235		H160::from_slice(account_id.as_ref())
236	}
237
238	fn to_account_id(address: &H160) -> T::AccountId {
239		Self::to_fallback_account_id(address)
240	}
241
242	fn to_fallback_account_id(address: &H160) -> T::AccountId {
243		(*address).into()
244	}
245
246	fn map(_account_id: &T::AccountId) -> DispatchResult {
247		Ok(())
248	}
249
250	fn unmap(_account_id: &T::AccountId) -> DispatchResult {
251		Ok(())
252	}
253
254	fn is_mapped(_account_id: &T::AccountId) -> bool {
255		true
256	}
257
258	fn is_eth_derived(_account_id: &T::AccountId) -> bool {
259		true
260	}
261}
262
263/// Determine the address of a contract using CREATE semantics.
264pub fn create1(deployer: &H160, nonce: u64) -> H160 {
265	let mut list = rlp::RlpStream::new_list(2);
266	list.append(&deployer.as_bytes());
267	list.append(&nonce);
268	let hash = keccak_256(&list.out());
269	H160::from_slice(&hash[12..])
270}
271
272/// Determine the address of a contract using the CREATE2 semantics.
273pub fn create2(deployer: &H160, code: &[u8], input_data: &[u8], salt: &[u8; 32]) -> H160 {
274	let init_code_hash = {
275		let init_code: Vec<u8> = code.into_iter().chain(input_data).cloned().collect();
276		keccak_256(init_code.as_ref())
277	};
278	let mut bytes = [0; 85];
279	bytes[0] = 0xff;
280	bytes[1..21].copy_from_slice(deployer.as_bytes());
281	bytes[21..53].copy_from_slice(salt);
282	bytes[53..85].copy_from_slice(&init_code_hash);
283	let hash = keccak_256(&bytes);
284	H160::from_slice(&hash[12..])
285}
286
287pub struct AutoMapper<T>(PhantomData<T>);
288
289impl<T: Config> OnNewAccount<T::AccountId> for AutoMapper<T> {
290	fn on_new_account(who: &T::AccountId) {
291		if T::AutoMap::get() &&
292			!T::AddressMapper::is_eth_derived(who) &&
293			let Err(err) = T::AddressMapper::map_no_deposit_unchecked(who)
294		{
295			log::warn!(
296				target: crate::LOG_TARGET,
297				"Failed to auto-map account {who:?}: {err:?}",
298			);
299		}
300	}
301}
302
303impl<T: Config> OnKilledAccount<T::AccountId> for AutoMapper<T> {
304	fn on_killed_account(who: &T::AccountId) {
305		if T::AutoMap::get() &&
306			!T::AddressMapper::is_eth_derived(who) &&
307			let Err(err) = T::AddressMapper::unmap(who)
308		{
309			log::warn!(
310				target: crate::LOG_TARGET,
311				"Failed to auto-unmap account {who:?}: {err:?}",
312			);
313		}
314	}
315}
316
317#[cfg(test)]
318mod test {
319	use super::*;
320	use crate::{
321		AddressMapper, Error, Pallet,
322		test_utils::*,
323		tests::{AutoMapFlag, ExtBuilder, RuntimeOrigin, Test},
324	};
325	use frame_support::{
326		assert_err,
327		dispatch::Pays,
328		traits::fungible::{InspectHold, Mutate},
329	};
330	use pretty_assertions::assert_eq;
331	use sp_core::{H160, hex2array};
332
333	#[test]
334	fn create1_works() {
335		assert_eq!(
336			create1(&ALICE_ADDR, 1u64),
337			H160(hex2array!("c851da37e4e8d3a20d8d56be2963934b4ad71c3b")),
338		)
339	}
340
341	#[test]
342	fn create2_works() {
343		assert_eq!(
344			create2(
345				&ALICE_ADDR,
346				&hex2array!("600060005560016000"),
347				&hex2array!("55"),
348				&hex2array!("1234567890123456789012345678901234567890123456789012345678901234")
349			),
350			H160(hex2array!("7f31e795e5836a19a8f919ab5a9de9a197ecd2b6")),
351		)
352	}
353
354	#[test]
355	fn fallback_map_works() {
356		assert!(<Test as Config>::AddressMapper::is_mapped(&ALICE));
357		assert_eq!(
358			ALICE_FALLBACK,
359			<Test as Config>::AddressMapper::to_fallback_account_id(&ALICE_ADDR)
360		);
361		assert_eq!(ALICE_ADDR, <Test as Config>::AddressMapper::to_address(&ALICE_FALLBACK));
362	}
363
364	#[test]
365	fn map_works() {
366		ExtBuilder::default().build().execute_with(|| {
367			<Test as Config>::Currency::set_balance(&EVE, 1_000_000);
368			// before mapping the fallback account is returned
369			assert!(!<Test as Config>::AddressMapper::is_mapped(&EVE));
370			assert_eq!(EVE_FALLBACK, <Test as Config>::AddressMapper::to_account_id(&EVE_ADDR));
371			assert_eq!(
372				<Test as Config>::Currency::balance_on_hold(
373					&HoldReason::AddressMapping.into(),
374					&EVE
375				),
376				0
377			);
378
379			// when mapped the full account id is returned
380			<Test as Config>::AddressMapper::map(&EVE).unwrap();
381			assert!(<Test as Config>::AddressMapper::is_mapped(&EVE));
382			assert_eq!(EVE, <Test as Config>::AddressMapper::to_account_id(&EVE_ADDR));
383			assert!(
384				<Test as Config>::Currency::balance_on_hold(
385					&HoldReason::AddressMapping.into(),
386					&EVE
387				) > 0
388			);
389		});
390	}
391
392	#[test]
393	fn map_fallback_account_fails() {
394		ExtBuilder::default().build().execute_with(|| {
395			assert!(<Test as Config>::AddressMapper::is_mapped(&ALICE));
396			// alice is an e suffixed account and hence cannot be mapped
397			assert_err!(
398				<Test as Config>::AddressMapper::map(&ALICE),
399				<Error<Test>>::AccountAlreadyMapped,
400			);
401			assert_eq!(
402				<Test as Config>::Currency::balance_on_hold(
403					&HoldReason::AddressMapping.into(),
404					&ALICE
405				),
406				0
407			);
408		});
409	}
410
411	#[test]
412	fn double_map_fails() {
413		ExtBuilder::default().build().execute_with(|| {
414			assert!(!<Test as Config>::AddressMapper::is_mapped(&EVE));
415			<Test as Config>::Currency::set_balance(&EVE, 1_000_000);
416			<Test as Config>::AddressMapper::map(&EVE).unwrap();
417			assert!(<Test as Config>::AddressMapper::is_mapped(&EVE));
418			let deposit = <Test as Config>::Currency::balance_on_hold(
419				&HoldReason::AddressMapping.into(),
420				&EVE,
421			);
422			assert_err!(
423				<Test as Config>::AddressMapper::map(&EVE),
424				<Error<Test>>::AccountAlreadyMapped,
425			);
426			assert!(<Test as Config>::AddressMapper::is_mapped(&EVE));
427			assert_eq!(
428				<Test as Config>::Currency::balance_on_hold(
429					&HoldReason::AddressMapping.into(),
430					&EVE
431				),
432				deposit
433			);
434		});
435	}
436
437	#[test]
438	fn unmap_works() {
439		ExtBuilder::default().build().execute_with(|| {
440			<Test as Config>::Currency::set_balance(&EVE, 1_000_000);
441			<Test as Config>::AddressMapper::map(&EVE).unwrap();
442			assert!(<Test as Config>::AddressMapper::is_mapped(&EVE));
443			assert!(
444				<Test as Config>::Currency::balance_on_hold(
445					&HoldReason::AddressMapping.into(),
446					&EVE
447				) > 0
448			);
449
450			<Test as Config>::AddressMapper::unmap(&EVE).unwrap();
451			assert!(!<Test as Config>::AddressMapper::is_mapped(&EVE));
452			assert_eq!(
453				<Test as Config>::Currency::balance_on_hold(
454					&HoldReason::AddressMapping.into(),
455					&EVE
456				),
457				0
458			);
459
460			// another unmap is a noop
461			<Test as Config>::AddressMapper::unmap(&EVE).unwrap();
462			assert!(!<Test as Config>::AddressMapper::is_mapped(&EVE));
463			assert_eq!(
464				<Test as Config>::Currency::balance_on_hold(
465					&HoldReason::AddressMapping.into(),
466					&EVE
467				),
468				0
469			);
470		});
471	}
472
473	#[test]
474	fn auto_mapper_maps_on_new_account() {
475		ExtBuilder::default().build().execute_with(|| {
476			AutoMapFlag::set(true);
477
478			assert!(!frame_system::Pallet::<Test>::account_exists(&EVE));
479			assert!(!<Test as Config>::AddressMapper::is_mapped(&EVE));
480			// Funding a new account triggers frame_system's OnNewAccount hook
481			<Test as Config>::Currency::set_balance(&EVE, 1_000_000);
482			assert!(<Test as Config>::AddressMapper::is_mapped(&EVE));
483			// no deposit taken
484			assert_eq!(
485				<Test as Config>::Currency::balance_on_hold(
486					&HoldReason::AddressMapping.into(),
487					&EVE
488				),
489				0
490			);
491		});
492	}
493
494	#[test]
495	fn auto_mapper_unmaps_on_killed_account() {
496		ExtBuilder::default().build().execute_with(|| {
497			AutoMapFlag::set(true);
498			<Test as Config>::Currency::set_balance(&EVE, 1_000_000);
499			assert!(<Test as Config>::AddressMapper::is_mapped(&EVE));
500
501			// Killing the account triggers frame_system's OnKilledAccount hook
502			<Test as Config>::Currency::set_balance(&EVE, 0);
503			assert!(!<Test as Config>::AddressMapper::is_mapped(&EVE));
504		});
505	}
506
507	#[test]
508	fn auto_mapper_noop_when_disabled() {
509		ExtBuilder::default().build().execute_with(|| {
510			AutoMapFlag::set(false);
511
512			assert!(!<Test as Config>::AddressMapper::is_mapped(&EVE));
513			<Test as Config>::Currency::set_balance(&EVE, 1_000_000);
514			assert!(!<Test as Config>::AddressMapper::is_mapped(&EVE));
515		});
516	}
517
518	#[test]
519	fn auto_mapper_ignores_eth_derived_accounts() {
520		ExtBuilder::default().build().execute_with(|| {
521			AutoMapFlag::set(true);
522
523			// ALICE is eth-derived and already considered mapped
524			assert!(<Test as Config>::AddressMapper::is_mapped(&ALICE));
525			// Funding an eth-derived account silently ignores the AccountAlreadyMapped error
526			<Test as Config>::Currency::set_balance(&ALICE, 1_000_000);
527			assert!(<Test as Config>::AddressMapper::is_mapped(&ALICE));
528		});
529	}
530
531	#[test]
532	#[cfg(not(feature = "runtime-benchmarks"))]
533	fn unmap_account_dispatchable_blocked_when_auto_map_enabled() {
534		use frame_support::assert_noop;
535		ExtBuilder::default().build().execute_with(|| {
536			AutoMapFlag::set(true);
537
538			assert_noop!(
539				Pallet::<Test>::unmap_account(RuntimeOrigin::signed(EVE)),
540				<Error<Test>>::AutoMappingEnabled,
541			);
542		});
543	}
544
545	#[test]
546	fn batch_map_accounts_empty_pays_yes() {
547		ExtBuilder::default().build().execute_with(|| {
548			let info =
549				Pallet::<Test>::batch_map_accounts(RuntimeOrigin::signed(ALICE), alloc::vec![])
550					.unwrap();
551
552			assert_eq!(info.pays_fee, Pays::Yes);
553		});
554	}
555
556	#[test]
557	fn batch_map_accounts_all_eth_derived_pays_yes() {
558		ExtBuilder::default().build().execute_with(|| {
559			// Eth-derived accounts are stateless mapped, so nothing useful happens
560			// and the caller is charged.
561			let info = Pallet::<Test>::batch_map_accounts(
562				RuntimeOrigin::signed(ALICE),
563				alloc::vec![ALICE, BOB, CHARLIE, DJANGO],
564			)
565			.unwrap();
566
567			assert_eq!(info.pays_fee, Pays::Yes);
568		});
569	}
570
571	#[test]
572	fn batch_map_accounts_pays_no_when_mostly_unmapped() {
573		ExtBuilder::default().build().execute_with(|| {
574			let unmapped: Vec<AccountId32> =
575				(10u8..19u8).map(|i| AccountId32::new([i; 32])).collect();
576			// fund each account so it exists on chain.
577			for a in &unmapped {
578				<Test as Config>::Currency::set_balance(a, 1_000_000);
579			}
580			let mut accounts = unmapped.clone();
581			accounts.push(ALICE); // 1 eth-derived account, not counted as useful
582
583			// 9 of 10 (90%) become useful → free
584			let info =
585				Pallet::<Test>::batch_map_accounts(RuntimeOrigin::signed(ALICE), accounts).unwrap();
586
587			assert_eq!(info.pays_fee, Pays::No);
588
589			for a in &unmapped {
590				assert!(<Test as Config>::AddressMapper::is_mapped(a));
591
592				// map_no_deposit_unchecked must not take a deposit
593				assert_eq!(
594					<Test as Config>::Currency::balance_on_hold(
595						&HoldReason::AddressMapping.into(),
596						a,
597					),
598					0
599				);
600			}
601		});
602	}
603
604	#[test]
605	fn batch_map_accounts_already_mapped_no_hold_pays_yes() {
606		ExtBuilder::default().build().execute_with(|| {
607			<Test as Config>::AddressMapper::map_no_deposit_unchecked(&EVE).unwrap();
608			assert!(<Test as Config>::AddressMapper::is_mapped(&EVE));
609
610			assert_eq!(
611				<Test as Config>::Currency::balance_on_hold(
612					&HoldReason::AddressMapping.into(),
613					&EVE,
614				),
615				0
616			);
617
618			let info = Pallet::<Test>::batch_map_accounts(
619				RuntimeOrigin::signed(ALICE),
620				alloc::vec![EVE; 10],
621			)
622			.unwrap();
623
624			assert_eq!(info.pays_fee, Pays::Yes);
625		});
626	}
627
628	#[test]
629	fn batch_map_accounts_pays_yes_below_threshold() {
630		ExtBuilder::default().build().execute_with(|| {
631			// 1 unmapped non-eth-derived account + 9 eth-derived (= 10% useful)
632			let mut accounts: Vec<AccountId32> = alloc::vec![AccountId32::new([10u8; 32])];
633			<Test as Config>::Currency::set_balance(&accounts[0], 1_000_000);
634			for _ in 0..9 {
635				accounts.push(ALICE);
636			}
637
638			let info =
639				Pallet::<Test>::batch_map_accounts(RuntimeOrigin::signed(ALICE), accounts).unwrap();
640
641			assert_eq!(info.pays_fee, Pays::Yes);
642		});
643	}
644
645	#[test]
646	fn batch_map_accounts_pays_yes_mixed() {
647		ExtBuilder::default().build().execute_with(|| {
648			// 17 existing accounts (get mapped) + 1 non-existent + 1 eth-derived.
649			// Below the threshold → Pays::Yes.
650			let existing: Vec<AccountId32> =
651				(10u8..27u8).map(|i| AccountId32::new([i; 32])).collect();
652			for a in &existing {
653				<Test as Config>::Currency::set_balance(a, 1_000_000);
654			}
655			let nonexistent = AccountId32::new([99u8; 32]);
656			let mut accounts = existing.clone();
657			accounts.push(nonexistent.clone());
658			accounts.push(ALICE); // eth-derived
659
660			let info =
661				Pallet::<Test>::batch_map_accounts(RuntimeOrigin::signed(ALICE), accounts).unwrap();
662
663			assert_eq!(info.pays_fee, Pays::Yes);
664			for a in &existing {
665				assert!(
666					<Test as Config>::AddressMapper::is_mapped(a),
667					"existing accounts must still be mapped alongside non-existent or eth-derived entries",
668				);
669			}
670			assert!(
671				!<Test as Config>::AddressMapper::is_mapped(&nonexistent),
672				"non-existent accounts must be skipped, not mapped",
673			);
674		});
675	}
676
677	#[test]
678	fn batch_map_accounts_rejects_nonexistent_accounts() {
679		ExtBuilder::default().build().execute_with(|| {
680			// Non-existent accounts must not be mapped.
681			// Otherwise any caller could insert mappings for arbitrary bytes at no cost.
682			let unknown = AccountId32::new([0xAB; 32]);
683			assert!(
684				!frame_system::Pallet::<Test>::account_exists(&unknown),
685				"unknown account must not pre-exist on chain",
686			);
687
688			let info = Pallet::<Test>::batch_map_accounts(
689				RuntimeOrigin::signed(ALICE),
690				alloc::vec![unknown.clone()],
691			)
692			.unwrap();
693
694			assert_eq!(
695				info.pays_fee,
696				Pays::Yes,
697				"non-existent accounts must not trigger the free path",
698			);
699			assert!(
700				!<Test as Config>::AddressMapper::is_mapped(&unknown),
701				"OriginalAccount must not be written for a non-existent account",
702			);
703		});
704	}
705}