first few LES tests, fix get_header logic bug
This commit is contained in:
parent
2d1a3ff091
commit
8d16f73795
113
ethcore/light/src/net/context.rs
Normal file
113
ethcore/light/src/net/context.rs
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
// Copyright 2015, 2016 Ethcore (UK) Ltd.
|
||||||
|
// This file is part of Parity.
|
||||||
|
|
||||||
|
// Parity is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
|
||||||
|
// Parity is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//! I/O and event context generalizations.
|
||||||
|
|
||||||
|
use network::{NetworkContext, PeerId};
|
||||||
|
|
||||||
|
use super::{Announcement, LightProtocol, ReqId};
|
||||||
|
use super::error::Error;
|
||||||
|
use request::Request;
|
||||||
|
|
||||||
|
/// An I/O context which allows sending and receiving packets as well as
|
||||||
|
/// disconnecting peers. This is used as a generalization of the portions
|
||||||
|
/// of a p2p network which the light protocol structure makes use of.
|
||||||
|
pub trait IoContext {
|
||||||
|
/// Send a packet to a specific peer.
|
||||||
|
fn send(&self, peer: PeerId, packet_id: u8, packet_body: Vec<u8>);
|
||||||
|
|
||||||
|
/// Respond to a peer's message. Only works if this context is a byproduct
|
||||||
|
/// of a packet handler.
|
||||||
|
fn respond(&self, packet_id: u8, packet_body: Vec<u8>);
|
||||||
|
|
||||||
|
/// Disconnect a peer.
|
||||||
|
fn disconnect_peer(&self, peer: PeerId);
|
||||||
|
|
||||||
|
/// Disable a peer -- this is a disconnect + a time-out.
|
||||||
|
fn disable_peer(&self, peer: PeerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> IoContext for NetworkContext<'a> {
|
||||||
|
fn send(&self, peer: PeerId, packet_id: u8, packet_body: Vec<u8>) {
|
||||||
|
if let Err(e) = self.send(peer, packet_id, packet_body) {
|
||||||
|
debug!(target: "les", "Error sending packet to peer {}: {}", peer, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn respond(&self, packet_id: u8, packet_body: Vec<u8>) {
|
||||||
|
if let Err(e) = self.respond(packet_id, packet_body) {
|
||||||
|
debug!(target: "les", "Error responding to peer message: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disconnect_peer(&self, peer: PeerId) {
|
||||||
|
NetworkContext::disconnect_peer(self, peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disable_peer(&self, peer: PeerId) {
|
||||||
|
NetworkContext::disable_peer(self, peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context for a protocol event.
|
||||||
|
pub trait EventContext {
|
||||||
|
/// Get the peer relevant to the event e.g. message sender,
|
||||||
|
/// disconnected/connected peer.
|
||||||
|
fn peer(&self) -> PeerId;
|
||||||
|
|
||||||
|
/// Make a request from a peer.
|
||||||
|
fn request_from(&self, peer: PeerId, request: Request) -> Result<ReqId, Error>;
|
||||||
|
|
||||||
|
/// Make an announcement of new capabilities to the rest of the peers.
|
||||||
|
// TODO: maybe just put this on a timer in LightProtocol?
|
||||||
|
fn make_announcement(&self, announcement: Announcement);
|
||||||
|
|
||||||
|
/// Disconnect a peer.
|
||||||
|
fn disconnect_peer(&self, peer: PeerId);
|
||||||
|
|
||||||
|
/// Disable a peer.
|
||||||
|
fn disable_peer(&self, peer: PeerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concrete implementation of `EventContext` over the light protocol struct and
|
||||||
|
/// an io context.
|
||||||
|
pub struct Ctx<'a> {
|
||||||
|
/// Io context to enable immediate response to events.
|
||||||
|
pub io: &'a IoContext,
|
||||||
|
/// Protocol implementation.
|
||||||
|
pub proto: &'a LightProtocol,
|
||||||
|
/// Relevant peer for event.
|
||||||
|
pub peer: PeerId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> EventContext for Ctx<'a> {
|
||||||
|
fn peer(&self) -> PeerId { self.peer }
|
||||||
|
fn request_from(&self, peer: PeerId, request: Request) -> Result<ReqId, Error> {
|
||||||
|
self.proto.request_from(self.io, &peer, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_announcement(&self, announcement: Announcement) {
|
||||||
|
self.proto.make_announcement(self.io, announcement);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disconnect_peer(&self, peer: PeerId) {
|
||||||
|
self.io.disconnect_peer(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disable_peer(&self, peer: PeerId) {
|
||||||
|
self.io.disable_peer(peer);
|
||||||
|
}
|
||||||
|
}
|
@ -44,6 +44,9 @@ mod context;
|
|||||||
mod error;
|
mod error;
|
||||||
mod status;
|
mod status;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
pub use self::status::{Status, Capabilities, Announcement, NetworkId};
|
pub use self::status::{Status, Capabilities, Announcement, NetworkId};
|
||||||
|
|
||||||
const TIMEOUT: TimerToken = 0;
|
const TIMEOUT: TimerToken = 0;
|
||||||
@ -181,8 +184,6 @@ struct Requested {
|
|||||||
|
|
||||||
/// Protocol parameters.
|
/// Protocol parameters.
|
||||||
pub struct Params {
|
pub struct Params {
|
||||||
/// Genesis hash.
|
|
||||||
pub genesis_hash: H256,
|
|
||||||
/// Network id.
|
/// Network id.
|
||||||
pub network_id: NetworkId,
|
pub network_id: NetworkId,
|
||||||
/// Buffer flow parameters.
|
/// Buffer flow parameters.
|
||||||
@ -217,9 +218,10 @@ pub struct LightProtocol {
|
|||||||
impl LightProtocol {
|
impl LightProtocol {
|
||||||
/// Create a new instance of the protocol manager.
|
/// Create a new instance of the protocol manager.
|
||||||
pub fn new(provider: Box<Provider>, params: Params) -> Self {
|
pub fn new(provider: Box<Provider>, params: Params) -> Self {
|
||||||
|
let genesis_hash = provider.chain_info().genesis_hash;
|
||||||
LightProtocol {
|
LightProtocol {
|
||||||
provider: provider,
|
provider: provider,
|
||||||
genesis_hash: params.genesis_hash,
|
genesis_hash: genesis_hash,
|
||||||
network_id: params.network_id,
|
network_id: params.network_id,
|
||||||
pending_peers: RwLock::new(HashMap::new()),
|
pending_peers: RwLock::new(HashMap::new()),
|
||||||
peers: RwLock::new(HashMap::new()),
|
peers: RwLock::new(HashMap::new()),
|
||||||
|
291
ethcore/light/src/net/tests/mod.rs
Normal file
291
ethcore/light/src/net/tests/mod.rs
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
// Copyright 2015, 2016 Ethcore (UK) Ltd.
|
||||||
|
// This file is part of Parity.
|
||||||
|
|
||||||
|
// Parity is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
|
||||||
|
// Parity is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//! Tests for the `LightProtocol` implementation.
|
||||||
|
//! These don't test of the higher level logic on top of
|
||||||
|
|
||||||
|
use ethcore::blockchain_info::BlockChainInfo;
|
||||||
|
use ethcore::client::{BlockChainClient, EachBlockWith, TestBlockChainClient};
|
||||||
|
use ethcore::ids::BlockID;
|
||||||
|
use ethcore::transaction::SignedTransaction;
|
||||||
|
use network::PeerId;
|
||||||
|
|
||||||
|
use net::buffer_flow::FlowParams;
|
||||||
|
use net::context::IoContext;
|
||||||
|
use net::status::{Capabilities, Status, NetworkId, write_handshake};
|
||||||
|
use net::{encode_request, LightProtocol, Params, packet};
|
||||||
|
use provider::Provider;
|
||||||
|
use request::{self, Request, Headers};
|
||||||
|
|
||||||
|
use rlp::*;
|
||||||
|
use util::{Bytes, H256};
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// expected result from a call.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum Expect {
|
||||||
|
/// Expect to have message sent to peer.
|
||||||
|
Send(PeerId, u8, Vec<u8>),
|
||||||
|
/// Expect this response.
|
||||||
|
Respond(u8, Vec<u8>),
|
||||||
|
/// Expect a punishment (disconnect/disable)
|
||||||
|
Punish(PeerId),
|
||||||
|
/// Expect nothing.
|
||||||
|
Nothing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IoContext for Expect {
|
||||||
|
fn send(&self, peer: PeerId, packet_id: u8, packet_body: Vec<u8>) {
|
||||||
|
assert_eq!(self, &Expect::Send(peer, packet_id, packet_body));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn respond(&self, packet_id: u8, packet_body: Vec<u8>) {
|
||||||
|
assert_eq!(self, &Expect::Respond(packet_id, packet_body));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disconnect_peer(&self, peer: PeerId) {
|
||||||
|
assert_eq!(self, &Expect::Punish(peer));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disable_peer(&self, peer: PeerId) {
|
||||||
|
assert_eq!(self, &Expect::Punish(peer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// can't implement directly for Arc due to cross-crate orphan rules.
|
||||||
|
struct TestProvider(Arc<TestProviderInner>);
|
||||||
|
|
||||||
|
struct TestProviderInner {
|
||||||
|
client: TestBlockChainClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Provider for TestProvider {
|
||||||
|
fn chain_info(&self) -> BlockChainInfo {
|
||||||
|
self.0.client.chain_info()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reorg_depth(&self, a: &H256, b: &H256) -> Option<u64> {
|
||||||
|
self.0.client.tree_route(a, b).map(|route| route.index as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn earliest_state(&self) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_headers(&self, req: request::Headers) -> Vec<Bytes> {
|
||||||
|
let best_num = self.0.client.chain_info().best_block_number;
|
||||||
|
let start_num = req.block_num;
|
||||||
|
|
||||||
|
match self.0.client.block_hash(BlockID::Number(req.block_num)) {
|
||||||
|
Some(hash) if hash == req.block_hash => {}
|
||||||
|
_=> {
|
||||||
|
trace!(target: "les_provider", "unknown/non-canonical start block in header request: {:?}", (req.block_num, req.block_hash));
|
||||||
|
return vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(0u64..req.max as u64)
|
||||||
|
.map(|x: u64| x.saturating_mul(req.skip + 1))
|
||||||
|
.take_while(|x| if req.reverse { x < &start_num } else { best_num - start_num >= *x })
|
||||||
|
.map(|x| if req.reverse { start_num - x } else { start_num + x })
|
||||||
|
.map(|x| self.0.client.block_header(BlockID::Number(x)))
|
||||||
|
.take_while(|x| x.is_some())
|
||||||
|
.flat_map(|x| x)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_bodies(&self, req: request::Bodies) -> Vec<Bytes> {
|
||||||
|
req.block_hashes.into_iter()
|
||||||
|
.map(|hash| self.0.client.block_body(BlockID::Hash(hash)))
|
||||||
|
.map(|body| body.unwrap_or_else(|| ::rlp::EMPTY_LIST_RLP.to_vec()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn receipts(&self, req: request::Receipts) -> Vec<Bytes> {
|
||||||
|
req.block_hashes.into_iter()
|
||||||
|
.map(|hash| self.0.client.block_receipts(&hash))
|
||||||
|
.map(|receipts| receipts.unwrap_or_else(|| ::rlp::EMPTY_LIST_RLP.to_vec()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proofs(&self, req: request::StateProofs) -> Vec<Bytes> {
|
||||||
|
req.requests.into_iter().map(|_| ::rlp::EMPTY_LIST_RLP.to_vec()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contract_code(&self, req: request::ContractCodes) -> Vec<Bytes> {
|
||||||
|
req.code_requests.into_iter().map(|_| Vec::new()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_proofs(&self, req: request::HeaderProofs) -> Vec<Bytes> {
|
||||||
|
req.requests.into_iter().map(|_| ::rlp::EMPTY_LIST_RLP.to_vec()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_transactions(&self) -> Vec<SignedTransaction> {
|
||||||
|
self.0.client.pending_transactions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_flow_params() -> FlowParams {
|
||||||
|
FlowParams::new(5_000_000.into(), Default::default(), 100_000.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities() -> Capabilities {
|
||||||
|
Capabilities {
|
||||||
|
serve_headers: true,
|
||||||
|
serve_chain_since: Some(1),
|
||||||
|
serve_state_since: Some(1),
|
||||||
|
tx_relay: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper for setting up the protocol handler and provider.
|
||||||
|
fn setup(flow_params: FlowParams, capabilities: Capabilities) -> (Arc<TestProviderInner>, LightProtocol) {
|
||||||
|
let provider = Arc::new(TestProviderInner {
|
||||||
|
client: TestBlockChainClient::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let proto = LightProtocol::new(Box::new(TestProvider(provider.clone())), Params {
|
||||||
|
network_id: NetworkId::Testnet,
|
||||||
|
flow_params: flow_params,
|
||||||
|
capabilities: capabilities,
|
||||||
|
});
|
||||||
|
|
||||||
|
(provider, proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(chain_info: BlockChainInfo) -> Status {
|
||||||
|
Status {
|
||||||
|
protocol_version: ::net::PROTOCOL_VERSION,
|
||||||
|
network_id: NetworkId::Testnet,
|
||||||
|
head_td: chain_info.total_difficulty,
|
||||||
|
head_hash: chain_info.best_block_hash,
|
||||||
|
head_num: chain_info.best_block_number,
|
||||||
|
genesis_hash: chain_info.genesis_hash,
|
||||||
|
last_head: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handshake_expected() {
|
||||||
|
let flow_params = make_flow_params();
|
||||||
|
let capabilities = capabilities();
|
||||||
|
|
||||||
|
let (provider, proto) = setup(flow_params.clone(), capabilities.clone());
|
||||||
|
|
||||||
|
let status = status(provider.client.chain_info());
|
||||||
|
|
||||||
|
let packet_body = write_handshake(&status, &capabilities, &flow_params);
|
||||||
|
|
||||||
|
proto.on_connect(&1, &Expect::Send(1, packet::STATUS, packet_body));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic]
|
||||||
|
fn genesis_mismatch() {
|
||||||
|
let flow_params = make_flow_params();
|
||||||
|
let capabilities = capabilities();
|
||||||
|
|
||||||
|
let (provider, proto) = setup(flow_params.clone(), capabilities.clone());
|
||||||
|
|
||||||
|
let mut status = status(provider.client.chain_info());
|
||||||
|
status.genesis_hash = H256::default();
|
||||||
|
|
||||||
|
let packet_body = write_handshake(&status, &capabilities, &flow_params);
|
||||||
|
|
||||||
|
proto.on_connect(&1, &Expect::Send(1, packet::STATUS, packet_body));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_overflow() {
|
||||||
|
let flow_params = make_flow_params();
|
||||||
|
let capabilities = capabilities();
|
||||||
|
|
||||||
|
let (provider, proto) = setup(flow_params.clone(), capabilities.clone());
|
||||||
|
|
||||||
|
let status = status(provider.client.chain_info());
|
||||||
|
|
||||||
|
{
|
||||||
|
let packet_body = write_handshake(&status, &capabilities, &flow_params);
|
||||||
|
proto.on_connect(&1, &Expect::Send(1, packet::STATUS, packet_body));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let my_status = write_handshake(&status, &capabilities, &flow_params);
|
||||||
|
proto.handle_packet(&Expect::Nothing, &1, packet::STATUS, &my_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1000 requests is far too many for the default flow params.
|
||||||
|
let request = encode_request(&Request::Headers(Headers {
|
||||||
|
block_num: 1,
|
||||||
|
block_hash: provider.client.chain_info().genesis_hash,
|
||||||
|
max: 1000,
|
||||||
|
skip: 0,
|
||||||
|
reverse: false,
|
||||||
|
}), 111);
|
||||||
|
|
||||||
|
proto.handle_packet(&Expect::Punish(1), &1, packet::GET_BLOCK_HEADERS, &request);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_block_headers() {
|
||||||
|
let flow_params = FlowParams::new(5_000_000.into(), Default::default(), 0.into());
|
||||||
|
let capabilities = capabilities();
|
||||||
|
|
||||||
|
let (provider, proto) = setup(flow_params.clone(), capabilities.clone());
|
||||||
|
|
||||||
|
let cur_status = status(provider.client.chain_info());
|
||||||
|
let my_status = write_handshake(&cur_status, &capabilities, &flow_params);
|
||||||
|
|
||||||
|
provider.client.add_blocks(100, EachBlockWith::Nothing);
|
||||||
|
|
||||||
|
let cur_status = status(provider.client.chain_info());
|
||||||
|
|
||||||
|
{
|
||||||
|
let packet_body = write_handshake(&cur_status, &capabilities, &flow_params);
|
||||||
|
proto.on_connect(&1, &Expect::Send(1, packet::STATUS, packet_body));
|
||||||
|
proto.handle_packet(&Expect::Nothing, &1, packet::STATUS, &my_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = Headers {
|
||||||
|
block_num: 1,
|
||||||
|
block_hash: provider.client.block_hash(BlockID::Number(1)).unwrap(),
|
||||||
|
max: 10,
|
||||||
|
skip: 0,
|
||||||
|
reverse: false,
|
||||||
|
};
|
||||||
|
let req_id = 111;
|
||||||
|
|
||||||
|
let request_body = encode_request(&Request::Headers(request.clone()), req_id);
|
||||||
|
let response = {
|
||||||
|
let headers: Vec<_> = (0..10).map(|i| provider.client.block_header(BlockID::Number(i + 1)).unwrap()).collect();
|
||||||
|
assert_eq!(headers.len(), 10);
|
||||||
|
|
||||||
|
let new_buf = *flow_params.limit() - flow_params.compute_cost(request::Kind::Headers, 10);
|
||||||
|
|
||||||
|
let mut response_stream = RlpStream::new_list(12);
|
||||||
|
|
||||||
|
response_stream.append(&req_id).append(&new_buf);
|
||||||
|
for header in headers {
|
||||||
|
response_stream.append_raw(&header, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
response_stream.out()
|
||||||
|
};
|
||||||
|
|
||||||
|
let expected = Expect::Respond(packet::BLOCK_HEADERS, response);
|
||||||
|
proto.handle_packet(&expected, &1, packet::GET_BLOCK_HEADERS, &request_body);
|
||||||
|
}
|
@ -108,8 +108,8 @@ impl<T: ProvingBlockChainClient + ?Sized> Provider for T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(0u64..req.max as u64)
|
(0u64..req.max as u64)
|
||||||
.map(|x: u64| x.saturating_mul(req.skip))
|
.map(|x: u64| x.saturating_mul(req.skip + 1))
|
||||||
.take_while(|x| if req.reverse { x < &start_num } else { best_num - start_num < *x })
|
.take_while(|x| if req.reverse { x < &start_num } else { best_num - start_num >= *x })
|
||||||
.map(|x| if req.reverse { start_num - x } else { start_num + x })
|
.map(|x| if req.reverse { start_num - x } else { start_num + x })
|
||||||
.map(|x| self.block_header(BlockID::Number(x)))
|
.map(|x| self.block_header(BlockID::Number(x)))
|
||||||
.take_while(|x| x.is_some())
|
.take_while(|x| x.is_some())
|
||||||
|
@ -92,8 +92,8 @@ pub struct TestBlockChainClient {
|
|||||||
pub first_block: RwLock<Option<(H256, u64)>>,
|
pub first_block: RwLock<Option<(H256, u64)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
/// Used for generating test client blocks.
|
/// Used for generating test client blocks.
|
||||||
|
#[derive(Clone)]
|
||||||
pub enum EachBlockWith {
|
pub enum EachBlockWith {
|
||||||
/// Plain block.
|
/// Plain block.
|
||||||
Nothing,
|
Nothing,
|
||||||
|
Loading…
Reference in New Issue
Block a user