mirror of
https://github.com/pezkuwichain/pezkuwi-subxt.git
synced 2026-04-25 12:57:58 +00:00
Metadata difference command (#1015)
* diffing pallets and runtime apis * print diff * clippy fix and format * change formatting * fmt * diff working with storage details * fix diff * cargo fmt * remove printing of node * change strings * handle parsing differently * clippy fix * cargo fmt * more abstraction * clippy fix and fmt * add unit test and ordering * fix small issue
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
use clap::Args;
|
||||
use codec::Decode;
|
||||
|
||||
use frame_metadata::RuntimeMetadataPrefixed;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
|
||||
use crate::utils::FileOrUrl;
|
||||
use color_eyre::owo_colors::OwoColorize;
|
||||
|
||||
use scale_info::form::PortableForm;
|
||||
use scale_info::Variant;
|
||||
|
||||
use subxt_metadata::{
|
||||
ConstantMetadata, Metadata, PalletMetadata, RuntimeApiMetadata, StorageEntryMetadata,
|
||||
StorageEntryType,
|
||||
};
|
||||
|
||||
/// Explore the differences between two nodes
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// subxt diff ./artifacts/polkadot_metadata_small.scale ./artifacts/polkadot_metadata_tiny.scale
|
||||
/// subxt diff ./artifacts/polkadot_metadata_small.scale wss://rpc.polkadot.io:443
|
||||
/// ```
|
||||
#[derive(Debug, Args)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Opts {
|
||||
/// metadata file or node URL
|
||||
metadata_or_url_1: FileOrUrl,
|
||||
/// metadata file or node URL
|
||||
metadata_or_url_2: FileOrUrl,
|
||||
}
|
||||
|
||||
pub async fn run(opts: Opts, output: &mut impl std::io::Write) -> color_eyre::Result<()> {
|
||||
let (entry_1_metadata, entry_2_metadata) = get_metadata(&opts).await?;
|
||||
|
||||
let metadata_diff = MetadataDiff::construct(&entry_1_metadata, &entry_2_metadata);
|
||||
|
||||
if metadata_diff.is_empty() {
|
||||
writeln!(output, "No difference in metadata found.")?;
|
||||
return Ok(());
|
||||
}
|
||||
if !metadata_diff.pallets.is_empty() {
|
||||
writeln!(output, "Pallets:")?;
|
||||
for diff in metadata_diff.pallets {
|
||||
match diff {
|
||||
Diff::Added(new) => {
|
||||
writeln!(output, "{}", format!(" + {}", new.name()).green())?
|
||||
}
|
||||
Diff::Removed(old) => {
|
||||
writeln!(output, "{}", format!(" - {}", old.name()).red())?
|
||||
}
|
||||
Diff::Changed { from, to } => {
|
||||
writeln!(output, "{}", format!(" ~ {}", from.name()).yellow())?;
|
||||
|
||||
let pallet_diff = PalletDiff::construct(&from, &to);
|
||||
if !pallet_diff.calls.is_empty() {
|
||||
writeln!(output, " Calls:")?;
|
||||
for diff in pallet_diff.calls {
|
||||
match diff {
|
||||
Diff::Added(new) => writeln!(
|
||||
output,
|
||||
"{}",
|
||||
format!(" + {}", &new.name).green()
|
||||
)?,
|
||||
Diff::Removed(old) => writeln!(
|
||||
output,
|
||||
"{}",
|
||||
format!(" - {}", &old.name).red()
|
||||
)?,
|
||||
Diff::Changed { from, to: _ } => {
|
||||
writeln!(
|
||||
output,
|
||||
"{}",
|
||||
format!(" ~ {}", &from.name).yellow()
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !pallet_diff.constants.is_empty() {
|
||||
writeln!(output, " Constants:")?;
|
||||
for diff in pallet_diff.constants {
|
||||
match diff {
|
||||
Diff::Added(new) => writeln!(
|
||||
output,
|
||||
"{}",
|
||||
format!(" + {}", new.name()).green()
|
||||
)?,
|
||||
Diff::Removed(old) => writeln!(
|
||||
output,
|
||||
"{}",
|
||||
format!(" - {}", old.name()).red()
|
||||
)?,
|
||||
Diff::Changed { from, to: _ } => writeln!(
|
||||
output,
|
||||
"{}",
|
||||
format!(" ~ {}", from.name()).yellow()
|
||||
)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !pallet_diff.storage_entries.is_empty() {
|
||||
writeln!(output, " Storage Entries:")?;
|
||||
for diff in pallet_diff.storage_entries {
|
||||
match diff {
|
||||
Diff::Added(new) => writeln!(
|
||||
output,
|
||||
"{}",
|
||||
format!(" + {}", new.name()).green()
|
||||
)?,
|
||||
Diff::Removed(old) => writeln!(
|
||||
output,
|
||||
"{}",
|
||||
format!(" - {}", old.name()).red()
|
||||
)?,
|
||||
Diff::Changed { from, to } => {
|
||||
let storage_diff = StorageEntryDiff::construct(
|
||||
from,
|
||||
to,
|
||||
&entry_1_metadata,
|
||||
&entry_2_metadata,
|
||||
);
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
"{}",
|
||||
format!(
|
||||
" ~ {} (Changed: {})",
|
||||
from.name(),
|
||||
storage_diff.to_strings().join(", ")
|
||||
)
|
||||
.yellow()
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !metadata_diff.runtime_apis.is_empty() {
|
||||
writeln!(output, "Runtime APIs:")?;
|
||||
for diff in metadata_diff.runtime_apis {
|
||||
match diff {
|
||||
Diff::Added(new) => {
|
||||
writeln!(output, "{}", format!(" + {}", new.name()).green())?
|
||||
}
|
||||
Diff::Removed(old) => {
|
||||
writeln!(output, "{}", format!(" - {}", old.name()).red())?
|
||||
}
|
||||
Diff::Changed { from, to: _ } => {
|
||||
writeln!(output, "{}", format!(" ~ {}", from.name()).yellow())?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct MetadataDiff<'a> {
|
||||
pallets: Vec<Diff<PalletMetadata<'a>>>,
|
||||
runtime_apis: Vec<Diff<RuntimeApiMetadata<'a>>>,
|
||||
}
|
||||
|
||||
impl<'a> MetadataDiff<'a> {
|
||||
fn construct(metadata_1: &'a Metadata, metadata_2: &'a Metadata) -> MetadataDiff<'a> {
|
||||
let pallets = pallet_differences(metadata_1, metadata_2);
|
||||
let runtime_apis = runtime_api_differences(metadata_1, metadata_2);
|
||||
MetadataDiff {
|
||||
pallets,
|
||||
runtime_apis,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.pallets.is_empty() && self.runtime_apis.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PalletDiff<'a> {
|
||||
calls: Vec<Diff<&'a Variant<PortableForm>>>,
|
||||
constants: Vec<Diff<&'a ConstantMetadata>>,
|
||||
storage_entries: Vec<Diff<&'a StorageEntryMetadata>>,
|
||||
}
|
||||
|
||||
impl<'a> PalletDiff<'a> {
|
||||
fn construct(
|
||||
pallet_metadata_1: &'a PalletMetadata<'a>,
|
||||
pallet_metadata_2: &'a PalletMetadata<'a>,
|
||||
) -> PalletDiff<'a> {
|
||||
let calls = calls_differences(pallet_metadata_1, pallet_metadata_2);
|
||||
let constants = constants_differences(pallet_metadata_1, pallet_metadata_2);
|
||||
let storage_entries = storage_differences(pallet_metadata_1, pallet_metadata_2);
|
||||
PalletDiff {
|
||||
calls,
|
||||
constants,
|
||||
storage_entries,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StorageEntryDiff {
|
||||
key_different: bool,
|
||||
value_different: bool,
|
||||
default_different: bool,
|
||||
modifier_different: bool,
|
||||
}
|
||||
|
||||
impl StorageEntryDiff {
|
||||
fn construct(
|
||||
storage_entry_1: &StorageEntryMetadata,
|
||||
storage_entry_2: &StorageEntryMetadata,
|
||||
metadata_1: &Metadata,
|
||||
metadata_2: &Metadata,
|
||||
) -> Self {
|
||||
let value_1_ty_id = match storage_entry_1.entry_type() {
|
||||
StorageEntryType::Plain(value_ty) | StorageEntryType::Map { value_ty, .. } => value_ty,
|
||||
};
|
||||
let value_1_hash = metadata_1
|
||||
.type_hash(*value_1_ty_id)
|
||||
.expect("type should be present");
|
||||
let value_2_ty_id = match storage_entry_2.entry_type() {
|
||||
StorageEntryType::Plain(value_ty) | StorageEntryType::Map { value_ty, .. } => value_ty,
|
||||
};
|
||||
let value_2_hash = metadata_1
|
||||
.type_hash(*value_2_ty_id)
|
||||
.expect("type should be present");
|
||||
let value_different = value_1_hash != value_2_hash;
|
||||
|
||||
let key_1_hash = match storage_entry_1.entry_type() {
|
||||
StorageEntryType::Plain(_) => None,
|
||||
StorageEntryType::Map { key_ty, .. } => Some(*key_ty),
|
||||
}
|
||||
.map(|key_ty| {
|
||||
metadata_1
|
||||
.type_hash(key_ty)
|
||||
.expect("type should be present")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let key_2_hash = match storage_entry_2.entry_type() {
|
||||
StorageEntryType::Plain(_) => None,
|
||||
StorageEntryType::Map { key_ty, .. } => Some(*key_ty),
|
||||
}
|
||||
.map(|key_ty| {
|
||||
metadata_2
|
||||
.type_hash(key_ty)
|
||||
.expect("type should be present")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let key_different = key_1_hash != key_2_hash;
|
||||
|
||||
StorageEntryDiff {
|
||||
key_different,
|
||||
value_different,
|
||||
default_different: storage_entry_1.default_bytes() != storage_entry_2.default_bytes(),
|
||||
modifier_different: storage_entry_1.modifier() != storage_entry_2.modifier(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_strings(&self) -> Vec<&str> {
|
||||
let mut strings = Vec::<&str>::new();
|
||||
if self.key_different {
|
||||
strings.push("key type");
|
||||
}
|
||||
if self.value_different {
|
||||
strings.push("value type");
|
||||
}
|
||||
if self.modifier_different {
|
||||
strings.push("modifier");
|
||||
}
|
||||
if self.default_different {
|
||||
strings.push("default value");
|
||||
}
|
||||
strings
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_metadata(opts: &Opts) -> color_eyre::Result<(Metadata, Metadata)> {
|
||||
let bytes = opts.metadata_or_url_1.fetch().await?;
|
||||
let entry_1_metadata: Metadata =
|
||||
RuntimeMetadataPrefixed::decode(&mut &bytes[..])?.try_into()?;
|
||||
|
||||
let bytes = opts.metadata_or_url_2.fetch().await?;
|
||||
let entry_2_metadata: Metadata =
|
||||
RuntimeMetadataPrefixed::decode(&mut &bytes[..])?.try_into()?;
|
||||
|
||||
Ok((entry_1_metadata, entry_2_metadata))
|
||||
}
|
||||
|
||||
fn storage_differences<'a>(
|
||||
pallet_metadata_1: &'a PalletMetadata<'a>,
|
||||
pallet_metadata_2: &'a PalletMetadata<'a>,
|
||||
) -> Vec<Diff<&'a StorageEntryMetadata>> {
|
||||
diff(
|
||||
pallet_metadata_1
|
||||
.storage()
|
||||
.map(|s| s.entries())
|
||||
.unwrap_or_default(),
|
||||
pallet_metadata_2
|
||||
.storage()
|
||||
.map(|s| s.entries())
|
||||
.unwrap_or_default(),
|
||||
|e| {
|
||||
pallet_metadata_1
|
||||
.storage_hash(e.name())
|
||||
.expect("storage entry should be present")
|
||||
},
|
||||
|e| {
|
||||
pallet_metadata_2
|
||||
.storage_hash(e.name())
|
||||
.expect("storage entry should be present")
|
||||
},
|
||||
|e| e.name(),
|
||||
)
|
||||
}
|
||||
|
||||
fn calls_differences<'a>(
|
||||
pallet_metadata_1: &'a PalletMetadata<'a>,
|
||||
pallet_metadata_2: &'a PalletMetadata<'a>,
|
||||
) -> Vec<Diff<&'a Variant<PortableForm>>> {
|
||||
return diff(
|
||||
pallet_metadata_1.call_variants().unwrap_or_default(),
|
||||
pallet_metadata_2.call_variants().unwrap_or_default(),
|
||||
|e| {
|
||||
pallet_metadata_1
|
||||
.call_hash(&e.name)
|
||||
.expect("call should be present")
|
||||
},
|
||||
|e| {
|
||||
pallet_metadata_2
|
||||
.call_hash(&e.name)
|
||||
.expect("call should be present")
|
||||
},
|
||||
|e| &e.name,
|
||||
);
|
||||
}
|
||||
|
||||
fn constants_differences<'a>(
|
||||
pallet_metadata_1: &'a PalletMetadata<'a>,
|
||||
pallet_metadata_2: &'a PalletMetadata<'a>,
|
||||
) -> Vec<Diff<&'a ConstantMetadata>> {
|
||||
diff(
|
||||
pallet_metadata_1.constants(),
|
||||
pallet_metadata_2.constants(),
|
||||
|e| {
|
||||
pallet_metadata_1
|
||||
.constant_hash(e.name())
|
||||
.expect("constant should be present")
|
||||
},
|
||||
|e| {
|
||||
pallet_metadata_2
|
||||
.constant_hash(e.name())
|
||||
.expect("constant should be present")
|
||||
},
|
||||
|e| e.name(),
|
||||
)
|
||||
}
|
||||
|
||||
fn runtime_api_differences<'a>(
|
||||
metadata_1: &'a Metadata,
|
||||
metadata_2: &'a Metadata,
|
||||
) -> Vec<Diff<RuntimeApiMetadata<'a>>> {
|
||||
diff(
|
||||
metadata_1.runtime_api_traits(),
|
||||
metadata_2.runtime_api_traits(),
|
||||
RuntimeApiMetadata::hash,
|
||||
RuntimeApiMetadata::hash,
|
||||
RuntimeApiMetadata::name,
|
||||
)
|
||||
}
|
||||
|
||||
fn pallet_differences<'a>(
|
||||
metadata_1: &'a Metadata,
|
||||
metadata_2: &'a Metadata,
|
||||
) -> Vec<Diff<PalletMetadata<'a>>> {
|
||||
diff(
|
||||
metadata_1.pallets(),
|
||||
metadata_2.pallets(),
|
||||
PalletMetadata::hash,
|
||||
PalletMetadata::hash,
|
||||
PalletMetadata::name,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Diff<T> {
|
||||
Added(T),
|
||||
Changed { from: T, to: T },
|
||||
Removed(T),
|
||||
}
|
||||
|
||||
fn diff<T, C: PartialEq, I: Hash + PartialEq + Eq + Ord>(
|
||||
items_a: impl IntoIterator<Item = T>,
|
||||
items_b: impl IntoIterator<Item = T>,
|
||||
hash_fn_a: impl Fn(&T) -> C,
|
||||
hash_fn_b: impl Fn(&T) -> C,
|
||||
key_fn: impl Fn(&T) -> I,
|
||||
) -> Vec<Diff<T>> {
|
||||
let mut entries: HashMap<I, (Option<T>, Option<T>)> = HashMap::new();
|
||||
|
||||
for t1 in items_a {
|
||||
let key = key_fn(&t1);
|
||||
let (e1, _) = entries.entry(key).or_default();
|
||||
*e1 = Some(t1);
|
||||
}
|
||||
|
||||
for t2 in items_b {
|
||||
let key = key_fn(&t2);
|
||||
let (e1, e2) = entries.entry(key).or_default();
|
||||
// skip all entries with the same hash:
|
||||
if let Some(e1_inner) = e1 {
|
||||
let e1_hash = hash_fn_a(e1_inner);
|
||||
let e2_hash = hash_fn_b(&t2);
|
||||
if e1_hash == e2_hash {
|
||||
entries.remove(&key_fn(&t2));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
*e2 = Some(t2);
|
||||
}
|
||||
|
||||
// sort the values by key before returning
|
||||
let mut diff_vec_with_keys: Vec<_> = entries.into_iter().collect();
|
||||
diff_vec_with_keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
diff_vec_with_keys
|
||||
.into_iter()
|
||||
.map(|(_, tuple)| match tuple {
|
||||
(None, None) => panic!("At least one value is inserted when the key exists; qed"),
|
||||
(Some(old), None) => Diff::Removed(old),
|
||||
(None, Some(new)) => Diff::Added(new),
|
||||
(Some(old), Some(new)) => Diff::Changed { from: old, to: new },
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::commands::diff::{diff, Diff};
|
||||
|
||||
#[test]
|
||||
fn test_diff_fn() {
|
||||
let old_pallets = [("Babe", 7), ("Claims", 9), ("Balances", 23)];
|
||||
let new_pallets = [("Claims", 9), ("Balances", 22), ("System", 3), ("NFTs", 5)];
|
||||
let hash_fn = |e: &(&str, i32)| e.0.len() as i32 * e.1;
|
||||
let differences = diff(old_pallets, new_pallets, hash_fn, hash_fn, |e| e.0);
|
||||
let expected_differences = vec![
|
||||
Diff::Removed(("Babe", 7)),
|
||||
Diff::Changed {
|
||||
from: ("Balances", 23),
|
||||
to: ("Balances", 22),
|
||||
},
|
||||
Diff::Added(("NFTs", 5)),
|
||||
Diff::Added(("System", 3)),
|
||||
];
|
||||
assert_eq!(differences, expected_differences);
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,6 @@ pub enum PalletSubcommand {
|
||||
Storage(StorageSubcommand),
|
||||
}
|
||||
|
||||
/// cargo run -- explore --file=../artifacts/polkadot_metadata.scale
|
||||
pub async fn run(opts: Opts, output: &mut impl std::io::Write) -> color_eyre::Result<()> {
|
||||
// get the metadata
|
||||
let bytes = opts.file_or_url.fetch().await?;
|
||||
|
||||
@@ -47,7 +47,7 @@ pub async fn explore_storage(
|
||||
};
|
||||
|
||||
// if specified call storage entry wrong, show user the storage entries to choose from (but this time as an error):
|
||||
let Some(storage) = storage_metadata.entries().find(|entry| entry.name().to_lowercase() == entry_name.to_lowercase()) else {
|
||||
let Some(storage) = storage_metadata.entries().iter().find(|entry| entry.name().to_lowercase() == entry_name.to_lowercase()) else {
|
||||
let storage_entries = print_available_storage_entries(storage_metadata, pallet_name);
|
||||
let description = format!("Usage:\n subxt explore {pallet_name} storage <STORAGE_ENTRY>\n view details for a specific storage entry\n\n{storage_entries}");
|
||||
return Err(eyre!("Storage entry \"{entry_name}\" not found in \"{pallet_name}\" pallet!\n\n{description}"));
|
||||
@@ -164,14 +164,18 @@ fn print_available_storage_entries(
|
||||
storage_metadata: &StorageMetadata,
|
||||
pallet_name: &str,
|
||||
) -> String {
|
||||
if storage_metadata.entries().len() == 0 {
|
||||
if storage_metadata.entries().is_empty() {
|
||||
format!("No <STORAGE_ENTRY>'s available in the \"{pallet_name}\" pallet.")
|
||||
} else {
|
||||
let mut output = format!(
|
||||
"Available <STORAGE_ENTRY>'s in the \"{}\" pallet:",
|
||||
pallet_name
|
||||
);
|
||||
let mut strings: Vec<_> = storage_metadata.entries().map(|s| s.name()).collect();
|
||||
let mut strings: Vec<_> = storage_metadata
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|s| s.name())
|
||||
.collect();
|
||||
strings.sort();
|
||||
for entry in strings {
|
||||
write!(output, "\n {}", entry).unwrap();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub mod codegen;
|
||||
pub mod compatibility;
|
||||
pub mod diff;
|
||||
pub mod explore;
|
||||
pub mod metadata;
|
||||
pub mod version;
|
||||
|
||||
@@ -15,6 +15,7 @@ enum Command {
|
||||
Metadata(commands::metadata::Opts),
|
||||
Codegen(commands::codegen::Opts),
|
||||
Compatibility(commands::compatibility::Opts),
|
||||
Diff(commands::diff::Opts),
|
||||
Version(commands::version::Opts),
|
||||
Explore(commands::explore::Opts),
|
||||
}
|
||||
@@ -28,6 +29,7 @@ async fn main() -> color_eyre::Result<()> {
|
||||
Command::Metadata(opts) => commands::metadata::run(opts, &mut output).await,
|
||||
Command::Codegen(opts) => commands::codegen::run(opts, &mut output).await,
|
||||
Command::Compatibility(opts) => commands::compatibility::run(opts, &mut output).await,
|
||||
Command::Diff(opts) => commands::diff::run(opts, &mut output).await,
|
||||
Command::Version(opts) => commands::version::run(opts, &mut output),
|
||||
Command::Explore(opts) => commands::explore::run(opts, &mut output).await,
|
||||
}
|
||||
|
||||
+26
-1
@@ -5,14 +5,16 @@
|
||||
use clap::Args;
|
||||
use color_eyre::eyre;
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::{fs, io::Read, path::PathBuf};
|
||||
|
||||
use subxt_codegen::utils::{MetadataVersion, Uri};
|
||||
|
||||
pub mod type_description;
|
||||
pub mod type_example;
|
||||
|
||||
/// The source of the metadata.
|
||||
#[derive(Debug, Args)]
|
||||
#[derive(Debug, Args, Clone)]
|
||||
pub struct FileOrUrl {
|
||||
/// The url of the substrate node to query for metadata for codegen.
|
||||
#[clap(long, value_parser)]
|
||||
@@ -94,3 +96,26 @@ pub fn with_indent(s: String, indent: usize) -> String {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
impl FromStr for FileOrUrl {
|
||||
type Err = &'static str;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let path = std::path::Path::new(s);
|
||||
if path.exists() {
|
||||
Ok(FileOrUrl {
|
||||
url: None,
|
||||
file: Some(PathBuf::from(s)),
|
||||
version: None,
|
||||
})
|
||||
} else {
|
||||
Uri::from_str(s)
|
||||
.map_err(|_| "no path or uri could be crated")
|
||||
.map(|uri| FileOrUrl {
|
||||
url: Some(uri),
|
||||
file: None,
|
||||
version: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user