referrerpolicy=no-referrer-when-downgrade

pallet_scarcity/
extension.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//! Scarcity transaction extension.
19//!
20//! # Runtime ordering
21//!
22//! [`AsScarcity`] changes a signed origin into [`Origin::Nft`] during validation. Following
23//! Coinage's purse model, runtime integrators must place it with the origin modifiers, **before**
24//! `frame_system::AuthorizeCall`, signed-account checks, and transaction payment:
25//!
26//! ```text
27//! AsScarcity -> AuthorizeCall -> ... -> CheckNonce -> ... ->
28//!     SkipCheckIfFeeless<ChargeAssetTxPayment>
29//! ```
30//!
31//! The account checks deliberately see a non-system origin and skip it, so an NFT-only purse needs
32//! neither a System account nor a balance. [`AsScarcity`] consumes the NFT during preparation to
33//! prevent concurrent use. A successful move increments its state nonce. Failed dispatch restores
34//! the same state and applies a temporary backoff lock; once that lock expires, the same signed
35//! transaction can be submitted again. This is the same retry model as Coinage.
36//!
37//! Placing this extension after payment prevents
38//! `pallet_skip_feeless_payment::SkipCheckIfFeeless` from observing [`Origin::Nft`] and makes a
39//! balance-less purse unable to transact.
40//!
41//! # Replay and mortality
42//!
43//! Purse authorization is not account-nonce-based: a signed NFT transaction stays valid for as
44//! long as its purse still holds the named instance at the named state nonce. Two rules bound
45//! stale intent, exactly as in Coinage:
46//!
47//! * Callers must sign **mortal** transactions with an era shorter than [`Config::LockPeriod`]. A
48//!   successful move invalidates every outstanding authorization by incrementing the state nonce,
49//!   but an unexecuted transaction is otherwise replayable by anyone who has seen it until its era
50//!   expires.
51//! * Because the era ends before the shortest failure lock does, a failed transaction can never
52//!   re-enter a block: every retry after a failure is a fresh signing decision rather than a
53//!   third-party replay of the old transaction.
54//!
55//! A holder can also cancel an outstanding authorization at any time by moving the NFT, which
56//! increments its state nonce.
57
58use crate::{pallet::*, weights::WeightInfo, Config, Nft};
59use codec::{Decode, DecodeWithMemTracking, Encode};
60use core::marker::PhantomData;
61use frame_support::{
62	pallet_prelude::{Get, TransactionSource},
63	traits::{IsSubType, OriginTrait, UnixTime},
64	weights::Weight,
65	CloneNoBound, DebugNoBound, DefaultNoBound, EqNoBound, PartialEqNoBound,
66};
67use scale_info::TypeInfo;
68use sp_runtime::{
69	traits::{
70		DispatchInfoOf, Implication, PostDispatchInfoOf, TransactionExtension, ValidateResult,
71	},
72	transaction_validity::{InvalidTransaction, TransactionValidityError, ValidTransaction},
73	DispatchResult,
74};
75
76/// The Scarcity authorization requested by this extension.
77#[derive(
78	Encode,
79	Decode,
80	TypeInfo,
81	EqNoBound,
82	CloneNoBound,
83	PartialEqNoBound,
84	DecodeWithMemTracking,
85	DebugNoBound,
86)]
87pub enum AsScarcityInfo {
88	/// Authorize a transfer or burn using the NFT's current purse-key state.
89	AsNft {
90		/// The permanent instance expected at the purse key.
91		instance: InstanceId,
92		/// The ownership-state revision expected for that instance.
93		state_nonce: u64,
94	},
95}
96
97/// An error reported while validating [`AsScarcity`].
98#[repr(u8)]
99pub enum CustomInvalidity {
100	/// The purse-key authorization must begin with a signed origin.
101	OriginToAsNftMustBeSigned = 0,
102	/// The purse key is temporarily locked after a failed dispatch.
103	NftTemporarilyLocked = 1,
104	/// The purse key has no NFT to authorize the requested action.
105	NoNft = 2,
106	/// The transfer destination equals the current purse key.
107	TransferToSelf = 3,
108	/// The transfer destination already holds an NFT.
109	DestinationOccupied = 4,
110	/// The purse key holds a different instance or ownership-state revision.
111	NftStateMismatch = 5,
112}
113
114impl From<CustomInvalidity> for TransactionValidityError {
115	fn from(value: CustomInvalidity) -> Self {
116		InvalidTransaction::Custom(value as u8).into()
117	}
118}
119
120/// Information carried from validation to preparation.
121pub enum Val<T: Config + Send + Sync> {
122	NotUsing,
123	UsingNft { owner: T::AccountId, instance: InstanceId, state_nonce: u64 },
124}
125
126/// Information carried from preparation to post-dispatch.
127pub enum Pre<T: Config + Send + Sync> {
128	NotUsing,
129	UsingNft { owner: T::AccountId, nft: Nft },
130}
131
132/// Purse-key authorization for Scarcity transfers and burns.
133///
134/// An authorization names the permanent instance and its ownership-state nonce. The instance
135/// identifier prevents an authorization from acting on a different NFT if a purse key is reused,
136/// while the state nonce invalidates it if the same instance is moved away and later returned.
137/// Like Coinage, failed dispatch restores the same purse state behind a temporary lock rather than
138/// consuming a System account nonce.
139///
140/// Runtime ordering is security-critical: place this extension before
141/// `frame_system::AuthorizeCall`, signed-account checks, and transaction payment. See the
142/// [module-level ordering requirements](crate::extension#runtime-ordering).
143#[derive(
144	Encode,
145	Decode,
146	TypeInfo,
147	EqNoBound,
148	CloneNoBound,
149	PartialEqNoBound,
150	DefaultNoBound,
151	DecodeWithMemTracking,
152	DebugNoBound,
153)]
154#[scale_info(skip_type_params(T))]
155pub struct AsScarcity<T: Config + Send + Sync>(Option<AsScarcityInfo>, PhantomData<T>);
156
157impl<T: Config + Send + Sync> AsScarcity<T> {
158	/// Create an extension. `None` is the identity extension for ordinary transactions.
159	pub fn new(explicit: Option<AsScarcityInfo>) -> Self {
160		Self(explicit, PhantomData)
161	}
162
163	fn failed_dispatch_lock(previous: Option<LockInfo>) -> LockInfo {
164		let retries = previous.map(|lock| lock.retries.saturating_add(1)).unwrap_or(1);
165		let exponent = retries.saturating_sub(1);
166		let multiplier = 2u64.saturating_pow(u32::from(exponent).min(63));
167		LockInfo {
168			retries,
169			until: T::UnixTime::now()
170				.as_secs()
171				.saturating_add(multiplier.saturating_mul(T::LockPeriod::get())),
172		}
173	}
174}
175
176impl<T: Config + Send + Sync> TransactionExtension<<T as frame_system::Config>::RuntimeCall>
177	for AsScarcity<T>
178{
179	const IDENTIFIER: &'static str = "AsScarcity";
180	type Implicit = ();
181	type Val = Val<T>;
182	type Pre = Pre<T>;
183
184	fn weight(&self, call: &<T as frame_system::Config>::RuntimeCall) -> Weight {
185		if matches!(self.0.as_ref(), Some(AsScarcityInfo::AsNft { .. })) &&
186			matches!(
187				call.is_sub_type(),
188				Some(Call::<T>::transfer { .. }) | Some(Call::<T>::burn {})
189			) {
190			T::WeightInfo::as_scarcity_pipeline()
191		} else {
192			Weight::zero()
193		}
194	}
195
196	fn validate(
197		&self,
198		mut origin: <T as frame_system::Config>::RuntimeOrigin,
199		call: &<T as frame_system::Config>::RuntimeCall,
200		_info: &DispatchInfoOf<<T as frame_system::Config>::RuntimeCall>,
201		_len: usize,
202		_self_implicit: Self::Implicit,
203		_inherited_implication: &impl Implication,
204		_source: TransactionSource,
205	) -> ValidateResult<Self::Val, <T as frame_system::Config>::RuntimeCall> {
206		let transfer_to = match call.is_sub_type() {
207			Some(Call::<T>::transfer { to }) => Some(to),
208			Some(Call::<T>::burn {}) => None,
209			_ => return Ok((ValidTransaction::default(), Val::NotUsing, origin)),
210		};
211		let Some(AsScarcityInfo::AsNft { instance, state_nonce }) = self.0.as_ref() else {
212			return Ok((ValidTransaction::default(), Val::NotUsing, origin));
213		};
214
215		let Some(frame_system::Origin::<T>::Signed(owner)) = origin.as_system_ref() else {
216			return Err(CustomInvalidity::OriginToAsNftMustBeSigned.into());
217		};
218		let owner = owner.clone();
219		let now = T::UnixTime::now().as_secs();
220		if let Some(lock) = Locked::<T>::get(&owner) {
221			if lock.until > now {
222				return Err(CustomInvalidity::NftTemporarilyLocked.into());
223			}
224		}
225		let nft = NftsByOwner::<T>::get(&owner).ok_or(CustomInvalidity::NoNft)?;
226		if nft.instance != *instance || nft.state_nonce != *state_nonce {
227			return Err(CustomInvalidity::NftStateMismatch.into());
228		}
229		if let Some(to) = transfer_to {
230			// Pre-validate the destination so ordinary user error is rejected at the pool and
231			// never reaches dispatch, where a failure triggers the backoff lock. The
232			// dispatch-time checks remain for genuine same-block races. Mirrors coinage's
233			// `validate_transfer` pattern. Burns have no destination checks.
234			if to == &owner {
235				return Err(CustomInvalidity::TransferToSelf.into());
236			}
237			if NftsByOwner::<T>::contains_key(to) {
238				return Err(CustomInvalidity::DestinationOccupied.into());
239			}
240		}
241		let priority = now.saturating_sub(nft.last_moved).min(T::MaxTransferPriority::get());
242		let validity = ValidTransaction::with_tag_prefix("Scarcity")
243			.and_provides((nft.instance, nft.state_nonce))
244			.priority(priority)
245			.into();
246		origin.set_caller_from(Origin::Nft { owner: owner.clone(), nft });
247		Ok((
248			validity,
249			Val::UsingNft { owner, instance: *instance, state_nonce: *state_nonce },
250			origin,
251		))
252	}
253
254	fn prepare(
255		self,
256		val: Self::Val,
257		_origin: &<T as frame_system::Config>::RuntimeOrigin,
258		_call: &<T as frame_system::Config>::RuntimeCall,
259		_info: &DispatchInfoOf<<T as frame_system::Config>::RuntimeCall>,
260		_len: usize,
261	) -> Result<Self::Pre, TransactionValidityError> {
262		match val {
263			Val::NotUsing => Ok(Pre::NotUsing),
264			Val::UsingNft { owner, instance, state_nonce } => {
265				let nft = NftsByOwner::<T>::try_mutate_exists(
266					&owner,
267					|maybe_nft| -> Result<Nft, TransactionValidityError> {
268						let nft = maybe_nft.as_ref().ok_or(CustomInvalidity::NoNft)?;
269						if nft.instance != instance || nft.state_nonce != state_nonce {
270							return Err(CustomInvalidity::NftStateMismatch.into());
271						}
272						// Dispatch assumes the source purse is empty. Taking the NFT here
273						// prevents same-block double use and lets post-dispatch restore the exact
274						// pre-state if dispatch fails.
275						Ok(maybe_nft.take().expect("NFT existence checked above; qed"))
276					},
277				)?;
278				Ok(Pre::UsingNft { owner, nft })
279			},
280		}
281	}
282
283	fn post_dispatch_details(
284		pre: Self::Pre,
285		_info: &DispatchInfoOf<<T as frame_system::Config>::RuntimeCall>,
286		_post_info: &PostDispatchInfoOf<<T as frame_system::Config>::RuntimeCall>,
287		_len: usize,
288		result: &DispatchResult,
289	) -> Result<Weight, TransactionValidityError> {
290		if let Pre::UsingNft { owner, nft } = pre {
291			if result.is_err() {
292				NftsByOwner::<T>::insert(&owner, nft);
293				Locked::<T>::insert(&owner, Self::failed_dispatch_lock(Locked::<T>::get(&owner)));
294			} else {
295				Locked::<T>::remove(&owner);
296			}
297		}
298		Ok(Weight::zero())
299	}
300}