bless
This commit is contained in:
parent
5b9834519d
commit
891f93de6b
@ -44,7 +44,7 @@ pub enum CliCommand {
|
||||
|
||||
/// Make a Transaction
|
||||
#[command(name = "tx")]
|
||||
Transaction(core::Tx),
|
||||
Transaction(core::Transaction),
|
||||
|
||||
/// Start new TcpListner on Addr
|
||||
#[command(name = "listen")]
|
||||
|
||||
@ -29,7 +29,7 @@ pub enum BlockchainError {
|
||||
InvalidAccountCreation,
|
||||
|
||||
#[error("Transactional error")]
|
||||
Tx(#[from] shared::core::TxError),
|
||||
Transaction(#[from] shared::core::TransactionError),
|
||||
|
||||
#[error("Validation Error")]
|
||||
Validation(#[from] ValidationError),
|
||||
@ -109,7 +109,7 @@ impl Blockchain {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_transaction(&mut self, tx: &core::Tx) -> Result<(), BlockchainError> {
|
||||
fn apply_transaction(&mut self, tx: &core::Transaction) -> Result<(), BlockchainError> {
|
||||
tx.validate()?;
|
||||
let from = tx.from();
|
||||
let to = tx.to();
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use bincode::{Decode, Encode};
|
||||
|
||||
use super::Tx;
|
||||
use super::Transaction;
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Encode, Decode, Debug, Clone)]
|
||||
pub enum ChainData {
|
||||
Transaction(Tx),
|
||||
Transaction(Transaction),
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ use super::{BlockHeader, ChainData};
|
||||
pub struct Hasher {}
|
||||
|
||||
impl Hasher {
|
||||
pub fn hash_chain_data(data: &ChainData) -> String {
|
||||
pub fn hash_chain_data(data: &ChainData) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
match data {
|
||||
ChainData::Transaction(tx) => {
|
||||
@ -17,7 +17,7 @@ impl Hasher {
|
||||
}
|
||||
}
|
||||
let res = hasher.finalize();
|
||||
hex::encode(res)
|
||||
res.into()
|
||||
}
|
||||
|
||||
pub fn calculate_next_level(level: &[String]) -> Vec<String> {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
use super::Address;
|
||||
use crate::log;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
@ -11,9 +10,37 @@ use thiserror::Error;
|
||||
//use ring::digest;
|
||||
//
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AddressParser {}
|
||||
|
||||
impl clap::builder::TypedValueParser for AddressParser {
|
||||
type Value = [u8; 20];
|
||||
|
||||
fn parse_ref(
|
||||
&self,
|
||||
_cmd: &clap::Command,
|
||||
_arg: Option<&clap::Arg>,
|
||||
value: &std::ffi::OsStr,
|
||||
) -> Result<Self::Value, clap::Error> {
|
||||
let str = value.to_str().ok_or_else(|| {
|
||||
clap::Error::new(clap::error::ErrorKind::InvalidValue)
|
||||
})?;
|
||||
|
||||
let stripped_value = str.strip_prefix("0x").unwrap_or(str);
|
||||
let bytes = hex::decode(stripped_value).map_err(|_| {
|
||||
clap::Error::new(clap::error::ErrorKind::InvalidValue)
|
||||
})?;
|
||||
|
||||
let mut addr = [0u8; 20];
|
||||
|
||||
addr.copy_from_slice(&bytes[..12]);
|
||||
Ok(addr)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Error, Debug)]
|
||||
pub enum TxError {
|
||||
pub enum TransactionError {
|
||||
#[error("from field is empty")]
|
||||
FromEmpty,
|
||||
#[error("to field is empty")]
|
||||
@ -29,30 +56,47 @@ pub enum TxError {
|
||||
#[derive(
|
||||
serde::Deserialize, serde::Serialize, Debug, clap::Args, Clone, bincode::Encode, bincode::Decode,
|
||||
)]
|
||||
pub struct Tx {
|
||||
pub struct Transaction {
|
||||
#[clap(value_parser = AddressParser {})]
|
||||
from: Address,
|
||||
#[clap(value_parser = AddressParser {})]
|
||||
to: Address,
|
||||
value: u64,
|
||||
data: String,
|
||||
nonce: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignedTransaction {
|
||||
tx: Tx,
|
||||
signature: [u8; 32],
|
||||
tx: Transaction,
|
||||
signature: [u8; 64],
|
||||
recovery_id: u8,
|
||||
}
|
||||
|
||||
/// Takes a [u8; 32] as arg for signature
|
||||
impl SignedTransaction {
|
||||
pub fn new(tx: Tx, signature: [u8; 32]) -> Self {
|
||||
pub fn new(tx: Transaction, signature: [u8; 64], recovery_id: u8) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
signature
|
||||
signature,
|
||||
recovery_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn signature(&self) -> &[u8] {
|
||||
&self.signature
|
||||
}
|
||||
|
||||
pub fn recovery_id(&self) -> u8 {
|
||||
self.recovery_id
|
||||
}
|
||||
|
||||
pub fn tx(&self) -> &Transaction {
|
||||
&self.tx
|
||||
}
|
||||
}
|
||||
|
||||
impl Tx {
|
||||
impl Transaction {
|
||||
pub fn new(from: Address, to: Address, value: u64, nonce: u64, data: String) -> Self {
|
||||
Self {
|
||||
from,
|
||||
@ -63,30 +107,33 @@ impl Tx {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), TxError> {
|
||||
pub fn validate(&self) -> Result<(), TransactionError> {
|
||||
if self.from.is_empty() {
|
||||
return Err(TxError::FromEmpty);
|
||||
return Err(TransactionError::FromEmpty);
|
||||
} else if self.to.is_empty() {
|
||||
return Err(TxError::ToEmpty);
|
||||
return Err(TransactionError::ToEmpty);
|
||||
} else if self.value == 0 {
|
||||
return Err(TxError::ValueEmpty);
|
||||
return Err(TransactionError::ValueEmpty);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn hash(&self) -> String {
|
||||
pub fn hash(&self) -> [u8; 32] {
|
||||
super::Hasher::hash_chain_data(&super::ChainData::Transaction(self.clone()))
|
||||
}
|
||||
|
||||
pub fn from(&self) -> &Address {
|
||||
&self.from
|
||||
}
|
||||
|
||||
pub fn to(&self) -> &Address {
|
||||
&self.to
|
||||
}
|
||||
|
||||
pub fn value(&self) -> u64 {
|
||||
self.value
|
||||
}
|
||||
|
||||
pub fn data(&self) -> &str {
|
||||
&self.data
|
||||
}
|
||||
|
||||
1203
testing/keys/Cargo.lock
generated
1203
testing/keys/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,9 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
age = { version = "0.11.1", features = ["cli-common"] }
|
||||
bincode = { version = "2.0.1", features = ["serde"] }
|
||||
pkcs8 = { version = "0.10.2", features = ["encryption", "std", "alloc", "pkcs5"] }
|
||||
rand = { version = "0.9.2", features = ["thread_rng"] }
|
||||
secp256k1 = { version = "0.31.1", features = ["hashes", "rand", "serde"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
|
||||
5
testing/keys/crypt.age
Normal file
5
testing/keys/crypt.age
Normal file
@ -0,0 +1,5 @@
|
||||
age-encryption.org/v1
|
||||
-> scrypt FP9Jzf9WMGgQj2HZPAbuDw 14
|
||||
i+HlcYKckQXUKAtoY8SIjJUz15IE2GucgQM0sZxLx78
|
||||
--- 5JVy3rsJpvCXTl41B6/k/aC0HqoAdyfH4I6efffwr/w
|
||||
-éÛÂ|Q÷ÚúÇ‘-D‰3´<%’¥|~<7E>¨˜<>韥<C5B8>)êÀÂ"ŠÈ+ùñá(Ýþ°}<7D>ÂéëKüv”É<>'#<23>ƒ°S<>tUuîX,’ù|‡ ïŒÝZ>CËõÐ
šó•Ú:Y”#úÔ:Á<>×Ô•Þã LNM¼ô“Ë‚ìÕÖ*-´¸èôãHnDˆ²£ÑU«O›cÚoùJ×6Šþ£^å³c¸òŽý«¦X˜0ؼp¸–K\’š—ê~âuX|Ì; |ßÎ7D¸MÄD‡;_Ÿlõ`’K†^Ž+ìAË
|
||||
6
testing/keys/out.crypt
Normal file
6
testing/keys/out.crypt
Normal file
@ -0,0 +1,6 @@
|
||||
age-encryption.org/v1
|
||||
-> scrypt FEp9+A7Sl9E295JnlNQDaQ 14
|
||||
i9VIye4v87oaN/J/Wgp+9r8PCoqrj1230s4hobeYFn0
|
||||
--- TYrKOs0QpxkYvblubIUXh3mkgpo8Fs0P7yI3SWl0Dik
|
||||
|
||||
J/—*•ÓPÍ>Äå¨KßýWÓ<1B>@û4£c*~—C.糉‡J„(õNÀ
|
||||
@ -1,59 +1,74 @@
|
||||
use pkcs8::der::Decode;
|
||||
use pkcs8::{EncryptedPrivateKeyInfo, PrivateKeyInfo};
|
||||
use pkcs8::pkcs5::scrypt::Params;
|
||||
use secp256k1::{Secp256k1, SecretKey};
|
||||
use rand::{rng, RngCore};
|
||||
//
|
||||
// OID for secp256k1 curve
|
||||
const SECP256K1_OID: pkcs8::ObjectIdentifier = pkcs8::ObjectIdentifier::new_unwrap("1.3.132.0.10");
|
||||
|
||||
fn generate_salt() -> [u8; 16] {
|
||||
let mut salt = [0u8; 16];
|
||||
rng().fill_bytes(&mut salt);
|
||||
salt
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let secp = Secp256k1::new();
|
||||
let (secret_key, public_key) = secp.generate_keypair(&mut rand::rng());
|
||||
|
||||
let password = "test";
|
||||
|
||||
let secret_bytes = &secret_key.secret_bytes();
|
||||
let pk_info = PrivateKeyInfo::new(
|
||||
pkcs8::AlgorithmIdentifierRef {
|
||||
oid: SECP256K1_OID,
|
||||
parameters: None,
|
||||
},
|
||||
secret_bytes
|
||||
);
|
||||
|
||||
println!("private key length: {}", secret_bytes.len());
|
||||
|
||||
let salt = generate_salt();
|
||||
|
||||
let enc_priv = pk_info.encrypt_with_params(
|
||||
pkcs8::pkcs5::pbes2::Parameters::scrypt_aes256cbc(
|
||||
Params::new(15, 8, 1, secret_bytes.len()).unwrap(),
|
||||
&salt,
|
||||
&salt
|
||||
).unwrap(),
|
||||
password
|
||||
).unwrap();
|
||||
|
||||
&enc_priv.write_der_file("./out.der").unwrap();
|
||||
let buf = std::fs::read("./out.der").unwrap();
|
||||
|
||||
let enc_key = EncryptedPrivateKeyInfo::from_der(buf.as_slice()).unwrap();
|
||||
let dec_doc = enc_key.decrypt(password.as_bytes()).unwrap();
|
||||
let dec_key = PrivateKeyInfo::from_der(dec_doc.as_bytes()).unwrap();
|
||||
|
||||
dbg!(&dec_key);
|
||||
|
||||
let sec_key_bytes = dec_key.private_key;
|
||||
let mut sec_key_bytes = [0u8; 32];
|
||||
sec_key_bytes.copy_from_slice(dec_key.private_key);
|
||||
let sec_key = SecretKey::from_byte_array(sec_key_bytes);
|
||||
println!("original key: {:#?}", secret_key);
|
||||
println!("decrypted key: {:#?}", sec_key);
|
||||
fn main() {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
const OUT_FILE: &str = "crypt.age";
|
||||
|
||||
#[derive(Debug, bincode::Encode, bincode::Decode, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Wallet {
|
||||
address: String,
|
||||
balance: u64,
|
||||
nonce: u64,
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
use std::io::{ Read, Write };
|
||||
|
||||
fn encrypt() {
|
||||
let passphrase = age::secrecy::SecretString::from("password");
|
||||
|
||||
let wallet = Wallet {
|
||||
address: "My home address".to_string(),
|
||||
balance: 500,
|
||||
nonce: 3,
|
||||
private_key: "thisisprivate".to_string()
|
||||
};
|
||||
|
||||
let b = bincode::serde::encode_to_vec::<Wallet, bincode::config::Configuration>(wallet, bincode::config::Configuration::default()).unwrap();
|
||||
|
||||
let encryptor = age::Encryptor::with_user_passphrase(passphrase);
|
||||
let file = std::fs::OpenOptions::new().create(true).write(true).open(OUT_FILE).unwrap();
|
||||
let mut stream = encryptor.wrap_output(file).unwrap();
|
||||
stream.write_all(&b).unwrap();
|
||||
stream.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt() {
|
||||
let passphrase = age::secrecy::SecretString::from("password");
|
||||
let identity = age::scrypt::Identity::new(passphrase);
|
||||
|
||||
let file = std::fs::OpenOptions::new().read(true).open(OUT_FILE).unwrap();
|
||||
|
||||
let decryptor = age::Decryptor::new(file).unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
|
||||
decryptor.decrypt(std::iter::once(&identity as &dyn age::Identity)).unwrap().read_to_end(&mut buf).unwrap();
|
||||
|
||||
let (wallet, _): (Wallet, usize) = bincode::serde::decode_from_slice::<Wallet, bincode::config::Configuration>(&buf, bincode::config::Configuration::default()).unwrap();
|
||||
|
||||
dbg!(&wallet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_decrypt() {
|
||||
let passphrase = age::cli_common::read_secret("Unlock you file", "Please Enter you passphrase", Some("confirm?")).unwrap();
|
||||
let identity = age::scrypt::Identity::new(passphrase);
|
||||
|
||||
let file = std::fs::OpenOptions::new().create(true).read(true).open("crypt.age").unwrap();
|
||||
|
||||
let decryptor = age::Decryptor::new(file).unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
|
||||
decryptor.decrypt(std::iter::once(&identity as &dyn age::Identity)).unwrap().read_to_end(&mut buf).unwrap();
|
||||
|
||||
let (b, _) = bincode::decode_from_slice::<Vec<u8>, bincode::config::Configuration>(&buf, bincode::config::Configuration::default()).unwrap();
|
||||
|
||||
let decrypt = age::decrypt(&identity, &b).unwrap();
|
||||
|
||||
assert_eq!(b"Hello World", decrypt.as_slice());
|
||||
}
|
||||
}
|
||||
|
||||
1121
wallet/Cargo.lock
generated
1121
wallet/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,9 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
age = { version = "0.11.1", features = ["cli-common"] }
|
||||
hex = "0.4.3"
|
||||
k256 = { version = "0.13.4", features = ["serde"] }
|
||||
sha3 = "0.10.8"
|
||||
shared = { path = "../shared" }
|
||||
thiserror = "2.0.16"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use k256::sha2::{Sha256, Digest};
|
||||
use k256::sha2::Digest;
|
||||
use k256::ecdsa::{
|
||||
self,
|
||||
SigningKey,
|
||||
@ -6,38 +6,70 @@ use k256::ecdsa::{
|
||||
RecoveryId,
|
||||
Signature,
|
||||
};
|
||||
use shared::core::Address;
|
||||
use shared::core::{ Transaction, SignedTransaction, Address, };
|
||||
use k256::elliptic_curve::rand_core::OsRng;
|
||||
use sha3::Keccak256;
|
||||
|
||||
use shared::core::{ Tx, SignedTransaction };
|
||||
use std::path;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WalletError {
|
||||
#[error("No Private Key present in Wallet")]
|
||||
NoPrivateKeyProvided,
|
||||
|
||||
#[error("Signature error: {0}")]
|
||||
SignatureError(#[from] ecdsa::Error),
|
||||
|
||||
#[error("Provided Recovery ID is invalid: {0}")]
|
||||
InvalidRecoveryId(u8),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
IO(#[from] std::io::Error),
|
||||
|
||||
#[error("")]
|
||||
InvalidHashLength
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Wallet {
|
||||
address: Address,
|
||||
balance: u64,
|
||||
nonce: u64,
|
||||
private_key: SigningKey,
|
||||
}
|
||||
|
||||
fn sign_key_test() -> Result<(Signature, RecoveryId), ecdsa::Error> {
|
||||
let signing_key = SigningKey::random(&mut OsRng);
|
||||
|
||||
let message = b"ECDSA message";
|
||||
|
||||
let hash = Keccak256::digest(message);
|
||||
signing_key.sign_prehash_recoverable(&hash)
|
||||
}
|
||||
|
||||
fn verify_message(
|
||||
message: &[u8; 32],
|
||||
signature: &Signature,
|
||||
recovery_id: &RecoveryId
|
||||
) -> Result<VerifyingKey, ecdsa::Error>{
|
||||
VerifyingKey::recover_from_prehash(message, signature, *recovery_id)
|
||||
private_key: Option<SigningKey>,
|
||||
}
|
||||
|
||||
impl Wallet {
|
||||
|
||||
fn verify_signature(tx: &SignedTransaction) -> Result<VerifyingKey, WalletError>{
|
||||
if let Some(rec_id) = RecoveryId::from_byte(tx.recovery_id()) {
|
||||
let sig = Signature::from_slice(tx.signature())?;
|
||||
let hash = tx.tx().hash();
|
||||
Ok(VerifyingKey::recover_from_prehash(&hash, &sig, rec_id)?)
|
||||
} else {
|
||||
Err(WalletError::InvalidRecoveryId(tx.recovery_id()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_private_key() -> SigningKey {
|
||||
SigningKey::random(&mut OsRng)
|
||||
}
|
||||
|
||||
pub fn address(&self) -> &[u8] {
|
||||
&self.address
|
||||
}
|
||||
|
||||
pub fn nonce(&self) -> u64 {
|
||||
self.nonce
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> Result<VerifyingKey, WalletError> {
|
||||
if let Some(pk) = &self.private_key {
|
||||
Ok(*pk.verifying_key())
|
||||
} else {
|
||||
Err(WalletError::NoPrivateKeyProvided)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verifying_key_to_address(private_key: &SigningKey) -> Address {
|
||||
let public_key = private_key.verifying_key();
|
||||
let public_key_bytes = public_key.to_encoded_point(false);
|
||||
@ -45,32 +77,85 @@ impl Wallet {
|
||||
|
||||
let hash = Keccak256::digest(&public_key_bytes[1..]);
|
||||
|
||||
let mut address: Address;
|
||||
let mut address: Address = [0; 20];
|
||||
address.copy_from_slice(&hash[12..]);
|
||||
address
|
||||
}
|
||||
|
||||
pub fn address_from_pubkey(key: VerifyingKey) -> Address {
|
||||
let addr = key.to_encoded_point(true);
|
||||
fn load(path: path::PathBuf) -> Result<Self, WalletError> {
|
||||
let content = std::fs::read(path)?;
|
||||
}
|
||||
|
||||
fn new() -> Self {
|
||||
let key = SigningKey::random(&mut OsRng);
|
||||
let address = Self::verifying_key_to_address(&key);
|
||||
fn new(pk: Option<SigningKey>) -> Self {
|
||||
let address = if let Some(pk) = &pk {
|
||||
Self::verifying_key_to_address(pk)
|
||||
} else {
|
||||
Address::default()
|
||||
};
|
||||
Self {
|
||||
nonce: 0,
|
||||
balance: 0,
|
||||
address,
|
||||
private_key: key
|
||||
private_key: pk
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sign(&self, transaction: Tx) -> SignedTransaction {
|
||||
pub fn sign(&self, transaction: Transaction) -> Result<SignedTransaction, WalletError> {
|
||||
let hash = transaction.hash();
|
||||
if let Some(pk) = &self.private_key {
|
||||
let (signature, recovery_id) = pk.sign_prehash_recoverable(&hash)?;
|
||||
Ok(SignedTransaction::new(
|
||||
transaction,
|
||||
signature.to_bytes().into(),
|
||||
recovery_id.into()
|
||||
))
|
||||
} else {
|
||||
Err(WalletError::NoPrivateKeyProvided)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acc_new() {
|
||||
Wallet::new();
|
||||
let (k, r) = sign_key_test().unwrap();
|
||||
fn acc_new_sign_no_key() {
|
||||
let wallet = Wallet::new(None);
|
||||
let to_address: [u8; 20] = [1u8; 20];
|
||||
let mut wallet_addr = [0u8; 20];
|
||||
wallet_addr.copy_from_slice(wallet.address());
|
||||
let tx = Transaction::new(
|
||||
wallet_addr,
|
||||
to_address,
|
||||
500,
|
||||
wallet.nonce(),
|
||||
format!("")
|
||||
);
|
||||
let ret = wallet.sign(tx);
|
||||
assert!(matches!(ret, Err(WalletError::NoPrivateKeyProvided)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acc_new_sign_with_key() -> Result<(), WalletError> {
|
||||
let pk = Wallet::generate_private_key();
|
||||
let wallet = Wallet::new(Some(pk));
|
||||
let orig_public_key = wallet.public_key()?;
|
||||
let mut wallet_addr = [0u8; 20];
|
||||
let to_address = [1u8; 20];
|
||||
wallet_addr.copy_from_slice(wallet.address());
|
||||
let tx = Transaction::new(
|
||||
wallet_addr,
|
||||
to_address,
|
||||
500,
|
||||
wallet.nonce(),
|
||||
format!("")
|
||||
);
|
||||
let ret = wallet.sign(tx);
|
||||
assert!(matches!(ret, Ok(SignedTransaction{..})));
|
||||
let msg = ret.unwrap();
|
||||
let pub_key = Wallet::verify_signature(&msg)?;
|
||||
|
||||
let orig_pub_key_bytes = orig_public_key.to_encoded_point(false);
|
||||
let new_pub_key_bytes = pub_key.to_encoded_point(false);
|
||||
|
||||
assert_eq!(orig_pub_key_bytes, new_pub_key_bytes);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user