mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-09 04:39:24 +00:00
Rename the coins folder to networks (#583)
* Rename the coins folder to networks Ethereum isn't a coin. It's a network. Resolves #357. * More renames of coins -> networks in orchestration * Correct paths in tests/ * cargo fmt
This commit is contained in:
44
networks/monero/primitives/Cargo.toml
Normal file
44
networks/monero/primitives/Cargo.toml
Normal file
@@ -0,0 +1,44 @@
|
||||
[package]
|
||||
name = "monero-primitives"
|
||||
version = "0.1.0"
|
||||
description = "Primitives for the Monero protocol"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/serai-dex/serai/tree/develop/networks/monero/primitives"
|
||||
authors = ["Luke Parker <lukeparker5132@gmail.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.79"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
std-shims = { path = "../../../common/std-shims", version = "^0.1.1", default-features = false }
|
||||
|
||||
zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] }
|
||||
|
||||
# Cryptographic dependencies
|
||||
sha3 = { version = "0.10", default-features = false }
|
||||
curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] }
|
||||
|
||||
# Other Monero dependencies
|
||||
monero-io = { path = "../io", version = "0.1", default-features = false }
|
||||
monero-generators = { path = "../generators", version = "0.4", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
hex = { version = "0.4", default-features = false, features = ["alloc"] }
|
||||
|
||||
[features]
|
||||
std = [
|
||||
"std-shims/std",
|
||||
|
||||
"zeroize/std",
|
||||
|
||||
"sha3/std",
|
||||
|
||||
"monero-generators/std",
|
||||
]
|
||||
default = ["std"]
|
||||
21
networks/monero/primitives/LICENSE
Normal file
21
networks/monero/primitives/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022-2024 Luke Parker
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
11
networks/monero/primitives/README.md
Normal file
11
networks/monero/primitives/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Monero Primitives
|
||||
|
||||
Primitive structures and functions for the Monero protocol.
|
||||
|
||||
This library is usable under no-std when the `std` feature (on by default) is
|
||||
disabled.
|
||||
|
||||
### Cargo Features
|
||||
|
||||
- `std` (on by default): Enables `std` (and with it, more efficient internal
|
||||
implementations).
|
||||
248
networks/monero/primitives/src/lib.rs
Normal file
248
networks/monero/primitives/src/lib.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
|
||||
#![doc = include_str!("../README.md")]
|
||||
#![deny(missing_docs)]
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
use std_shims::{io, vec::Vec};
|
||||
#[cfg(feature = "std")]
|
||||
use std_shims::sync::OnceLock;
|
||||
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
use sha3::{Digest, Keccak256};
|
||||
use curve25519_dalek::{
|
||||
constants::ED25519_BASEPOINT_POINT,
|
||||
traits::VartimePrecomputedMultiscalarMul,
|
||||
scalar::Scalar,
|
||||
edwards::{EdwardsPoint, VartimeEdwardsPrecomputation},
|
||||
};
|
||||
|
||||
use monero_io::*;
|
||||
use monero_generators::H;
|
||||
|
||||
mod unreduced_scalar;
|
||||
pub use unreduced_scalar::UnreducedScalar;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// On std, we cache some variables in statics.
|
||||
#[cfg(feature = "std")]
|
||||
static INV_EIGHT_CELL: OnceLock<Scalar> = OnceLock::new();
|
||||
/// The inverse of 8 over l.
|
||||
#[cfg(feature = "std")]
|
||||
#[allow(non_snake_case)]
|
||||
pub fn INV_EIGHT() -> Scalar {
|
||||
*INV_EIGHT_CELL.get_or_init(|| Scalar::from(8u8).invert())
|
||||
}
|
||||
// In no-std environments, we prefer the reduced memory use and calculate it ad-hoc.
|
||||
/// The inverse of 8 over l.
|
||||
#[cfg(not(feature = "std"))]
|
||||
#[allow(non_snake_case)]
|
||||
pub fn INV_EIGHT() -> Scalar {
|
||||
Scalar::from(8u8).invert()
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
static G_PRECOMP_CELL: OnceLock<VartimeEdwardsPrecomputation> = OnceLock::new();
|
||||
/// A cached (if std) pre-computation of the Ed25519 generator, G.
|
||||
#[cfg(feature = "std")]
|
||||
#[allow(non_snake_case)]
|
||||
pub fn G_PRECOMP() -> &'static VartimeEdwardsPrecomputation {
|
||||
G_PRECOMP_CELL.get_or_init(|| VartimeEdwardsPrecomputation::new([ED25519_BASEPOINT_POINT]))
|
||||
}
|
||||
/// A cached (if std) pre-computation of the Ed25519 generator, G.
|
||||
#[cfg(not(feature = "std"))]
|
||||
#[allow(non_snake_case)]
|
||||
pub fn G_PRECOMP() -> VartimeEdwardsPrecomputation {
|
||||
VartimeEdwardsPrecomputation::new([ED25519_BASEPOINT_POINT])
|
||||
}
|
||||
|
||||
/// The Keccak-256 hash function.
|
||||
pub fn keccak256(data: impl AsRef<[u8]>) -> [u8; 32] {
|
||||
Keccak256::digest(data.as_ref()).into()
|
||||
}
|
||||
|
||||
/// Hash the provided data to a scalar via keccak256(data) % l.
|
||||
///
|
||||
/// This function panics if it finds the Keccak-256 preimage for [0; 32].
|
||||
pub fn keccak256_to_scalar(data: impl AsRef<[u8]>) -> Scalar {
|
||||
let scalar = Scalar::from_bytes_mod_order(keccak256(data.as_ref()));
|
||||
// Monero will explicitly error in this case
|
||||
// This library acknowledges its practical impossibility of it occurring, and doesn't bother to
|
||||
// code in logic to handle it. That said, if it ever occurs, something must happen in order to
|
||||
// not generate/verify a proof we believe to be valid when it isn't
|
||||
assert!(scalar != Scalar::ZERO, "ZERO HASH: {:?}", data.as_ref());
|
||||
scalar
|
||||
}
|
||||
|
||||
/// Transparent structure representing a Pedersen commitment's contents.
|
||||
#[allow(non_snake_case)]
|
||||
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct Commitment {
|
||||
/// The mask for this commitment.
|
||||
pub mask: Scalar,
|
||||
/// The amount committed to by this commitment.
|
||||
pub amount: u64,
|
||||
}
|
||||
|
||||
impl core::fmt::Debug for Commitment {
|
||||
fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> {
|
||||
fmt.debug_struct("Commitment").field("amount", &self.amount).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Commitment {
|
||||
/// A commitment to zero, defined with a mask of 1 (as to not be the identity).
|
||||
pub fn zero() -> Commitment {
|
||||
Commitment { mask: Scalar::ONE, amount: 0 }
|
||||
}
|
||||
|
||||
/// Create a new Commitment.
|
||||
pub fn new(mask: Scalar, amount: u64) -> Commitment {
|
||||
Commitment { mask, amount }
|
||||
}
|
||||
|
||||
/// Calculate the Pedersen commitment, as a point, from this transparent structure.
|
||||
pub fn calculate(&self) -> EdwardsPoint {
|
||||
EdwardsPoint::vartime_double_scalar_mul_basepoint(&Scalar::from(self.amount), &H(), &self.mask)
|
||||
}
|
||||
|
||||
/// Write the Commitment.
|
||||
///
|
||||
/// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
|
||||
/// defined serialization.
|
||||
pub fn write<W: io::Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
w.write_all(&self.mask.to_bytes())?;
|
||||
w.write_all(&self.amount.to_le_bytes())
|
||||
}
|
||||
|
||||
/// Serialize the Commitment to a `Vec<u8>`.
|
||||
///
|
||||
/// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
|
||||
/// defined serialization.
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
let mut res = Vec::with_capacity(32 + 8);
|
||||
self.write(&mut res).unwrap();
|
||||
res
|
||||
}
|
||||
|
||||
/// Read a Commitment.
|
||||
///
|
||||
/// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
|
||||
/// defined serialization.
|
||||
pub fn read<R: io::Read>(r: &mut R) -> io::Result<Commitment> {
|
||||
Ok(Commitment::new(read_scalar(r)?, read_u64(r)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoy data, as used for producing Monero's ring signatures.
|
||||
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct Decoys {
|
||||
offsets: Vec<u64>,
|
||||
signer_index: u8,
|
||||
ring: Vec<[EdwardsPoint; 2]>,
|
||||
}
|
||||
|
||||
impl core::fmt::Debug for Decoys {
|
||||
fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> {
|
||||
fmt
|
||||
.debug_struct("Decoys")
|
||||
.field("offsets", &self.offsets)
|
||||
.field("ring", &self.ring)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
impl Decoys {
|
||||
/// Create a new instance of decoy data.
|
||||
///
|
||||
/// `offsets` are the positions of each ring member within the Monero blockchain, offset from the
|
||||
/// prior member's position (with the initial ring member offset from 0).
|
||||
pub fn new(offsets: Vec<u64>, signer_index: u8, ring: Vec<[EdwardsPoint; 2]>) -> Option<Self> {
|
||||
if (offsets.len() != ring.len()) || (usize::from(signer_index) >= ring.len()) {
|
||||
None?;
|
||||
}
|
||||
Some(Decoys { offsets, signer_index, ring })
|
||||
}
|
||||
|
||||
/// The length of the ring.
|
||||
pub fn len(&self) -> usize {
|
||||
self.offsets.len()
|
||||
}
|
||||
|
||||
/// The positions of the ring members within the Monero blockchain, as their offsets.
|
||||
///
|
||||
/// The list is formatted as the position of the first ring member, then the offset from each
|
||||
/// ring member to its prior.
|
||||
pub fn offsets(&self) -> &[u64] {
|
||||
&self.offsets
|
||||
}
|
||||
|
||||
/// The positions of the ring members within the Monero blockchain.
|
||||
pub fn positions(&self) -> Vec<u64> {
|
||||
let mut res = Vec::with_capacity(self.len());
|
||||
res.push(self.offsets[0]);
|
||||
for m in 1 .. self.len() {
|
||||
res.push(res[m - 1] + self.offsets[m]);
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// The index of the signer within the ring.
|
||||
pub fn signer_index(&self) -> u8 {
|
||||
self.signer_index
|
||||
}
|
||||
|
||||
/// The ring.
|
||||
pub fn ring(&self) -> &[[EdwardsPoint; 2]] {
|
||||
&self.ring
|
||||
}
|
||||
|
||||
/// The [key, commitment] pair of the signer.
|
||||
pub fn signer_ring_members(&self) -> [EdwardsPoint; 2] {
|
||||
self.ring[usize::from(self.signer_index)]
|
||||
}
|
||||
|
||||
/// Write the Decoys.
|
||||
///
|
||||
/// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
|
||||
/// defined serialization.
|
||||
pub fn write(&self, w: &mut impl io::Write) -> io::Result<()> {
|
||||
write_vec(write_varint, &self.offsets, w)?;
|
||||
w.write_all(&[self.signer_index])?;
|
||||
write_vec(
|
||||
|pair, w| {
|
||||
write_point(&pair[0], w)?;
|
||||
write_point(&pair[1], w)
|
||||
},
|
||||
&self.ring,
|
||||
w,
|
||||
)
|
||||
}
|
||||
|
||||
/// Serialize the Decoys to a `Vec<u8>`.
|
||||
///
|
||||
/// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
|
||||
/// defined serialization.
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
let mut res =
|
||||
Vec::with_capacity((1 + (2 * self.offsets.len())) + 1 + 1 + (self.ring.len() * 64));
|
||||
self.write(&mut res).unwrap();
|
||||
res
|
||||
}
|
||||
|
||||
/// Read a set of Decoys.
|
||||
///
|
||||
/// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
|
||||
/// defined serialization.
|
||||
pub fn read(r: &mut impl io::Read) -> io::Result<Decoys> {
|
||||
Decoys::new(
|
||||
read_vec(read_varint, r)?,
|
||||
read_byte(r)?,
|
||||
read_vec(|r| Ok([read_point(r)?, read_point(r)?]), r)?,
|
||||
)
|
||||
.ok_or_else(|| io::Error::other("invalid Decoys"))
|
||||
}
|
||||
}
|
||||
32
networks/monero/primitives/src/tests.rs
Normal file
32
networks/monero/primitives/src/tests.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use curve25519_dalek::scalar::Scalar;
|
||||
|
||||
use crate::UnreducedScalar;
|
||||
|
||||
#[test]
|
||||
fn recover_scalars() {
|
||||
let test_recover = |stored: &str, recovered: &str| {
|
||||
let stored = UnreducedScalar(hex::decode(stored).unwrap().try_into().unwrap());
|
||||
let recovered =
|
||||
Scalar::from_canonical_bytes(hex::decode(recovered).unwrap().try_into().unwrap()).unwrap();
|
||||
assert_eq!(stored.recover_monero_slide_scalar(), recovered);
|
||||
};
|
||||
|
||||
// https://www.moneroinflation.com/static/data_py/report_scalars_df.pdf
|
||||
// Table 4.
|
||||
test_recover(
|
||||
"cb2be144948166d0a9edb831ea586da0c376efa217871505ad77f6ff80f203f8",
|
||||
"b8ffd6a1aee47828808ab0d4c8524cb5c376efa217871505ad77f6ff80f20308",
|
||||
);
|
||||
test_recover(
|
||||
"343d3df8a1051c15a400649c423dc4ed58bef49c50caef6ca4a618b80dee22f4",
|
||||
"21113355bc682e6d7a9d5b3f2137a30259bef49c50caef6ca4a618b80dee2204",
|
||||
);
|
||||
test_recover(
|
||||
"c14f75d612800ca2c1dcfa387a42c9cc086c005bc94b18d204dd61342418eba7",
|
||||
"4f473804b1d27ab2c789c80ab21d034a096c005bc94b18d204dd61342418eb07",
|
||||
);
|
||||
test_recover(
|
||||
"000102030405060708090a0b0c0d0e0f826c4f6e2329a31bc5bc320af0b2bcbb",
|
||||
"a124cfd387f461bf3719e03965ee6877826c4f6e2329a31bc5bc320af0b2bc0b",
|
||||
);
|
||||
}
|
||||
143
networks/monero/primitives/src/unreduced_scalar.rs
Normal file
143
networks/monero/primitives/src/unreduced_scalar.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use core::cmp::Ordering;
|
||||
use std_shims::{
|
||||
sync::OnceLock,
|
||||
io::{self, *},
|
||||
};
|
||||
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use curve25519_dalek::scalar::Scalar;
|
||||
|
||||
use monero_io::*;
|
||||
|
||||
static PRECOMPUTED_SCALARS_CELL: OnceLock<[Scalar; 8]> = OnceLock::new();
|
||||
// Precomputed scalars used to recover an incorrectly reduced scalar.
|
||||
#[allow(non_snake_case)]
|
||||
fn PRECOMPUTED_SCALARS() -> [Scalar; 8] {
|
||||
*PRECOMPUTED_SCALARS_CELL.get_or_init(|| {
|
||||
let mut precomputed_scalars = [Scalar::ONE; 8];
|
||||
for (i, scalar) in precomputed_scalars.iter_mut().enumerate().skip(1) {
|
||||
*scalar = Scalar::from(u8::try_from((i * 2) + 1).unwrap());
|
||||
}
|
||||
precomputed_scalars
|
||||
})
|
||||
}
|
||||
|
||||
/// An unreduced scalar.
|
||||
///
|
||||
/// While most of modern Monero enforces scalars be reduced, certain legacy parts of the code did
|
||||
/// not. These section can generally simply be read as a scalar/reduced into a scalar when the time
|
||||
/// comes, yet a couple have non-standard reductions performed.
|
||||
///
|
||||
/// This struct delays scalar conversions and offers the non-standard reduction.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Zeroize)]
|
||||
pub struct UnreducedScalar(pub [u8; 32]);
|
||||
|
||||
impl UnreducedScalar {
|
||||
/// Write an UnreducedScalar.
|
||||
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
w.write_all(&self.0)
|
||||
}
|
||||
|
||||
/// Read an UnreducedScalar.
|
||||
pub fn read<R: Read>(r: &mut R) -> io::Result<UnreducedScalar> {
|
||||
Ok(UnreducedScalar(read_bytes(r)?))
|
||||
}
|
||||
|
||||
fn as_bits(&self) -> [u8; 256] {
|
||||
let mut bits = [0; 256];
|
||||
for (i, bit) in bits.iter_mut().enumerate() {
|
||||
*bit = core::hint::black_box(1 & (self.0[i / 8] >> (i % 8)))
|
||||
}
|
||||
|
||||
bits
|
||||
}
|
||||
|
||||
// Computes the non-adjacent form of this scalar with width 5.
|
||||
//
|
||||
// This matches Monero's `slide` function and intentionally gives incorrect outputs under
|
||||
// certain conditions in order to match Monero.
|
||||
//
|
||||
// This function does not execute in constant time.
|
||||
fn non_adjacent_form(&self) -> [i8; 256] {
|
||||
let bits = self.as_bits();
|
||||
let mut naf = [0i8; 256];
|
||||
for (b, bit) in bits.into_iter().enumerate() {
|
||||
naf[b] = i8::try_from(bit).unwrap();
|
||||
}
|
||||
|
||||
for i in 0 .. 256 {
|
||||
if naf[i] != 0 {
|
||||
// if the bit is a one, work our way up through the window
|
||||
// combining the bits with this bit.
|
||||
for b in 1 .. 6 {
|
||||
if (i + b) >= 256 {
|
||||
// if we are at the length of the array then break out
|
||||
// the loop.
|
||||
break;
|
||||
}
|
||||
// potential_carry - the value of the bit at i+b compared to the bit at i
|
||||
let potential_carry = naf[i + b] << b;
|
||||
|
||||
if potential_carry != 0 {
|
||||
if (naf[i] + potential_carry) <= 15 {
|
||||
// if our current "bit" plus the potential carry is less than 16
|
||||
// add it to our current "bit" and set the potential carry bit to 0.
|
||||
naf[i] += potential_carry;
|
||||
naf[i + b] = 0;
|
||||
} else if (naf[i] - potential_carry) >= -15 {
|
||||
// else if our current "bit" minus the potential carry is more than -16
|
||||
// take it away from our current "bit".
|
||||
// we then work our way up through the bits setting ones to zero, when
|
||||
// we hit the first zero we change it to one then stop, this is to factor
|
||||
// in the minus.
|
||||
naf[i] -= potential_carry;
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for k in (i + b) .. 256 {
|
||||
if naf[k] == 0 {
|
||||
naf[k] = 1;
|
||||
break;
|
||||
}
|
||||
naf[k] = 0;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
naf
|
||||
}
|
||||
|
||||
/// Recover the scalar that an array of bytes was incorrectly interpreted as by Monero's `slide`
|
||||
/// function.
|
||||
///
|
||||
/// In Borromean range proofs, Monero was not checking that the scalars used were
|
||||
/// reduced. This lead to the scalar stored being interpreted as a different scalar.
|
||||
/// This function recovers that scalar.
|
||||
///
|
||||
/// See <https://github.com/monero-project/monero/issues/8438> for more info.
|
||||
pub fn recover_monero_slide_scalar(&self) -> Scalar {
|
||||
if self.0[31] & 128 == 0 {
|
||||
// Computing the w-NAF of a number can only give an output with 1 more bit than
|
||||
// the number, so even if the number isn't reduced, the `slide` function will be
|
||||
// correct when the last bit isn't set.
|
||||
return Scalar::from_bytes_mod_order(self.0);
|
||||
}
|
||||
|
||||
let precomputed_scalars = PRECOMPUTED_SCALARS();
|
||||
|
||||
let mut recovered = Scalar::ZERO;
|
||||
for &numb in self.non_adjacent_form().iter().rev() {
|
||||
recovered += recovered;
|
||||
match numb.cmp(&0) {
|
||||
Ordering::Greater => recovered += precomputed_scalars[usize::try_from(numb).unwrap() / 2],
|
||||
Ordering::Less => recovered -= precomputed_scalars[usize::try_from(-numb).unwrap() / 2],
|
||||
Ordering::Equal => (),
|
||||
}
|
||||
}
|
||||
recovered
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user