Skip to content

feat: Real-time Risk Score & Loan Visualization#177

Merged
anonfedora merged 5 commits intoanonfedora:masterfrom
Pvsaint:loan-visualization
Mar 29, 2026
Merged

feat: Real-time Risk Score & Loan Visualization#177
anonfedora merged 5 commits intoanonfedora:masterfrom
Pvsaint:loan-visualization

Conversation

@Pvsaint
Copy link
Copy Markdown
Contributor

@Pvsaint Pvsaint commented Mar 29, 2026

Summary

Real-time Risk Score & Loan Visualization

Closes #126

Type of Change

  • Bug fix
  • New feature
  • Refactor / code cleanup
  • Docs / config update
  • Contract change (Soroban)
  • Breaking change

Changes Made

Testing

  • cargo test passes (contracts)
  • npm test passes (server)
  • tsc --noEmit passes (server)
  • cargo fmt + cargo clippy clean (contracts)
  • Manually tested locally

Contract Changes (if applicable)

  • ABI / interface changed
  • Migration required
  • Snapshot tests updated

Checklist

  • Self-reviewed the diff
  • No secrets or private keys committed
  • Relevant docs updated (README, .env.example, etc.)
  • PR title follows type(scope): description format (e.g. feat(escrow): add release timeout)

Summary by CodeRabbit

  • New Features

    • Added time range selector (6 months, 1 year, all time) for wallet risk data instead of manual date inputs.
    • Integrated real-time score updates with a stale-data indicator when unavailable.
    • Enhanced loan simulator with fixed simulation amount and projected score visualization.
    • Added loan timeline panel displaying borrowing history.
    • Added info tooltips to risk score breakdown components for metric explanations.
  • Chores

    • Updated development dependencies.

@Pvsaint Pvsaint requested a review from anonfedora as a code owner March 29, 2026 22:26
@drips-wave
Copy link
Copy Markdown

drips-wave bot commented Mar 29, 2026

@Pvsaint Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 29, 2026

Warning

Rate limit exceeded

@Pvsaint has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 12 minutes and 58 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 12 minutes and 58 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4ddaf8f7-eed2-4fb4-a218-02243ae1a143

📥 Commits

Reviewing files that changed from the base of the PR and between 32ebca7 and fc56d1c.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • frontend/src/app/loans/[id]/page.tsx
  • frontend/src/app/page.tsx
  • frontend/src/components/auth/WalletPickerModal.tsx
  • frontend/src/components/kyc/KycVerificationBanner.tsx
  • frontend/src/components/loans/LoanCard.tsx
  • frontend/src/hooks/useLoans.ts
  • frontend/src/hooks/useWebSocket.ts
  • frontend/src/types/index.ts
  • frontend/src/utils/stellar.ts
📝 Walkthrough

Walkthrough

Implemented real-time risk score dashboard with WebSocket integration, loan timeline visualization, score simulation mode, and interactive tooltips. Updated risk score hook to fetch current scores, historical data, and simulation results from backend APIs. Added new components for timeline visualization and accessible tooltips, with parser utilities for backend response handling.

Changes

Cohort / File(s) Summary
Dependencies
frontend/package.json
Added fast-check v3.23.2 to devDependencies.
API Utilities & Configuration
frontend/src/utils/riskScoreParsers.ts, frontend/src/components/risk/tooltipConfig.ts
Added response parsers for v2 backend risk score, historical score, and simulation endpoints with safe type coercion. Created tooltip configuration mapping metric keys to descriptions.
Core Hooks
frontend/src/hooks/useRiskScore.ts, frontend/src/hooks/useWebSocket.ts
Refactored useRiskScore to fetch current score, history, and handle simulation via separate API endpoints with range-based filtering. Added new useWebSocket hook with exponential backoff reconnection, parsing real-time score updates and invoking callbacks.
Visualization Components
frontend/src/components/risk/ScoreHistoryChart.tsx, frontend/src/components/risk/ScoreGauge.tsx, frontend/src/components/risk/LoanTimeline.tsx
Enhanced ScoreHistoryChart with loading skeleton, simulation projection overlay, and improved tick formatting. Extended ScoreGauge with optional projected score arc. Added new LoanTimeline component rendering status-colored loan bars with interactive tooltips and time axis.
Interactive UI Components
frontend/src/components/risk/Tooltip.tsx, frontend/src/components/risk/ScoreBreakdown.tsx
Created accessible Tooltip component with keyboard/mouse interactions and ARIA attributes. Updated ScoreBreakdown to display per-item info tooltips and adjusted progress bar width calculation (0–1000 scale).
Page Integration
frontend/src/app/risk/[wallet]/page.tsx
Refactored to use typed range selector (6m/1y/all) persisted via URL query. Integrated WebSocket for real-time history appends with stale-data banner. Replaced manual input simulation with fixed 5,000 USDC scenario, displaying projected score and delta. Added LoanTimeline panel below risk gauges.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Page as WalletRiskPage
    participant Hook as useRiskScore
    participant API as Backend API
    participant DB as Database

    User->>Page: Navigate to risk page with [wallet]
    Page->>Hook: Call useRiskScore(wallet, range)
    activate Hook
    Hook->>API: GET /api/risk/{wallet}
    API->>DB: Query current risk score
    DB-->>API: Return RiskScoreData
    API-->>Hook: Score + breakdown components
    Hook->>API: GET /api/risk/{wallet}/history?range={range}
    API->>DB: Query historical scores
    DB-->>API: Return HistoricalScore[]
    API-->>Hook: Parsed RiskHistoryEntry[]
    Hook-->>Page: {score, grade, breakdown, history, historyLoading}
    deactivate Hook
    Page->>Page: Render ScoreGauge, ScoreHistoryChart, ScoreBreakdown
    User-->>Page: Charts display current & historical data
Loading
sequenceDiagram
    participant User
    participant Page as WalletRiskPage
    participant WS as useWebSocket
    participant WSServer as WebSocket Server
    participant RealTime as RiskScore Calculator

    Page->>WS: Call useWebSocket(wallet, onScoreUpdate)
    activate WS
    WS->>WSServer: Connect to /ws
    WSServer-->>WS: Connection established
    WS-->>Page: {connected: true}
    deactivate WS
    
    RealTime->>RealTime: New score calculated
    RealTime->>WSServer: Emit RiskScoreUpdated event
    WSServer->>WS: Push JSON message
    activate WS
    WS->>WS: Parse & validate message type
    WS->>Page: Invoke onScoreUpdate callback
    deactivate WS
    Page->>Page: appendHistoryPoint(newScore)
    Page->>Page: Re-render ScoreHistoryChart with updated history
    User-->>Page: Chart animates new data point
Loading
sequenceDiagram
    participant User
    participant Page as WalletRiskPage
    participant Hook as useRiskScore
    participant API as Backend API

    User->>Page: Click "Simulate 5,000 USDC" button
    Page->>Hook: Call simulationStart()
    activate Hook
    Hook->>API: POST /api/risk/{wallet}/simulate<br/>{loanAmount: 5000}
    API->>API: Calculate projected score & delta
    API-->>Hook: SimulationResult{currentScore, projectedScore, scoreDelta}
    Hook-->>Page: {simulationResult, simulationLoading: false}
    deactivate Hook
    Page->>Page: Compute simulationPoint {date+30d, projectedScore}
    Page->>Page: Render ScoreGauge with projectedScore arc
    Page->>Page: Render ScoreHistoryChart with dashed projection line
    User-->>Page: Simulation visualization displayed
    User->>Page: Click "Deactivate Simulation" button
    Page->>Hook: Call simulationStop()
    Hook-->>Page: {simulationResult: null, simulationLoading: false}
    Page->>Page: Remove projected arc & projection line
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • anonfedora

Poem

🐰 A dashboard blooms with rabbit's delight,
Real-time scores shimmer, WebSocket so bright!
Loans dance on timelines, simulations take flight,
Tooltips explain what makes finance feel right! ✨💹

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat: Real-time Risk Score & Loan Visualization' accurately summarizes the main changes by highlighting both real-time score updates and loan visualization features, the core additions in this changeset.
Description check ✅ Passed The PR description includes the required template sections with the summary, type of change marked as new feature, linked issue reference, testing confirmation, and completed checklist items, though the 'Changes Made' bullet list is empty.
Linked Issues check ✅ Passed The PR successfully implements all four main objectives from issue #126: ScoreHistoryChart integrated with backend API via useRiskScore hook, LoanTimeline component added with SVG visualization, Simulation Mode implemented with 5000 USDC fixed scenario, and interactive tooltips added to ScoreBreakdown.
Out of Scope Changes check ✅ Passed All changes directly support the linked issue objectives. The WebSocket hook and risk score parsers enable real-time updates, new components and hooks fulfill visualization and simulation requirements, and package.json changes add necessary dev dependencies for testing.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/package.json`:
- Line 26: Remove the unused devDependency "fast-check" from frontend
package.json: open package.json, locate the "fast-check" entry in
devDependencies and delete that line, then run dependency cleanup (e.g.,
npm/yarn install or prune) to update lockfile and node_modules; if you later add
property-based tests, re-add "fast-check" at that time.

In `@frontend/src/app/risk/`[wallet]/page.tsx:
- Around line 53-58: The websocket onScoreUpdate currently only calls
appendHistoryPoint and doesn't update the primary score state used by ScoreGauge
and ScoreBreakdown, so make the handler also update that score state/reducer
with the incoming ScoreUpdateEvent fields (event.overall_score, event.grade,
event.components). Inside the useWebSocket call's onScoreUpdate callback (the
useCallback shown), after appendHistoryPoint(...) dispatch or call the existing
score state updater (e.g., setScore, updateScore, or the score reducer action
used elsewhere) to set the new overall score, grade, and components; ensure the
updater is added to the useCallback dependency array. Keep appendHistoryPoint so
history still updates.

In `@frontend/src/components/risk/LoanTimeline.tsx`:
- Around line 19-27: The component's ApiLoan shape doesn't match the API's
camelCase response, causing mapApiLoan to read undefined values; update the
ApiLoan interface in LoanTimeline.tsx to use the actual response keys (amount,
interestRate, createdAt, updatedAt, dueDate) and adjust any uses in mapApiLoan
to those camelCase properties, or alternatively implement a serializer in the
server loan controller that maps the API's camelCase fields to the snake_case
names expected by ApiLoan before returning data so mapApiLoan receives the
expected shape.

In `@frontend/src/components/risk/Tooltip.tsx`:
- Around line 61-80: The tooltip trigger div should reference the tooltip panel
via aria-describedby and also close when the trigger loses focus; generate or
use a stable unique id (e.g., tooltipId from useId or a prop) and set that id on
the tooltip div (the element rendered when visible) and add
aria-describedby={tooltipId} to the trigger div, and add an onBlur handler on
the trigger (calling scheduleHide) so keyboard users closing/tabbing away will
hide the tooltip; ensure the tooltip still has role="tooltip" and keep existing
mouse enter/leave handlers (clearHideTimer/scheduleHide/visible) intact so
behavior is unchanged.

In `@frontend/src/components/risk/tooltipConfig.ts`:
- Around line 1-6: TOOLTIP_CONFIG currently uses keys like on_chain_activity and
document_verification that don't match the risk engine's emitted keys, so update
the TOOLTIP_CONFIG object to use the engine's actual component keys (e.g.
transactionHistory, repaymentRecord, collateralCoverage, disputeHistory) and
replace or remove document_verification; keep the human-readable descriptions
mapped to these exact keys so ScoreBreakdown.tsx can find them (update the
description text as needed to match the original messages for on-chain activity,
repayment history, collateral quality and dispute history).

In `@frontend/src/hooks/useRiskScore.ts`:
- Around line 111-115: In useRiskScore's early-return branch inside the
useEffect that checks `!walletAddress`, reset the loading flags so they can't
remain true when an in-flight request is cancelled: call `setLoading(false)` and
`setHistoryLoading(false)` along with `setData(null)` and `setError(null)`; do
the same for the other similar early-return at lines ~147-151 (the other effect
in useRiskScore) so `loading`/`historyLoading` are always cleared when the
wallet is cleared.
- Around line 106-108: The simulation state (simulationResult, simulationError,
simulationLoading) must be reset when the active wallet changes and guarded so
late POST responses don't overwrite state for a new wallet: in the hook
(useRiskScore) clear setSimulationResult(null), setSimulationError(null), and
setSimulationLoading(false) whenever the wallet identifier changes (or at start
of a new simulate call), and scope each async request by attaching a per-request
token (e.g., incrementing requestId or an AbortController) captured inside the
async simulate function — before calling setSimulationResult/setSimulationError
check that the token still matches the latest request (or that the controller
wasn't aborted). Apply the same pattern to the other simulation-related logic
referenced around the 188-224 block so stale responses are ignored and state is
wallet-scoped.

In `@frontend/src/hooks/useWebSocket.ts`:
- Around line 68-90: The WebSocket connect() currently accepts every
RiskScoreUpdated frame from the shared '/ws' stream, leaking other wallets'
updates into the active view; update connect()/ws lifecycle to subscribe for the
active walletAddress on open and/or filter incoming messages by walletAddress:
when ws.onopen fires send a subscription message including walletAddress (e.g.
ws.send(JSON.stringify({ type: 'subscribe', walletAddress }))) and ensure
ws.onmessage only processes events where data.type === 'RiskScoreUpdated' AND
data.walletAddress === walletAddress before calling
onScoreUpdateRef.current((data as ScoreUpdateEvent)), and also ensure you
unsubscribe or resubscribe when walletAddress changes or on stop.

In `@frontend/src/utils/riskScoreParsers.ts`:
- Around line 67-80: The map over Object.entries(components) currently assumes
each componentRaw is an object with .score and .weight, so
component.score/weight fall back to 0 when the engine returns flat numeric
values; update the mapping in the function that builds breakdown so that if
componentRaw is a number (typeof componentRaw === 'number') you call
safeNumber(componentRaw, `components.${key}`) to set value and set weight to a
sensible default (e.g., 0 or another chosen default) instead of reading
component.score/weight; otherwise keep the existing object handling (use
safeNumber(component.score, `components.${key}.score`) and
safeNumber(component.weight, `components.${key}.weight`)) so breakdown, label,
componentKey, value and weight are populated correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 136ff8eb-9f14-4453-b0c5-02d99ad9cfa5

📥 Commits

Reviewing files that changed from the base of the PR and between 4e07456 and 32ebca7.

📒 Files selected for processing (11)
  • frontend/package.json
  • frontend/src/app/risk/[wallet]/page.tsx
  • frontend/src/components/risk/LoanTimeline.tsx
  • frontend/src/components/risk/ScoreBreakdown.tsx
  • frontend/src/components/risk/ScoreGauge.tsx
  • frontend/src/components/risk/ScoreHistoryChart.tsx
  • frontend/src/components/risk/Tooltip.tsx
  • frontend/src/components/risk/tooltipConfig.ts
  • frontend/src/hooks/useRiskScore.ts
  • frontend/src/hooks/useWebSocket.ts
  • frontend/src/utils/riskScoreParsers.ts

},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"fast-check": "^3.23.2",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for imports and usage of fast-check in the codebase

# Search for fast-check imports in test files and source files
echo "=== Searching for fast-check imports ==="
rg -n --type=ts --type=tsx --type=js --type=jsx -C3 "from ['\"]fast-check['\"]|require\(['\"]fast-check['\"]"

# Search for fc.* usage patterns (common fast-check API usage)
echo -e "\n=== Searching for fast-check API usage (fc.*) ==="
rg -n --type=ts --type=tsx --type=js --type=jsx -C3 '\bfc\.(property|assert|integer|string|array|record)'

# List test files that might use it
echo -e "\n=== Listing test files ==="
fd -e test.ts -e test.tsx -e spec.ts -e spec.tsx -e test.js -e test.jsx

Repository: anonfedora/stellovault

Length of output: 434


🏁 Script executed:

#!/bin/bash
# Description: Check fast-check version validity and security advisories

# Check npm for the specific version
echo "=== Checking if fast-check@3.23.2 exists on npm ==="
curl -s https://registry.npmjs.org/fast-check/3.23.2 | jq -r '.version // "Version not found"'

# Check for latest version
echo -e "\n=== Latest version of fast-check ==="
curl -s https://registry.npmjs.org/fast-check/latest | jq -r '.version'

# Check for security advisories
echo -e "\n=== Security advisories for fast-check ==="
gh api graphql -f query='
{
  securityVulnerabilities(first: 5, ecosystem: NPM, package: "fast-check") {
    nodes {
      advisory {
        summary
        severity
        publishedAt
      }
      vulnerableVersionRange
      firstPatchedVersion {
        identifier
      }
    }
  }
}'

Repository: anonfedora/stellovault

Length of output: 373


Remove unused fast-check dependency.

The fast-check library was added to devDependencies but is not imported or used anywhere in the codebase. Codebase search returned no results for imports or API usage patterns despite 4 existing test files. This is a dead dependency that adds unnecessary overhead to the project and should be removed.

If property-based testing will be used in future test files, consider adding this dependency only when tests are written.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/package.json` at line 26, Remove the unused devDependency
"fast-check" from frontend package.json: open package.json, locate the
"fast-check" entry in devDependencies and delete that line, then run dependency
cleanup (e.g., npm/yarn install or prune) to update lockfile and node_modules;
if you later add property-based tests, re-add "fast-check" at that time.

