Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 105 additions & 17 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand All @@ -802,6 +859,16 @@
}
}
},
"400": {
"description": "Invalid Stellar G-address.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"402": {
"$ref": "#/components/responses/PaymentRequired"
},
Expand Down Expand Up @@ -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"
Expand All @@ -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."
}
}
}
Expand Down
108 changes: 108 additions & 0 deletions apps/api/src/lib/reputation-cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, MemoryEntry>();

let redisClient: RedisClientType | null = null;
let redisReady: Promise<RedisClientType | null> | null = null;

async function getRedis(): Promise<RedisClientType | null> {
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<CachedReputation | null> {
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<void> {
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<void> {
memoryCache.clear();
if (redisClient?.isOpen) {
try {
await redisClient.quit();
} catch {
/* ignore */
}
}
redisClient = null;
redisReady = null;
}
20 changes: 20 additions & 0 deletions apps/api/src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
33 changes: 27 additions & 6 deletions apps/api/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
Expand Down Expand Up @@ -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.",
},
},
},
},
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading