From 7e7cd800f4037157fd6a85c304bed28f938cfe0b Mon Sep 17 00:00:00 2001 From: mysterio123865 Date: Wed, 29 Jul 2026 19:00:53 +0100 Subject: [PATCH] feat(api): implement on-chain reputation signal endpoint Wire GET /reputation/:address to trade history with validation, 60s Redis/memory cache, and integration tests (Closes #332). Co-authored-by: Cursor --- apps/api/openapi.json | 122 +++++++++++-- apps/api/src/lib/reputation-cache.ts | 108 +++++++++++ apps/api/src/lib/store.ts | 20 ++ apps/api/src/openapi.ts | 33 +++- apps/api/src/routes/openapi.test.ts | 1 + apps/api/src/routes/reputation.test.ts | 242 +++++++++++++++++++++++++ apps/api/src/routes/reputation.ts | 37 +++- 7 files changed, 534 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/lib/reputation-cache.ts create mode 100644 apps/api/src/routes/reputation.test.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 27292cd..3ea2ffc 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -372,6 +372,63 @@ } } }, + "/api/v1/cash/pause": { + "get": { + "operationId": "getEscrowPause", + "tags": [ + "cash" + ], + "summary": "Escrow emergency pause / circuit breaker state", + "description": "Reads whether the escrow contract is currently rejecting new locks. When paused is true, POST /cash/request and /cash/request/prepare return 503. Existing locked trades can still be released or refunded.", + "x-rate-limit": { + "max": 60, + "timeWindow": "1 minute" + }, + "responses": { + "200": { + "description": "Current on-chain pause state.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "paused", + "pause_effective_ledger", + "pause_delay_ledgers" + ], + "properties": { + "paused": { + "type": "boolean" + }, + "pause_effective_ledger": { + "type": [ + "integer", + "null" + ] + }, + "pause_delay_ledgers": { + "type": "integer" + }, + "message": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "502": { + "description": "Failed to read pause state from chain." + } + } + } + }, "/api/v1/cash/request/prepare": { "post": { "operationId": "prepareCashRequest", @@ -793,7 +850,7 @@ ], "responses": { "200": { - "description": "Reputation summary. Fields are null until the on-chain reputation source is wired up.", + "description": "Reputation summary derived from trade history for the address. Results are cached for 60 seconds.", "content": { "application/json": { "schema": { @@ -802,6 +859,16 @@ } } }, + "400": { + "description": "Invalid Stellar G-address.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "402": { "$ref": "#/components/responses/PaymentRequired" }, @@ -1043,6 +1110,26 @@ "type": "string", "format": "date-time" }, + "timeoutLedger": { + "type": "integer", + "description": "First Stellar ledger at which permissionless refund() succeeds." + }, + "latestLedger": { + "type": "integer", + "description": "Chain tip used to compute the refund countdown (locked/expired only)." + }, + "ledgersUntilRefund": { + "type": "integer", + "description": "Ledgers remaining before refund becomes available; 0 when available." + }, + "refundAvailable": { + "type": "boolean", + "description": "True once latestLedger >= timeoutLedger. Funds still require a refund call." + }, + "estimatedSecondsUntilRefund": { + "type": "integer", + "description": "Wall-clock estimate only (ledgers × ~6s). Not an on-chain gate." + }, "disputedAt": { "type": "string", "format": "date-time" @@ -1069,31 +1156,32 @@ "type": "object", "required": [ "address", + "total_trades", + "successful_claims", "completion_rate", - "trades", - "trusted" + "trusted", + "cached" ], "properties": { "address": { "type": "string" }, - "completion_rate": { - "type": [ - "number", - "null" - ] + "total_trades": { + "type": "integer" }, - "trades": { - "type": [ - "integer", - "null" - ] + "successful_claims": { + "type": "integer" + }, + "completion_rate": { + "type": "number" }, "trusted": { - "type": [ - "boolean", - "null" - ] + "type": "boolean", + "description": "True when total_trades >= 5 and completion_rate >= 0.90." + }, + "cached": { + "type": "boolean", + "description": "True when the response was served from the 60s cache." } } } diff --git a/apps/api/src/lib/reputation-cache.ts b/apps/api/src/lib/reputation-cache.ts new file mode 100644 index 0000000..0429e39 --- /dev/null +++ b/apps/api/src/lib/reputation-cache.ts @@ -0,0 +1,108 @@ +import { createClient, type RedisClientType } from "redis"; +import type { ReputationMetrics } from "./store.js"; + +export type CachedReputation = ReputationMetrics & { address: string }; + +const TTL_SECONDS = 60; +const CACHE_KEY_PREFIX = "velo:reputation:"; + +type MemoryEntry = { value: CachedReputation; expiresAt: number }; + +const memoryCache = new Map(); + +let redisClient: RedisClientType | null = null; +let redisReady: Promise | null = null; + +async function getRedis(): Promise { + const url = process.env.REDIS_URL; + if (!url) return null; + + if (redisClient?.isOpen) return redisClient; + if (redisReady) return redisReady; + + redisReady = (async () => { + try { + const client = createClient({ url }); + client.on("error", () => { + /* fall back to memory on redis errors */ + }); + await client.connect(); + redisClient = client; + return client; + } catch { + redisClient = null; + return null; + } finally { + redisReady = null; + } + })(); + + return redisReady; +} + +function memoryGet(key: string): CachedReputation | null { + const entry = memoryCache.get(key); + if (!entry) return null; + if (Date.now() >= entry.expiresAt) { + memoryCache.delete(key); + return null; + } + return entry.value; +} + +function memorySet(key: string, value: CachedReputation): void { + memoryCache.set(key, { value, expiresAt: Date.now() + TTL_SECONDS * 1000 }); +} + +/** Read reputation from Redis, falling back to an in-memory TTL map. */ +export async function getCachedReputation( + address: string, +): Promise { + const key = CACHE_KEY_PREFIX + address; + + try { + const redis = await getRedis(); + if (redis) { + const raw = await redis.get(key); + if (raw) return JSON.parse(raw) as CachedReputation; + return null; + } + } catch { + /* use memory fallback */ + } + + return memoryGet(key); +} + +/** Write reputation into Redis (or memory) with a 60s TTL. */ +export async function setCachedReputation( + value: CachedReputation, +): Promise { + const key = CACHE_KEY_PREFIX + value.address; + + try { + const redis = await getRedis(); + if (redis) { + await redis.set(key, JSON.stringify(value), { EX: TTL_SECONDS }); + return; + } + } catch { + /* use memory fallback */ + } + + memorySet(key, value); +} + +/** Test helper — clears the in-memory cache and disconnects Redis. */ +export async function clearReputationCache(): Promise { + memoryCache.clear(); + if (redisClient?.isOpen) { + try { + await redisClient.quit(); + } catch { + /* ignore */ + } + } + redisClient = null; + redisReady = null; +} diff --git a/apps/api/src/lib/store.ts b/apps/api/src/lib/store.ts index 361ab06..8f01344 100644 --- a/apps/api/src/lib/store.ts +++ b/apps/api/src/lib/store.ts @@ -162,6 +162,26 @@ export function getProviderTrades(sellerAddress: string): CashRequestRecord[] { ); } +export interface ReputationMetrics { + total_trades: number; + successful_claims: number; + completion_rate: number; + trusted: boolean; +} + +/** + * Aggregate trust signal for a Stellar address from local trade history. + * `trusted` requires at least 5 trades and a ≥90% release completion rate. + */ +export function getReputationMetrics(address: string): ReputationMetrics { + const trades = getProviderTrades(address); + const total_trades = trades.length; + const successful_claims = trades.filter((t) => t.status === "released").length; + const completion_rate = total_trades === 0 ? 0 : successful_claims / total_trades; + const trusted = total_trades >= 5 && completion_rate >= 0.9; + return { total_trades, successful_claims, completion_rate, trusted }; +} + export function setProviderPayoutMode( stellarAddress: string, payoutMode: "immediate" | "batched" diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 6c8e161..a046f7c 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -628,14 +628,20 @@ export const openApiDocument = { responses: { "200": { description: - "Reputation summary. Fields are null until the on-chain " + - "reputation source is wired up.", + "Reputation summary derived from trade history for the address. " + + "Results are cached for 60 seconds.", content: { "application/json": { schema: { $ref: "#/components/schemas/Reputation" }, }, }, }, + "400": { + description: "Invalid Stellar G-address.", + content: { + "application/json": { schema: { $ref: "#/components/schemas/Error" } }, + }, + }, "402": { $ref: "#/components/responses/PaymentRequired" }, "429": { $ref: "#/components/responses/RateLimited" }, }, @@ -785,12 +791,27 @@ export const openApiDocument = { }, Reputation: { type: "object", - required: ["address", "completion_rate", "trades", "trusted"], + required: [ + "address", + "total_trades", + "successful_claims", + "completion_rate", + "trusted", + "cached", + ], properties: { address: { type: "string" }, - completion_rate: { type: ["number", "null"] }, - trades: { type: ["integer", "null"] }, - trusted: { type: ["boolean", "null"] }, + total_trades: { type: "integer" }, + successful_claims: { type: "integer" }, + completion_rate: { type: "number" }, + trusted: { + type: "boolean", + description: "True when total_trades >= 5 and completion_rate >= 0.90.", + }, + cached: { + type: "boolean", + description: "True when the response was served from the 60s cache.", + }, }, }, }, diff --git a/apps/api/src/routes/openapi.test.ts b/apps/api/src/routes/openapi.test.ts index 7146e58..566f98c 100644 --- a/apps/api/src/routes/openapi.test.ts +++ b/apps/api/src/routes/openapi.test.ts @@ -35,6 +35,7 @@ describe("GET /api/v1/openapi.json", () => { "/api/v1/services", "/api/v1/status", "/api/v1/cash/agents", + "/api/v1/cash/pause", "/api/v1/cash/request", "/api/v1/cash/request/prepare", "/api/v1/cash/request/submit", diff --git a/apps/api/src/routes/reputation.test.ts b/apps/api/src/routes/reputation.test.ts new file mode 100644 index 0000000..d5d4aa8 --- /dev/null +++ b/apps/api/src/routes/reputation.test.ts @@ -0,0 +1,242 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ApiError } from "../lib/errors.js"; +import { clearReputationCache } from "../lib/reputation-cache.js"; +import { + clearStore, + saveCashRequest, + type CashRequestRecord, +} from "../lib/store.js"; +import { reputationRoutes } from "./reputation.js"; + +const VALID_ADDRESS = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const OTHER_ADDRESS = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; + +function makeTrade( + overrides: Partial & Pick, +): CashRequestRecord { + return { + contractId: "CTEST", + buyer: OTHER_ADDRESS, + amountStroops: "10000000", + secretHex: "aa".repeat(32), + secretHashHex: "bb".repeat(32), + qrPayload: "qr", + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +describe("GET /api/v1/reputation/:address", () => { + let app: ReturnType; + let paymentStub: (req: any, reply: any, price: string) => Promise; + + beforeEach(async () => { + clearStore(); + await clearReputationCache(); + delete process.env.REDIS_URL; + + paymentStub = async (req: any, reply: any, priceUsdc: string) => { + const payment = req.headers["x-payment"]; + if (!payment) { + reply.code(402).send({ + challenge: { + amount_usdc: priceUsdc, + pay_to: "G...SET_ME", + memo: "velo:request", + }, + }); + return false; + } + return true; + }; + + app = Fastify(); + app.setErrorHandler((error: Error, _request: FastifyRequest, reply: FastifyReply) => { + if (error instanceof ApiError) { + return reply.status(error.statusCode).send(error.toJSON()); + } + throw error; + }); + app.decorate("requirePayment", async (req: any, reply: any, price: string) => + paymentStub(req, reply, price), + ); + await app.register(reputationRoutes, { prefix: "/api/v1" }); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + await clearReputationCache(); + clearStore(); + }); + + it("returns 402 payment challenge without X-Payment", async () => { + const res = await app.inject({ + method: "GET", + url: `/api/v1/reputation/${VALID_ADDRESS}`, + }); + + expect(res.statusCode).toBe(402); + expect(res.json()).toEqual({ + challenge: { + amount_usdc: "0.0005", + pay_to: "G...SET_ME", + memo: "velo:request", + }, + }); + }); + + it("returns 400 for invalid Stellar addresses", async () => { + const cases = ["not-an-address", "GSHORT", "C" + "A".repeat(55), "g" + "A".repeat(55)]; + + for (const address of cases) { + const res = await app.inject({ + method: "GET", + url: `/api/v1/reputation/${address}`, + headers: { "x-payment": "ok" }, + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ + code: "INVALID_PARAMETER", + statusCode: 400, + }); + } + }); + + it("returns zeroed metrics for an address with no trades", async () => { + const res = await app.inject({ + method: "GET", + url: `/api/v1/reputation/${VALID_ADDRESS}`, + headers: { "x-payment": "ok" }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + address: VALID_ADDRESS, + total_trades: 0, + successful_claims: 0, + completion_rate: 0, + trusted: false, + cached: false, + }); + }); + + it("computes completion rate and trusted flag from trade history", async () => { + for (let i = 0; i < 9; i++) { + saveCashRequest( + makeTrade({ + id: `released-${i}`, + seller: VALID_ADDRESS, + status: "released", + }), + ); + } + saveCashRequest( + makeTrade({ + id: "refunded-0", + seller: VALID_ADDRESS, + status: "refunded", + }), + ); + // Unrelated seller must not affect metrics. + saveCashRequest( + makeTrade({ + id: "other-released", + seller: OTHER_ADDRESS, + status: "released", + }), + ); + + const res = await app.inject({ + method: "GET", + url: `/api/v1/reputation/${VALID_ADDRESS}`, + headers: { "x-payment": "ok" }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + address: VALID_ADDRESS, + total_trades: 10, + successful_claims: 9, + completion_rate: 0.9, + trusted: true, + cached: false, + }); + }); + + it("marks trusted false when completion rate is below 0.90", async () => { + for (let i = 0; i < 4; i++) { + saveCashRequest( + makeTrade({ + id: `ok-${i}`, + seller: VALID_ADDRESS, + status: "released", + }), + ); + } + saveCashRequest( + makeTrade({ + id: "bad-0", + seller: VALID_ADDRESS, + status: "refunded", + }), + ); + + const res = await app.inject({ + method: "GET", + url: `/api/v1/reputation/${VALID_ADDRESS}`, + headers: { "x-payment": "ok" }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ + total_trades: 5, + successful_claims: 4, + completion_rate: 0.8, + trusted: false, + cached: false, + }); + }); + + it("serves a cache hit within the 60s TTL", async () => { + saveCashRequest( + makeTrade({ + id: "cache-1", + seller: VALID_ADDRESS, + status: "released", + }), + ); + + const first = await app.inject({ + method: "GET", + url: `/api/v1/reputation/${VALID_ADDRESS}`, + headers: { "x-payment": "ok" }, + }); + expect(first.statusCode).toBe(200); + expect(first.json().cached).toBe(false); + expect(first.json().total_trades).toBe(1); + + // New trade after cache write should not appear until TTL expires. + saveCashRequest( + makeTrade({ + id: "cache-2", + seller: VALID_ADDRESS, + status: "released", + }), + ); + + const second = await app.inject({ + method: "GET", + url: `/api/v1/reputation/${VALID_ADDRESS}`, + headers: { "x-payment": "ok" }, + }); + expect(second.statusCode).toBe(200); + expect(second.json()).toMatchObject({ + total_trades: 1, + successful_claims: 1, + cached: true, + }); + }); +}); diff --git a/apps/api/src/routes/reputation.ts b/apps/api/src/routes/reputation.ts index c784a7c..e5b1d0a 100644 --- a/apps/api/src/routes/reputation.ts +++ b/apps/api/src/routes/reputation.ts @@ -1,4 +1,13 @@ import type { FastifyInstance } from "fastify"; +import { ApiError } from "../lib/errors.js"; +import { + getCachedReputation, + setCachedReputation, +} from "../lib/reputation-cache.js"; +import { getReputationMetrics } from "../lib/store.js"; + +/** Stellar account (G…) — 56 chars, base32 alphabet excluding 0/O/I/L. */ +const STELLAR_G_ADDRESS = /^G[1-9A-HJ-NP-Za-km-z]{55}$/; /** GET /api/v1/reputation/:address — on-chain trust signal ($0.0005) */ export async function reputationRoutes(app: FastifyInstance) { @@ -10,11 +19,27 @@ export async function reputationRoutes(app: FastifyInstance) { }, }, async (req, reply) => { - const paid = await (app as any).requirePayment(req, reply, "0.0005"); - if (!paid) return; + const paid = await (app as any).requirePayment(req, reply, "0.0005"); + if (!paid) return; + + const { address } = req.params as { address: string }; + if (!STELLAR_G_ADDRESS.test(address)) { + throw new ApiError( + 400, + "INVALID_PARAMETER", + "Invalid Stellar address: expected a 56-character G-address", + ); + } - const { address } = req.params as { address: string }; - // TODO: read the soulbound reputation NFT / on-chain trade history. - return { address, completion_rate: null, trades: null, trusted: null }; - }); + const cached = await getCachedReputation(address); + if (cached) { + return { ...cached, cached: true }; + } + + const metrics = getReputationMetrics(address); + const payload = { address, ...metrics }; + await setCachedReputation(payload); + return { ...payload, cached: false }; + }, + ); }