Comment on lines +53 to +58
const { connectionFailed } = useWebSocket({
walletAddress,
onScoreUpdate: useCallback((event) => {
appendHistoryPoint({ date: event.calculated_at, score: event.overall_score });
}, [appendHistoryPoint]),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Push websocket payloads into the primary score state too.

appendHistoryPoint only updates the history reducer, even though each ScoreUpdateEvent already carries the new overall_score, grade, and components. That means the trend line refreshes, but ScoreGauge and ScoreBreakdown keep rendering stale data, so the page is not actually real-time beyond the chart.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/app/risk/`[wallet]/page.tsx around lines 53 - 58, The websocket
onScoreUpdate currently only calls appendHistoryPoint and doesn't update the
primary score state used by ScoreGauge and ScoreBreakdown, so make the handler
also update that score state/reducer with the incoming ScoreUpdateEvent fields
(event.overall_score, event.grade, event.components). Inside the useWebSocket
call's onScoreUpdate callback (the useCallback shown), after
appendHistoryPoint(...) dispatch or call the existing score state updater (e.g.,
setScore, updateScore, or the score reducer action used elsewhere) to set the
new overall score, grade, and components; ensure the updater is added to the
useCallback dependency array. Keep appendHistoryPoint so history still updates.

Comment on lines +19 to +27
interface ApiLoan {
id: string;
principal_amount: number;
interest_rate: number;
status: string;
created_at: string;
due_at: string;
updated_at: string;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Inspecting loan API response shape and serializers...\n\n'
rg -n -C3 '/api/loans|listLoans\(|principal_amount|created_at|due_at|updated_at|amount|interestRate|createdAt|dueDate|updatedAt'

Repository: anonfedora/stellovault

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Finding API route handlers for /api/loans ==="
fd -e ts -e js | xargs rg -l 'api.*loans|/loans' | head -20

echo ""
echo "=== Searching for routes directory and loan endpoint ==="
fd -type d -name 'routes|api' | head -10

Repository: anonfedora/stellovault

Length of output: 476


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Inspecting LoanTimeline.tsx component ==="
cat -n frontend/src/components/risk/LoanTimeline.tsx | head -100

Repository: anonfedora/stellovault

Length of output: 3683


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Searching for /api/loans route definition ==="
rg -n '/api/loans|loanRoutes|loanController' --type ts --type js -A 10 | head -50

Repository: anonfedora/stellovault

Length of output: 3252


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Inspecting loan controller listLoans function ==="
rg -n 'export.*listLoans|function listLoans|const listLoans' server/src/controllers/loan.controller.ts -A 30 | head -60

Repository: anonfedora/stellovault

Length of output: 1002


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Full loan controller file ==="
wc -l server/src/controllers/loan.controller.ts

Repository: anonfedora/stellovault

Length of output: 145


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Looking for loan controller file ==="
fd -name 'loan.controller.ts'

Repository: anonfedora/stellovault

Length of output: 354


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Checking loan.service.ts listLoans return value ==="
sed -n '187,210p' server/src/services/loan.service.ts

Repository: anonfedora/stellovault

Length of output: 895


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Checking Prisma Loan model schema ==="
rg -n 'model Loan|schema.prisma' server/prisma/ -A 20 | head -50

Repository: anonfedora/stellovault

Length of output: 1591


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Looking for the Loan model definition in schema ==="
fd -name 'schema.prisma' -o -name '*.prisma'

Repository: anonfedora/stellovault

Length of output: 369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Checking for response serialization middleware ==="
rg -n 'middleware|serialize|transform' server/src/app.ts -B 2 -A 2 | head -40

Repository: anonfedora/stellovault

Length of output: 472


🏁 Script executed:

#!/bin/bash
set -eulo pipefail

echo "=== Full app.ts setup to see if there's response transformation ==="
head -100 server/src/app.ts

Repository: anonfedora/stellovault

Length of output: 3945


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Checking if Prisma has any response transformation ==="
rg -n 'prisma.*select|serialization|toJSON' server/src/services/loan.service.ts -B 1 -A 1

Repository: anonfedora/stellovault

Length of output: 124


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Double-check: what does Prisma actually return? ==="
rg -n 'model Loan|dueDate|dueAt' server/prisma/schema.prisma

Repository: anonfedora/stellovault

Length of output: 167


The API response shape does not match the component's expected interface—fix the mismatch before calling mapApiLoan.

The /api/loans endpoint returns camelCase fields (amount, interestRate, createdAt, updatedAt, dueDate) from the Prisma schema, but ApiLoan expects snake_case (principal_amount, interest_rate, created_at, due_at, updated_at). This causes mapApiLoan to read undefined values, producing NaN and Invalid Date objects that break SVG rendering and timeline calculations.

Either update the component's ApiLoan interface to match the actual camelCase response shape, or add a response serializer in the loan controller to transform the data to snake_case before returning it.

Also applies to: 56-69, 95-98

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/risk/LoanTimeline.tsx` around lines 19 - 27, The
component's ApiLoan shape doesn't match the API's camelCase response, causing
mapApiLoan to read undefined values; update the ApiLoan interface in
LoanTimeline.tsx to use the actual response keys (amount, interestRate,
createdAt, updatedAt, dueDate) and adjust any uses in mapApiLoan to those
camelCase properties, or alternatively implement a serializer in the server loan
controller that maps the API's camelCase fields to the snake_case names expected
by ApiLoan before returning data so mapApiLoan receives the expected shape.

Comment on lines +61 to +80
<div
onMouseEnter={show}
onMouseLeave={scheduleHide}
onKeyDown={handleTriggerKeyDown}
tabIndex={0}
role="button"
aria-expanded={visible}
aria-haspopup="true"
className="inline-flex items-center cursor-pointer focus:outline-none"
>
{children}
</div>

{/* Tooltip body */}
{visible && (
<div
role="tooltip"
onMouseEnter={clearHideTimer}
onMouseLeave={scheduleHide}
className="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-50 w-64 rounded-lg bg-gray-900 text-white text-xs shadow-xl p-3 space-y-1.5"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Associate the tooltip with its trigger and close it on blur.

The panel is rendered with role="tooltip", but the trigger never points to it with aria-describedby, so assistive tech will not announce the help text on focus. Keyboard users can also tab away with the tooltip still open because the trigger has no blur handler.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/risk/Tooltip.tsx` around lines 61 - 80, The tooltip
trigger div should reference the tooltip panel via aria-describedby and also
close when the trigger loses focus; generate or use a stable unique id (e.g.,
tooltipId from useId or a prop) and set that id on the tooltip div (the element
rendered when visible) and add aria-describedby={tooltipId} to the trigger div,
and add an onBlur handler on the trigger (calling scheduleHide) so keyboard
users closing/tabbing away will hide the tooltip; ensure the tooltip still has
role="tooltip" and keep existing mouse enter/leave handlers
(clearHideTimer/scheduleHide/visible) intact so behavior is unchanged.

Comment on lines +1 to +6
export const TOOLTIP_CONFIG: Record<string, string> = {
on_chain_activity: "Measures the volume and diversity of your on-chain transactions over the past 90 days.",
repayment_history: "Tracks your record of repaying loans on time. Defaults and late payments reduce this score.",
collateral_quality: "Evaluates the value and diversity of collateral assets you have posted.",
document_verification: "Reflects the completeness and recency of your KYC document verification.",
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use the same component keys the risk engine actually emits.

The UI now looks up tooltip copy by item.componentKey, but the current engine emits transactionHistory, repaymentRecord, collateralCoverage, and disputeHistory, not the keys configured here. As written, frontend/src/components/risk/ScoreBreakdown.tsx never finds a description, and document_verification advertises a metric we do not currently score.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/risk/tooltipConfig.ts` around lines 1 - 6,
TOOLTIP_CONFIG currently uses keys like on_chain_activity and
document_verification that don't match the risk engine's emitted keys, so update
the TOOLTIP_CONFIG object to use the engine's actual component keys (e.g.
transactionHistory, repaymentRecord, collateralCoverage, disputeHistory) and
replace or remove document_verification; keep the human-readable descriptions
mapped to these exact keys so ScoreBreakdown.tsx can find them (update the
description text as needed to match the original messages for on-chain activity,
repayment history, collateral quality and dispute history).

Comment on lines +106 to +108
const [simulationResult, setSimulationResult] = useState<SimulationResult | null>(null);
const [simulationLoading, setSimulationLoading] = useState(false);
const [simulationError, setSimulationError] = useState<string | null>(null);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reset and scope simulation state per wallet/request.

simulationResult and simulationError survive wallet changes, and a late POST response can still overwrite state after the hook has already switched to a different wallet. That leaks stale simulation output into the wrong view.

Suggested shape
+ // Also add `useRef` to the React import at Line 1.
+ const simulationRequestIdRef = useRef(0);
+
+ useEffect(() => {
+   simulationRequestIdRef.current += 1;
+   setSimulationResult(null);
+   setSimulationError(null);
+   setSimulationLoading(false);
+ }, [walletAddress]);
+
   const activateSimulation = useCallback(async () => {
     if (!walletAddress || !data) return;
+    const requestId = ++simulationRequestIdRef.current;
     setSimulationLoading(true);
     setSimulationError(null);
     try {
@@
-      setSimulationResult({
-        currentScore,
-        projectedScore,
-        scoreDelta: projectedScore - currentScore,
-        scenarioDescription,
-      });
+      if (simulationRequestIdRef.current === requestId) {
+        setSimulationResult({
+          currentScore,
+          projectedScore,
+          scoreDelta: projectedScore - currentScore,
+          scenarioDescription,
+        });
+      }
     } catch (err: unknown) {
-      setSimulationError((err as Error).message ?? 'Simulation failed');
-      setSimulationResult(null);
+      if (simulationRequestIdRef.current === requestId) {
+        setSimulationError((err as Error).message ?? 'Simulation failed');
+        setSimulationResult(null);
+      }
     } finally {
-      setSimulationLoading(false);
+      if (simulationRequestIdRef.current === requestId) {
+        setSimulationLoading(false);
+      }
     }
   }, [walletAddress, data]);

Also applies to: 188-224

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/useRiskScore.ts` around lines 106 - 108, The simulation
state (simulationResult, simulationError, simulationLoading) must be reset when
the active wallet changes and guarded so late POST responses don't overwrite
state for a new wallet: in the hook (useRiskScore) clear
setSimulationResult(null), setSimulationError(null), and
setSimulationLoading(false) whenever the wallet identifier changes (or at start
of a new simulate call), and scope each async request by attaching a per-request
token (e.g., incrementing requestId or an AbortController) captured inside the
async simulate function — before calling setSimulationResult/setSimulationError
check that the token still matches the latest request (or that the controller
wasn't aborted). Apply the same pattern to the other simulation-related logic
referenced around the 188-224 block so stale responses are ignored and state is
wallet-scoped.

Comment on lines 111 to 115
useEffect(() => {
if (!walletAddress) {
setData(null);
setError(null);
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Reset loading state on the !walletAddress fast path.

If the wallet is cleared while either request is still in flight, the previous effect's finally is skipped by cancelled, so these branches can leave loading / historyLoading stuck at true.

Suggested fix
   useEffect(() => {
     if (!walletAddress) {
       setData(null);
       setError(null);
+      setLoading(false);
       return;
     }
@@
   useEffect(() => {
     if (!walletAddress) {
       dispatchHistory({ type: 'SET', entries: [] });
       setHistoryError(null);
+      setHistoryLoading(false);
       return;
     }

Also applies to: 147-151

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/useRiskScore.ts` around lines 111 - 115, In useRiskScore's
early-return branch inside the useEffect that checks `!walletAddress`, reset the
loading flags so they can't remain true when an in-flight request is cancelled:
call `setLoading(false)` and `setHistoryLoading(false)` along with
`setData(null)` and `setError(null)`; do the same for the other similar
early-return at lines ~147-151 (the other effect in useRiskScore) so
`loading`/`historyLoading` are always cleared when the wallet is cleared.

Comment on lines +68 to +90
function connect() {
if (stopped) return;

attempt += 1;
ws = new WebSocket('/ws');

ws.onopen = () => {
if (stopped) {
ws?.close();
return;
}
consecutiveFailures = 0;
setConnected(true);
setConnectionFailed(false);
};

ws.onmessage = (event: MessageEvent) => {
if (stopped) return;
try {
const data = JSON.parse(event.data as string);
if (data && data.type === 'RiskScoreUpdated') {
onScoreUpdateRef.current(data as ScoreUpdateEvent);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Route WebSocket updates through the active wallet.

This socket never subscribes with walletAddress, and the handler currently accepts every RiskScoreUpdated frame it sees. On any shared /ws stream, that will append another wallet’s updates into the active view.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/useWebSocket.ts` around lines 68 - 90, The WebSocket
connect() currently accepts every RiskScoreUpdated frame from the shared '/ws'
stream, leaking other wallets' updates into the active view; update connect()/ws
lifecycle to subscribe for the active walletAddress on open and/or filter
incoming messages by walletAddress: when ws.onopen fires send a subscription
message including walletAddress (e.g. ws.send(JSON.stringify({ type:
'subscribe', walletAddress }))) and ensure ws.onmessage only processes events
where data.type === 'RiskScoreUpdated' AND data.walletAddress === walletAddress
before calling onScoreUpdateRef.current((data as ScoreUpdateEvent)), and also
ensure you unsubscribe or resubscribe when walletAddress changes or on stop.

Comment on lines +67 to +80
let breakdown: RiskScoreBreakdown[] = [];
if (raw.components !== null && typeof raw.components === 'object' && !Array.isArray(raw.components)) {
const components = raw.components as Record<string, unknown>;
breakdown = Object.entries(components).map(([key, componentRaw]) => {
const component =
componentRaw !== null && typeof componentRaw === 'object' && !Array.isArray(componentRaw)
? (componentRaw as Record<string, unknown>)
: {};
const value = safeNumber(component.score, `components.${key}.score`);
const weight = safeNumber(component.weight, `components.${key}.weight`);
// Convert snake_case key to a human-readable label
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return { label, componentKey: key, value, weight };
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Parse flat component scores instead of { score, weight } objects.

The current risk engine returns flat numeric component values, not nested objects, so component.score and component.weight both fall back to 0 for every entry. That makes the new breakdown bars, weights, and tooltip payloads empty even when overall_score is correct.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/utils/riskScoreParsers.ts` around lines 67 - 80, The map over
Object.entries(components) currently assumes each componentRaw is an object with
.score and .weight, so component.score/weight fall back to 0 when the engine
returns flat numeric values; update the mapping in the function that builds
breakdown so that if componentRaw is a number (typeof componentRaw === 'number')
you call safeNumber(componentRaw, `components.${key}`) to set value and set
weight to a sensible default (e.g., 0 or another chosen default) instead of
reading component.score/weight; otherwise keep the existing object handling (use
safeNumber(component.score, `components.${key}.score`) and
safeNumber(component.weight, `components.${key}.weight`)) so breakdown, label,
componentKey, value and weight are populated correctly.

@anonfedora anonfedora merged commit 15bc691 into anonfedora:master Mar 29, 2026
5 checks 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.

Real-time Risk Score & Loan Visualization

2 participants