mirror of
https://github.com/mimblewimble/grin.git
synced 2025-01-21 03:21:08 +03:00
Miner querying wallet receiver for coinbase output
With the coinbase receiver daemon in place, when starting a Grin server in mining mode, the miner will now ask the receiver for a coinbase output. The output is then used to insert in a block when successfully mined.
This commit is contained in:
parent
f45cfe97f2
commit
791d2355ee
8 changed files with 114 additions and 29 deletions
|
@ -228,6 +228,7 @@ impl Default for Block {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Block {
|
impl Block {
|
||||||
|
|
||||||
/// Builds a new block from the header of the previous block, a vector of
|
/// Builds a new block from the header of the previous block, a vector of
|
||||||
/// transactions and the private key that will receive the reward. Checks
|
/// transactions and the private key that will receive the reward. Checks
|
||||||
/// that all transactions are valid and calculates the Merkle tree.
|
/// that all transactions are valid and calculates the Merkle tree.
|
||||||
|
@ -239,12 +240,24 @@ impl Block {
|
||||||
let secp = Secp256k1::with_caps(secp::ContextFlag::Commit);
|
let secp = Secp256k1::with_caps(secp::ContextFlag::Commit);
|
||||||
let (reward_out, reward_proof) = try!(Block::reward_output(reward_key, &secp));
|
let (reward_out, reward_proof) = try!(Block::reward_output(reward_key, &secp));
|
||||||
|
|
||||||
|
Block::with_reward(prev, txs, reward_out, reward_proof)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a new block ready to mine from the header of the previous block,
|
||||||
|
/// a vector of transactions and the reward information. Checks
|
||||||
|
/// that all transactions are valid and calculates the Merkle tree.
|
||||||
|
pub fn with_reward(prev: &BlockHeader,
|
||||||
|
txs: Vec<&mut Transaction>,
|
||||||
|
reward_out: Output,
|
||||||
|
reward_kern: TxKernel)
|
||||||
|
-> Result<Block, secp::Error> {
|
||||||
// note: the following reads easily but may not be the most efficient due to
|
// note: the following reads easily but may not be the most efficient due to
|
||||||
// repeated iterations, revisit if a problem
|
// repeated iterations, revisit if a problem
|
||||||
|
let secp = Secp256k1::with_caps(secp::ContextFlag::Commit);
|
||||||
|
|
||||||
// validate each transaction and gather their kernels
|
// validate each transaction and gather their kernels
|
||||||
let mut kernels = try_map_vec!(txs, |tx| tx.verify_sig(&secp));
|
let mut kernels = try_map_vec!(txs, |tx| tx.verify_sig(&secp));
|
||||||
kernels.push(reward_proof);
|
kernels.push(reward_kern);
|
||||||
|
|
||||||
// build vectors with all inputs and all outputs, ordering them by hash
|
// build vectors with all inputs and all outputs, ordering them by hash
|
||||||
// needs to be a fold so we don't end up with a vector of vectors and we
|
// needs to be a fold so we don't end up with a vector of vectors and we
|
||||||
|
@ -284,6 +297,7 @@ impl Block {
|
||||||
.compact())
|
.compact())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Blockhash, computed using only the header
|
/// Blockhash, computed using only the header
|
||||||
pub fn hash(&self) -> Hash {
|
pub fn hash(&self) -> Hash {
|
||||||
self.header.hash()
|
self.header.hash()
|
||||||
|
|
|
@ -20,14 +20,19 @@ use std::sync::{Arc, Mutex};
|
||||||
use time;
|
use time;
|
||||||
|
|
||||||
use adapters::ChainToNetAdapter;
|
use adapters::ChainToNetAdapter;
|
||||||
|
use api;
|
||||||
use core::consensus;
|
use core::consensus;
|
||||||
use core::core;
|
use core::core;
|
||||||
use core::core::hash::{Hash, Hashed};
|
use core::core::hash::{Hash, Hashed};
|
||||||
use core::pow::cuckoo;
|
use core::pow::cuckoo;
|
||||||
|
use core::ser;
|
||||||
use chain;
|
use chain;
|
||||||
use secp;
|
use secp;
|
||||||
|
use types::{MinerConfig, Error};
|
||||||
|
use util;
|
||||||
|
|
||||||
pub struct Miner {
|
pub struct Miner {
|
||||||
|
config: MinerConfig,
|
||||||
chain_head: Arc<Mutex<chain::Tip>>,
|
chain_head: Arc<Mutex<chain::Tip>>,
|
||||||
chain_store: Arc<chain::ChainStore>,
|
chain_store: Arc<chain::ChainStore>,
|
||||||
/// chain adapter to net
|
/// chain adapter to net
|
||||||
|
@ -37,11 +42,13 @@ pub struct Miner {
|
||||||
impl Miner {
|
impl Miner {
|
||||||
/// Creates a new Miner. Needs references to the chain state and its
|
/// Creates a new Miner. Needs references to the chain state and its
|
||||||
/// storage.
|
/// storage.
|
||||||
pub fn new(chain_head: Arc<Mutex<chain::Tip>>,
|
pub fn new(config: MinerConfig,
|
||||||
|
chain_head: Arc<Mutex<chain::Tip>>,
|
||||||
chain_store: Arc<chain::ChainStore>,
|
chain_store: Arc<chain::ChainStore>,
|
||||||
chain_adapter: Arc<ChainToNetAdapter>)
|
chain_adapter: Arc<ChainToNetAdapter>)
|
||||||
-> Miner {
|
-> Miner {
|
||||||
Miner {
|
Miner {
|
||||||
|
config: config,
|
||||||
chain_head: chain_head,
|
chain_head: chain_head,
|
||||||
chain_store: chain_store,
|
chain_store: chain_store,
|
||||||
chain_adapter: chain_adapter,
|
chain_adapter: chain_adapter,
|
||||||
|
@ -52,6 +59,7 @@ impl Miner {
|
||||||
/// chain anytime required and looking for PoW solution.
|
/// chain anytime required and looking for PoW solution.
|
||||||
pub fn run_loop(&self) {
|
pub fn run_loop(&self) {
|
||||||
info!("Starting miner loop.");
|
info!("Starting miner loop.");
|
||||||
|
let mut coinbase = self.get_coinbase();
|
||||||
loop {
|
loop {
|
||||||
// get the latest chain state and build a block on top of it
|
// get the latest chain state and build a block on top of it
|
||||||
let head: core::BlockHeader;
|
let head: core::BlockHeader;
|
||||||
|
@ -60,7 +68,7 @@ impl Miner {
|
||||||
head = self.chain_store.head_header().unwrap();
|
head = self.chain_store.head_header().unwrap();
|
||||||
latest_hash = self.chain_head.lock().unwrap().last_block_h;
|
latest_hash = self.chain_head.lock().unwrap().last_block_h;
|
||||||
}
|
}
|
||||||
let mut b = self.build_block(&head);
|
let mut b = self.build_block(&head, coinbase.clone());
|
||||||
|
|
||||||
// look for a pow for at most 2 sec on the same block (to give a chance to new
|
// look for a pow for at most 2 sec on the same block (to give a chance to new
|
||||||
// transactions) and as long as the head hasn't changed
|
// transactions) and as long as the head hasn't changed
|
||||||
|
@ -101,6 +109,7 @@ impl Miner {
|
||||||
} else if let Ok(Some(tip)) = res {
|
} else if let Ok(Some(tip)) = res {
|
||||||
let chain_head = self.chain_head.clone();
|
let chain_head = self.chain_head.clone();
|
||||||
let mut head = chain_head.lock().unwrap();
|
let mut head = chain_head.lock().unwrap();
|
||||||
|
coinbase = self.get_coinbase();
|
||||||
*head = tip;
|
*head = tip;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -112,7 +121,7 @@ impl Miner {
|
||||||
|
|
||||||
/// Builds a new block with the chain head as previous and eligible
|
/// Builds a new block with the chain head as previous and eligible
|
||||||
/// transactions from the pool.
|
/// transactions from the pool.
|
||||||
fn build_block(&self, head: &core::BlockHeader) -> core::Block {
|
fn build_block(&self, head: &core::BlockHeader, coinbase: (core::Output, core::TxKernel)) -> core::Block {
|
||||||
let mut now_sec = time::get_time().sec;
|
let mut now_sec = time::get_time().sec;
|
||||||
let head_sec = head.timestamp.to_timespec().sec;
|
let head_sec = head.timestamp.to_timespec().sec;
|
||||||
if now_sec == head_sec {
|
if now_sec == head_sec {
|
||||||
|
@ -121,17 +130,45 @@ impl Miner {
|
||||||
let (difficulty, cuckoo_len) =
|
let (difficulty, cuckoo_len) =
|
||||||
consensus::next_target(now_sec, head_sec, head.difficulty.clone(), head.cuckoo_len);
|
consensus::next_target(now_sec, head_sec, head.difficulty.clone(), head.cuckoo_len);
|
||||||
|
|
||||||
let mut rng = rand::OsRng::new().unwrap();
|
|
||||||
let secp_inst = secp::Secp256k1::with_caps(secp::ContextFlag::Commit);
|
|
||||||
// TODO get a new key from the user's wallet or something
|
|
||||||
let skey = secp::key::SecretKey::new(&secp_inst, &mut rng);
|
|
||||||
|
|
||||||
// TODO populate inputs and outputs from pool transactions
|
// TODO populate inputs and outputs from pool transactions
|
||||||
let mut b = core::Block::new(head, vec![], skey).unwrap();
|
let (output, kernel) = coinbase;
|
||||||
|
let mut b = core::Block::with_reward(head, vec![], output, kernel).unwrap();
|
||||||
|
|
||||||
|
let mut rng = rand::OsRng::new().unwrap();
|
||||||
b.header.nonce = rng.gen();
|
b.header.nonce = rng.gen();
|
||||||
b.header.cuckoo_len = cuckoo_len;
|
b.header.cuckoo_len = cuckoo_len;
|
||||||
b.header.difficulty = difficulty;
|
b.header.difficulty = difficulty;
|
||||||
b.header.timestamp = time::at(time::Timespec::new(now_sec, 0));
|
b.header.timestamp = time::at(time::Timespec::new(now_sec, 0));
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_coinbase(&self) -> (core::Output, core::TxKernel) {
|
||||||
|
if self.config.burn_reward {
|
||||||
|
let mut rng = rand::OsRng::new().unwrap();
|
||||||
|
let secp_inst = secp::Secp256k1::with_caps(secp::ContextFlag::Commit);
|
||||||
|
let skey = secp::key::SecretKey::new(&secp_inst, &mut rng);
|
||||||
|
core::Block::reward_output(skey, &secp_inst).unwrap()
|
||||||
|
} else {
|
||||||
|
let url = format!("{}/v1/receive_coinbase", self.config.wallet_receiver_url.as_str());
|
||||||
|
let res: CbData = api::client::post(url.as_str(), &CbAmount { amount: consensus::REWARD })
|
||||||
|
.expect("Wallet receiver unreachable, could not claim reward. Is it running?");
|
||||||
|
let out_bin = util::from_hex(res.output).unwrap();
|
||||||
|
let kern_bin = util::from_hex(res.kernel).unwrap();
|
||||||
|
let output = ser::deserialize(&mut &out_bin[..]).unwrap();
|
||||||
|
let kernel = ser::deserialize(&mut &kern_bin[..]).unwrap();
|
||||||
|
|
||||||
|
(output, kernel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
struct CbAmount {
|
||||||
|
amount: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
struct CbData {
|
||||||
|
output: String,
|
||||||
|
kernel: String,
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,10 +57,10 @@ impl Server {
|
||||||
/// Instantiates and starts a new server.
|
/// Instantiates and starts a new server.
|
||||||
pub fn start(config: ServerConfig) -> Result<Server, Error> {
|
pub fn start(config: ServerConfig) -> Result<Server, Error> {
|
||||||
let mut evtlp = reactor::Core::new().unwrap();
|
let mut evtlp = reactor::Core::new().unwrap();
|
||||||
let enable_mining = config.enable_mining;
|
let mining_config = config.mining_config.clone();
|
||||||
let serv = Server::future(config, &evtlp.handle())?;
|
let serv = Server::future(config, &evtlp.handle())?;
|
||||||
if enable_mining {
|
if mining_config.enable_mining {
|
||||||
serv.start_miner();
|
serv.start_miner(mining_config);
|
||||||
}
|
}
|
||||||
|
|
||||||
let forever = Timer::default()
|
let forever = Timer::default()
|
||||||
|
@ -133,8 +133,9 @@ impl Server {
|
||||||
|
|
||||||
/// Start mining for blocks on a separate thread. Relies on a toy miner,
|
/// Start mining for blocks on a separate thread. Relies on a toy miner,
|
||||||
/// mostly for testing.
|
/// mostly for testing.
|
||||||
pub fn start_miner(&self) {
|
pub fn start_miner(&self, config: MinerConfig) {
|
||||||
let miner = miner::Miner::new(self.chain_head.clone(),
|
let miner = miner::Miner::new(config,
|
||||||
|
self.chain_head.clone(),
|
||||||
self.chain_store.clone(),
|
self.chain_store.clone(),
|
||||||
self.chain_adapter.clone());
|
self.chain_adapter.clone());
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
use std::convert::From;
|
use std::convert::From;
|
||||||
|
|
||||||
|
use api;
|
||||||
use chain;
|
use chain;
|
||||||
use p2p;
|
use p2p;
|
||||||
use store;
|
use store;
|
||||||
|
@ -27,6 +28,8 @@ pub enum Error {
|
||||||
Chain(chain::Error),
|
Chain(chain::Error),
|
||||||
/// Error originating from the peer-to-peer network.
|
/// Error originating from the peer-to-peer network.
|
||||||
P2P(p2p::Error),
|
P2P(p2p::Error),
|
||||||
|
/// Error originating from HTTP API calls
|
||||||
|
API(api::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<chain::Error> for Error {
|
impl From<chain::Error> for Error {
|
||||||
|
@ -47,6 +50,12 @@ impl From<store::Error> for Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<api::Error> for Error {
|
||||||
|
fn from(e: api::Error) -> Error {
|
||||||
|
Error::API(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Type of seeding the server will use to find other peers on the network.
|
/// Type of seeding the server will use to find other peers on the network.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum Seeding {
|
pub enum Seeding {
|
||||||
|
@ -81,8 +90,22 @@ pub struct ServerConfig {
|
||||||
/// Configuration for the peer-to-peer server
|
/// Configuration for the peer-to-peer server
|
||||||
pub p2p_config: p2p::P2PConfig,
|
pub p2p_config: p2p::P2PConfig,
|
||||||
|
|
||||||
/// Whethere to start the miner with the server
|
/// Configuration for the mining daemon
|
||||||
|
pub mining_config: MinerConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mining configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MinerConfig {
|
||||||
|
/// Whether to start the miner with the server
|
||||||
pub enable_mining: bool,
|
pub enable_mining: bool,
|
||||||
|
|
||||||
|
/// Base address to the HTTP wallet receiver
|
||||||
|
pub wallet_receiver_url: String,
|
||||||
|
|
||||||
|
/// Attributes the reward to a random private key instead of contacting the
|
||||||
|
/// wallet receiver. Mostly used for tests.
|
||||||
|
pub burn_reward: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
|
@ -94,7 +117,17 @@ impl Default for ServerConfig {
|
||||||
capabilities: p2p::FULL_NODE,
|
capabilities: p2p::FULL_NODE,
|
||||||
seeding_type: Seeding::None,
|
seeding_type: Seeding::None,
|
||||||
p2p_config: p2p::P2PConfig::default(),
|
p2p_config: p2p::P2PConfig::default(),
|
||||||
enable_mining: false,
|
mining_config: MinerConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MinerConfig {
|
||||||
|
fn default() -> MinerConfig {
|
||||||
|
MinerConfig {
|
||||||
|
enable_mining: false,
|
||||||
|
wallet_receiver_url: "http://localhost:13416".to_string(),
|
||||||
|
burn_reward: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -141,7 +141,7 @@ fn server_command(server_args: &ArgMatches) {
|
||||||
server_config.p2p_config.port = port.parse().unwrap();
|
server_config.p2p_config.port = port.parse().unwrap();
|
||||||
}
|
}
|
||||||
if server_args.is_present("mine") {
|
if server_args.is_present("mine") {
|
||||||
server_config.enable_mining = true;
|
server_config.mining_config.enable_mining = true;
|
||||||
}
|
}
|
||||||
if let Some(seeds) = server_args.values_of("seed") {
|
if let Some(seeds) = server_args.values_of("seed") {
|
||||||
server_config.seeding_type = grin::Seeding::List(seeds.map(|s| s.to_string()).collect());
|
server_config.seeding_type = grin::Seeding::List(seeds.map(|s| s.to_string()).collect());
|
||||||
|
@ -218,7 +218,6 @@ fn default_config() -> grin::ServerConfig {
|
||||||
grin::ServerConfig {
|
grin::ServerConfig {
|
||||||
cuckoo_size: 12,
|
cuckoo_size: 12,
|
||||||
seeding_type: grin::Seeding::WebStatic,
|
seeding_type: grin::Seeding::WebStatic,
|
||||||
enable_mining: false,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ use std::num;
|
||||||
pub fn to_hex(bytes: Vec<u8>) -> String {
|
pub fn to_hex(bytes: Vec<u8>) -> String {
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
for byte in bytes {
|
for byte in bytes {
|
||||||
write!(&mut s, "{:X}", byte).expect("Unable to write");
|
write!(&mut s, "{:02X}", byte).expect("Unable to write");
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
@ -36,7 +36,7 @@ pub fn from_hex(hex_str: String) -> Result<Vec<u8>, num::ParseIntError> {
|
||||||
} else {
|
} else {
|
||||||
hex_str.clone()
|
hex_str.clone()
|
||||||
};
|
};
|
||||||
split_n(&hex_str.trim()[..], 2).iter()
|
split_n(&hex_trim.trim()[..], 2).iter()
|
||||||
.map(|b| u8::from_str_radix(b, 16))
|
.map(|b| u8::from_str_radix(b, 16))
|
||||||
.collect::<Result<Vec<u8>, _>>()
|
.collect::<Result<Vec<u8>, _>>()
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,7 +55,7 @@ use secp::key::SecretKey;
|
||||||
|
|
||||||
use core::core::{Block, Transaction, TxKernel, Output, build};
|
use core::core::{Block, Transaction, TxKernel, Output, build};
|
||||||
use core::ser;
|
use core::ser;
|
||||||
use api::*;
|
use api::{self, ApiEndpoint, Operation, ApiResult};
|
||||||
use extkey::{self, ExtendedKey};
|
use extkey::{self, ExtendedKey};
|
||||||
use types::*;
|
use types::*;
|
||||||
use util;
|
use util;
|
||||||
|
@ -93,22 +93,22 @@ impl ApiEndpoint for WalletReceiver {
|
||||||
fn operation(&self, op: String, input: CbAmount) -> ApiResult<CbData> {
|
fn operation(&self, op: String, input: CbAmount) -> ApiResult<CbData> {
|
||||||
debug!("Operation {} with amount {}", op, input.amount);
|
debug!("Operation {} with amount {}", op, input.amount);
|
||||||
if input.amount == 0 {
|
if input.amount == 0 {
|
||||||
return Err(ApiError::Argument(format!("Zero amount not allowed.")))
|
return Err(api::Error::Argument(format!("Zero amount not allowed.")))
|
||||||
}
|
}
|
||||||
match op.as_str() {
|
match op.as_str() {
|
||||||
"receive_coinbase" => {
|
"receive_coinbase" => {
|
||||||
let (out, kern) = receive_coinbase(&self.key, input.amount).
|
let (out, kern) = receive_coinbase(&self.key, input.amount).
|
||||||
map_err(|e| ApiError::Internal(format!("Error building coinbase: {:?}", e)))?;
|
map_err(|e| api::Error::Internal(format!("Error building coinbase: {:?}", e)))?;
|
||||||
let out_bin = ser::ser_vec(&out).
|
let out_bin = ser::ser_vec(&out).
|
||||||
map_err(|e| ApiError::Internal(format!("Error serializing output: {:?}", e)))?;
|
map_err(|e| api::Error::Internal(format!("Error serializing output: {:?}", e)))?;
|
||||||
let kern_bin = ser::ser_vec(&kern).
|
let kern_bin = ser::ser_vec(&kern).
|
||||||
map_err(|e| ApiError::Internal(format!("Error serializing kernel: {:?}", e)))?;
|
map_err(|e| api::Error::Internal(format!("Error serializing kernel: {:?}", e)))?;
|
||||||
Ok(CbData {
|
Ok(CbData {
|
||||||
output: util::to_hex(out_bin),
|
output: util::to_hex(out_bin),
|
||||||
kernel: util::to_hex(kern_bin),
|
kernel: util::to_hex(kern_bin),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
_ => Err(ApiError::Argument(format!("Unknown operation: {}", op))),
|
_ => Err(api::Error::Argument(format!("Unknown operation: {}", op))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -131,5 +131,7 @@ fn receive_coinbase(ext_key: &ExtendedKey, amount: u64) -> Result<(Output, TxKer
|
||||||
});
|
});
|
||||||
wallet_data.write()?;
|
wallet_data.write()?;
|
||||||
|
|
||||||
|
info!("Using child {} for a new coinbase output.", coinbase_key.n_child);
|
||||||
|
|
||||||
Block::reward_output(ext_key.key, &secp).map_err(&From::from)
|
Block::reward_output(ext_key.key, &secp).map_err(&From::from)
|
||||||
}
|
}
|
||||||
|
|
|
@ -152,7 +152,6 @@ impl WalletData {
|
||||||
pub fn next_child(&self, fingerprint: [u8; 4]) -> u32 {
|
pub fn next_child(&self, fingerprint: [u8; 4]) -> u32 {
|
||||||
let mut max_n = 0;
|
let mut max_n = 0;
|
||||||
for out in &self.outputs {
|
for out in &self.outputs {
|
||||||
println!("{:?} {:?}", out.fingerprint, fingerprint);
|
|
||||||
if max_n < out.n_child && out.fingerprint == fingerprint {
|
if max_n < out.n_child && out.fingerprint == fingerprint {
|
||||||
max_n = out.n_child;
|
max_n = out.n_child;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue