Skip to content

feat: score trust with a time-decayed, Sybil-dampened reputation engine - #185

Merged
meshackyaro merged 2 commits into
trustflow-protocol:mainfrom
CillaSam:feature/issue-167-reputation-scoring-engine
Aug 16, 2026
Merged

feat: score trust with a time-decayed, Sybil-dampened reputation engine#185
meshackyaro merged 2 commits into
trustflow-protocol:mainfrom
CillaSam:feature/issue-167-reputation-scoring-engine

Conversation

@CillaSam

Copy link
Copy Markdown
Contributor

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

  • ✨ New feature (non-breaking change which adds functionality)

Component

  • Backend (Node.js API)

Changes Made

  • New reputation module. ReputationService maintains 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.
  • Time decay: before any new contribution is added, or before a score is read, the stored score is exponentially decayed to "now" using a 90-day half-life (REPUTATION_DECAY_HALF_LIFE_MS) and the decayed value is persisted. Stale history stops dominating current trust without ever re-walking the event log.
  • Sybil dampening: each contribution is scaled by 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 (see reputation.service.spec.ts's "naturally dampens self-dealing" test for the exact arithmetic).
  • Contribution magnitude also scales with escrow amount (sqrt(amountXLM), capped) so higher-stakes escrows count for more without letting a single whale transaction dominate.
  • Wired into the flows that actually produce this history, rather than left standalone:
    • EscrowController.release() now records an ESCROW_COMPLETED event for both parties after a clean (non-disputed) release.
    • DisputeSagaService.executePayout() records a won/lost/split outcome for both parties once the verdict's payout executes, via a small DisputeVerdict → ReputationOutcome mapping 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/:address and GET /reputation/leaderboard (ranked, paginated via limit) 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 with EscrowController's read endpoints.

Architectural decisions (documented per the issue's task list)

  • No new dependencies — pure TS/NestJS, consistent with the rest of the backend.
  • Chose an incrementally-decayed accumulator over storing the full raw event history and recomputing on each read — this is what makes the store "materialized" and O(1) per update rather than O(n) per read, matching the acceptance criteria's explicit framing.
  • Chose harmonic per-counterparty dampening (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.
  • The DB here is still the in-memory store the rest of this backend uses (as established in Implement Chain↔Database Escrow State Reconciliation #166) — the module boundaries are written so a persistent store could be swapped in without touching the scoring logic itself.

Testing

Automated Testing

  • Unit tests added/updated
  • All tests passing locally

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 existing rate-limit.guard.spec.ts pattern — covers route ordering for /reputation/leaderboard vs /reputation/:address), plus updated dispute-saga.service.spec.ts and a new escrow.controller.spec.ts covering the new call sites. 316 tests passing across 34 suites; the new reputation module is at 100% line/branch coverage.

Manual Testing

  • Tested locally: npm run lint:check, npm run format:check, npx tsc --noEmit, npm run test:ci, npm run build all pass clean.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

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
@CillaSam
CillaSam requested a review from meshackyaro as a code owner August 16, 2026 13:29
@meshackyaro

Copy link
Copy Markdown
Contributor

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

  • ✨ New feature (non-breaking change which adds functionality)

Component

  • Backend (Node.js API)

Changes Made

  • New reputation module. ReputationService maintains 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.

  • Time decay: before any new contribution is added, or before a score is read, the stored score is exponentially decayed to "now" using a 90-day half-life (REPUTATION_DECAY_HALF_LIFE_MS) and the decayed value is persisted. Stale history stops dominating current trust without ever re-walking the event log.

  • Sybil dampening: each contribution is scaled by 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 (see reputation.service.spec.ts's "naturally dampens self-dealing" test for the exact arithmetic).

  • Contribution magnitude also scales with escrow amount (sqrt(amountXLM), capped) so higher-stakes escrows count for more without letting a single whale transaction dominate.

  • Wired into the flows that actually produce this history, rather than left standalone:

    • EscrowController.release() now records an ESCROW_COMPLETED event for both parties after a clean (non-disputed) release.
    • DisputeSagaService.executePayout() records a won/lost/split outcome for both parties once the verdict's payout executes, via a small DisputeVerdict → ReputationOutcome mapping 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/:address and GET /reputation/leaderboard (ranked, paginated via limit) 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 with EscrowController's read endpoints.

Architectural decisions (documented per the issue's task list)

  • No new dependencies — pure TS/NestJS, consistent with the rest of the backend.
  • Chose an incrementally-decayed accumulator over storing the full raw event history and recomputing on each read — this is what makes the store "materialized" and O(1) per update rather than O(n) per read, matching the acceptance criteria's explicit framing.
  • Chose harmonic per-counterparty dampening (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.
  • The DB here is still the in-memory store the rest of this backend uses (as established in Implement Chain↔Database Escrow State Reconciliation #166) — the module boundaries are written so a persistent store could be swapped in without touching the scoring logic itself.

Testing

Automated Testing

  • Unit tests added/updated
  • All tests passing locally

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 existing rate-limit.guard.spec.ts pattern — covers route ordering for /reputation/leaderboard vs /reputation/:address), plus updated dispute-saga.service.spec.ts and a new escrow.controller.spec.ts covering the new call sites. 316 tests passing across 34 suites; the new reputation module is at 100% line/branch coverage.

Manual Testing

  • Tested locally: npm run lint:check, npm run format:check, npx tsc --noEmit, npm run test:ci, npm run build all pass clean.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

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

  1. Concurrency for decay + update: the code decays a stored score to "now", persists it, then applies the new contribution. That pattern can create lost updates if two updates run concurrently (both decay from the same base and then overwrite). Please either:

    • Use an atomic compare-and-set / versioned update so the decay+apply is safe under concurrent writers, or
    • Document why concurrent updates cannot happen in practice and add a unit test that demonstrates the intended behavior under concurrent update attempts.
      This is important because escrow/dispute events may happen in quick succession and the reputation store must not lose contributions.
  2. Leaderboard deterministic ordering and pagination: ensure the leaderboard sort is deterministic for ties (e.g., score desc, then eventCount desc, then address asc). Add a test that verifies stable pagination across ties.

Non-blocking but strongly suggested
3. Integration test: add one end-to-end integration test that exercises the end-to-end flow (EscrowController.release and DisputeSagaService.executePayout → reputation updates → GET /reputation/:address or leaderboard). Unit coverage is great; an integration test will catch any miswiring between modules.

  1. Explain parameter choices and limits: the 90-day half-life, sqrt(amountXLM) scaling, and any caps are reasonable, but please add a short doc comment (or brief markdown in the repo) stating the rationale and how to tune them in production. That makes future tuning and audits easier.

  2. Safety & validation:

    • Add guards/tests for edge inputs: extremely large amounts, zero/negative amounts, and NaN/Infinity in decay math.
    • Confirm the per-counterparty map and recent-events log are bounded and that eviction behavior is tested (I saw the "capped recent-events log" — add a test that shows eviction).
  3. Public endpoints and privacy: you made GET /reputation/* unauthenticated which matches existing read endpoints, but please add a note in the PR description or docs confirming this is intentional and acceptable for the product/privacy model.

Minor / nits
7. Add a short changelog entry (or mention in the release notes) so consumers know the new endpoints and scoring semantics.
8. Consider adding a small metric (e.g., reputation_updates_total, leaderboard_requests_total) so we can monitor usage and performance once this is live.
9. In the Swagger docs, document the sorting/pagination semantics and any rate-limit guidance for the leaderboard endpoint.

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.
@CillaSam

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! Addressed in ba007e3:

Blocking

  1. Concurrency — I dug into this: applyContribution's read (decayedRecord) → modify → write (store.save) sequence has no await anywhere in it. In Node, a synchronous function body runs to completion before the event loop yields to any other queued callback, so two "concurrent" callers (e.g. two requests racing to call recordEscrowCompleted at once) can never interleave mid-update — one call's entire read-modify-write always finishes before the next one starts, even though the enclosing recordEscrowCompleted/recordDisputeResolved are async. I went with your documented-alternative option rather than adding CAS/versioning, since there's nothing to race against in a fully synchronous in-memory store — added a doc comment explaining this precisely, plus a concurrent updates test suite that fires several recordEscrowCompleted calls via Promise.all (both across distinct counterparties and repeated ones) and asserts every contribution landed with the expected dampening. I also noted explicitly that this guarantee is specific to the current synchronous store and would need an optimistic-concurrency guard if it's ever swapped for one backed by real I/O.

  2. Leaderboard tie-breaking — now score desc, then eventCount desc, then address asc, so ordering is deterministic and no longer depends on incidental Map iteration order. Added two tests covering both tiebreak levels.

  3. Integration test — added reputation.integration.spec.ts: wires EscrowModule, DisputeModule, and ReputationModule together via a real TestingModule (no mocks, same assembly AppModule uses in production) and drives both a clean release and a full escalate → assign jurors → vote → payout dispute saga through to GET /reputation/:address and the leaderboard. All 4 pass.

Strongly suggested

  1. Expanded the doc comments on REPUTATION_DECAY_HALF_LIFE_MS, REPUTATION_WEIGHTS, and REPUTATION_MAX_AMOUNT_WEIGHT with the rationale behind each number and how to tune them.

  2. Added edge-case tests: extremely large amount (capped, stays finite), negative amount, non-finite (Infinity) amount, and eviction of the capped recentEvents log once more than the limit has accumulated (score/eventCount still reflect every event — only the audit-trail log is capped).

  3. Confirmed intentional — added a line to both endpoints' Swagger descriptions: the score is derived, publicly-checkable data that can't be set directly by a caller, same reasoning as the existing unauthenticated escrow read endpoints.

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 meshackyaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

@meshackyaro
meshackyaro merged commit 02e400e into trustflow-protocol:main Aug 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Build a Sybil-Resistant Reputation Scoring Engine with Time Decay

2 participants