feat: score trust with a time-decayed, Sybil-dampened reputation engine - #185
Conversation
Adds a reputation module that computes per-address trust scores from escrow completion and dispute-resolution history, served from a single materialized record per address that's updated incrementally (O(1) per event) instead of replayed from full history on every read or write: - Time decay: each address's stored score is exponentially decayed to "now" (90-day half-life) before any new contribution is added or the score is read, so stale history stops dominating current trust. - Sybil dampening: each contribution is scaled by 1/(1+priorInteractions) with that specific counterparty (harmonic diminishing returns), so two colluding addresses looping fake escrows back and forth see their mutual contribution shrink every round, while a wide base of distinct counterparties does not. This also naturally dampens self-dealing (depositor === beneficiary) with no special-casing, since both sides of that single escrow already count as a repeat interaction with each other. Wired into the existing flows that produce this history: EscrowController records a completion when an escrow is released outside of a dispute, and DisputeSagaService records a win/loss/split for both parties once a verdict's payout executes, mapping the jury verdict onto the engine's domain-neutral outcome type so the reputation module stays decoupled from dispute-specific types. GET /reputation/:address and GET /reputation/leaderboard expose the computed scores; reads are public since the score is derived data, not directly settable. No new external dependencies. Closes trustflow-protocol#167
Thanks — this is an excellent, well-tested, and thoughtfully-argued feature addition. The reputation module design is clear and pragmatic: I like the materialized-per-address approach, the incremental O(1) updates, and the documented tradeoffs (harmonic dampening vs full graph algorithms). The tests are thorough and the Swagger endpoints make the results easy to consume. A few requests and suggestions before I approve and merge: Blocking / must-address
Non-blocking but strongly suggested
Minor / nits If you address 1 and 2 and add the integration test (3), I will approve this and we can merge. Overall — excellent work: clean design, solid tests, and good documentation in the PR body. |
…n test - Leaderboard sort is now fully deterministic: score desc, then eventCount desc, then address ascending, so ties no longer depend on incidental Map iteration order. Covered with dedicated tie-break tests. - Documented why the decay-then-write sequence in applyContribution cannot lose updates under concurrent callers: it's entirely synchronous (no await between the read and the store.save), and Node runs a synchronous function body to completion before yielding to any other queued callback, so two calls can never interleave mid-update. Added a test that fires several recordEscrowCompleted calls via Promise.all and asserts every contribution landed, both across distinct counterparties and repeated ones. Noted that this guarantee is specific to the current synchronous in-memory store and would need an explicit optimistic-concurrency guard if that store is ever swapped for one backed by real I/O. - Added a reputation.integration.spec.ts that wires EscrowModule, DisputeModule, and ReputationModule together via a real NestJS TestingModule (no mocks) and drives a release and a full dispute saga through to GET /reputation/:address and the leaderboard, to catch any cross-module wiring bugs unit tests can't see. - Expanded the tuning-rationale comments on the decay half-life, event weights, and amount-scaling cap. - Added edge-case coverage: extremely large/negative/non-finite amounts, and eviction of the capped recent-events log. - Documented in the Swagger descriptions that the read endpoints are intentionally unauthenticated (derived, publicly-checkable data, not directly settable), and documented the leaderboard's sort/pagination semantics.
|
Thanks for the thorough review! Addressed in ba007e3: Blocking
Strongly suggested
Nits (7–9): leaving metrics and a changelog entry as follow-ups — there's no existing metrics-emission or changelog convention elsewhere in this backend to hook into, so I didn't want to invent one unilaterally in this PR. Added the leaderboard sort/pagination semantics to its Swagger description per #9. All CI checks are green on the latest commit. |
meshackyaro
left a comment
There was a problem hiding this comment.
Thanks — this is excellent work and I approve this PR.
Summary of what I checked:
- Concurrency: you added the doc comment explaining the synchronous in-memory guarantee and a concurrent-updates test that demonstrates safety for the current store. Good note that a persistent/I/O-backed store will need optimistic concurrency / CAS.
- Deterministic leaderboard ordering: sorting now uses score DESC, eventCount DESC, address ASC and there are tests for the tiebreakers.
- Integration test: the end-to-end integration spec exercises EscrowController → DisputeSaga → Reputation updates and the leaderboard/read endpoints.
- Edge cases & docs: amount/eviction/Infinity tests added and the decay/weight constants have rationale comments; Swagger descriptions updated to document public/unauthenticated reads and leaderboard pagination.
CI looks green on your last commit — approved and ready for merge. Well done!
Description
Implements a Sybil-resistant reputation scoring engine with time decay, as requested in the issue: trust scores computed from escrow/dispute history, time-decay weighted, Sybil-dampened, served from an incrementally recomputed materialized store.
Closes #167
Type of Change
Component
Changes Made
reputationmodule.ReputationServicemaintains a single materialized record per address (score, event count, per-counterparty interaction counts, a capped recent-events log) that's updated incrementally — O(1) per event — never replayed from full history on read or write.REPUTATION_DECAY_HALF_LIFE_MS) and the decayed value is persisted. Stale history stops dominating current trust without ever re-walking the event log.1 / (1 + priorInteractionsWithThatCounterparty)— harmonic diminishing returns. Two colluding addresses looping fake escrows back and forth see their mutual contribution shrink every round; a wide base of distinct, one-off counterparties does not. This also naturally dampens self-dealing (depositor === beneficiary) with no special-casing — both sides of that one escrow already count as a repeat interaction with each other (seereputation.service.spec.ts's "naturally dampens self-dealing" test for the exact arithmetic).sqrt(amountXLM), capped) so higher-stakes escrows count for more without letting a single whale transaction dominate.EscrowController.release()now records anESCROW_COMPLETEDevent for both parties after a clean (non-disputed) release.DisputeSagaService.executePayout()records awon/lost/splitoutcome for both parties once the verdict's payout executes, via a smallDisputeVerdict → ReputationOutcomemapping local to the dispute module — this keeps the reputation module fully decoupled from dispute-specific types (it only knows a domain-neutral'won' | 'lost' | 'split').GET /reputation/:addressandGET /reputation/leaderboard(ranked, paginated vialimit) expose the computed scores, documented with Swagger. Left unguarded (no JWT) since the score is derived, publicly-checkable data, not something a caller can set directly — consistent withEscrowController's read endpoints.Architectural decisions (documented per the issue's task list)
1/(1+n)) over more complex graph-based Sybil resistance (e.g. EigenTrust-style trust propagation) as the right complexity/effort tradeoff for this codebase — it's simple, deterministic, easily testable, and directly targets the concrete attack this issue calls out (wash-trading / self-dealing to inflate a score), while a full trust-graph approach would need a lot more infrastructure this backend doesn't have.Testing
Automated Testing
New specs:
reputation.service.spec.ts(decay math, dampening math incl. the self-dealing case, verdict→outcome mapping, leaderboard ranking/limit, amount scaling, zero/invalid-amount edge cases),reputation.controller.spec.ts(including a Supertest HTTP-level suite per the issue's task list, following the existingrate-limit.guard.spec.tspattern — covers route ordering for/reputation/leaderboardvs/reputation/:address), plus updateddispute-saga.service.spec.tsand a newescrow.controller.spec.tscovering the new call sites. 316 tests passing across 34 suites; the newreputationmodule is at 100% line/branch coverage.Manual Testing
npm run lint:check,npm run format:check,npx tsc --noEmit,npm run test:ci,npm run buildall pass clean.Checklist