//! # News Unit Pallet
//!
//! Pallet that allows token holders to create and edit posts and comments for an asset
//! Also the token holders can upvote on comments and posts.
//!
//! ## Overview
//!
//! News module provides functionality to the token holders to
//! create and edit posts, create comments on other users posts, and upvote
//! comments of posts of other users.
//!
//! The supported dispatchable functions are documented in the [`Call`] enum.
//!
//! ### Goals
//!
//! The pallet is designed to make the following possible:
//!
//! * Create a post.
//! * Create a comment in a post.
//! * Upvote a post.
//! * Upvote a post comment.
//! * Edit a post by the post´s creator.
//! * Edit a post comment by the comment´s creator.
//! * Remove a vote.
//!
//! ## Interface
//!
//! //! ### Permissionless Functions
//!
//! * `create_post`: Creates a new post.
//! * `create_comment`: Create a comment on an existing post.
//! * `upvote_post`: Upvote an existing post, remove vote if already voted.
//! * `upvote_comment`: Upvote an existing comment in a post, remove vote if already voted.
//!
//! ### Privileged Functions
//!
//! * `update_post`: Update the content of a post, called by the post´s creator.
//! * `update_comment`: Update the content of a comment, called by the comment´s owner.
//!
//! Please refer to the [`Call`] enum and its associated variants for documentation on each
//! function.
#![cfg_attr(not(feature = "std"), no_std)]
pub use pallet::*;
#[cfg(test)]
mod mock;
mod types;
#[cfg(test)]
mod test;
pub use scale_info;
pub const MIN_TEXT_CONTENT: u32 = 1;
pub const MAX_TEXT_CONTENT: u32 = 10000;
#[frame_support::pallet]
pub mod pallet {
use super::*;
use frame_support::{
dispatch::DispatchError,
pallet_prelude::*,
sp_runtime::traits::Hash,
traits::fungibles,
};
use frame_system::pallet_prelude::*;
use traits::{ subaccounts::{ SubAccounts, AccountOrigin } };
pub type AssetIdOf<T> =
<<T as pallet::Config>::Fungibles as fungibles::Inspect<<T as frame_system::Config>::AccountId>>::AssetId;
pub type BalanceOf<T> =
<<T as pallet::Config>::Fungibles as fungibles::Inspect<<T as frame_system::Config>::AccountId>>::Balance;
pub type PostId = u128;
#[pallet::pallet]
pub struct Pallet<T>(_);
#[pallet::config]
pub trait Config: frame_system::Config {
type RuntimeEvent: From<Event<Self>> + IsType<<Self as frame_system::Config>::RuntimeEvent>;
/// Maximun length of the user inputs.
#[pallet::constant]
type MaxLength: Get<u32>;
/// Minimun amount of tokens that a user should hold to call extrinsics.
#[pallet::constant]
type MinAmountToAsk: Get<BalanceOf<Self>>;
/// Type to access the asset pallet.
type Fungibles: fungibles::Inspect<Self::AccountId> +
fungibles::Mutate<Self::AccountId> +
fungibles::metadata::Inspect<Self::AccountId>;
/// Type to access the sub account pallet
type SubAccounts: SubAccounts<Self::AccountId, AccountOrigin>;
}
#[pallet::storage]
#[pallet::getter(fn next_post_id)]
/// The next post id. First post Id = 0.
pub type NextPostId<T: Config> = StorageValue<_, PostId, ValueQuery, GetDefault>;
#[pallet::storage]
#[pallet::getter(fn posts)]
/// Details of each post
pub(super) type PostsStore<T: Config> = StorageMap<
_,
Twox64Concat,
u128,
types::Posts<T::AccountId, AssetIdOf<T>, T::MaxLength>,
OptionQuery
>;
#[pallet::storage]
#[pallet::getter(fn comments)]
/// Details of each comment
pub(super) type CommentStore<T: Config> = StorageMap<
_,
Twox64Concat,
T::Hash,
types::PostComments<T::AccountId, T::MaxLength>,
OptionQuery
>;
#[pallet::storage]
#[pallet::getter(fn commentsvotes)]
/// Store if an account id has voted for a comment.
pub type CommentVotesStore<T: Config> = StorageDoubleMap<
_,
Blake2_128Concat,
T::AccountId,
Blake2_128Concat,
T::Hash,
bool,
ValueQuery
>;
#[pallet::storage]
/// Store if an account id has voted for a post.
#[pallet::getter(fn postvotes)]
pub type PostVotesStore<T: Config> = StorageDoubleMap<
_,
Blake2_128Concat,
T::AccountId,
Blake2_128Concat,
u128,
bool,
ValueQuery
>;
#[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> {
/// Post was created
PostCreated {
title: BoundedVec<u8, T::MaxLength>,
user_address: T::AccountId,
post_id: u128,
},
/// Post was edited
PostEdited {
post_id: u128,
user_address: T::AccountId,
},
/// Comment was created
PostCommentCreated {
comment_text: BoundedVec<u8, T::MaxLength>,
comment_id: T::Hash,
user_id: T::AccountId,
post_id: u128,
},
/// Comment was edited
PostCommentEdited {
comment_id: T::Hash,
user_id: T::AccountId,
},
/// Post was upvoted
UpvotePost {
post_id: u128,
user_address: T::AccountId,
vote_count: u128,
},
/// Comment was upvoted
UpvoteComment {
comment_id: T::Hash,
user_address: T::AccountId,
post_id: u128,
vote_count: u128,
},
}
#[pallet::error]
pub enum Error<T> {
/// Input are not large enough.
PostNotEnoughBytes,
/// User don´t hold enough token balance.
NotEnoughTokens,
/// Inputs are too large.
PostTooManyBytes,
/// The post id doesn´t exists.
PostNotFound,
/// Only creator of post/comment can update it
NotCreator,
}
#[pallet::call]
impl<T: Config> Pallet<T> {
/// The origin can create post for a particular asset.
///
/// The origin must be Signed.
///
/// The title length is bounded.
///
/// The origin should hold a minimun amount of tokens.
///
/// Parameters:
/// - `title`: The title of the post.
/// - `post_url`: The link contained in the post.
/// - `token_id`: The identifier of the asset to create the post.
///
/// Emits `PostCreated` event when successful.
///
/// Weight: `O(1)` TODO: Add correct weight
#[pallet::call_index(0)]
#[pallet::weight(10_000 + T::DbWeight::get().writes(1).ref_time())]
pub fn create_post(
origin: OriginFor<T>,
title: BoundedVec<u8, T::MaxLength>,
posturl: BoundedVec<u8, T::MaxLength>,
token_id: AssetIdOf<T>
) -> DispatchResult {
// Check that the extrinsic was signed and get the signer.
let mut who = ensure_signed(origin)?;
// Mutate the origin to use the main account to store the info
who = <T as Config>::SubAccounts::get_main_account(who)?;
// check if post is valid
ensure!((title.len() as u32) > MIN_TEXT_CONTENT, <Error<T>>::PostNotEnoughBytes);
ensure!((title.len() as u32) < MAX_TEXT_CONTENT, <Error<T>>::PostTooManyBytes);
let who_balance =
<<T as Config>::Fungibles as fungibles::Inspect<<T as frame_system::Config>::AccountId>>::balance(
token_id.clone(),
&who
);
ensure!(who_balance >= T::MinAmountToAsk::get(), Error::<T>::NotEnoughTokens);
// create a new id, this is a counter that should increase by 1
let id = Self::next_post_id();
// post creation
let post: types::Posts<T::AccountId, AssetIdOf<T>, T::MaxLength> = types::Posts {
user_address: who.clone(),
posttitle: title.clone(),
posturl,
// posttext: content.clone(),
postvotes: 0,
postcomments: 0,
postask: false,
posthide: false,
postblocked: false,
token_id: token_id.clone(),
};
PostsStore::<T>::insert(id, post);
NextPostId::<T>::set(id.saturating_add(1));
Self::deposit_event(Event::<T>::PostCreated {
title,
user_address: who.clone(),
post_id: id,
});
return Ok(());
}
/// The origin can create a comment in a particular post.
///
/// The origin must be Signed.
///
/// The post should exist.
///
/// The comment length is bounded.
///
/// The origin should hold a minimun amount of tokens.
///
/// Parameters:
/// - `post_id`: The identifier of the post to add the comment.
/// - `comment_text`: The content of the comment.
///
/// Emits `PostCommentCreated` event when successful.
/// Weight: `O(1)` TODO: Add correct weight
#[pallet::call_index(1)]
#[pallet::weight(10_000 + T::DbWeight::get().writes(1).ref_time())]
pub fn create_comment(
origin: OriginFor<T>,
post_id: u128,
comment_text: BoundedVec<u8, T::MaxLength>
) -> DispatchResult {
// Check that the extrinsic was signed and get the signer.
let mut who = ensure_signed(origin)?;
// Mutate the origin to use the main account to store the info
who = <T as Config>::SubAccounts::get_main_account(who)?;
// let Some(post_data) = PostsStore::<T>::get(post_id);
let post_data = PostsStore::<T>::get(post_id).expect("Post not found");
let who_balance =
<<T as Config>::Fungibles as fungibles::Inspect<<T as frame_system::Config>::AccountId>>::balance(
post_data.token_id,
&who
);
ensure!(who_balance >= T::MinAmountToAsk::get(), Error::<T>::NotEnoughTokens);
// ensure post exists
ensure!(<PostsStore<T>>::contains_key(post_id.clone()), <Error<T>>::PostNotFound);
// check if comment is valid
ensure!((comment_text.len() as u32) > MIN_TEXT_CONTENT, <Error<T>>::PostNotEnoughBytes);
ensure!((comment_text.len() as u32) < MAX_TEXT_CONTENT, <Error<T>>::PostTooManyBytes);
// post creation
let create_comment = types::PostComments::<T::AccountId, T::MaxLength> {
post_id: post_id.clone(),
user_address: who.clone(),
comment_text: comment_text.clone(),
comment_votes: 0,
};
let comment_id = T::Hashing::hash_of(&create_comment);
// push to storage
CommentStore::<T>::insert(comment_id.clone(), create_comment);
// update votes number
PostsStore::<T>::mutate(
post_id.clone(),
|maybe_value: &mut Option<types::Posts<T::AccountId, AssetIdOf<T>, T::MaxLength>>| {
if let Some(value) = maybe_value {
value.postcomments = value.postcomments.saturating_add(1);
}
}
);
// emit event
Self::deposit_event(Event::PostCommentCreated {
comment_text,
comment_id,
post_id,
user_id: who,
});
return Ok(());
}
/// The origin can upvote a particular post.
///
/// The origin must be Signed.
///
/// The post should exist.
///
/// The origin should hold a minimun amount of tokens.
///
/// If the origin already upvote the post, the upvote is removed.
///
/// Parameters:
/// - `post_id`: The identifier of the post to add the upvote.
///
/// Emits `UpvotePost` event when successful.
/// Weight: `O(1)` TODO: Add correct weight
#[pallet::call_index(2)]
#[pallet::weight(10_000 + T::DbWeight::get().writes(1).ref_time())]
pub fn upvote_post(origin: OriginFor<T>, post_id: u128) -> DispatchResult {
let mut who = ensure_signed(origin)?;
// Mutate the origin to use the main account to store the info
who = <T as Config>::SubAccounts::get_main_account(who)?;
let post_data = PostsStore::<T>::get(post_id).expect("Post not Found");
let who_balance =
<<T as Config>::Fungibles as fungibles::Inspect<<T as frame_system::Config>::AccountId>>::balance(
post_data.token_id,
&who
);
ensure!(who_balance >= T::MinAmountToAsk::get(), Error::<T>::NotEnoughTokens);
// check if post exists
ensure!(<PostsStore<T>>::get(post_id.clone()).is_some(), <Error<T>>::PostNotFound);
// create vote
let user_vote = PostVotesStore::<T>::get(who.clone(), post_id);
let new_user_vote = !user_vote;
// store vote
<PostVotesStore<T>>::insert(who.clone(), post_id.clone(), new_user_vote);
// if post exists, retreive post and mutate the vote count
PostsStore::<T>::mutate(
post_id.clone(),
|maybe_value: &mut Option<types::Posts<T::AccountId, AssetIdOf<T>, T::MaxLength>>| {
if let Some(value) = maybe_value {
if new_user_vote {
value.postvotes = value.postvotes.saturating_add(1);
} else {
value.postvotes = value.postvotes.saturating_sub(1);
}
}
}
);
let updated_votes = PostsStore::<T>::get(post_id).expect("Post not found!");
// emit event
Self::deposit_event(Event::UpvotePost {
post_id,
user_address: who.clone(),
vote_count: updated_votes.postvotes,
});
Ok(())
}
/// The origin can upvote a particular comment in a particular post.
///
/// The origin must be Signed.
///
/// The post and the comment should exist.
///
/// The origin should hold a minimun amount of tokens.
///
/// If the origin already upvote the comment, the upvote is removed.
///
/// Parameters:
/// - `post_id`: The identifier of the post to add the upvote.
/// - `comment_id`: The identifier of the comment to add the upvote.
///
/// Emits `UpvoteComment` event when successful.
/// Weight: `O(1)` TODO: Add correct weight
#[pallet::call_index(3)]
#[pallet::weight(10_000 + T::DbWeight::get().writes(1).ref_time())]
pub fn upvote_comment(
origin: OriginFor<T>,
post_id: u128,
comment_id: T::Hash
) -> DispatchResult {
let mut who = ensure_signed(origin)?;
// Mutate the origin to use the main account to store the info
who = <T as Config>::SubAccounts::get_main_account(who)?;
let post_data = PostsStore::<T>::get(post_id.clone()).expect("Post not Found");
let who_balance =
<<T as Config>::Fungibles as fungibles::Inspect<<T as frame_system::Config>::AccountId>>::balance(
post_data.token_id,
&who
);
ensure!(who_balance >= T::MinAmountToAsk::get(), Error::<T>::NotEnoughTokens);
// enure comment exists
ensure!(<CommentStore<T>>::contains_key(comment_id.clone()), <Error<T>>::PostNotFound);
// create vote
let user_vote = CommentVotesStore::<T>::get(who.clone(), comment_id);
let new_user_vote = !user_vote;
<CommentVotesStore<T>>::insert(who.clone(), comment_id, new_user_vote);
// if comment exists, retreive comment and mutate the vote count
CommentStore::<T>::mutate(comment_id.clone(), |maybe_value| {
if let Some(value) = maybe_value {
if new_user_vote {
value.comment_votes = value.comment_votes.saturating_add(1);
} else {
value.comment_votes = value.comment_votes.saturating_sub(1);
}
}
});
let updated_votes = CommentStore::<T>::get(comment_id).expect("Comment not found!");
// emit event
Self::deposit_event(Event::UpvoteComment {
comment_id,
user_address: who.clone(),
post_id,
vote_count: updated_votes.comment_votes,
});
Ok(())
}
/// The origin can update a particular post.
///
/// The origin must be Signed and the sender must be the creator of the post.
///
/// The post should exist.
///
/// The origin should hold a minimun amount of tokens.
///
/// The title length is bounded.
///
/// Parameters:
/// - `post_id`: The identifier of the post to update.
/// - `post_url`: The new link that the post will contain.
/// - `title`: The new title that the post will have.
///
/// Emits `PostEdited` event when successful.
/// Weight: `O(1)` TODO: Add correct weight
#[pallet::call_index(4)]
#[pallet::weight(10_000 + T::DbWeight::get().writes(1).ref_time())]
pub fn update_post(
origin: OriginFor<T>,
post_id: u128,
post_url: BoundedVec<u8, T::MaxLength>,
title: BoundedVec<u8, T::MaxLength>
) -> DispatchResult {
// Check that the extrinsic was signed and get the signer.
let mut who = ensure_signed(origin)?;
// Mutate the origin to use the main account to store the info
who = <T as Config>::SubAccounts::get_main_account(who)?;
let post_data = PostsStore::<T>::get(post_id).expect("Post not Found");
// Only the creator of the post can update it
ensure!(who == post_data.user_address, Error::<T>::NotCreator);
let who_balance =
<<T as Config>::Fungibles as fungibles::Inspect<<T as frame_system::Config>::AccountId>>::balance(
post_data.token_id,
&who
);
ensure!(who_balance >= T::MinAmountToAsk::get(), Error::<T>::NotEnoughTokens);
// check if post exists
ensure!(<PostsStore<T>>::get(post_id.clone()).is_some(), <Error<T>>::PostNotFound);
// check if postt is valid
ensure!((title.len() as u32) > MIN_TEXT_CONTENT, <Error<T>>::PostNotEnoughBytes);
ensure!((title.len() as u32) < MAX_TEXT_CONTENT, <Error<T>>::PostTooManyBytes);
// check if post is blocked
let old_post = <PostsStore<T>>::get(post_id.clone()).expect("Post not found!");
if old_post.postblocked {
return Err(DispatchError::Unavailable);
}
PostsStore::<T>::mutate(
post_id.clone(),
|maybe_value: &mut Option<types::Posts<T::AccountId, AssetIdOf<T>, T::MaxLength>>| {
if let Some(value) = maybe_value {
value.posttitle = title.clone();
value.posturl = post_url.clone();
}
}
);
// emit event
Self::deposit_event(Event::PostEdited { post_id, user_address: who.clone() });
// Return a successful DispatchResultWithPostInfo
Ok(())
}
/// The origin can update a particular comment in a post.
///
/// The origin must be Signed and the sender must be the creator of the comment.
///
/// The post and comment should exist.
///
/// The origin should hold a minimun amount of tokens.
///
/// The content length is bounded.
///
/// Parameters:
/// - `post_id`: The identifier of the post to update.
/// - `comment_id`: The identifier of the comment to update.
/// - `content`: The new content that the comment will have.
///
/// Emits `PostCommentEdited` event when successful.
/// Weight: `O(1)` TODO: Add correct weight
#[pallet::call_index(5)]
#[pallet::weight(10_000 + T::DbWeight::get().writes(1).ref_time())]
pub fn update_comment(
origin: OriginFor<T>,
post_id: u128,
comment_id: T::Hash,
content: BoundedVec<u8, T::MaxLength>
) -> DispatchResult {
// Check that the extrinsic was signed and get the signer.
let mut who = ensure_signed(origin)?;
// Mutate the origin to use the main account to store the info
who = <T as Config>::SubAccounts::get_main_account(who)?;
let post_data = PostsStore::<T>::get(post_id).expect("Post not Found");
let who_balance =
<<T as Config>::Fungibles as fungibles::Inspect<<T as frame_system::Config>::AccountId>>::balance(
post_data.token_id,
&who
);
ensure!(who_balance >= T::MinAmountToAsk::get(), Error::<T>::NotEnoughTokens);
// check if comment exists
let comment = <CommentStore<T>>
::get(comment_id.clone())
.ok_or(<Error<T>>::PostNotFound)?;
// Only the creator of the post can update it
ensure!(who == comment.user_address, Error::<T>::NotCreator);
// check if comment is valid
ensure!((content.len() as u32) > MIN_TEXT_CONTENT, <Error<T>>::PostNotEnoughBytes);
ensure!((content.len() as u32) < MAX_TEXT_CONTENT, <Error<T>>::PostTooManyBytes);
CommentStore::<T>::mutate(comment_id.clone(), |maybe_value| {
if let Some(value) = maybe_value {
value.comment_text = content.clone();
}
});
// emit event
Self::deposit_event(Event::PostCommentEdited { comment_id, user_id: who.clone() });
// Return a successful DispatchResultWithPostInfo
Ok(())
}
}
}