Feedback on my Reputation Canister — ZenDB + State Machine + RBAC in Motoko

Hey ICP devs! :waving_hand:

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:

  1. 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.
  1. 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";    
  1. 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?
  1. 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

  1. getReputationScore scalability — scanning all reviews on every call. Is there a better pattern in Motoko? Pre-computed counters? Trie-based aggregation?
  2. ZenDB vs HashMap — I chose ZenDB for persistence, but for a small canister like reputation, would stable var reviews : [Review] + manual serialization be simpler?
  3. RBAC pattern — I use assertNotAnonymous(caller) → ?Text and chain it in every public method. Is there a cleaner way (decorator/middleware pattern) in Motoko?
  4. 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!

Here’s some feedback from Claude:

The genuinely serious problems

initAdmin is a land grab. Any principal can call it and permanently become admin, and whoever wins the race after deployment owns review moderation forever. On ICP you don’t need this pattern at all — gate admin calls on Principal.isController(caller), or pass the admin in as an install argument via an actor class so it’s set atomically at creation. Also, using Principal.fromText("aaaaa-aa") (the management canister) as a “not set yet” sentinel is fragile; var admin : ?Principal = null says what it means.

The review isn’t actually tied to a real deal. The post says “a user can only review a deal they participated in,” but nothing in the code enforces that. escrowOrderId is an arbitrary caller-supplied string, so I can invent an ID and tank a competitor’s score, or farm five-star reviews for myself from throwaway principals. This needs an inter-canister call to the escrow canister to confirm the order exists, is in a completed state, and that caller and reviewee are its two counterparties. And once you add that await, the duplicate check that runs before the await is no longer atomic — a caller can fire two leaveReview calls that both pass the check and both insert. Re-check for duplicates after the await, or hold an in-flight guard set keyed on (caller, escrowOrderId).

Caller-controlled id. Nothing verifies id is unique, so a caller can collide with an existing document, and deleteReview deletes by query on id, meaning a collision could take out multiple documents. Derive the ID server-side instead — escrowOrderId # "/" # Principal.toText(caller) is naturally unique per direction and makes the duplicate check a primary-key lookup.

The duplicate guard fails open. case (#err(_)) {} swallows a query failure and then proceeds to insert. Security checks should fail closed: on #err, refuse the write.

assert false; loop {} at collection creation traps with no diagnostic. Use Debug.trap("createCollection failed: " # e). More importantly: verify what createCollection does on the second run, i.e. after an upgrade when the collection already exists in the stable store. If it returns #err, that assert false bricks the canister on every upgrade. This deserves an explicit upgrade test before more data accumulates.

Answering the four questions

Scalability of getReputationScore. The immediate win costs almost nothing: you already know how to use the query builder — you used it for the duplicate check — but both read methods call reviews.search(ZenDB.QueryBuilder()) with no predicate and filter in Motoko. Push the filter down and add an index on reviewee:

motoko

reviews.search(
  ZenDB.QueryBuilder().Where("reviewee", #eq(#Principal(user))).Limit(limit).Skip(offset)
)

That alone probably buys you a couple of orders of magnitude. Beyond that, yes, maintain pre-computed aggregates — a scores collection or stable map from Principal to { sum : Nat; count : Nat; dist : [Nat] }, updated incrementally in leaveReview and decremented in deleteReview (read the doc before deleting so you know which bucket to adjust). Keep an admin-only recomputeScore(user) for drift repair. The urgency is higher than “could be slow”: query calls have a hard instruction limit, so a full scan doesn’t degrade gracefully — it traps, and reputation scores stop working entirely at some unpredictable dataset size. getUserReviews has the same cliff on response size, so it needs pagination regardless.

ZenDB vs. a plain stable array. For this canister ZenDB is arguably over-engineering, but I’d keep it, because the moment you add “reviews for user X, newest first, page 2” you’ll reinvent indexing badly. What you’d gain from stable var reviews : [Review] is simplicity; what you’d lose is the indexed lookups you actually need. The real problem isn’t the choice of ZenDB, it’s that you’re not using it as a database yet.

RBAC middleware. Motoko has no decorators, but higher-order functions get you most of the way. Return a Result rather than ?Text and compose:

motoko

type Guard = Principal -> Result.Result<(), Text>;

func authed<T>(caller : Principal, guards : [Guard], body : () -> Result.Result<T, Text>)
  : Result.Result<T, Text> {
  for (g in guards.vals()) {
    switch (g(caller)) { case (#err e) return #err e; case (#ok) {} };
  };
  body();
};

Then each method is authed(caller, [notAnonymous, isAdmin], func() { ... }). Which leads to the broader API point: stop encoding errors as Text in async Text. Clients currently have to string-match "Error: " prefixes, and any typo silently becomes a success path. Return Result.Result<T, ReviewError> with a variant error type; Candid handles it cleanly and your Spring Boot layer gets real discrimination.

Smaller things worth fixing

The ratings : [Nat] field holds counts, not ratings, and its meaning depends entirely on a comment saying it’s descending 5→1. Name it distribution and use a record with named fields; positional arrays across a Candid boundary are a bug waiting to happen. comment : Text is unbounded — cap it (a few hundred to a thousand characters) or someone will pay a few cycles to write a megabyte into your stable memory. Storing averageRating as a Float is fine for display but I’d return sum and count too so clients can aggregate without precision loss. deleteReview hard-deletes moderated content; a soft-delete flag preserves the audit trail and makes aggregate recomputation possible. And add a schema version field to Review now — evolving a Candify’d record later means from_candid returning null and silently dropping documents.

One ICP-specific note: public query results aren’t certified, so a malicious replica can return any reputation score it likes. If scores influence purchasing decisions, offer a certified path (an update call, or CertifiedData) for anything a buyer relies on.

Finally, the hardcoded store principal with “update canister ID after first deploy” is a foot-gun that will bite whoever deploys to a fresh network. Principal.fromActor(Reputation) or an actor-class init parameter removes the manual step.

thanks! I am just a new developer from Spring boot! but i like motoko and icp system, i am try to build something, but thanks for answer

Keep it up. There are unique challenges to coding in ICP that don’t always present themselves with other languages and platforms (though in my opinion the challenges are less with learning to program in ICP than in some other cypto spaces). Keep learning and use the AI tools available to you to help speed up the process. Best of luck!

Thanks! I will update here with some features, learning something new in Web 3.0 is really amazing, but only about money, but just helping people where they are living without trust and corruption.