diff --git a/modules/proposals/codex/src/lib.rs b/modules/proposals/codex/src/lib.rs index ddc5a3f59d..e7b7d22eb7 100644 --- a/modules/proposals/codex/src/lib.rs +++ b/modules/proposals/codex/src/lib.rs @@ -11,21 +11,22 @@ // Do not delete! Cannot be uncommented by default, because of Parity decl_module! issue. //#![warn(missing_docs)] -pub use proposal_types::{ProposalType, RuntimeUpgradeProposalExecutable, TextProposalExecutable}; - mod proposal_types; #[cfg(test)] mod tests; use codec::Encode; -use proposal_engine::*; use rstd::clone::Clone; use rstd::marker::PhantomData; use rstd::prelude::*; use rstd::vec::Vec; +use runtime_primitives::traits::One; use srml_support::{decl_error, decl_module, decl_storage, ensure}; use system::RawOrigin; +use proposal_engine::*; +pub use proposal_types::{ProposalType, RuntimeUpgradeProposalExecutable, TextProposalExecutable}; + /// 'Proposals codex' substrate module Trait pub trait Trait: system::Trait + proposal_engine::Trait + proposal_discussion::Trait { /// Defines max allowed text proposal length. @@ -100,6 +101,7 @@ decl_module! { let discussion_thread_id = >::create_thread( cloned_origin1, + T::ThreadAuthorId::one(), //TODO: temporary stub, provide implementation title.clone(), )?; @@ -142,6 +144,7 @@ decl_module! { let discussion_thread_id = >::create_thread( cloned_origin1, + T::ThreadAuthorId::one(), //TODO: temporary stub, provide implementation title.clone(), )?; diff --git a/modules/proposals/codex/src/tests/mock.rs b/modules/proposals/codex/src/tests/mock.rs index abe8d7dbcb..ac941da93c 100644 --- a/modules/proposals/codex/src/tests/mock.rs +++ b/modules/proposals/codex/src/tests/mock.rs @@ -105,15 +105,23 @@ impl proposal_engine::Trait for Test { type MaxActiveProposalLimit = MaxActiveProposalLimit; } +impl proposal_discussion::ActorOriginValidator for () { + fn validate_actor_origin(_: Origin, _: u64) -> bool { + true + } +} + parameter_types! { pub const MaxPostEditionNumber: u32 = 5; + pub const MaxThreadInARowNumber: u32 = 3; pub const ThreadTitleLengthLimit: u32 = 200; pub const PostLengthLimit: u32 = 2000; } impl proposal_discussion::Trait for Test { - type ThreadAuthorOrigin = system::EnsureSigned; - type PostAuthorOrigin = system::EnsureSigned; + type Event = (); + type ThreadAuthorOriginValidator = (); + type PostAuthorOriginValidator = (); type ThreadId = u32; type PostId = u32; type ThreadAuthorId = u64; @@ -121,6 +129,7 @@ impl proposal_discussion::Trait for Test { type MaxPostEditionNumber = MaxPostEditionNumber; type ThreadTitleLengthLimit = ThreadTitleLengthLimit; type PostLengthLimit = PostLengthLimit; + type MaxThreadInARowNumber = MaxThreadInARowNumber; } pub struct MockVotersParameters; diff --git a/modules/proposals/discussion/src/errors.rs b/modules/proposals/discussion/src/errors.rs new file mode 100644 index 0000000000..9cdc1367d8 --- /dev/null +++ b/modules/proposals/discussion/src/errors.rs @@ -0,0 +1,14 @@ +pub(crate) const MSG_NOT_AUTHOR: &str = "Author should match the post creator"; +pub(crate) const MSG_POST_EDITION_NUMBER_EXCEEDED: &str = "Post edition limit reached."; +pub(crate) const MSG_EMPTY_TITLE_PROVIDED: &str = "Discussion cannot have an empty title"; +pub(crate) const MSG_TOO_LONG_TITLE: &str = "Title is too long"; +pub(crate) const MSG_THREAD_DOESNT_EXIST: &str = "Thread doesn't exist"; +pub(crate) const MSG_POST_DOESNT_EXIST: &str = "Post doesn't exist"; +pub(crate) const MSG_EMPTY_POST_PROVIDED: &str = "Post cannot be empty"; +pub(crate) const MSG_TOO_LONG_POST: &str = "Post is too long"; +pub(crate) const MSG_MAX_THREAD_IN_A_ROW_LIMIT_EXCEEDED: &str = + "Max number of threads by same author in a row limit exceeded"; +pub(crate) const MSG_INVALID_THREAD_AUTHOR_ORIGIN: &str = + "Invalid combination of the origin and thread_author_id"; +pub(crate) const MSG_INVALID_POST_AUTHOR_ORIGIN: &str = + "Invalid combination of the origin and post_author_id"; diff --git a/modules/proposals/discussion/src/lib.rs b/modules/proposals/discussion/src/lib.rs index e043c77c2b..47bf123011 100644 --- a/modules/proposals/discussion/src/lib.rs +++ b/modules/proposals/discussion/src/lib.rs @@ -3,6 +3,7 @@ //! //! Supported extrinsics: //! - add_post - adds a post to existing discussion thread +//! - update_post - updates existing post //! //! Public API: //! - create_discussion - creates a discussion @@ -14,6 +15,7 @@ // Do not delete! Cannot be uncommented by default, because of Parity decl_module! issue. //#![warn(missing_docs)] +mod errors; #[cfg(test)] mod tests; mod types; @@ -21,31 +23,45 @@ mod types; use rstd::clone::Clone; use rstd::prelude::*; use rstd::vec::Vec; -use runtime_primitives::traits::EnsureOrigin; -use srml_support::{decl_module, decl_storage, ensure, Parameter}; +use srml_support::{decl_event, decl_module, decl_storage, ensure, Parameter}; +use runtime_primitives::traits::SimpleArithmetic; use srml_support::traits::Get; -use types::{Post, Thread}; - -// TODO: create events -// TODO: move errors to decl_error macro - -pub(crate) const MSG_NOT_AUTHOR: &str = "Author should match the post creator"; -pub(crate) const MSG_POST_EDITION_NUMBER_EXCEEDED: &str = "Post edition limit reached."; -pub(crate) const MSG_EMPTY_TITLE_PROVIDED: &str = "Discussion cannot have an empty title"; -pub(crate) const MSG_TOO_LONG_TITLE: &str = "Title is too long"; -pub(crate) const MSG_THREAD_DOESNT_EXIST: &str = "Thread doesn't exist"; -pub(crate) const MSG_POST_DOESNT_EXIST: &str = "Post doesn't exist"; -pub(crate) const MSG_EMPTY_POST_PROVIDED: &str = "Post cannot be empty"; -pub(crate) const MSG_TOO_LONG_POST: &str = "Post is too long"; +pub use types::ActorOriginValidator; +use types::{Post, Thread, ThreadCounter}; + +// TODO: move errors to decl_error macro (after substrate version upgrade) + +decl_event!( + /// Proposals engine events + pub enum Event + where + ::ThreadId, + ::ThreadAuthorId, + ::PostId, + ::PostAuthorId, + { + /// Emits on thread creation. + ThreadCreated(ThreadId, ThreadAuthorId), + + /// Emits on post creation. + PostCreated(PostId, PostAuthorId), + + /// Emits on post update. + PostUpdated(PostId, PostAuthorId), + } +); /// 'Proposal discussion' substrate module Trait pub trait Trait: system::Trait { - /// Origin from which thread author must come. - type ThreadAuthorOrigin: EnsureOrigin; + /// Engine event type. + type Event: From> + Into<::Event>; - /// Origin from which commenter must come. - type PostAuthorOrigin: EnsureOrigin; + /// Validates thread author id and origin combination + type ThreadAuthorOriginValidator: ActorOriginValidator; + + /// Validates post author id and origin combination + type PostAuthorOriginValidator: ActorOriginValidator; /// Discussion thread Id type type ThreadId: From + Into + Parameter + Default + Copy; @@ -54,7 +70,7 @@ pub trait Trait: system::Trait { type PostId: From + Parameter + Default + Copy; /// Type for the thread author id. Should be authenticated by account id. - type ThreadAuthorId: From + Parameter + Default; + type ThreadAuthorId: From + Parameter + Default + SimpleArithmetic; /// Type for the post author id. Should be authenticated by account id. type PostAuthorId: From + Parameter + Default; @@ -67,6 +83,9 @@ pub trait Trait: system::Trait { /// Defines post length limit. type PostLengthLimit: Get; + + /// Defines max thread by same author in a row number limit. + type MaxThreadInARowNumber: Get; } // Storage for the proposals discussion module @@ -85,6 +104,10 @@ decl_storage! { /// Count of all posts that have been created. pub PostCount get(fn post_count): u32; + + /// Last author thread counter (part of the antispam mechanism) + pub LastThreadAuthorCounter get(fn last_thread_author_counter): + Option>; } } @@ -92,17 +115,26 @@ decl_module! { /// 'Proposal discussion' substrate module pub struct Module for enum Call where origin: T::Origin { - /// Adds a post with author origin check. - pub fn add_post(origin, thread_id : T::ThreadId, text : Vec) { - let account_id = T::ThreadAuthorOrigin::ensure_origin(origin)?; - let post_author_id = T::PostAuthorId::from(account_id); + /// Emits an event. Default substrate implementation. + fn deposit_event() = default; - ensure!(>::exists(thread_id), MSG_THREAD_DOESNT_EXIST); + /// Adds a post with author origin check. + pub fn add_post( + origin, + post_author_id: T::PostAuthorId, + thread_id : T::ThreadId, + text : Vec + ) { + ensure!( + T::PostAuthorOriginValidator::validate_actor_origin(origin, post_author_id.clone()), + errors::MSG_INVALID_POST_AUTHOR_ORIGIN + ); + ensure!(>::exists(thread_id), errors::MSG_THREAD_DOESNT_EXIST); - ensure!(!text.is_empty(), MSG_EMPTY_POST_PROVIDED); + ensure!(!text.is_empty(), errors::MSG_EMPTY_POST_PROVIDED); ensure!( text.len() as u32 <= T::PostLengthLimit::get(), - MSG_TOO_LONG_POST + errors::MSG_TOO_LONG_POST ); // mutation @@ -114,7 +146,7 @@ decl_module! { text, created_at: Self::current_block(), updated_at: Self::current_block(), - author_id: post_author_id, + author_id: post_author_id.clone(), edition_number : 0, thread_id, }; @@ -122,27 +154,36 @@ decl_module! { let post_id = T::PostId::from(new_post_id); >::insert(thread_id, post_id, new_post); PostCount::put(next_post_count_value); + Self::deposit_event(RawEvent::PostCreated(post_id, post_author_id)); } - /// Updates a post with author origin check. Update attempts number is limited. - pub fn update_post(origin, thread_id: T::ThreadId, post_id : T::PostId, text : Vec) { - let account_id = T::ThreadAuthorOrigin::ensure_origin(origin)?; - let post_author_id = T::PostAuthorId::from(account_id); + /// Updates a post with author origin check. Update attempts number is limited. + pub fn update_post( + origin, + post_author_id: T::PostAuthorId, + thread_id: T::ThreadId, + post_id : T::PostId, + text : Vec + ){ + ensure!( + T::PostAuthorOriginValidator::validate_actor_origin(origin, post_author_id.clone()), + errors::MSG_INVALID_POST_AUTHOR_ORIGIN + ); - ensure!(>::exists(thread_id), MSG_THREAD_DOESNT_EXIST); - ensure!(>::exists(thread_id, post_id), MSG_POST_DOESNT_EXIST); + ensure!(>::exists(thread_id), errors::MSG_THREAD_DOESNT_EXIST); + ensure!(>::exists(thread_id, post_id), errors::MSG_POST_DOESNT_EXIST); - ensure!(!text.is_empty(), MSG_EMPTY_POST_PROVIDED); + ensure!(!text.is_empty(), errors::MSG_EMPTY_POST_PROVIDED); ensure!( text.len() as u32 <= T::PostLengthLimit::get(), - MSG_TOO_LONG_POST + errors::MSG_TOO_LONG_POST ); let post = >::get(&thread_id, &post_id); - ensure!(post.author_id == post_author_id, MSG_NOT_AUTHOR); + ensure!(post.author_id == post_author_id, errors::MSG_NOT_AUTHOR); ensure!(post.edition_number < T::MaxPostEditionNumber::get(), - MSG_POST_EDITION_NUMBER_EXCEEDED); + errors::MSG_POST_EDITION_NUMBER_EXCEEDED); let new_post = Post { text, @@ -154,6 +195,7 @@ decl_module! { // mutation >::insert(thread_id, post_id, new_post); + Self::deposit_event(RawEvent::PostUpdated(post_id, post_author_id)); } } } @@ -164,15 +206,30 @@ impl Module { >::block_number() } - /// Create the discussion thread - pub fn create_thread(origin: T::Origin, title: Vec) -> Result { - let account_id = T::ThreadAuthorOrigin::ensure_origin(origin)?; - let thread_author_id = T::ThreadAuthorId::from(account_id); + /// Create the discussion thread. Cannot add more threads than 'predefined limit = MaxThreadInARowNumber' + /// times in a row by the same author. + pub fn create_thread( + origin: T::Origin, + thread_author_id: T::ThreadAuthorId, + title: Vec, + ) -> Result { + ensure!( + T::ThreadAuthorOriginValidator::validate_actor_origin(origin, thread_author_id.clone()), + errors::MSG_INVALID_THREAD_AUTHOR_ORIGIN + ); - ensure!(!title.is_empty(), MSG_EMPTY_TITLE_PROVIDED); + ensure!(!title.is_empty(), errors::MSG_EMPTY_TITLE_PROVIDED); ensure!( title.len() as u32 <= T::ThreadTitleLengthLimit::get(), - MSG_TOO_LONG_TITLE + errors::MSG_TOO_LONG_TITLE + ); + + // get new 'threads in a row' counter for the author + let current_thread_counter = Self::get_updated_thread_counter(thread_author_id.clone()); + + ensure!( + current_thread_counter.counter as u32 <= T::MaxThreadInARowNumber::get(), + errors::MSG_MAX_THREAD_IN_A_ROW_LIMIT_EXCEEDED ); let next_thread_count_value = Self::thread_count() + 1; @@ -181,7 +238,7 @@ impl Module { let new_thread = Thread { title, created_at: Self::current_block(), - author_id: thread_author_id, + author_id: thread_author_id.clone(), }; // mutation @@ -189,7 +246,25 @@ impl Module { let thread_id = T::ThreadId::from(new_thread_id); >::insert(thread_id, new_thread); ThreadCount::put(next_thread_count_value); + >::put(current_thread_counter); + Self::deposit_event(RawEvent::ThreadCreated(thread_id, thread_author_id)); Ok(thread_id) } + + // returns incremented thread counter if last thread author equals with provided parameter + fn get_updated_thread_counter( + author_id: T::ThreadAuthorId, + ) -> ThreadCounter { + // if thread counter exists + if let Some(last_thread_author_counter) = Self::last_thread_author_counter() { + // if last(previous) author is the same as current author + if last_thread_author_counter.author_id == author_id { + return last_thread_author_counter.increment(); + } + } + + // else return new counter (set with 1 thread number) + ThreadCounter::new(author_id) + } } diff --git a/modules/proposals/discussion/src/tests/mock.rs b/modules/proposals/discussion/src/tests/mock.rs index 6b9980510f..597f96e10e 100644 --- a/modules/proposals/discussion/src/tests/mock.rs +++ b/modules/proposals/discussion/src/tests/mock.rs @@ -10,7 +10,8 @@ pub use runtime_primitives::{ BuildStorage, Perbill, }; -use srml_support::{impl_outer_origin, parameter_types}; +use crate::ActorOriginValidator; +use srml_support::{impl_outer_event, impl_outer_origin, parameter_types}; impl_outer_origin! { pub enum Origin for Test {} @@ -30,13 +31,25 @@ parameter_types! { parameter_types! { pub const MaxPostEditionNumber: u32 = 5; + pub const MaxThreadInARowNumber: u32 = 3; pub const ThreadTitleLengthLimit: u32 = 200; pub const PostLengthLimit: u32 = 2000; } +mod discussion { + pub use crate::Event; +} + +impl_outer_event! { + pub enum TestEvent for Test { + discussion, + } +} + impl crate::Trait for Test { - type ThreadAuthorOrigin = system::EnsureSigned; - type PostAuthorOrigin = system::EnsureSigned; + type Event = TestEvent; + type ThreadAuthorOriginValidator = (); + type PostAuthorOriginValidator = (); type ThreadId = u32; type PostId = u32; type ThreadAuthorId = u64; @@ -44,6 +57,17 @@ impl crate::Trait for Test { type MaxPostEditionNumber = MaxPostEditionNumber; type ThreadTitleLengthLimit = ThreadTitleLengthLimit; type PostLengthLimit = PostLengthLimit; + type MaxThreadInARowNumber = MaxThreadInARowNumber; +} + +impl ActorOriginValidator for () { + fn validate_actor_origin(origin: Origin, actor_id: u64) -> bool { + if system::ensure_none(origin).is_ok() { + return true; + } + + actor_id == 1 + } } impl system::Trait for Test { @@ -56,7 +80,7 @@ impl system::Trait for Test { type AccountId = u64; type Lookup = IdentityLookup; type Header = Header; - type Event = (); + type Event = TestEvent; type BlockHashCount = BlockHashCount; type MaximumBlockWeight = MaximumBlockWeight; type MaximumBlockLength = MaximumBlockLength; @@ -79,3 +103,4 @@ pub fn initial_test_ext() -> runtime_io::TestExternalities { } pub type Discussions = crate::Module; +pub type System = system::Module; diff --git a/modules/proposals/discussion/src/tests/mod.rs b/modules/proposals/discussion/src/tests/mod.rs index c9b72701fc..9d6853e4a9 100644 --- a/modules/proposals/discussion/src/tests/mod.rs +++ b/modules/proposals/discussion/src/tests/mod.rs @@ -2,8 +2,26 @@ mod mock; use mock::*; +use crate::errors::*; use crate::*; use system::RawOrigin; +use system::{EventRecord, Phase}; + +struct EventFixture; +impl EventFixture { + fn assert_events(expected_raw_events: Vec>) { + let expected_events = expected_raw_events + .iter() + .map(|ev| EventRecord { + phase: Phase::ApplyExtrinsic(0), + event: TestEvent::discussion(ev.clone()), + topics: vec![], + }) + .collect::>>(); + + assert_eq!(System::events(), expected_events); + } +} struct TestPostEntry { pub post_id: u32, @@ -46,6 +64,7 @@ fn assert_thread_content(thread_entry: TestThreadEntry, post_entries: Vec, pub origin: RawOrigin, + pub author_id: u64, } impl Default for DiscussionFixture { @@ -53,6 +72,7 @@ impl Default for DiscussionFixture { DiscussionFixture { title: b"title".to_vec(), origin: RawOrigin::Signed(1), + author_id: 1, } } } @@ -61,6 +81,22 @@ impl DiscussionFixture { fn with_title(self, title: Vec) -> Self { DiscussionFixture { title, ..self } } + + fn with_author(self, author_id: u64) -> Self { + DiscussionFixture { author_id, ..self } + } + + fn create_discussion_and_assert(&self, result: Result) -> Option { + let create_discussion_result = Discussions::create_thread( + self.origin.clone().into(), + self.author_id, + self.title.clone(), + ); + + assert_eq!(create_discussion_result, result); + + create_discussion_result.ok() + } } struct PostFixture { @@ -68,12 +104,14 @@ struct PostFixture { pub origin: RawOrigin, pub thread_id: u32, pub post_id: Option, + pub author_id: u64, } impl PostFixture { fn default_for_thread(thread_id: u32) -> Self { PostFixture { text: b"text".to_vec(), + author_id: 1, thread_id, origin: RawOrigin::Signed(1), post_id: None, @@ -88,6 +126,10 @@ impl PostFixture { PostFixture { origin, ..self } } + fn with_author(self, author_id: u64) -> Self { + PostFixture { author_id, ..self } + } + fn change_thread_id(self, thread_id: u32) -> Self { PostFixture { thread_id, ..self } } @@ -102,6 +144,7 @@ impl PostFixture { fn add_post_and_assert(&mut self, result: Result<(), &'static str>) -> Option { let add_post_result = Discussions::add_post( self.origin.clone().into(), + self.author_id, self.thread_id, self.text.clone(), ); @@ -122,6 +165,7 @@ impl PostFixture { ) { let add_post_result = Discussions::update_post( self.origin.clone().into(), + self.author_id, self.thread_id, self.post_id.unwrap(), new_text, @@ -135,17 +179,6 @@ impl PostFixture { } } -impl DiscussionFixture { - fn create_discussion_and_assert(&self, result: Result) -> Option { - let create_discussion_result = - Discussions::create_thread(self.origin.clone().into(), self.title.clone()); - - assert_eq!(create_discussion_result, result); - - create_discussion_result.ok() - } -} - #[test] fn create_discussion_call_succeeds() { initial_test_ext().execute_with(|| { @@ -183,6 +216,12 @@ fn update_post_call_succeeds() { post_fixture.add_post_and_assert(Ok(())); post_fixture.update_post_and_assert(Ok(())); + + EventFixture::assert_events(vec![ + RawEvent::ThreadCreated(1, 1), + RawEvent::PostCreated(1, 1), + RawEvent::PostUpdated(1, 1), + ]); }); } @@ -220,7 +259,11 @@ fn update_post_call_failes_because_of_the_wrong_author() { post_fixture.add_post_and_assert(Ok(())); - post_fixture = post_fixture.with_origin(RawOrigin::Signed(2)); + post_fixture = post_fixture.with_author(2); + + post_fixture.update_post_and_assert(Err(MSG_INVALID_POST_AUTHOR_ORIGIN)); + + post_fixture = post_fixture.with_origin(RawOrigin::None).with_author(2); post_fixture.update_post_and_assert(Err(MSG_NOT_AUTHOR)); }); @@ -267,10 +310,10 @@ fn thread_content_check_succeeded() { fn create_discussion_call_with_bad_title_failed() { initial_test_ext().execute_with(|| { let mut discussion_fixture = DiscussionFixture::default().with_title(Vec::new()); - discussion_fixture.create_discussion_and_assert(Err(crate::MSG_EMPTY_TITLE_PROVIDED)); + discussion_fixture.create_discussion_and_assert(Err(MSG_EMPTY_TITLE_PROVIDED)); discussion_fixture = DiscussionFixture::default().with_title([0; 201].to_vec()); - discussion_fixture.create_discussion_and_assert(Err(crate::MSG_TOO_LONG_TITLE)); + discussion_fixture.create_discussion_and_assert(Err(MSG_TOO_LONG_TITLE)); }); } @@ -354,3 +397,27 @@ fn update_post_call_with_invalid_text_failed() { post_fixture3.update_post_and_assert(Err(MSG_TOO_LONG_POST)); }); } + +#[test] +fn add_discussion_thread_fails_because_of_max_thread_by_same_author_in_a_row_limit_exceeded() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + for idx in 1..=3 { + discussion_fixture + .create_discussion_and_assert(Ok(idx)) + .unwrap(); + } + + discussion_fixture + .create_discussion_and_assert(Err(MSG_MAX_THREAD_IN_A_ROW_LIMIT_EXCEEDED)); + }); +} + +#[test] +fn add_discussion_thread_fails_because_of_invalid_author_origin() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default().with_author(2); + + discussion_fixture.create_discussion_and_assert(Err(MSG_INVALID_THREAD_AUTHOR_ORIGIN)); + }); +} diff --git a/modules/proposals/discussion/src/types.rs b/modules/proposals/discussion/src/types.rs index 50a2d4f049..d9aa1c7a25 100644 --- a/modules/proposals/discussion/src/types.rs +++ b/modules/proposals/discussion/src/types.rs @@ -40,3 +40,38 @@ pub struct Post { /// Defines how many times this post was edited. Zero on creation. pub edition_number: u32, } + +/// Post for the discussion thread +#[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] +#[derive(Encode, Decode, Default, Clone, Copy, PartialEq, Eq)] +pub struct ThreadCounter { + /// Author of the threads. + pub author_id: ThreadAuthorId, + + /// ThreadCount + pub counter: u32, +} + +impl ThreadCounter { + /// Increments existing counter + pub fn increment(&self) -> Self { + ThreadCounter { + counter: self.counter + 1, + author_id: self.author_id.clone(), + } + } + + /// Creates new counter by author_id. Counter instantiated with 1. + pub fn new(author_id: ThreadAuthorId) -> Self { + ThreadCounter { + author_id, + counter: 1, + } + } +} + +/// Abstract validator for the origin(account_id) and actor_id (eg.: thread author id). +pub trait ActorOriginValidator { + /// Check for valid combination of origin and actor_id + fn validate_actor_origin(origin: Origin, actor_id: ActorId) -> bool; +}