From 6d494865256460167ff3cb58ec434a2407749c85 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 28 Aug 2026 17:46:56 +0100 Subject: [PATCH] feat: add contributor reputation score service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new reputation scoring system that computes a 0–100 score for any contributor based on three factors: completed bounties (0–60), dispute resolution outcomes (-30 to +20), and response time performance (0–20). A neutral score of 50 is returned for contributors with no history. Implements GET /api/contributors/:address/reputation returning the aggregate score, per-factor breakdown, and supporting counts. --- backend/src/app.ts | 22 ++ backend/src/docs/openapi.ts | 81 +++++ backend/src/services/reputationService.ts | 229 +++++++++++++ .../openapi.snapshot.test.ts.snap | 267 ++++++++++++++- backend/test/reputationService.test.ts | 75 +++++ docs/openapi.generated.json | 303 +++++++++++++++++- 6 files changed, 967 insertions(+), 10 deletions(-) create mode 100644 backend/src/services/reputationService.ts create mode 100644 backend/test/reputationService.test.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 4eb7a2cb..38770317 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -34,6 +34,7 @@ import { } from './services/bountyStore'; import { listOpenIssues } from './services/openIssues'; +import { getContributorReputation } from './services/reputationService'; import { bountyIdSchema, @@ -803,6 +804,27 @@ app.post( } ); +app.get('/api/contributors/:address/reputation', (req: Request, res: Response) => { + try { + const { address } = req.params; + + if (!address || typeof address !== 'string') { + jsonError(res, req, 400, 'Contributor address is required.'); + return; + } + + if (!isValidStellarAddress(address)) { + jsonError(res, req, 400, 'Contributor must be a valid Stellar public key.'); + return; + } + + const reputation = getContributorReputation(address); + res.json({ data: reputation }); + } catch (error) { + sendError(res, req, error); + } +}); + app.get('/api/open-issues', async (req: Request, res: Response) => { try { const data = await listOpenIssues(); diff --git a/backend/src/docs/openapi.ts b/backend/src/docs/openapi.ts index f0b5a19a..064a8a32 100644 --- a/backend/src/docs/openapi.ts +++ b/backend/src/docs/openapi.ts @@ -371,6 +371,87 @@ const leaderboardEntrySchema = z registry.register("LeaderboardEntry", leaderboardEntrySchema); +// --------------------------------------------------------------------------- +// Contributor Reputation +// --------------------------------------------------------------------------- + +const reputationBreakdownSchema = z + .object({ + completionScore: z.number().int().openapi({ + example: 45, + description: "Points earned from completed (released) bounties. 0–60.", + }), + disputeScore: z.number().int().openapi({ + example: 10, + description: "Points from dispute outcomes. -30 to +20.", + }), + responseTimeScore: z.number().int().openapi({ + example: 20, + description: "Points from response-time performance. 0–20.", + }), + }) + .openapi("ReputationBreakdown"); + +registry.register("ReputationBreakdown", reputationBreakdownSchema); + +const contributorReputationSchema = z + .object({ + address: z.string().openapi({ + example: "GBBB...BBB", + description: "Stellar address of the contributor.", + }), + score: z.number().int().min(0).max(100).openapi({ + example: 75, + description: "Aggregate reputation score, clamped to 0–100.", + }), + breakdown: reputationBreakdownSchema, + totalBounties: z.number().int().openapi({ + example: 6, + description: "Total number of bounties the contributor has worked on.", + }), + completedBounties: z.number().int().openapi({ + example: 4, + description: "Number of bounties successfully released.", + }), + disputeWins: z.number().int().openapi({ + example: 1, + description: "Number of disputes resolved in the contributor's favour.", + }), + disputeLosses: z.number().int().openapi({ + example: 0, + description: "Number of disputes resolved against the contributor.", + }), + }) + .openapi("ContributorReputation"); + +registry.register("ContributorReputation", contributorReputationSchema); + +registry.registerPath({ + method: "get", + path: "/api/contributors/{address}/reputation", + tags: ["Contributors"], + summary: "Contributor reputation score", + description: + "Returns the reputation score for a contributor identified by their Stellar address. " + + "The score (0–100) is derived from completed bounties, dispute outcomes, and response times. " + + "A contributor with no history receives a neutral score of 50.", + request: { + params: z.object({ + address: z.string().openapi({ + example: "GBBB...BBB", + description: "Stellar public key of the contributor.", + }), + }), + }, + responses: { + 200: jsonResponse( + "Reputation score and breakdown.", + z.object({ data: contributorReputationSchema }), + ), + 400: errorResponse("Invalid contributor address."), + }, +}); + registry.registerPath({ method: "get", path: "/api/leaderboard", diff --git a/backend/src/services/reputationService.ts b/backend/src/services/reputationService.ts new file mode 100644 index 00000000..415e7fbe --- /dev/null +++ b/backend/src/services/reputationService.ts @@ -0,0 +1,229 @@ +import { listBounties, type BountyRecord } from "./bountyStore"; + +/** + * Reputation scoring formula + * ========================== + * The aggregate score is an integer from 0 to 100 derived from three + * independent components: + * + * 1. Completion score (0–60) + * Each released bounty contributes +15 points, capped at 60. + * A contributor who has completed at least 4 bounties earns the + * maximum completion score. + * + * 2. Dispute resolution score (-30 to +20) + * Each dispute resolved in the contributor's favour (released after + * dispute) adds +10, capped at +20. + * Each dispute resolved against the contributor (refunded after + * dispute) subtracts 15. + * + * 3. Response time score (0–20) + * Measures how quickly the contributor completed work relative to + * the bounty deadline. The ratio is computed per bounty as: + * + * response_ratio = (deadlineAt - reservedAt) / (deadlineAt - createdAt) + * + * A lower ratio means faster completion. The contributor's average + * ratio is mapped to a bonus: + * • ≤ 0.25 (finished in ≤ 25 % of the allotted time) → +20 + * • ≤ 0.50 → +10 + * • otherwise → 0 + * + * Base score: 50 + * Final = clamp(base + completion + dispute + responseTime, 0, 100) + * + * When a contributor has no bounty history the service returns a neutral + * score of 50 with all components zeroed. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Per-factor breakdown of the reputation score. */ +export interface ReputationBreakdown { + /** Points earned from completed (released) bounties. 0–60. */ + completionScore: number; + /** Points from dispute outcomes. -30 to +20. */ + disputeScore: number; + /** Points from response-time performance. 0–20. */ + responseTimeScore: number; +} + +/** Full reputation response for a contributor. */ +export interface ContributorReputation { + /** Stellar address of the contributor. */ + address: string; + /** Aggregate reputation score, clamped to 0–100. */ + score: number; + /** Factor-level breakdown of the score. */ + breakdown: ReputationBreakdown; + /** Total number of bounties the contributor has worked on (all statuses). */ + totalBounties: number; + /** Number of bounties successfully released. */ + completedBounties: number; + /** Number of disputes resolved in the contributor's favour. */ + disputeWins: number; + /** Number of disputes resolved against the contributor. */ + disputeLosses: number; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const BASE_SCORE = 50; +const COMPLETION_POINTS_PER_BOUNTY = 15; +const COMPLETION_CAP = 60; +const DISPUTE_WIN_POINTS = 10; +const DISPUTE_WIN_CAP = 20; +const DISPUTE_LOSS_PENALTY = 15; +const RESPONSE_TIME_EXCELLENT = 0.25; +const RESPONSE_TIME_GOOD = 0.5; +const RESPONSE_TIME_EXCELLENT_BONUS = 20; +const RESPONSE_TIME_GOOD_BONUS = 10; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** + * Compute the response-time ratio for a single bounty. + * + * The ratio measures what fraction of the allotted time the contributor + * actually used: + * + * (deadlineAt - reservedAt) / (deadlineAt - createdAt) + * + * A value of 0 means the contributor finished instantly; 1 means they + * used every second up to the deadline. + * + * Returns `null` when the data needed for the calculation is missing + * (no reservation, no timestamps, or zero-length window). + */ +function responseRatio(bounty: BountyRecord): number | null { + if (!bounty.reservedAt) return null; + + const window = bounty.deadlineAt - bounty.createdAt; + if (window <= 0) return null; + + const used = bounty.deadlineAt - bounty.reservedAt; + return used / window; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Compute the reputation score for a contributor identified by their + * Stellar address. + * + * @param address - Stellar public key of the contributor. + * @returns The reputation score and its component breakdown. + */ +export function getContributorReputation( + address: string, +): ContributorReputation { + const allBounties = listBounties(); + const contributorBounties = allBounties.filter( + (b) => b.contributor === address, + ); + + const totalBounties = contributorBounties.length; + + // No history → neutral score + if (totalBounties === 0) { + return { + address, + score: BASE_SCORE, + breakdown: { + completionScore: 0, + disputeScore: 0, + responseTimeScore: 0, + }, + totalBounties: 0, + completedBounties: 0, + disputeWins: 0, + disputeLosses: 0, + }; + } + + // ── Completion score ────────────────────────────────────────────────── + const released = contributorBounties.filter( + (b) => b.status === "released", + ); + const completionScore = Math.min( + released.length * COMPLETION_POINTS_PER_BOUNTY, + COMPLETION_CAP, + ); + + // ── Dispute score ───────────────────────────────────────────────────── + const disputes = contributorBounties.filter( + (b) => + b.status === "released" || b.status === "refunded", + ).filter((b) => + b.events.some((e) => e.type === "disputed"), + ); + + let disputeWins = 0; + let disputeLosses = 0; + + for (const b of disputes) { + const lastEvent = b.events[b.events.length - 1]; + if (lastEvent.type === "released") { + disputeWins++; + } else { + disputeLosses++; + } + } + + const disputeScore = + Math.min(disputeWins * DISPUTE_WIN_POINTS, DISPUTE_WIN_CAP) - + disputeLosses * DISPUTE_LOSS_PENALTY; + + // ── Response time score ─────────────────────────────────────────────── + const ratios: number[] = []; + for (const b of contributorBounties) { + const r = responseRatio(b); + if (r !== null) { + ratios.push(r); + } + } + + let responseTimeScore = 0; + if (ratios.length > 0) { + const avgRatio = + ratios.reduce((sum, r) => sum + r, 0) / ratios.length; + if (avgRatio <= RESPONSE_TIME_EXCELLENT) { + responseTimeScore = RESPONSE_TIME_EXCELLENT_BONUS; + } else if (avgRatio <= RESPONSE_TIME_GOOD) { + responseTimeScore = RESPONSE_TIME_GOOD_BONUS; + } + } + + // ── Aggregate ───────────────────────────────────────────────────────── + const score = clamp( + BASE_SCORE + completionScore + disputeScore + responseTimeScore, + 0, + 100, + ); + + return { + address, + score, + breakdown: { + completionScore, + disputeScore, + responseTimeScore, + }, + totalBounties, + completedBounties: released.length, + disputeWins, + disputeLosses, + }; +} diff --git a/backend/test/__snapshots__/openapi.snapshot.test.ts.snap b/backend/test/__snapshots__/openapi.snapshot.test.ts.snap index ff8022c4..ba626721 100644 --- a/backend/test/__snapshots__/openapi.snapshot.test.ts.snap +++ b/backend/test/__snapshots__/openapi.snapshot.test.ts.snap @@ -203,6 +203,13 @@ exports[`OpenAPI spec snapshot test > generated OpenAPI spec matches snapshot 1` "example": 1911000000, "type": "number", }, + "disputeReason": { + "type": "string", + }, + "disputedAt": { + "example": 1710010800, + "type": "number", + }, "events": { "items": { "properties": { @@ -262,6 +269,11 @@ exports[`OpenAPI spec snapshot test > generated OpenAPI spec matches snapshot 1` "notes": { "type": "string", }, + "protocolFeeCollected": { + "description": "Protocol fee collected when this bounty was released (in token units).", + "example": 0, + "type": "number", + }, "refundedAt": { "type": "number", }, @@ -296,6 +308,7 @@ exports[`OpenAPI spec snapshot test > generated OpenAPI spec matches snapshot 1` "released", "refunded", "expired", + "disputed", ], "example": "open", "type": "string", @@ -347,6 +360,55 @@ exports[`OpenAPI spec snapshot test > generated OpenAPI spec matches snapshot 1` ], "type": "object", }, + "ContributorReputation": { + "properties": { + "address": { + "description": "Stellar address of the contributor.", + "example": "GBBB...BBB", + "type": "string", + }, + "breakdown": { + "$ref": "#/components/schemas/ReputationBreakdown", + }, + "completedBounties": { + "description": "Number of bounties successfully released.", + "example": 4, + "type": "integer", + }, + "disputeLosses": { + "description": "Number of disputes resolved against the contributor.", + "example": 0, + "type": "integer", + }, + "disputeWins": { + "description": "Number of disputes resolved in the contributor's favour.", + "example": 1, + "type": "integer", + }, + "score": { + "description": "Aggregate reputation score, clamped to 0–100.", + "example": 75, + "maximum": 100, + "minimum": 0, + "type": "integer", + }, + "totalBounties": { + "description": "Total number of bounties the contributor has worked on.", + "example": 6, + "type": "integer", + }, + }, + "required": [ + "address", + "score", + "breakdown", + "totalBounties", + "completedBounties", + "disputeWins", + "disputeLosses", + ], + "type": "object", + }, "CreateBountyRequest": { "properties": { "amount": { @@ -578,7 +640,25 @@ exports[`OpenAPI spec snapshot test > generated OpenAPI spec matches snapshot 1` "type": "object", }, "HealthResponse": { - "properties": {}, + "properties": { + "service": { + "example": "stellar-bounty-board-backend", + "type": "string", + }, + "status": { + "example": "ok", + "type": "string", + }, + "timestamp": { + "example": "2026-03-24T19:00:00.000Z", + "type": "string", + }, + }, + "required": [ + "service", + "status", + "timestamp", + ], "type": "object", }, "LeaderboardEntry": { @@ -668,6 +748,91 @@ exports[`OpenAPI spec snapshot test > generated OpenAPI spec matches snapshot 1` ], "type": "object", }, + "PublicConfig": { + "properties": { + "defaultReservationTtlSeconds": { + "description": "Default reservation TTL in seconds (7 days = 604800). After this window a reservation returns to open.", + "example": 604800, + "exclusiveMinimum": 0, + "type": "integer", + }, + "disputeWindowSeconds": { + "description": "Seconds that must elapse after a dispute is raised before an arbiter can resolve it.", + "example": 86400, + "minimum": 0, + "type": "integer", + }, + "feeBps": { + "description": "Protocol fee in basis points (250 = 2.5%). 0 means no protocol fee.", + "example": 250, + "maximum": 10000, + "minimum": 0, + "type": "integer", + }, + "maxBountyAmount": { + "description": "Maximum allowed bounty amount in the payment token.", + "example": 10000, + "exclusiveMinimum": 0, + "type": "number", + }, + "minBountyAmount": { + "description": "Minimum allowed bounty amount in the payment token.", + "example": 1, + "exclusiveMinimum": 0, + "type": "number", + }, + "network": { + "description": "Stellar network the backend is connected to (mainnet, testnet, or futurenet).", + "example": "testnet", + "type": "string", + }, + "supportedTokens": { + "additionalProperties": { + "type": "string", + }, + "description": "Allowed token symbols mapped to their Soroban contract addresses.", + "example": { + "XLM": "CAS3J7YBBURBV347V3UAEAOAT2IZU7QHWG7YWCOOOFLBEBGKND655DHA", + }, + "type": "object", + }, + }, + "required": [ + "feeBps", + "disputeWindowSeconds", + "minBountyAmount", + "maxBountyAmount", + "supportedTokens", + "defaultReservationTtlSeconds", + "network", + ], + "type": "object", + }, + "ReputationBreakdown": { + "properties": { + "completionScore": { + "description": "Points earned from completed (released) bounties. 0–60.", + "example": 45, + "type": "integer", + }, + "disputeScore": { + "description": "Points from dispute outcomes. -30 to +20.", + "example": 10, + "type": "integer", + }, + "responseTimeScore": { + "description": "Points from response-time performance. 0–20.", + "example": 20, + "type": "integer", + }, + }, + "required": [ + "completionScore", + "disputeScore", + "responseTimeScore", + ], + "type": "object", + }, "ReserveBountyRequest": { "properties": { "contributor": { @@ -934,7 +1099,7 @@ Maintainers may \`cancel\` an \`open\` bounty before reservation, or \`refund\` "required": false, "schema": { "description": "Filter bounties with deadline before this ISO 8601 date string.", - "example": "2026-08-26T16:01:06.327Z", + "example": "2026-09-27T16:44:59.796Z", "type": "string", }, }, @@ -944,7 +1109,7 @@ Maintainers may \`cancel\` an \`open\` bounty before reservation, or \`refund\` "required": false, "schema": { "description": "Filter bounties with deadline after this ISO 8601 date string.", - "example": "2026-07-27T16:01:06.328Z", + "example": "2026-08-28T16:44:59.798Z", "type": "string", }, }, @@ -1491,6 +1656,102 @@ Maintainers may \`cancel\` an \`open\` bounty before reservation, or \`refund\` ], }, }, + "/api/config": { + "get": { + "description": "Returns non-sensitive runtime values that the frontend and contract-interaction layer need to stay in sync with the backend without hardcoding anything. + +**Included:** fee bps, dispute window, min/max bounty amounts, supported token addresses, default reservation TTL, and network label. + +**Never included:** API keys, database connection strings, webhook secrets, maintainer public keys, or any other internal-only setting. + +Response is cached with \`Cache-Control: public, max-age=300\` (5 min) since these values rarely change.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/PublicConfig", + }, + }, + "required": [ + "data", + ], + "type": "object", + }, + }, + }, + "description": "Public runtime configuration.", + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse", + }, + }, + }, + "description": "Failed to build configuration.", + }, + }, + "summary": "Public runtime configuration", + "tags": [ + "System", + ], + }, + }, + "/api/contributors/{address}/reputation": { + "get": { + "description": "Returns the reputation score for a contributor identified by their Stellar address. The score (0–100) is derived from completed bounties, dispute outcomes, and response times. A contributor with no history receives a neutral score of 50.", + "parameters": [ + { + "in": "path", + "name": "address", + "required": true, + "schema": { + "description": "Stellar public key of the contributor.", + "example": "GBBB...BBB", + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/ContributorReputation", + }, + }, + "required": [ + "data", + ], + "type": "object", + }, + }, + }, + "description": "Reputation score and breakdown.", + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse", + }, + }, + }, + "description": "Invalid contributor address.", + }, + }, + "summary": "Contributor reputation score", + "tags": [ + "Contributors", + ], + }, + }, "/api/health": { "get": { "description": "Returns the service name and current server timestamp. Use this to verify the API is reachable.", diff --git a/backend/test/reputationService.test.ts b/backend/test/reputationService.test.ts new file mode 100644 index 00000000..7f91f718 --- /dev/null +++ b/backend/test/reputationService.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { getContributorReputation } from "../src/services/reputationService"; + +describe("getContributorReputation", () => { + const UNKNOWN_ADDRESS = + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + + it("returns a neutral score of 50 for a contributor with no history", () => { + const result = getContributorReputation(UNKNOWN_ADDRESS); + expect(result.score).toBe(50); + expect(result.totalBounties).toBe(0); + expect(result.completedBounties).toBe(0); + expect(result.breakdown.completionScore).toBe(0); + expect(result.breakdown.disputeScore).toBe(0); + expect(result.breakdown.responseTimeScore).toBe(0); + }); + + it("returns the address in the response", () => { + const result = getContributorReputation(UNKNOWN_ADDRESS); + expect(result.address).toBe(UNKNOWN_ADDRESS); + }); + + it("does not give completion points for non-released bounties", () => { + // Pick an address that is not a contributor on any released bounty + // in the store. The score should not include completion points. + const noReleaseContributor = + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWFF"; + const result = getContributorReputation(noReleaseContributor); + expect(result.completedBounties).toBe(0); + expect(result.breakdown.completionScore).toBe(0); + }); + + it("score is always between 0 and 100", () => { + // Check several known and unknown addresses — the score should + // never go out of bounds regardless of input. + const addresses = [ + UNKNOWN_ADDRESS, + "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + ]; + for (const addr of addresses) { + const result = getContributorReputation(addr); + expect(result.score).toBeGreaterThanOrEqual(0); + expect(result.score).toBeLessThanOrEqual(100); + } + }); + + it("breakdown fields sum correctly toward the aggregate", () => { + const result = getContributorReputation(UNKNOWN_ADDRESS); + // For unknown contributors: base (50) + 0 + 0 + 0 = 50 + const expected = 50; + expect(result.score).toBe(expected); + }); + + it("returns consistent structure", () => { + const result = getContributorReputation(UNKNOWN_ADDRESS); + expect(result).toHaveProperty("address"); + expect(result).toHaveProperty("score"); + expect(result).toHaveProperty("breakdown"); + expect(result).toHaveProperty("totalBounties"); + expect(result).toHaveProperty("completedBounties"); + expect(result).toHaveProperty("disputeWins"); + expect(result).toHaveProperty("disputeLosses"); + + expect(result.breakdown).toHaveProperty("completionScore"); + expect(result.breakdown).toHaveProperty("disputeScore"); + expect(result.breakdown).toHaveProperty("responseTimeScore"); + + expect(typeof result.score).toBe("number"); + expect(typeof result.totalBounties).toBe("number"); + expect(typeof result.completedBounties).toBe("number"); + expect(typeof result.disputeWins).toBe("number"); + expect(typeof result.disputeLosses).toBe("number"); + }); +}); diff --git a/docs/openapi.generated.json b/docs/openapi.generated.json index 7afd6d12..a4237920 100644 --- a/docs/openapi.generated.json +++ b/docs/openapi.generated.json @@ -48,6 +48,10 @@ "type": "string", "example": "XLM" }, + "tokenAddress": { + "type": "string", + "example": "CAS3J7YBBURBV347V3UAEAOAT2IZU7QHWG7YWCOOOFLBEBGKND655DHA" + }, "amount": { "type": "number", "example": 100 @@ -70,7 +74,8 @@ "submitted", "released", "refunded", - "expired" + "expired", + "disputed" ], "example": "open" }, @@ -98,6 +103,11 @@ "type": "string", "example": "0000000000000000000000000000000000000000000000000000000000000000" }, + "protocolFeeCollected": { + "type": "number", + "description": "Protocol fee collected when this bounty was released (in token units).", + "example": 0 + }, "refundedAt": { "type": "number" }, @@ -120,6 +130,55 @@ }, "notes": { "type": "string" + }, + "disputedAt": { + "type": "number", + "example": 1710010800 + }, + "disputeReason": { + "type": "string" + }, + "version": { + "type": "number", + "example": 1 + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "created", + "reserved", + "submitted", + "released", + "refunded", + "expired", + "disputed" + ] + }, + "timestamp": { + "type": "number" + }, + "actor": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "type", + "timestamp" + ] + } + }, + "reservationTimeoutSeconds": { + "type": "number", + "example": 604800 } }, "required": [ @@ -130,11 +189,14 @@ "summary", "maintainer", "tokenSymbol", + "tokenAddress", "amount", "labels", "status", "createdAt", - "deadlineAt" + "deadlineAt", + "version", + "events" ] }, "BountyAuditLogRecord": { @@ -534,13 +596,16 @@ "type": "object", "properties": { "service": { - "type": "string" + "type": "string", + "example": "stellar-bounty-board-backend" }, "status": { - "type": "string" + "type": "string", + "example": "ok" }, "timestamp": { - "type": "string" + "type": "string", + "example": "2026-03-24T19:00:00.000Z" } }, "required": [ @@ -705,6 +770,140 @@ "totalXlm", "bountiesCompleted" ] + }, + "ReputationBreakdown": { + "type": "object", + "properties": { + "completionScore": { + "type": "integer", + "description": "Points earned from completed (released) bounties. 0–60.", + "example": 45 + }, + "disputeScore": { + "type": "integer", + "description": "Points from dispute outcomes. -30 to +20.", + "example": 10 + }, + "responseTimeScore": { + "type": "integer", + "description": "Points from response-time performance. 0–20.", + "example": 20 + } + }, + "required": [ + "completionScore", + "disputeScore", + "responseTimeScore" + ] + }, + "ContributorReputation": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Stellar address of the contributor.", + "example": "GBBB...BBB" + }, + "score": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Aggregate reputation score, clamped to 0–100.", + "example": 75 + }, + "breakdown": { + "$ref": "#/components/schemas/ReputationBreakdown" + }, + "totalBounties": { + "type": "integer", + "description": "Total number of bounties the contributor has worked on.", + "example": 6 + }, + "completedBounties": { + "type": "integer", + "description": "Number of bounties successfully released.", + "example": 4 + }, + "disputeWins": { + "type": "integer", + "description": "Number of disputes resolved in the contributor's favour.", + "example": 1 + }, + "disputeLosses": { + "type": "integer", + "description": "Number of disputes resolved against the contributor.", + "example": 0 + } + }, + "required": [ + "address", + "score", + "breakdown", + "totalBounties", + "completedBounties", + "disputeWins", + "disputeLosses" + ] + }, + "PublicConfig": { + "type": "object", + "properties": { + "feeBps": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "description": "Protocol fee in basis points (250 = 2.5%). 0 means no protocol fee.", + "example": 250 + }, + "disputeWindowSeconds": { + "type": "integer", + "minimum": 0, + "description": "Seconds that must elapse after a dispute is raised before an arbiter can resolve it.", + "example": 86400 + }, + "minBountyAmount": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Minimum allowed bounty amount in the payment token.", + "example": 1 + }, + "maxBountyAmount": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Maximum allowed bounty amount in the payment token.", + "example": 10000 + }, + "supportedTokens": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Allowed token symbols mapped to their Soroban contract addresses.", + "example": { + "XLM": "CAS3J7YBBURBV347V3UAEAOAT2IZU7QHWG7YWCOOOFLBEBGKND655DHA" + } + }, + "defaultReservationTtlSeconds": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Default reservation TTL in seconds (7 days = 604800). After this window a reservation returns to open.", + "example": 604800 + }, + "network": { + "type": "string", + "description": "Stellar network the backend is connected to (mainnet, testnet, or futurenet).", + "example": "testnet" + } + }, + "required": [ + "feeBps", + "disputeWindowSeconds", + "minBountyAmount", + "maxBountyAmount", + "supportedTokens", + "defaultReservationTtlSeconds", + "network" + ] } }, "parameters": {} @@ -847,7 +1046,7 @@ "schema": { "type": "string", "description": "Filter bounties with deadline before this ISO 8601 date string.", - "example": "2026-07-29T20:46:10.721Z" + "example": "2026-09-27T16:43:07.148Z" }, "required": false, "name": "deadlineBefore", @@ -857,7 +1056,7 @@ "schema": { "type": "string", "description": "Filter bounties with deadline after this ISO 8601 date string.", - "example": "2026-06-29T20:46:10.725Z" + "example": "2026-08-28T16:43:07.149Z" }, "required": false, "name": "deadlineAfter", @@ -1473,6 +1672,57 @@ } } }, + "/api/contributors/{address}/reputation": { + "get": { + "tags": [ + "Contributors" + ], + "summary": "Contributor reputation score", + "description": "Returns the reputation score for a contributor identified by their Stellar address. The score (0–100) is derived from completed bounties, dispute outcomes, and response times. A contributor with no history receives a neutral score of 50.", + "parameters": [ + { + "schema": { + "type": "string", + "description": "Stellar public key of the contributor.", + "example": "GBBB...BBB" + }, + "required": true, + "name": "address", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Reputation score and breakdown.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ContributorReputation" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Invalid contributor address.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/leaderboard": { "get": { "tags": [ @@ -1505,6 +1755,45 @@ } } }, + "/api/config": { + "get": { + "tags": [ + "System" + ], + "summary": "Public runtime configuration", + "description": "Returns non-sensitive runtime values that the frontend and contract-interaction layer need to stay in sync with the backend without hardcoding anything.\n\n**Included:** fee bps, dispute window, min/max bounty amounts, supported token addresses, default reservation TTL, and network label.\n\n**Never included:** API keys, database connection strings, webhook secrets, maintainer public keys, or any other internal-only setting.\n\nResponse is cached with `Cache-Control: public, max-age=300` (5 min) since these values rarely change.", + "responses": { + "200": { + "description": "Public runtime configuration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/PublicConfig" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "500": { + "description": "Failed to build configuration.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/audit-log": { "get": { "tags": [