Hey ICP devs! ![]()
I’m building KeneshICP — a decentralized escrow marketplace for car parts in Kyrgyzstan. 4 canisters on mainnet (escrow, marketplace, feed, reputation), Motoko + ZenDB, Spring Boot orchestrator. Would love your feedback on my reputation canister — specifically around the ZenDB pattern and the getReputationScore implementation.
import Principal "mo:base/Principal";
import Text "mo:base/Text";
import Time "mo:base/Time";
import Blob "mo:base/Blob";
import Array "mo:base/Array";
import Float "mo:base/Float";
import ZenDB "mo:zendb";
persistent actor Reputation {
func assertNotAnonymous(caller : Principal) : ?Text {
if (Principal.isAnonymous(caller)) {
?"Error: Anonymous callers not allowed"
} else { null }
};
var admin : Principal = Principal.fromText("aaaaa-aa");
public type Review = {
id : Text;
reviewer : Principal;
reviewee : Principal;
escrowOrderId : Text;
rating : Nat;
comment : Text;
createdAt : Time.Time;
};
public type ReputationScore = {
averageRating : Float;
totalReviews : Nat;
ratings : [Nat]; // distribution: [5star, 4star, 3star, 2star, 1star]
};
// ZenDB setup
// NOTE: Update canister ID after first deploy
let zendb_store = ZenDB.newStableStore(Principal.fromText("st3ai-hqaaa-aaaae-qkh4a-cai"), null);
transient let db = ZenDB.launchDefaultDB(zendb_store);
transient let ReviewSchema : ZenDB.Schema = #Record([
("id", #Text),
("reviewer", #Principal),
("reviewee", #Principal),
("escrowOrderId", #Text),
("rating", #Nat),
("comment", #Text),
("createdAt", #Int),
]);
transient let candify : ZenDB.Candify<Review> = {
to_blob = func(r : Review) : Blob = to_candid (r);
from_blob = func(blob : Blob) : ?Review = from_candid (blob);
};
transient let reviews = switch (db.createCollection<Review>("reviews", ReviewSchema, candify, null)) {
case (#ok(col)) col;
case (#err(_)) { assert false; loop {} };
};
// ===== PUBLIC METHODS =====
// Leave a review (after a completed deal)
public shared ({ caller }) func leaveReview(
id : Text,
reviewee : Principal,
escrowOrderId : Text,
rating : Nat,
comment : Text,
) : async Text {
switch (assertNotAnonymous(caller)) { case (?msg) return msg; case (null) {} };
if (rating < 1 or rating > 5) return "Error: Rating must be 1-5";
if (caller == reviewee) return "Error: Cannot review yourself";
// Check for duplicate review on same order
switch (reviews.search(
ZenDB.QueryBuilder()
.Where("reviewer", #eq(#Principal(caller)))
.And("escrowOrderId", #eq(#Text(escrowOrderId)))
.Limit(1),
)) {
case (#ok(results)) {
if (results.documents.size() > 0) return "Error: Already reviewed this order";
};
case (#err(_)) {};
};
let review : Review = {
id = id;
reviewer = caller;
reviewee = reviewee;
escrowOrderId = escrowOrderId;
rating = rating;
comment = comment;
createdAt = Time.now();
};
switch (reviews.insert(review)) {
case (#ok(_)) "Review submitted!";
case (#err(e)) "Error: " # e;
};
};
// Get all reviews for a user
public query func getUserReviews(user : Principal) : async [Review] {
switch (reviews.search(ZenDB.QueryBuilder())) {
case (#ok(results)) {
let all = Array.map<(ZenDB.Types.DocumentId, Review, [ZenDB.Types.TextMatch]), Review>(
results.documents,
func((_, r, _) : (ZenDB.Types.DocumentId, Review, [ZenDB.Types.TextMatch])) : Review = r,
);
Array.filter<Review>(all, func(r : Review) : Bool { r.reviewee == user });
};
case (#err(_)) [];
};
};
// Get reputation score for a user
public query func getReputationScore(user : Principal) : async ReputationScore {
switch (reviews.search(ZenDB.QueryBuilder())) {
case (#ok(results)) {
let all = Array.map<(ZenDB.Types.DocumentId, Review, [ZenDB.Types.TextMatch]), Review>(
results.documents,
func((_, r, _) : (ZenDB.Types.DocumentId, Review, [ZenDB.Types.TextMatch])) : Review = r,
);
let userReviews = Array.filter<Review>(all, func(r : Review) : Bool { r.reviewee == user });
if (userReviews.size() == 0) {
return { averageRating = 0.0; totalReviews = 0; ratings = [0, 0, 0, 0, 0] };
};
var total : Nat = 0;
var count5 : Nat = 0;
var count4 : Nat = 0;
var count3 : Nat = 0;
var count2 : Nat = 0;
var count1 : Nat = 0;
for (r in userReviews.vals()) {
total += r.rating;
switch (r.rating) {
case (5) count5 += 1;
case (4) count4 += 1;
case (3) count3 += 1;
case (2) count2 += 1;
case (1) count1 += 1;
case (_) {};
};
};
{
averageRating = Float.fromInt(total) / Float.fromInt(userReviews.size());
totalReviews = userReviews.size();
ratings = [count5, count4, count3, count2, count1];
};
};
case (#err(_)) {
{ averageRating = 0.0; totalReviews = 0; ratings = [0, 0, 0, 0, 0] };
};
};
};
// ===== ADMIN =====
public shared ({ caller }) func initAdmin() : async Text {
if (admin != Principal.fromText("aaaaa-aa")) return "Admin already set";
admin := caller;
"Admin set to " # Principal.toText(caller);
};
public query func getAdmin() : async Principal {
admin;
};
// Admin: delete inappropriate review
public shared ({ caller }) func deleteReview(id : Text) : async Text {
if (caller != admin) return "Error: Only admin can delete reviews";
switch (reviews.delete(
ZenDB.QueryBuilder().Where("id", #eq(#Text(id))),
)) {
case (#ok(_)) "Review deleted.";
case (#err(e)) "Error: " # e;
};
};
};
Key Design Decisions:
- One review per escrow order
// Duplicate check: same reviewer + same order
┃ reviews.search(
┃ ZenDB.QueryBuilder()
┃ .Where("reviewer", #eq(#Principal(caller)))
┃ .And("escrowOrderId", #eq(#Text(escrowOrderId)))
┃ .Limit(1)
┃ )
┃
A user can only review a deal they participated in, and only once. The escrowOrderId links the review to a completed transaction.
- Self-review prevention + 1-5 validation
if (caller == reviewee) return "Error: Cannot review yourself";
if (rating < 1 or rating > 5) return "Error: Rating must be 1-5";
- getReputationScore — computed on-chain
public query func getReputationScore(user : Principal) : async ReputationScore {
┃ // 1. Scan ALL reviews (could be slow at scale?)
┃ // 2. Filter by reviewee
┃ // 3. Calculate: average + distribution [5★,4★,3★,2★,1★]
┃ }
┃
This scans the entire collection. Currently fine for small datasets, but I'm worried about scalability. Should I add a materialized view or pre-computed aggregates?
-
ZenDB Pattern
• persistent actor for upgrade survival
• stable let for the store, transient let for db/collections (recreated on upgrade)
• Schema + Candify + createCollection — the mandatory ZenDB trio
Questions for the Community
- getReputationScore scalability — scanning all reviews on every call. Is there a better pattern in Motoko? Pre-computed counters? Trie-based aggregation?
- ZenDB vs HashMap — I chose ZenDB for persistence, but for a small canister like reputation, would stable var reviews : [Review] + manual serialization be simpler?
- RBAC pattern — I use assertNotAnonymous(caller) → ?Text and chain it in every public method. Is there a cleaner way (decorator/middleware pattern) in Motoko?
- General architecture feedback — anything you’d do differently?
┃ Full project: 4 canisters (escrow state machine, marketplace with inter-canister calls, feed, reputation) + Spring Boot orchestrator + Redis caching + rate limiting. Open to all feedback!