Skip to content
Open
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
70 changes: 32 additions & 38 deletions backend/src/__tests__/eventIndexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const mockGetScoreConfig = jest.fn(() => ({
repaymentDelta: 15,
defaultPenalty: 50,
}));
const mockUpdateUserScoresBulk = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);

jest.unstable_mockModule("../db/connection.js", () => ({
query: mockQuery,
Expand All @@ -32,6 +33,10 @@ jest.unstable_mockModule("../services/sorobanService.js", () => ({
sorobanService: { getScoreConfig: mockGetScoreConfig },
}));

jest.unstable_mockModule("../services/scoresService.js", () => ({
updateUserScoresBulk: mockUpdateUserScoresBulk,
}));

jest.unstable_mockModule("../utils/logger.js", () => ({
default: {
info: jest.fn(),
Expand Down Expand Up @@ -126,7 +131,6 @@ describe("EventIndexer", () => {
const borrowerRepaid = makeAddress();
const borrowerDefaulted = makeAddress();
const insertedLoanEvents: unknown[][] = [];
const scoreUpdates: unknown[][] = [];

mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => {
if (sql === "BEGIN" || sql === "COMMIT") {
Expand All @@ -138,11 +142,6 @@ describe("EventIndexer", () => {
return { rows: [{ event_id: params[0] }], rowCount: 1 };
}

if (sql.includes("INSERT INTO scores")) {
scoreUpdates.push(params);
return { rows: [], rowCount: 1 };
}

return { rows: [], rowCount: 0 };
});

Expand Down Expand Up @@ -208,10 +207,13 @@ describe("EventIndexer", () => {
expect(insertedLoanEvents[3]?.[2]).toBe(9);
expect(insertedLoanEvents[3]?.[3]).toBe(borrowerDefaulted);

expect(scoreUpdates).toEqual([
[borrowerRepaid, 515, 15],
[borrowerDefaulted, 450, -50],
]);
expect(mockUpdateUserScoresBulk).toHaveBeenCalledTimes(1);
expect(mockUpdateUserScoresBulk).toHaveBeenCalledWith(
new Map([
[borrowerRepaid, 15],
[borrowerDefaulted, -50],
]),
);
expect(mockGetScoreConfig).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenCalledTimes(4);
expect(mockBroadcast).toHaveBeenCalledTimes(4);
Expand All @@ -236,10 +238,6 @@ describe("EventIndexer", () => {
};
}

if (sql.includes("INSERT INTO scores")) {
return { rows: [], rowCount: 1 };
}

return { rows: [], rowCount: 0 };
});

Expand Down Expand Up @@ -269,6 +267,8 @@ describe("EventIndexer", () => {
expect(mockBroadcast).toHaveBeenCalledTimes(1);
expect(mockCreateNotification).toHaveBeenCalledTimes(1);
expect(mockGetScoreConfig).toHaveBeenCalledTimes(1);
expect(mockUpdateUserScoresBulk).toHaveBeenCalledTimes(1);
expect(mockUpdateUserScoresBulk).toHaveBeenCalledWith(new Map([[borrower, 15]]));
});

it("initializes missing indexer state and persists the last indexed ledger during polling", async () => {
Expand All @@ -279,47 +279,41 @@ describe("EventIndexer", () => {
return { rows: [], rowCount: 0 };
}

if (sql.includes("INSERT INTO indexer_state")) {
stateWrites.push(Number(params[0] ?? 0));
return { rows: [], rowCount: 1 };
if (sql.includes("INSERT INTO indexer_state") && params.length === 0) {
return { rows: [{ last_indexed_ledger: 0 }], rowCount: 1 };
}

if (sql.includes("UPDATE indexer_state")) {
stateWrites.push(Number(params[0]));
return { rows: [], rowCount: 1 };
}

if (sql === "BEGIN" || sql === "COMMIT") {
return { rows: [], rowCount: 0 };
}

if (sql.includes("INSERT INTO loan_events")) {
return { rows: [{ event_id: params[0] }], rowCount: 1 };
}

return { rows: [], rowCount: 0 };
});

const indexer = new EventIndexer({
rpcUrl: "https://rpc.test",
contractId: "CINDEXERTEST",
pollIntervalMs: 5,
batchSize: 50,
});

(indexer as { running: boolean }).running = true;
(indexer as {
rpc: {
getLatestLedger: () => Promise<{ sequence: number }>;
getEvents: () => Promise<{ events: unknown[] }>;
};
}).rpc = {
getLatestLedger: jest.fn().mockResolvedValue({ sequence: 15 }),
getEvents: jest.fn().mockResolvedValue({
events: [makeRawEvent({ id: "evt-poll", ledger: 15, type: "LoanRequested" })],
}),
const processChunk = jest
.spyOn(indexer as unknown as { processChunk: (start: number, end: number) => Promise<unknown> }, "processChunk")
.mockResolvedValue({
lastProcessedLedger: 25,
fetchedEvents: 0,
insertedEvents: 0,
});

(indexer as { rpc: { getLatestLedger: () => Promise<{ sequence: number }> } }).rpc = {
getLatestLedger: jest.fn().mockResolvedValue({ sequence: 25 }),
};

await (indexer as { pollOnce: () => Promise<void> }).pollOnce();
await indexer.start();
indexer.stop();

expect(stateWrites).toEqual([0, 15]);
expect(processChunk).toHaveBeenCalledWith(1, 25);
expect(stateWrites).toContain(25);
});
});
21 changes: 18 additions & 3 deletions frontend/src/app/components/ui/CreditScoreGauge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useMemo } from "react";
import { ArrowUp, ArrowDown } from "lucide-react";
import { FinancialTermTooltip } from "./tooltip";
import { FINANCIAL_EXPLANATIONS } from "./financial-terms";

interface CreditScoreGaugeProps {
score: number;
Expand Down Expand Up @@ -119,7 +121,14 @@ export function CreditScoreGauge({

{/* Center score display */}
<div className="absolute inset-0 flex flex-col items-center justify-center pt-4">
<span className={`text-4xl font-bold ${band.color}`}>{score}</span>
<div className="flex flex-col items-center">
<span className={`text-4xl font-bold ${band.color}`}>{score}</span>
<FinancialTermTooltip
term="Credit Score"
explanation={FINANCIAL_EXPLANATIONS.CREDIT_SCORE}
className="text-[10px] uppercase tracking-wider font-bold text-zinc-500 mt-1"
/>
</div>
</div>
</div>

Expand All @@ -143,8 +152,14 @@ export function CreditScoreGauge({

{/* Tooltip / explanation */}
<p className="max-w-xs text-center text-xs text-zinc-500 dark:text-zinc-400">
Your credit score ranges from {min} to {max}. Maintain on-time repayments and low
utilization to improve your score.
Your credit score ranges from {min} to {max}. Maintain on-time repayments and low{" "}
<FinancialTermTooltip
term="utilization"
explanation={FINANCIAL_EXPLANATIONS.UTILIZATION_RATE}
className="border-none p-0 h-auto font-medium text-zinc-600 dark:text-zinc-300"
icon={() => null} // Hide icon here to keep text flow clean
/>{" "}
to improve your score.
</p>
</div>
);
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app/components/ui/financial-terms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const FINANCIAL_EXPLANATIONS = {
APY: "Annual Percentage Yield: The real rate of return on your deposit, accounting for compound interest over one year.",
UTILIZATION_RATE: "The percentage of available liquidity currently being borrowed. Higher rates may affect borrowing costs.",
CREDIT_SCORE: "A measure of your repayment reliability based on on-chain history. Higher scores improve loan terms.",
COLLATERAL_RATIO: "The value of your collateral compared to your loan amount. Keep this high to avoid liquidation.",
LTV: "Loan-to-Value: The maximum loan amount as a percentage of your collateral's value.",
GRACE_PERIOD: "The extra time after a due date when you can repay without penalties.",
};
71 changes: 71 additions & 0 deletions frontend/src/app/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";

import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Info, HelpCircle } from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/** Tool to merge Tailwind classes safely */
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;

const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;

/**
* Standardized tooltip for financial terms with an info icon.
*/
export function FinancialTermTooltip({
term,
explanation,
className,
icon: Icon = Info,
}: {
term: string;
explanation: string;
className?: string;
icon?: React.ElementType;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-flex items-center gap-1 cursor-help border-b border-dotted border-zinc-400/50 hover:border-zinc-400 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded-sm",
className,
)}
tabIndex={0}
>
{term}
{Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground" />}
</span>
</TooltipTrigger>
<TooltipContent side="top">
<p className="max-w-[220px] leading-snug">{explanation}</p>
</TooltipContent>
</Tooltip>
);
}

export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
13 changes: 8 additions & 5 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { GlobalXPGain } from "./components/global_ui/GlobalXPGain";
import { ErrorBoundary } from "./components/global_ui/ErrorBoundary";
import { NextIntlClientProvider } from "next-intl";
import { THEME_STORAGE_KEY } from "./lib/theme";
import { TooltipProvider } from "./components/ui/tooltip";

const geistSans = Geist({
variable: "--font-geist-sans",
Expand Down Expand Up @@ -47,11 +48,13 @@ export default async function RootLayout({
<NextIntlClientProvider locale="en" messages={messages}>
<QueryProvider>
<WalletProvider>
<DashboardShell>
<ErrorBoundary scope="active page" variant="section">
{children}
</ErrorBoundary>
</DashboardShell>
<TooltipProvider delayDuration={300}>
<DashboardShell>
<ErrorBoundary scope="active page" variant="section">
{children}
</ErrorBoundary>
</DashboardShell>
</TooltipProvider>
</WalletProvider>
<Toaster />
<LevelUpModal />
Expand Down
Loading
Loading