From edd3eceb09585c4af0c7ae9e03fc45b53552f595 Mon Sep 17 00:00:00 2001 From: Sang Kim Date: Wed, 8 May 2024 15:49:15 +0900 Subject: [PATCH] [docs] Reviews Rating (#16520) ## Description Describe the changes or additions included in this PR. ## Test Plan How did you test the new or updated feature? --- If your changes are not user-facing and do not break anything, you can skip the following section. Otherwise, please briefly describe what has changed under the Release Notes section. ### Type of Change (Check all that apply) - [ ] protocol change - [ ] user-visible impact - [ ] breaking change for a client SDKs - [ ] breaking change for FNs (FN binary must upgrade) - [ ] breaking change for validators or node operators (must upgrade binaries) - [ ] breaking change for on-chain data layout - [ ] necessitate either a data wipe or data migration ### Release notes --------- Co-authored-by: Ronny Roland Co-authored-by: Ashok Menon Co-authored-by: ronny-mysten <118224482+ronny-mysten@users.noreply.github.com> --- .../content/guides/developer/app-examples.mdx | 1 + .../developer/app-examples/reviews-rating.mdx | 453 ++++++++++++++++++ docs/content/sidebars/guides.js | 1 + examples/move/reviews_rating/Move.toml | 10 + .../reviews_rating/sources/dashboard.move | 30 ++ .../reviews_rating/sources/moderator.move | 44 ++ .../move/reviews_rating/sources/review.move | 102 ++++ .../move/reviews_rating/sources/service.move | 282 +++++++++++ 8 files changed, 923 insertions(+) create mode 100644 docs/content/guides/developer/app-examples/reviews-rating.mdx create mode 100644 examples/move/reviews_rating/Move.toml create mode 100644 examples/move/reviews_rating/sources/dashboard.move create mode 100644 examples/move/reviews_rating/sources/moderator.move create mode 100644 examples/move/reviews_rating/sources/review.move create mode 100644 examples/move/reviews_rating/sources/service.move diff --git a/docs/content/guides/developer/app-examples.mdx b/docs/content/guides/developer/app-examples.mdx index e6dd6be5a82c9..4c790008cbdad 100644 --- a/docs/content/guides/developer/app-examples.mdx +++ b/docs/content/guides/developer/app-examples.mdx @@ -18,3 +18,4 @@ Sui is dedicated to providing a wide range of examples to guide you in proper pr - [Tic-tac-toe](./app-examples/tic-tac-toe.mdx): Three implementations of the classic tic-tac-toe game on the Sui network to demonstrate different approaches to user interaction. - [Trustless Swap](./app-examples/trustless-swap.mdx): This example demonstrates trustless swaps on the Sui blockchain using a shared object as an escrow account. - [Weather Oracle](./app-examples/weather-oracle.mdx): The Sui Weather Oracle demonstrates how to create a basic weather oracle that provides real-time weather data. +- [Reviews Rating](./app-examples/reviews-rating.mdx): This example demonstrates implementing a reviews-rating platform for the food service industry on Sui. diff --git a/docs/content/guides/developer/app-examples/reviews-rating.mdx b/docs/content/guides/developer/app-examples/reviews-rating.mdx new file mode 100644 index 0000000000000..e0b831b450533 --- /dev/null +++ b/docs/content/guides/developer/app-examples/reviews-rating.mdx @@ -0,0 +1,453 @@ +--- +title: Review Rating +--- + +The following documentation goes through an example implementation of a review rating platform for the food service industry on Sui. +Unlike traditional review rating platforms that often do not disclose the algorithm used to rate reviews, this example uses an algorithm that is published on-chain for everyone to see and verify. The low gas cost of computation on Sui make it financially feasible to submit, score, and order all reviews on-chain. + +## Personas + +There are four actors in the typical workflow of the Reviews Rating example. + +- Service: Review requester. +- Dashboard: Review hub. +- Reviewer: Review creator. +- Moderator: Review list editor. + +```mermaid +sequenceDiagram + Service ->> Dashboard: Add service to dashboard + Service ->> Reviewer: Send proof of experience + Reviewer ->> Service: Send review + Service ->> Reviewer: Send reward + Review reader ->> Service: Vote on review + Moderator ->> Service: Remove abused review +``` + +### Service owners + +Service owners are entities like restaurants that list their services on the platform. They want to attract more customers by receiving high-rated reviews for their services. + +Service owners allocate a specific amount of SUI as a reward pool. Assets from the pool are used to provide rewards for high-rated reviews. A proof of experience (PoE) NFT confirms a reviewer used the service, which the reviewer can burn later to provide a verified review. Service owners provide their customers with unique identifiers (perhaps using QR codes) to identify individual reviewers. + +### Reviewers + +Reviewers are consumers of services that use the review system. Reviewers provide feedback in the form of comments that detail specific aspects of the service as well as a star rating to inform others. The reviews are rated, with the most effective reviews getting the highest rating. Service owners award the 10 highest rated reviews for their service. How often the rewards are distributed is up to the service owner's discretion; for example, the rewards can be distributed once a week or once a month. + +### Review readers + +Review readers access reviews to make informed decisions on selecting services. Readers rate reviews by casting votes. The review readers' ratings are factored into the algorithm that rates the reviews, with the authors of the highest-rated reviews getting rewarded. Although it is not implemented as part of this guide, this example could be extended to award review readers a portion of the rewards for casting votes for reviews. + +### Moderators + +Moderators monitor content of the reviews and can delete any reviews that contain inappropriate content. + +The incentive mechanism for moderators is not implemented for this guide, but service owners can all pay into a pool that goes to moderators on a rolling basis. People can stake moderators to influence what portion of the reward each moderator gets, up to a limit (similar to how validators are staked on chain), and moderator decisions are decided by quorum of stake weight. This process installs incentives for moderators to perform their job well. + +## How reviews are scored + +The reviews are scored on chain using the following criteria: +- Intrinsic score (IS): Length of review content. +- Extrinsic score (ES): Number of votes a review receives. +- Verification multiplier (VM): Reviews with PoE receive a multiplier to improve rating. + +``` +Total Score = (IS + ES) * VM +``` + +## Smart contracts + +There are several modules that create the backend logic for the example. + +### dashboard.move + +The `dashboard.move` module defines the `Dashboard` struct that groups services. + +```move +/// Dashboard is a collection of services +public struct Dashboard has key, store { + id: UID, + service_type: String +} +``` + +The services are grouped by attributes, which can be cuisine type, geographical location, operating hours, Google Maps ID, and so on. To keep it basic, the example stores only `service_type` (for example, fast food, Chinese, Italian). + +```move +/// Creates a new dashboard +public fun create_dashboard( + service_type: String, + ctx: &mut TxContext, +) { + let db = Dashboard { + id: object::new(ctx), + service_type + }; + transfer::share_object(db); +} + +/// Registers a service to a dashboard +public fun register_service(db: &mut Dashboard, service_id: ID) { + df::add(&mut db.id, service_id, service_id); +} +``` + +A `Dashboard` is a [shared object](../../../concepts/object-ownership/shared.mdx), so any service owner can register their service to a dashboard. +A service owner should look for dashboards that best match their service attribute and register. +A [dynamic field](../../../concepts/dynamic-fields.mdx) stores the list of services that are registered to a dashboard. +A service may be registered to multiple dashboards at the same time. For example, a Chinese-Italian fusion restaurant may be registered to both the Chinese and Italian dashboards. + +:::info + +See [Shared versus Owned Objects](../sui-101/shared-owned.mdx) for more information on the differences between object types. + +::: + +### review.move + +This module defines the `Review` struct. + +```move +/// Represents a review of a service +public struct Review has key, store { + id: UID, + owner: address, + service_id: ID, + content: String, + // intrinsic score + len: u64, + // extrinsic score + votes: u64, + time_issued: u64, + // proof of experience + has_poe: bool, + total_score: u64, + overall_rate: u8, +} + +/// Updates the total score of a review +fun update_total_score(rev: &mut Review) { + rev.total_score = rev.calculate_total_score(); +} + +/// Calculates the total score of a review +fun calculate_total_score(rev: &Review): u64 { + let mut intrinsic_score: u64 = rev.len; + intrinsic_score = math::min(intrinsic_score, 150); + let extrinsic_score: u64 = 10 * rev.votes; + // VM = either 1.0 or 2.0 (if user has proof of experience) + let vm: u64 = if (rev.has_poe) { 2 } else { 1 }; + (intrinsic_score + extrinsic_score) * vm +} +``` + +In addition to the content of a review, all the elements that are required to compute total score are stored in a `Review` object. + +A `Review` is a [shared object](../../../concepts/object-ownership/shared.mdx), so anyone can cast a vote on a review and update its `total_score` field. +After `total_score` is updated, the [`update_top_reviews`](#casting-votes) function can be called to update the `top_reviews` field of the `Service` object. + +### service.move + +This module defines the `Service` struct that service owners manage. + +```move +const MAX_REVIEWERS_TO_REWARD: u64 = 10; + +/// Represents a service +public struct Service has key, store { + id: UID, + reward_pool: Balance, + reward: u64, + top_reviews: vector, + reviews: ObjectTable, + overall_rate: u64, + name: String +} +``` + +#### Reward distribution + +The same amount is rewarded to top reviewers, and the reward is distributed to 10 participants at most. +The pool of `SUI` tokens to be distributed to reviewers is stored in the `reward_pool` field, and the amount of `SUI` tokens awarded to each participant is configured in `reward` field. + +#### Storage for reviews + +Because anyone can submit a review for a service, `Service` is defined as a shared object. All the reviews are stored in the `reviews` field, which has [ObjectTable](../../../concepts/sui-move-concepts/collections.mdx#object_table)`` type. The `reviews` are stored as children of the shared object, but they are still accessible by their `ID`. +In other words, anyone can go to a transaction explorer and find a review object by its object ID, but they won't be able to use a review as an input to a transaction by its object ID. + +:::info + +See [Table and Bag](../../../concepts/dynamic-fields/tables-bags.mdx) for more information on the differences between `Table` and `ObjectTable`. + +::: + +The top rated reviews are stored in `top_reviews` field, which has `vector` type. A simple vector can store the top rated reviews because the maximum number of reviews that can be rewarded is 10. +The elements of `top_reviews` are sorted by the `total_score` of the reviews, with the highest rated reviews coming first. The vector contains the `ID` of the reviews, which can be used to retrieve content and vote count from the relevant `reviews`. + +#### Casting votes + +A reader can cast a vote on a review to rate it as follows: + +```move +/// Upvotes a review and reorders top_reviews +public fun upvote(service: &mut Service, review_id: ID) { + let review = &mut service.reviews[review_id]; + review.upvote(); + service.reorder(review_id, review.get_total_score()); +} + +/// Reorders top_reviews after a review is updated +/// If the review is not in top_reviews, it will be added if it is in the top 10 +/// Otherwise, it will be reordered +fun reorder( + service: &mut Service, + review_id: ID, + total_score: u64 +) { + let (contains, idx) = service.top_reviews.index_of(&review_id); + if (!contains) { + service.update_top_reviews(review_id, total_score); + } else { + service.top_reviews.remove(idx); + let idx = service.find_idx(total_score); + service.top_reviews.insert(review_id, idx); + } +} + +/// Updates top_reviews if necessary +fun update_top_reviews( + service: &mut Service, + review_id: ID, + total_score: u64 +) { + if (service.should_update_top_reviews(total_score)) { + let idx = service.find_idx(total_score); + service.top_reviews.insert(review_id, idx); + service.prune_top_reviews(); + }; +} + +/// Finds the index of a review in top_reviews +fun find_idx(service: &Service, total_score: u64): u64 { + let mut i = service.top_reviews.length(); + while (0 < i) { + let review_id = service.top_reviews[i - 1]; + if (service.get_total_score(review_id) > total_score) { + break + }; + i = i - 1; + }; + i +} + +/// Prunes top_reviews if it exceeds MAX_REVIEWERS_TO_REWARD +fun prune_top_reviews( + service: &mut Service +) { + let len = service.top_reviews.length(); + if (len > MAX_REVIEWERS_TO_REWARD) { + service.top_reviews.pop_back(); + }; +} +``` + +Whenever someone casts a vote on a review, the `total_score` of the review is updated and the `update_top_reviews` function updates the `top_reviews` field, as needed. +Casting a vote also triggers a reordering of the `top_reviews` field to ensure that the top rated reviews are always at the top. + +#### Authorization + +```move +/// A capability that can be used to perform admin operations on a service +struct AdminCap has key, store { + id: UID, + service_id: ID +} + +/// Represents a moderator that can be used to delete reviews +struct Moderator has key { + id: UID, +} +``` + +This example follows a [capabilities pattern](../../../concepts/sui-move-concepts/patterns/capabilities.mdx) to manage authorizations. +For example, `SERVICE OWNERS` are given `AdminCap` and `MODERATORS` are given `Moderator` such that only they are allowed to perform privileged operations. + +## Deployment + +Navigate to the [setup folder](https://github.com/MystenLabs/reviews-ratings-poc/tree/main/setup) of the repository and execute the `publish.sh` script. Refer to the [README instructions](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/README.md) for deploying the smart contracts. + +## Frontend + +The frontend module is written in React, and is structured to provide a responsive user experience for interacting with a review rating platform. The [`page` component](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/app/page.tsx) supports user log in as a `SERVICE OWNER`, a `MODERATOR`, or a `REVIEWER`. A `REVIEW READER` role is not implemented for this example, but a `REVIEWER` can also read reviews and cast votes. + +### Directories structure + +The frontend is a NextJS project, that follows the NextJS App Router [project structure](https://nextjs.org/docs/app/building-your-application/routing). +The main code of the frontend is located in the [app/src/](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/app/) directory. + +The main sub-directories are: + - [app/](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/app/): The main code of the pages and the global styles. + - [components/](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/components): The reusable components of the app, organized in sub-directories. + - [hooks/](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/hooks): The custom hooks used in the app. + - [moderator/](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/moderator): The pages for `MODERATOR`. + - [serviceOwner/](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/serviceOwner): The pages for `SERVICE OWNER`. + - [types/](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/types): The types/interfaces used in the app. + - [user/](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/user): The pages for `REVIEWER`. + +### Connect button + +The Wallet Kit comes with a pre-built React.js component called `ConnectButton` that displays a button to connect and disconnect a wallet. The component handles connecting and disconnecting wallet logic. + +Place the `ConnectButton` in the navigation bar for users to connect their wallets: + +```ts title='src/app/components/navbar/Navbar.tsx' +import { ConnectButton } from "@mysten/wallet-kit"; +import { usePathname } from "next/navigation"; +import { useAuthentication } from "@/app/hooks/useAuthentication"; + +export const Navbar = () => { + const pathname = usePathname(); + console.log(pathname); + const { user, handleLogout } = useAuthentication(); + + return ( +
+
+
+ Restaurant Reviews +
+
+ +
+ {pathname !== "/" && ( +
+ logged in as{" "} + + {user.role === "user" && "USER"} + {user.role === "serviceOwner" && "SERVICE OWNER"} + {user.role === "moderator" && "MODERATOR"} + +
+ )} +
+ +
+ +
+
+ ); +}; +``` + +### Type definitions + +All the type definitions are in `src/app/types/`. + +`Review` and `Service` represent the review and service objects. + +```ts title='src/app/types/Review.ts' +export interface Review { + id: string; + owner: string; + service_id: string; + content: string; + len: number; + votes: number; + time_issued: number; + has_poe: boolean; + total_score: number; +} +``` +```ts title='src/app/types/Service.ts' +export interface Service { + id: string; + name: string; + stars: number; + reward?: number; + pool?: number; +} +``` + +### Execute transaction hook + +In the frontend, you might need to execute a transaction block in multiple places, hence it's better to extract the transaction execution logic and reuse it everywhere. Let's examine the execute transaction hook. + +```ts title='src/app/hooks/useSignAndExecuteTransaction.ts' +import { useWalletKit } from "@mysten/wallet-kit" +import { toast } from "react-hot-toast"; +import { useSui } from "./useSui"; +import { TransactionBlock } from "@mysten/sui.js/transactions"; + +export const useSignAndExecuteTransaction = () => { + const { executeSignedTransactionBlock } = useSui(); + const { signTransactionBlock } = useWalletKit(); + const handleSignAndExecuteTransaction = async ( + tx: TransactionBlock, + operation: String, + setIsLoading: any + ) => { + return signTransactionBlock({ + transactionBlock: tx, + }).then((signedTx: any) => { + return executeSignedTransactionBlock({ + signedTx, + requestType: "WaitForLocalExecution", + options: { + showEffects: true, + showEvents: true, + }, + }).then((resp) => { + setIsLoading(false); + console.log(resp); + if (resp.effects?.status.status === "success") { + console.log(`${operation} operation successful`); + toast.success(`${operation} operation successful`); + return + } else { + console.log(`${operation} operation failed`); + toast.error(`${operation} operation failed.`); + return + } + }).catch((err) => { + setIsLoading(false); + console.log(`${operation} operation failed`); + console.log(`${operation} error : `,err); + toast.error(`Something went wrong, ${operation} operation failed.`); + }); + }).catch((err) => { + setIsLoading(false); + console.log(`signing goes wrong ${operation} error : `,err); + toast.error(`signing goes wrong, ${operation} operation failed.`); + }); + } + return { handleSignAndExecuteTransaction } +} +``` +A `TransactionBlock` is the input, sign it with the current connected wallet account, execute the transaction block, return the execution result, and finally display a basic toast message to indicate whether the transaction is successful or not. + +Use the `useWalletKit()` hook from the Wallet Kit to retrieve the Sui client instance configured in `WalletKitProvider`. The `signTransactionBlock()` function is another hook that helps to sign the transaction block using the currently connected wallet. It displays the UI for users to review and sign their transactions with their selected wallet. To execute a transaction block, the `executeSignedTransactionBlock()` on the Sui client instance of the Sui TypeScript SDK. Use `react-hot-toast` as another dependency to toast transaction status to users. + +### Components and custom hooks for state management + +- Custom hooks: To keep the code as structured as possible, multiple custom hooks are utilized to manage the list of reviews associated with a service. The [useGetReviews](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/app/hooks/useGetReviews.ts) +custom hook encapsulates the service, exposing all the required information (with fields such as `nameOfService`, `listOfReviews`, `listOfStars`) to display the reviews in a table. +Multiple additional custom hooks, such as [useDashboardCreation](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/app/hooks/useDashboardCreation.ts), and [useServiceReview](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/app/hooks/useServiceReview.ts) are encapsulating their own piece of state and logic to make the code readable and maintainable. + +- Component for adding a new review: The [AddReview](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/app/components/review/AddReview.tsx) component is implemented to facilitate the creation of a new review. It is rendered by the [servicePage](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/app/service/[id]/page.tsx) to collect a review entry from a `USER` and uses the `signAndExecuteTransactionBlock` function of the [useWalletKit] hook to execute the transaction. + +- Proof of experience generation: PoE is an NFT that is minted by `SERVICE OWNER` for customers after they dine at the restaurant; customers can then burn the PoE to write a high-rated review later. Minting an NFT is facilitated by the [ownedServicePage](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/app/serviceOwner/ownedServices/page.tsx) component. This component is using the [useServicePoEGeneration](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/app/hooks/useServicePoeGeneration.ts) custom hook. + +- Delete a review: The moderator can delete a review that contains inappropriate content. [moderatorRemovePage](https://github.com/MystenLabs/reviews-ratings-poc/blob/main/app/src/app/moderator/remove/[id]/[nft]/page.tsx) component is used to delete a review. + +## Related links + +[Reviews Rating repository](https://github.com/MystenLabs/sui/tree/main/examples/move/reviews_rating). + diff --git a/docs/content/sidebars/guides.js b/docs/content/sidebars/guides.js index d0cc4f6bd5923..050cab7024313 100644 --- a/docs/content/sidebars/guides.js +++ b/docs/content/sidebars/guides.js @@ -163,6 +163,7 @@ const guides = [ }, 'guides/developer/app-examples/plinko', 'guides/developer/app-examples/recaptcha', + 'guides/developer/app-examples/reviews-rating', 'guides/developer/app-examples/tic-tac-toe', { type: 'category', diff --git a/examples/move/reviews_rating/Move.toml b/examples/move/reviews_rating/Move.toml new file mode 100644 index 0000000000000..ca2a06f097fe9 --- /dev/null +++ b/examples/move/reviews_rating/Move.toml @@ -0,0 +1,10 @@ +[package] +name = "reviews_rating" +version = "0.0.1" +edition = "2024.beta" + +[dependencies] +Sui = { local = "../../../crates/sui-framework/packages/sui-framework", override = true } + +[addresses] +reviews_rating = "0x0" diff --git a/examples/move/reviews_rating/sources/dashboard.move b/examples/move/reviews_rating/sources/dashboard.move new file mode 100644 index 0000000000000..d057a610e3830 --- /dev/null +++ b/examples/move/reviews_rating/sources/dashboard.move @@ -0,0 +1,30 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module reviews_rating::dashboard { + use std::string::String; + + use sui::dynamic_field as df; + + /// Dashboard is a collection of services + public struct Dashboard has key, store { + id: UID, + service_type: String + } + + /// Create a new dashboard + public fun create_dashboard( + service_type: String, + ctx: &mut TxContext, + ) { + let db = Dashboard { + id: object::new(ctx), + service_type + }; + transfer::share_object(db); + } + + public fun register_service(db: &mut Dashboard, service_id: ID) { + df::add(&mut db.id, service_id, service_id); + } +} diff --git a/examples/move/reviews_rating/sources/moderator.move b/examples/move/reviews_rating/sources/moderator.move new file mode 100644 index 0000000000000..36b74b59a10a3 --- /dev/null +++ b/examples/move/reviews_rating/sources/moderator.move @@ -0,0 +1,44 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module reviews_rating::moderator { + use sui::tx_context::{sender}; + + /// Represents a moderator that can be used to delete reviews + public struct Moderator has key { + id: UID, + } + + /// A capability that can be used to setup moderators + public struct ModCap has key, store { + id: UID + } + + fun init(ctx: &mut TxContext) { + let mod_cap = ModCap { + id: object::new(ctx) + }; + transfer::transfer(mod_cap, sender(ctx)); + } + + /// Adds a moderator + public fun add_moderator( + _: &ModCap, + recipient: address, + ctx: &mut TxContext + ) { + // generate an NFT and transfer it to moderator who may use it to delete reviews + let mod = Moderator { + id: object::new(ctx) + }; + transfer::transfer(mod, recipient); + } + + /// Deletes a moderator + public fun delete_moderator( + mod: Moderator + ) { + let Moderator { id } = mod; + object::delete(id); + } +} diff --git a/examples/move/reviews_rating/sources/review.move b/examples/move/reviews_rating/sources/review.move new file mode 100644 index 0000000000000..d51e219ce490f --- /dev/null +++ b/examples/move/reviews_rating/sources/review.move @@ -0,0 +1,102 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module reviews_rating::review { + use std::string::String; + + use sui::clock::Clock; + use sui::math; + + const EInvalidContentLen: u64 = 1; + + const MIN_REVIEW_CONTENT_LEN: u64 = 5; + const MAX_REVIEW_CONTENT_LEN: u64 = 1000; + + /// Represents a review of a service + public struct Review has key, store { + id: UID, + owner: address, + service_id: ID, + content: String, + // intrinsic score + len: u64, + // extrinsic score + votes: u64, + time_issued: u64, + // proof of experience + has_poe: bool, + // total score + total_score: u64, + // overall rating value; max=5 + overall_rate: u8, + } + + /// Creates a new review + public(package) fun new_review( + owner: address, + service_id: ID, + content: String, + has_poe: bool, + overall_rate: u8, + clock: &Clock, + ctx: &mut TxContext + ): Review { + let len = content.length(); + assert!(len > MIN_REVIEW_CONTENT_LEN && len <= MAX_REVIEW_CONTENT_LEN, EInvalidContentLen); + let mut new_review = Review { + id: object::new(ctx), + owner, + service_id, + content, + len, + votes: 0, + time_issued: clock.timestamp_ms(), + has_poe, + total_score: 0, + overall_rate, + }; + new_review.total_score = new_review.calculate_total_score(); + new_review + } + + /// Deletes a review + public(package) fun delete_review(rev: Review) { + let Review { + id, owner: _, service_id: _, content: _, len: _, votes: _, time_issued: _, + has_poe: _, total_score: _, overall_rate: _ + } = rev; + object::delete(id); + } + + /// Calculates the total score of a review + fun calculate_total_score(rev: &Review): u64 { + let mut intrinsic_score: u64 = rev.len; + intrinsic_score = math::min(intrinsic_score, 150); + let extrinsic_score: u64 = 10 * rev.votes; + let vm: u64 = if (rev.has_poe) { 2 } else { 1 }; + (intrinsic_score + extrinsic_score) * vm + } + + /// Updates the total score of a review + fun update_total_score(rev: &mut Review) { + rev.total_score = rev.calculate_total_score(); + } + + /// Upvotes a review + public fun upvote(rev: &mut Review) { + rev.votes = rev.votes + 1; + rev.update_total_score(); + } + + public fun get_id(rev: &Review): ID { + rev.id.to_inner() + } + + public fun get_total_score(rev: &Review): u64 { + rev.total_score + } + + public fun get_time_issued(rev: &Review): u64 { + rev.time_issued + } +} diff --git a/examples/move/reviews_rating/sources/service.move b/examples/move/reviews_rating/sources/service.move new file mode 100644 index 0000000000000..114e7f7381117 --- /dev/null +++ b/examples/move/reviews_rating/sources/service.move @@ -0,0 +1,282 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module reviews_rating::service { + use std::string::String; + + use sui::balance::{Self, Balance}; + use sui::clock::Clock; + use sui::coin::{Self, Coin}; + use sui::dynamic_field as df; + use sui::sui::SUI; + use sui::object_table::{Self, ObjectTable}; + + use reviews_rating::moderator::{Moderator}; + use reviews_rating::review::{Self, Review}; + + const EInvalidPermission: u64 = 1; + const ENotEnoughBalance: u64 = 2; + const ENotExists: u64 = 3; + + const MAX_REVIEWERS_TO_REWARD: u64 = 10; + + /// A capability that can be used to perform admin operations on a service + public struct AdminCap has key, store { + id: UID, + service_id: ID + } + + /// Represents a service + public struct Service has key, store { + id: UID, + reward_pool: Balance, + reward: u64, + top_reviews: vector, + reviews: ObjectTable, + overall_rate: u64, + name: String + } + + /// Represents a proof of experience that can be used to write a review with higher score + public struct ProofOfExperience has key { + id: UID, + service_id: ID, + } + + /// Represents a review record + public struct ReviewRecord has store, drop { + owner: address, + overall_rate: u8, + time_issued: u64, + } + + #[allow(lint(self_transfer))] + /// Creates a new service + public fun create_service( + name: String, + ctx: &mut TxContext, + ): ID { + let id = object::new(ctx); + let service_id = id.to_inner(); + let service = Service { + id, + reward: 1000000, + reward_pool: balance::zero(), + reviews: object_table::new(ctx), + top_reviews: vector[], + overall_rate: 0, + name + }; + + let admin_cap = AdminCap { + id: object::new(ctx), + service_id + }; + + transfer::share_object(service); + transfer::public_transfer(admin_cap, tx_context::sender(ctx)); + service_id + } + + /// Writes a new review + public fun write_new_review( + service: &mut Service, + owner: address, + content: String, + overall_rate: u8, + clock: &Clock, + poe: ProofOfExperience, + ctx: &mut TxContext + ) { + assert!(poe.service_id == service.id.to_inner(), EInvalidPermission); + let ProofOfExperience { id, service_id: _ } = poe; + object::delete(id); + let review = review::new_review( + owner, + service.id.to_inner(), + content, + true, + overall_rate, + clock, + ctx + ); + service.add_review(review, owner, overall_rate); + } + + /// Writes a new review without proof of experience + public fun write_new_review_without_poe( + service: &mut Service, + owner: address, + content: String, + overall_rate: u8, + clock: &Clock, + ctx: &mut TxContext + ) { + let review = review::new_review( + owner, + service.id.to_inner(), + content, + false, + overall_rate, + clock, + ctx + ); + service.add_review(review, owner, overall_rate); + } + + /// Adds a review to the service + fun add_review( + service: &mut Service, + review: Review, + owner: address, + overall_rate: u8 + ) { + let id = review.get_id(); + let total_score = review.get_total_score(); + let time_issued = review.get_time_issued(); + service.reviews.add(id, review); + service.update_top_reviews(id, total_score); + df::add(&mut service.id, id, ReviewRecord { owner, overall_rate, time_issued }); + let overall_rate = (overall_rate as u64); + service.overall_rate = service.overall_rate + overall_rate; + } + + /// Returns true if top_reviews should be updated given a total score + fun should_update_top_reviews( + service: &Service, + total_score: u64 + ): bool { + let len = service.top_reviews.length(); + len < MAX_REVIEWERS_TO_REWARD + || total_score > service.get_total_score(service.top_reviews[len - 1]) + } + + /// Prunes top_reviews if it exceeds MAX_REVIEWERS_TO_REWARD + fun prune_top_reviews( + service: &mut Service + ) { + while (service.top_reviews.length() > MAX_REVIEWERS_TO_REWARD) { + service.top_reviews.pop_back(); + }; + } + + /// Updates top_reviews if necessary + fun update_top_reviews( + service: &mut Service, + review_id: ID, + total_score: u64 + ) { + if (service.should_update_top_reviews(total_score)) { + let idx = service.find_idx(total_score); + service.top_reviews.insert(review_id, idx); + service.prune_top_reviews(); + }; + } + + /// Finds the index of a review in top_reviews + fun find_idx(service: &Service, total_score: u64): u64 { + let mut i = service.top_reviews.length(); + while (0 < i) { + let review_id = service.top_reviews[i - 1]; + if (service.get_total_score(review_id) > total_score) { + break + }; + i = i - 1; + }; + i + } + + /// Gets the total score of a review + fun get_total_score(service: &Service, review_id: ID): u64 { + service.reviews[review_id].get_total_score() + } + + /// Distributes rewards + public fun distribute_reward( + cap: &AdminCap, + service: &mut Service, + ctx: &mut TxContext + ) { + assert!(cap.service_id == service.id.to_inner(), EInvalidPermission); + // distribute a fixed amount to top MAX_REVIEWERS_TO_REWARD reviewers + let mut len = service.top_reviews.length(); + if (len > MAX_REVIEWERS_TO_REWARD) { + len = MAX_REVIEWERS_TO_REWARD; + }; + // check balance + assert!(service.reward_pool.value() >= (service.reward * len), ENotEnoughBalance); + let mut i = 0; + while (i < len) { + let sub_balance = service.reward_pool.split(service.reward); + let reward = coin::from_balance(sub_balance, ctx); + let review_id = &service.top_reviews[i]; + let record = df::borrow(&service.id, *review_id); + transfer::public_transfer(reward, record.owner); + i = i + 1; + }; + } + + /// Adds coins to reward pool + public fun top_up_reward( + service: &mut Service, + coin: Coin + ) { + service.reward_pool.join(coin.into_balance()); + } + + /// Mints a proof of experience for a customer + public fun generate_proof_of_experience( + cap: &AdminCap, + service: &Service, + recipient: address, + ctx: &mut TxContext + ) { + // generate an NFT and transfer it to customer who can use it to write a review with higher score + assert!(cap.service_id == service.id.to_inner(), EInvalidPermission); + let poe = ProofOfExperience { + id: object::new(ctx), + service_id: cap.service_id + }; + transfer::transfer(poe, recipient); + } + + /// Removes a review (only moderators can do this) + public fun remove_review( + _: &Moderator, + service: &mut Service, + review_id: ID, + ) { + assert!(service.reviews.contains(review_id), ENotExists); + let record: ReviewRecord = df::remove(&mut service.id, review_id); + service.overall_rate = service.overall_rate - (record.overall_rate as u64); + let (contains, i) = service.top_reviews.index_of(&review_id); + if (contains) { + service.top_reviews.remove(i); + }; + service.reviews.remove(review_id).delete_review(); + } + + /// Reorder top_reviews after a review is updated + fun reorder( + service: &mut Service, + review_id: ID, + total_score: u64 + ) { + let (contains, idx) = service.top_reviews.index_of(&review_id); + if (!contains) { + service.update_top_reviews(review_id, total_score); + } else { + // remove existing review from vector and insert back + service.top_reviews.remove(idx); + let idx = service.find_idx(total_score); + service.top_reviews.insert(review_id, idx); + } + } + + /// Upvotes a review + public fun upvote(service: &mut Service, review_id: ID) { + let review = &mut service.reviews[review_id]; + review.upvote(); + service.reorder(review_id, review.get_total_score()); + } +}