pallet_stateful_storage/
types.rs

1//! Types for the Stateful Storage Pallet
2use crate::Config;
3use common_primitives::{
4	node::EIP712Encode,
5	schema::{IntentId, SchemaId},
6	signatures::get_eip712_encoding_prefix,
7	stateful_storage::{PageHash, PageId, PageNonce},
8	utils::to_abi_compatible_number,
9};
10use frame_support::pallet_prelude::*;
11use frame_system::pallet_prelude::*;
12use lazy_static::lazy_static;
13use parity_scale_codec::{Decode, Encode, MaxEncodedLen};
14use scale_info::TypeInfo;
15use sp_core::bounded::BoundedVec;
16extern crate alloc;
17use alloc::{boxed::Box, collections::btree_map::BTreeMap, vec::Vec};
18use core::{
19	cmp::*,
20	fmt::{Debug, Formatter},
21	hash::{Hash, Hasher},
22};
23use frame_support::traits::Len;
24use sp_core::U256;
25use twox_hash::XxHash64;
26
27/// Current storage version of the pallet.
28pub const STATEFUL_STORAGE_VERSION: StorageVersion = StorageVersion::new(2);
29/// pallet storage prefix
30pub const PALLET_STORAGE_PREFIX: &[u8] = b"stateful-storage";
31/// itemized storage prefix
32pub const ITEMIZED_STORAGE_PREFIX: &[u8] = b"itemized";
33/// paginated storage prefix
34pub const PAGINATED_STORAGE_PREFIX: &[u8] = b"paginated";
35
36/// MultipartKey type for Itemized storage
37pub type ItemizedKey = (IntentId,);
38/// MultipartKey type for Paginated storage (full key)
39pub type PaginatedKey = (IntentId, PageId);
40/// MultipartKey type for Paginated storage (prefix lookup)
41pub type PaginatedPrefixKey = (IntentId,);
42/// Itemized page type
43pub type ItemizedPage<T> = Page<<T as Config>::MaxItemizedPageSizeBytes>;
44/// Paginated Page type
45pub type PaginatedPage<T> = Page<<T as Config>::MaxPaginatedPageSizeBytes>;
46
47/// Operations on Itemized storage
48pub trait ItemizedOperations<T: Config> {
49	/// Applies all actions to specified page and returns the updated page
50	fn apply_item_actions(
51		&self,
52		schema_id: SchemaId,
53		actions: &[ItemAction<T::MaxItemizedBlobSizeBytes>],
54	) -> Result<ItemizedPage<T>, PageError>;
55
56	/// Parses all the items inside an ItemPage
57	fn try_parse(&self, include_header: bool) -> Result<ParsedItemPage, PageError>;
58}
59/// Defines the actions that can be applied to an Itemized storage
60#[derive(
61	Clone, Encode, Decode, DecodeWithMemTracking, Debug, TypeInfo, MaxEncodedLen, PartialEq,
62)]
63#[scale_info(skip_type_params(DataSize))]
64#[codec(mel_bound(DataSize: MaxEncodedLen))]
65pub enum ItemAction<DataSize: Get<u32> + Clone + core::fmt::Debug + PartialEq> {
66	/// Adding new Item into page
67	Add {
68		/// The data to add
69		data: BoundedVec<u8, DataSize>,
70	},
71	/// Removing an existing item by index number. Index number starts from 0
72	Delete {
73		/// Index (0+) to delete
74		index: u16,
75	},
76}
77
78/// This header is used to specify the byte size of an item stored inside the buffer
79/// All items will require this header to be inserted before the item data
80#[derive(Encode, Decode, PartialEq, MaxEncodedLen, Debug)]
81pub enum ItemHeader {
82	/// Version 1 - was never used on-chain; for information only
83	V1 {
84		/// The length of this item, not including the size of this header.
85		payload_len: u16,
86	},
87	/// Version 2 item header
88	V2 {
89		/// The SchemaId used to serialize this item
90		schema_id: SchemaId,
91		/// The length of this item, not including the size of this header.
92		payload_len: u16,
93	},
94}
95
96impl ItemHeader {
97	/// Getter for schema_id across variants
98	pub fn schema_id(&self) -> Option<SchemaId> {
99		match self {
100			// V1 unused but here for Rust match arm completeness
101			ItemHeader::V1 { .. } => None,
102			ItemHeader::V2 { schema_id, .. } => Some(*schema_id),
103		}
104	}
105
106	/// Getter for payload_len across variants
107	pub fn payload_len(&self) -> u16 {
108		match self {
109			// V1 unused but here for Rust match arm completeness
110			ItemHeader::V1 { payload_len } => *payload_len,
111			ItemHeader::V2 { payload_len, .. } => *payload_len,
112		}
113	}
114}
115
116/// Errors dedicated to parsing or modifying pages
117#[derive(Debug, PartialEq)]
118pub enum PageError {
119	/// Unable to decode the data in the item
120	ErrorParsing(&'static str),
121	/// Add or Delete Operation was not possible
122	InvalidAction(&'static str),
123	/// ItemPage count overflow catch
124	ArithmeticOverflow,
125	/// Page byte length over the max size
126	PageSizeOverflow,
127	/// Unsupported Item type
128	UnsupportedItemType,
129}
130
131// REMOVED ItemizedSignaturePayload
132
133/// Payload containing all necessary fields to verify Itemized related signatures
134#[derive(
135	Encode,
136	Decode,
137	DecodeWithMemTracking,
138	TypeInfo,
139	MaxEncodedLen,
140	PartialEq,
141	RuntimeDebugNoBound,
142	Clone,
143)]
144#[scale_info(skip_type_params(T))]
145pub struct ItemizedSignaturePayloadV2<T: Config> {
146	/// Schema id of this storage
147	#[codec(compact)]
148	pub schema_id: SchemaId,
149
150	/// Hash of targeted page to avoid race conditions
151	#[codec(compact)]
152	pub target_hash: PageHash,
153
154	/// The block number at which the signed proof will expire
155	pub expiration: BlockNumberFor<T>,
156
157	/// Actions to apply to storage from possible: [`ItemAction`]
158	pub actions: BoundedVec<
159		ItemAction<<T as Config>::MaxItemizedBlobSizeBytes>,
160		<T as Config>::MaxItemizedActionsCount,
161	>,
162}
163
164impl<T: Config> EIP712Encode for ItemizedSignaturePayloadV2<T> {
165	fn encode_eip_712(&self, chain_id: u32) -> Box<[u8]> {
166		lazy_static! {
167			// signed payload
168			static ref MAIN_TYPE_HASH: [u8; 32] =
169				sp_io::hashing::keccak_256(b"ItemizedSignaturePayloadV2(uint16 schemaId,uint32 targetHash,uint32 expiration,ItemAction[] actions)ItemAction(string actionType,bytes data,uint16 index)");
170
171			static ref SUB_TYPE_HASH: [u8; 32] =
172				sp_io::hashing::keccak_256(b"ItemAction(string actionType,bytes data,uint16 index)");
173
174			static ref ITEM_ACTION_ADD: [u8; 32] = sp_io::hashing::keccak_256(b"Add");
175			static ref ITEM_ACTION_DELETE: [u8; 32] = sp_io::hashing::keccak_256(b"Delete");
176
177			static ref EMPTY_BYTES_HASH: [u8; 32] = sp_io::hashing::keccak_256([].as_slice());
178		}
179		// get prefix and domain separator
180		let prefix_domain_separator: Box<[u8]> =
181			get_eip712_encoding_prefix("0xcccccccccccccccccccccccccccccccccccccccc", chain_id);
182		let coded_schema_id = to_abi_compatible_number(self.schema_id);
183		let coded_target_hash = to_abi_compatible_number(self.target_hash);
184		let expiration: U256 = self.expiration.into();
185		let coded_expiration = to_abi_compatible_number(expiration.as_u128());
186		let coded_actions = {
187			let values: Vec<u8> = self
188				.actions
189				.iter()
190				.flat_map(|a| match a {
191					ItemAction::Add { data } => sp_io::hashing::keccak_256(
192						&[
193							SUB_TYPE_HASH.as_slice(),
194							ITEM_ACTION_ADD.as_slice(),
195							&sp_io::hashing::keccak_256(data.as_slice()),
196							[0u8; 32].as_slice(),
197						]
198						.concat(),
199					),
200					ItemAction::Delete { index } => sp_io::hashing::keccak_256(
201						&[
202							SUB_TYPE_HASH.as_slice(),
203							ITEM_ACTION_DELETE.as_slice(),
204							EMPTY_BYTES_HASH.as_slice(),
205							to_abi_compatible_number(*index).as_slice(),
206						]
207						.concat(),
208					),
209				})
210				.collect();
211			sp_io::hashing::keccak_256(&values)
212		};
213		let message = sp_io::hashing::keccak_256(
214			&[
215				MAIN_TYPE_HASH.as_slice(),
216				&coded_schema_id,
217				&coded_target_hash,
218				&coded_expiration,
219				&coded_actions,
220			]
221			.concat(),
222		);
223		let combined = [prefix_domain_separator.as_ref(), &message].concat();
224		combined.into_boxed_slice()
225	}
226}
227
228// REMOVED PaginatedSignaturePayload
229
230/// Payload containing all necessary fields to verify signatures to upsert a Paginated storage
231#[derive(
232	Encode,
233	Decode,
234	DecodeWithMemTracking,
235	TypeInfo,
236	MaxEncodedLen,
237	PartialEq,
238	RuntimeDebugNoBound,
239	Clone,
240)]
241#[scale_info(skip_type_params(T))]
242pub struct PaginatedUpsertSignaturePayloadV2<T: Config> {
243	/// Schema id of this storage
244	#[codec(compact)]
245	pub schema_id: SchemaId,
246
247	/// Page id of this storage
248	#[codec(compact)]
249	pub page_id: PageId,
250
251	/// Hash of targeted page to avoid race conditions
252	#[codec(compact)]
253	pub target_hash: PageHash,
254
255	/// The block number at which the signed proof will expire
256	pub expiration: BlockNumberFor<T>,
257
258	/// payload to update the page with
259	pub payload: BoundedVec<u8, <T as Config>::MaxPaginatedPageSizeBytes>,
260}
261
262impl<T: Config> EIP712Encode for PaginatedUpsertSignaturePayloadV2<T> {
263	fn encode_eip_712(&self, chain_id: u32) -> Box<[u8]> {
264		lazy_static! {
265			// signed payload
266			static ref MAIN_TYPE_HASH: [u8; 32] =
267				sp_io::hashing::keccak_256(b"PaginatedUpsertSignaturePayloadV2(uint16 schemaId,uint16 pageId,uint32 targetHash,uint32 expiration,bytes payload)");
268		}
269		// get prefix and domain separator
270		let prefix_domain_separator: Box<[u8]> =
271			get_eip712_encoding_prefix("0xcccccccccccccccccccccccccccccccccccccccc", chain_id);
272		let coded_schema_id = to_abi_compatible_number(self.schema_id);
273		let coded_page_id = to_abi_compatible_number(self.page_id);
274		let coded_target_hash = to_abi_compatible_number(self.target_hash);
275		let expiration: U256 = self.expiration.into();
276		let coded_expiration = to_abi_compatible_number(expiration.as_u128());
277		let coded_payload = sp_io::hashing::keccak_256(self.payload.as_slice());
278		let message = sp_io::hashing::keccak_256(
279			&[
280				MAIN_TYPE_HASH.as_slice(),
281				&coded_schema_id,
282				&coded_page_id,
283				&coded_target_hash,
284				&coded_expiration,
285				&coded_payload,
286			]
287			.concat(),
288		);
289		let combined = [prefix_domain_separator.as_ref(), &message].concat();
290		combined.into_boxed_slice()
291	}
292}
293
294// REMOVED PaginatedDeleteSignaturePayload
295
296/// Payload containing all necessary fields to verify signatures to delete a Paginated storage
297#[derive(
298	Encode,
299	Decode,
300	DecodeWithMemTracking,
301	TypeInfo,
302	MaxEncodedLen,
303	PartialEq,
304	RuntimeDebugNoBound,
305	Clone,
306)]
307#[scale_info(skip_type_params(T))]
308pub struct PaginatedDeleteSignaturePayloadV2<T: Config> {
309	/// Schema id of this storage
310	#[codec(compact)]
311	pub schema_id: SchemaId,
312
313	/// Page id of this storage
314	#[codec(compact)]
315	pub page_id: PageId,
316
317	/// Hash of targeted page to avoid race conditions
318	#[codec(compact)]
319	pub target_hash: PageHash,
320
321	/// The block number at which the signed proof will expire
322	pub expiration: BlockNumberFor<T>,
323}
324
325impl<T: Config> EIP712Encode for PaginatedDeleteSignaturePayloadV2<T> {
326	fn encode_eip_712(&self, chain_id: u32) -> Box<[u8]> {
327		lazy_static! {
328			// signed payload
329			static ref MAIN_TYPE_HASH: [u8; 32] =
330				sp_io::hashing::keccak_256(b"PaginatedDeleteSignaturePayloadV2(uint16 schemaId,uint16 pageId,uint32 targetHash,uint32 expiration)");
331		}
332		// get prefix and domain separator
333		let prefix_domain_separator: Box<[u8]> =
334			get_eip712_encoding_prefix("0xcccccccccccccccccccccccccccccccccccccccc", chain_id);
335		let coded_schema_id = to_abi_compatible_number(self.schema_id);
336		let coded_page_id = to_abi_compatible_number(self.page_id);
337		let coded_target_hash = to_abi_compatible_number(self.target_hash);
338		let expiration: U256 = self.expiration.into();
339		let coded_expiration = to_abi_compatible_number(expiration.as_u128());
340		let message = sp_io::hashing::keccak_256(
341			&[
342				MAIN_TYPE_HASH.as_slice(),
343				&coded_schema_id,
344				&coded_page_id,
345				&coded_target_hash,
346				&coded_expiration,
347			]
348			.concat(),
349		);
350		let combined = [prefix_domain_separator.as_ref(), &message].concat();
351		combined.into_boxed_slice()
352	}
353}
354
355/// Payload containing all necessary fields to verify signatures to delete a Paginated storage
356#[derive(
357	Encode,
358	Decode,
359	DecodeWithMemTracking,
360	TypeInfo,
361	MaxEncodedLen,
362	PartialEq,
363	RuntimeDebugNoBound,
364	Clone,
365)]
366#[scale_info(skip_type_params(T))]
367pub struct PaginatedDeleteSignaturePayloadV3<T: Config> {
368	/// Intent id of this storage
369	#[codec(compact)]
370	pub intent_id: IntentId,
371
372	/// Page id of this storage
373	#[codec(compact)]
374	pub page_id: PageId,
375
376	/// Hash of targeted page to avoid race conditions
377	#[codec(compact)]
378	pub target_hash: PageHash,
379
380	/// The block number at which the signed proof will expire
381	pub expiration: BlockNumberFor<T>,
382}
383
384impl<T: Config> EIP712Encode for PaginatedDeleteSignaturePayloadV3<T> {
385	fn encode_eip_712(&self, chain_id: u32) -> Box<[u8]> {
386		lazy_static! {
387			// signed payload
388			static ref MAIN_TYPE_HASH: [u8; 32] =
389				sp_io::hashing::keccak_256(b"PaginatedDeleteSignaturePayloadV3(uint16 intentId,uint16 pageId,uint32 targetHash,uint32 expiration)");
390		}
391		// get prefix and domain separator
392		let prefix_domain_separator: Box<[u8]> =
393			get_eip712_encoding_prefix("0xcccccccccccccccccccccccccccccccccccccccc", chain_id);
394		let coded_intent_id = to_abi_compatible_number(self.intent_id);
395		let coded_page_id = to_abi_compatible_number(self.page_id);
396		let coded_target_hash = to_abi_compatible_number(self.target_hash);
397		let expiration: U256 = self.expiration.into();
398		let coded_expiration = to_abi_compatible_number(expiration.as_u128());
399		let message = sp_io::hashing::keccak_256(
400			&[
401				MAIN_TYPE_HASH.as_slice(),
402				&coded_intent_id,
403				&coded_page_id,
404				&coded_target_hash,
405				&coded_expiration,
406			]
407			.concat(),
408		);
409		let combined = [prefix_domain_separator.as_ref(), &message].concat();
410		combined.into_boxed_slice()
411	}
412}
413
414/// Indicates the version of the Page storage (header format, etc)
415#[derive(
416	Encode, Decode, DecodeWithMemTracking, Default, Clone, TypeInfo, MaxEncodedLen, Debug, PartialEq,
417)]
418#[repr(u8)]
419pub enum PageVersion {
420	/// Page storage version 1
421	/// No pages were ever written with this version; included for completeness
422	#[codec(index = 1)]
423	V1,
424
425	/// Page storage version 2
426	#[codec(index = 2)]
427	#[default] // NOTE: Move the default attribute when adding a new variant
428	V2,
429}
430
431/// A generic page of data which supports both Itemized and Paginated
432#[derive(Encode, Decode, TypeInfo, MaxEncodedLen, Default)]
433#[scale_info(skip_type_params(PageDataSize))]
434#[codec(mel_bound(PageDataSize: MaxEncodedLen))]
435pub struct Page<PageDataSize: Get<u32>> {
436	/// Page "header" version
437	/// This field should always be first in the page as the structure evolves, so pallet reads
438	/// can adapt to evolving page structure.
439	pub page_version: PageVersion,
440	/// SchemaId used to serialize this page's data.
441	/// Use `None` for Itemized pages (schema_id will be per-item)
442	pub schema_id: Option<SchemaId>,
443	/// Incremental nonce to eliminate of signature replay attacks
444	pub nonce: PageNonce,
445	/// Data for the page
446	/// - Itemized is limited by [`Config::MaxItemizedPageSizeBytes`]
447	/// - Paginated is limited by [`Config::MaxPaginatedPageSizeBytes`]
448	pub data: BoundedVec<u8, PageDataSize>,
449}
450
451impl<PageDataSize: Get<u32>> Debug for Page<PageDataSize> {
452	fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
453		write!(
454			f,
455			"Page<Size> {{ page_version: {:?}, schema_id: {:?}, nonce: {}, data: {} bytes }}",
456			self.page_version,
457			self.schema_id,
458			self.nonce,
459			self.data.len()
460		)
461	}
462}
463
464/// An internal struct representing a single parsed item
465#[derive(Debug, PartialEq)]
466pub struct ParsedItem<'a> {
467	/// The item header
468	pub header: ItemHeader,
469	/// The item payload (may include the unparsed header)
470	pub payload: &'a [u8],
471}
472
473/// An internal struct which contains the parsed items in a page
474#[derive(Debug, PartialEq)]
475pub struct ParsedItemPage<'a> {
476	/// Page current size
477	pub page_size: usize,
478	/// A map of item index to a slice of blob (including a header is optional)
479	pub items: BTreeMap<u16, ParsedItem<'a>>,
480}
481
482impl<PageDataSize: Get<u32>> Page<PageDataSize> {
483	/// Check if the page is empty
484	pub fn is_empty(&self) -> bool {
485		self.data.is_empty()
486	}
487
488	/// Retrieve the hash of the page
489	pub fn get_hash(&self) -> PageHash {
490		if self.is_empty() {
491			return PageHash::default();
492		}
493		let mut hasher = XxHash64::with_seed(0);
494		self.hash(&mut hasher);
495		let value_bytes: [u8; 4] =
496			hasher.finish().to_be_bytes()[..4].try_into().expect("incorrect hash size");
497		PageHash::from_be_bytes(value_bytes)
498	}
499}
500
501/// PartialEq and Hash should be both derived or implemented manually based on clippy rules
502impl<PageDataSize: Get<u32>> Hash for Page<PageDataSize> {
503	fn hash<H: Hasher>(&self, state: &mut H) {
504		state.write(&self.page_version.encode());
505		state.write(&self.schema_id.encode());
506		state.write(&self.nonce.encode());
507		state.write(&self.data[..]);
508	}
509}
510
511/// PartialEq and Hash should be both derived or implemented manually based on clippy rules
512impl<PageDataSize: Get<u32>> PartialEq for Page<PageDataSize> {
513	fn eq(&self, other: &Self) -> bool {
514		self.page_version.eq(&other.page_version) &&
515			self.schema_id.eq(&other.schema_id) &&
516			self.nonce.eq(&other.nonce) &&
517			self.data.eq(&other.data)
518	}
519}
520
521/// Deserializing a Page from a BoundedVec is used for the input payload,
522/// and also for rebuilding an Itemized page after applying actions--
523/// so there is no schema_id and no nonce to be read, just the raw data.
524/// The rest of the metadata gets filled in before the new/updated page is written.
525impl<PageDataSize: Get<u32>> From<BoundedVec<u8, PageDataSize>> for Page<PageDataSize> {
526	fn from(bounded: BoundedVec<u8, PageDataSize>) -> Self {
527		Self {
528			page_version: PageVersion::default(),
529			schema_id: None,
530			nonce: PageNonce::default(),
531			data: bounded,
532		}
533	}
534}
535
536impl<T: Config> ItemizedOperations<T> for ItemizedPage<T> {
537	/// Applies all actions to specified page and returns the updated page
538	/// This has O(n) complexity when n is the number of all the bytes in that itemized storage
539	fn apply_item_actions(
540		&self,
541		schema_id: SchemaId,
542		actions: &[ItemAction<T::MaxItemizedBlobSizeBytes>],
543	) -> Result<ItemizedPage<T>, PageError> {
544		let mut parsed = ItemizedOperations::<T>::try_parse(self, true)?;
545
546		let mut updated_page_buffer = Vec::with_capacity(parsed.page_size);
547		let mut add_buffer = Vec::new();
548
549		for action in actions {
550			match action {
551				ItemAction::Delete { index } => {
552					ensure!(
553						parsed.items.contains_key(index),
554						PageError::InvalidAction("item index is invalid")
555					);
556					parsed.items.remove(index);
557				},
558				ItemAction::Add { data } => {
559					let header = ItemHeader::V2 {
560						schema_id,
561						payload_len: data
562							.len()
563							.try_into()
564							.map_err(|_| PageError::InvalidAction("invalid payload size"))?,
565					};
566					add_buffer.extend_from_slice(&header.encode()[..]);
567					add_buffer.extend_from_slice(&data[..]);
568				},
569			}
570		}
571
572		// since BTreeMap is sorted by key, all items will be kept in their existing order
573		for (_, slice) in parsed.items.iter() {
574			updated_page_buffer.extend_from_slice(slice.payload);
575		}
576		updated_page_buffer.append(&mut add_buffer);
577
578		Ok(ItemizedPage::<T>::from(
579			BoundedVec::try_from(updated_page_buffer).map_err(|_| PageError::PageSizeOverflow)?,
580		))
581	}
582
583	/// Parses all the items inside an ItemPage
584	/// This has O(n) complexity when n is the number of all the bytes in that itemized storage
585	fn try_parse(&self, include_header: bool) -> Result<ParsedItemPage, PageError> {
586		let mut count = 0u16;
587		let mut items = BTreeMap::new();
588		let mut page_slice = &self.data[..];
589
590		while page_slice.len() > 0 {
591			let item_slice = page_slice;
592			let header = <ItemHeader>::decode(&mut page_slice)
593				.map_err(|_| PageError::ErrorParsing("unable to decode item header"))?;
594			if let ItemHeader::V1 { .. } = header {
595				return Err(PageError::UnsupportedItemType);
596			}
597			ensure!(
598				page_slice.len() >= header.payload_len() as usize,
599				PageError::ErrorParsing("item payload exceeds page data")
600			);
601			let header_len = item_slice.len() - page_slice.len();
602
603			let payload: &[u8];
604			(payload, page_slice) = match include_header {
605				true => item_slice
606					.split_at_checked(header_len + header.payload_len() as usize)
607					.ok_or(PageError::ErrorParsing("item payload exceeds page data"))?,
608				false => page_slice
609					.split_at_checked(header.payload_len() as usize)
610					.ok_or(PageError::ErrorParsing("item payload exceeds page data"))?,
611			};
612
613			items.insert(count, ParsedItem { header, payload });
614			count = count.checked_add(1).ok_or(PageError::ArithmeticOverflow)?;
615		}
616
617		Ok(ParsedItemPage { page_size: self.data.len(), items })
618	}
619}