From c6cffcac4a5e2cd10ba8a5ed0ca6a7b244c76a28 Mon Sep 17 00:00:00 2001 From: eitighis Date: Mon, 21 Sep 2026 12:15:05 +0100 Subject: [PATCH] fix(web): remove fake vouch XDR, externalize config, drop node-crypto fallback, split bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve four code-lapse issues surfaced in the CI/quality pass: - Vouch signing theater: Vouch.tsx and MentorDashboard.tsx fabricated a random XDR string and asked Freighter to sign it, then discarded the signature entirely — a blind-sign trust hazard that accomplished nothing. Vouch approval is an authenticated, off-chain backend transition (POST /vouching/approve, authorized by the mentor's wallet-bound JWT), so the bogus signing step is removed and the handlers call the mutation directly. Dead txHash plumbing dropped from useOptimisticVouch and vouching.service. If on-chain vouching is ever wired up, it should route through useTransaction + a backend-built XDR the way deposits do — never a client-fabricated one. - Hardcoded config: API_BASE_URL, STELLAR_NETWORK and the five CONTRACT_IDS now read from VITE_* env vars with testnet defaults, matching the existing SOROBAN_RPC_URL pattern that .env.example already advertised but the code ignored. .env.example documents every variable. - Browser-incompatible crypto: soroban.service computeSha256 dropped its `await import('crypto')` Node fallback, which triggered Vite's "'crypto' externalized for browser compatibility" warning and emitted a __vite-browser-external chunk. Web Crypto (crypto.subtle) is the single portable SHA-256 path across secure browser contexts and Node 19+. - Monolithic bundle: the router now lazy-loads every route page, so the Stellar SDK and other heavy per-page deps leave the initial chunk. Initial entry drops from ~2,008 kB (565 kB gzip) to 386 kB (124 kB gzip); stellar-sdk is isolated in a lazy chunk loaded only on the pages that need it. Verified: eslint clean, 49/49 vitest pass, production build green with the crypto externalization warning gone. Co-Authored-By: Claude Opus 4.8 --- .env.example | 18 +++++++ src/constants/config.ts | 17 ++++-- src/hooks/useOptimisticVouch.ts | 5 +- src/pages/MentorDashboard.tsx | 36 ++++--------- src/pages/Vouch.tsx | 36 ++++--------- src/router/index.tsx | 91 ++++++++++++++++++++------------ src/services/soroban.service.ts | 23 ++++---- src/services/vouching.service.ts | 7 ++- 8 files changed, 125 insertions(+), 108 deletions(-) diff --git a/.env.example b/.env.example index b1a390f..4083a7e 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,20 @@ +# StepFi Web — environment configuration +# Copy to `.env` (or `.env.local`) and adjust per environment. Every value has a +# testnet default in src/constants/config.ts, so all of these are optional +# locally; set them to target staging/mainnet without touching source. + +# Backend API base URL (includes the /api/v1 prefix). VITE_API_BASE_URL=https://stepfi-api.onrender.com/api/v1 + +# Stellar network: TESTNET or PUBLIC. VITE_STELLAR_NETWORK=TESTNET + +# Soroban RPC endpoint. +VITE_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org + +# Deployed contract IDs (override when pointing at a different deployment). +VITE_CONTRACT_CREDITLINE=CAQDHYG3TALPNXG466SZUMJEPOI7VYV732LPFF3GHE4ASPBCNMIQBS3X +VITE_CONTRACT_REPUTATION=CC3BO57ZRJGA63QJBIBSOMI25Z3X2I5CYTARYRAUXUAILX6L3OWBL5SB +VITE_CONTRACT_LIQUIDITY_POOL=CACKE7ML2BTOAGQTAAW5NEARHCFX4PXXKGEO6GMU6NHFBVYQFZRJS2BT +VITE_CONTRACT_VENDOR_REGISTRY=CCZ6T6NYCDNI26VGTPXKKWQDR7JCIZZ24LCEG4MMYHZJAG6BPWIVAU2L +VITE_CONTRACT_PARAMETERS=CCAE72SKYX55C5L56DBEFIMFVXRUIJY6JYLBREHEWRFNOW7AX5NBIJ5B diff --git a/src/constants/config.ts b/src/constants/config.ts index 445e12b..d0b47fb 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -1,25 +1,36 @@ +// Runtime configuration. Every environment-specific value is read from a +// `VITE_*` build-time env var with a testnet-friendly default, so the app runs +// out of the box locally yet can be pointed at staging/mainnet purely through +// environment — no source edits. See `.env.example` for the full list. + export const API_BASE_URL = - 'https://stepfi-api.onrender.com/api/v1' + import.meta.env?.VITE_API_BASE_URL || 'https://stepfi-api.onrender.com/api/v1' -export const STELLAR_NETWORK = 'TESTNET' +export const STELLAR_NETWORK = + import.meta.env?.VITE_STELLAR_NETWORK || 'TESTNET' export const SOROBAN_RPC_URL = import.meta.env?.VITE_SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org' - export const CONTRACT_IDS = { creditline: + import.meta.env?.VITE_CONTRACT_CREDITLINE || 'CAQDHYG3TALPNXG466SZUMJEPOI7VYV732LPFF3GHE4ASPBCNMIQBS3X', reputation: + import.meta.env?.VITE_CONTRACT_REPUTATION || 'CC3BO57ZRJGA63QJBIBSOMI25Z3X2I5CYTARYRAUXUAILX6L3OWBL5SB', liquidityPool: + import.meta.env?.VITE_CONTRACT_LIQUIDITY_POOL || 'CACKE7ML2BTOAGQTAAW5NEARHCFX4PXXKGEO6GMU6NHFBVYQFZRJS2BT', vendorRegistry: + import.meta.env?.VITE_CONTRACT_VENDOR_REGISTRY || 'CCZ6T6NYCDNI26VGTPXKKWQDR7JCIZZ24LCEG4MMYHZJAG6BPWIVAU2L', parameters: + import.meta.env?.VITE_CONTRACT_PARAMETERS || 'CCAE72SKYX55C5L56DBEFIMFVXRUIJY6JYLBREHEWRFNOW7AX5NBIJ5B', } +// Public program links — fixed StepFi properties, not environment-specific. export const GRANTFOX_URL = 'https://contribute.grantfox.xyz/org/StepFi-app' diff --git a/src/hooks/useOptimisticVouch.ts b/src/hooks/useOptimisticVouch.ts index 3130fbe..3fd6799 100644 --- a/src/hooks/useOptimisticVouch.ts +++ b/src/hooks/useOptimisticVouch.ts @@ -5,15 +5,14 @@ import type { VouchRequest, ActiveVouch } from '../types' interface SubmitVouchParams { learnerAddress: string - txHash?: string } export function useSubmitVouch() { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ learnerAddress, txHash }: SubmitVouchParams) => - vouchingService.submitVouch(learnerAddress, txHash), + mutationFn: ({ learnerAddress }: SubmitVouchParams) => + vouchingService.submitVouch(learnerAddress), onMutate: async ({ learnerAddress }) => { await queryClient.cancelQueries({ queryKey: queryKeys.vouches.requests() }) await queryClient.cancelQueries({ queryKey: queryKeys.vouches.myVouches() }) diff --git a/src/pages/MentorDashboard.tsx b/src/pages/MentorDashboard.tsx index 472c575..b839c60 100644 --- a/src/pages/MentorDashboard.tsx +++ b/src/pages/MentorDashboard.tsx @@ -5,7 +5,6 @@ import { Clock, DollarSign, Percent, Ban, ExternalLink, XCircle, UserCheck, TrendingUp, Wallet, } from 'lucide-react' -import { signTransaction, isConnected, requestAccess } from '@stellar/freighter-api' import { useSubmitVouch, useRevokeVouch } from '../hooks/useOptimisticVouch' import { useMentor } from '../hooks/useMentor' import { Card } from '../components/ui/Card' @@ -16,7 +15,6 @@ import { VouchRequestCard } from '../components/vouch/VouchRequestCard' import { VouchImpactPreview } from '../components/vouch/VouchImpactPreview' import { useWallet } from '../hooks/useWallet' import { useToast } from '../hooks/useToast' -import { STELLAR_NETWORK } from '../constants/config' import type { VouchRequest } from '../types' const REPAYMENT_VARIANTS: Record = { @@ -209,35 +207,19 @@ export function MentorDashboard() { if (!previewRequest) return try { + // A mentor must be connected + authenticated (wallet-based JWT) to approve. if (!walletConnected) { await connectFreighter() } - const connection = await isConnected() - if (!connection.isConnected) { - throw new Error('Freighter not installed. Download at freighter.app') - } - - const access = await requestAccess() - if (access.error) { - throw new Error(access.error.message) - } - - const txXdr = `AAAAAgAAAABz...${Math.random().toString(36).slice(2)}` - const result = await signTransaction(txXdr, { - networkPassphrase: - STELLAR_NETWORK === 'TESTNET' - ? 'Test SDF Network ; September 2015' - : 'Public Global Stellar Network ; September 2015', - }) - - const txHash = 'signedTxXdr' in result ? (result as { signedTxXdr: string }).signedTxXdr : '' - + // Approving a vouch is an authenticated backend operation: POST + // /vouching/approve transitions the vouch record from PENDING to APPROVED + // using the mentor's JWT identity. It is intentionally off-chain today, so + // there is no transaction for the mentor to sign here. If on-chain vouching + // is wired up later, route it through useTransaction + a backend-built XDR + // the way deposits do — never a client-fabricated one. submitMutation.mutate( - { - learnerAddress: previewRequest.learnerAddress, - txHash, - }, + { learnerAddress: previewRequest.learnerAddress }, { onSuccess: () => { setPreviewRequest(null) @@ -250,7 +232,7 @@ export function MentorDashboard() { } ) } catch (err) { - const message = err instanceof Error ? err.message : 'Transaction failed' + const message = err instanceof Error ? err.message : 'Failed to submit vouch.' toast.error(message) } } diff --git a/src/pages/Vouch.tsx b/src/pages/Vouch.tsx index ae6623b..7ac2ff4 100644 --- a/src/pages/Vouch.tsx +++ b/src/pages/Vouch.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { ClipboardList, ShieldCheck, Award, AlertTriangle, RotateCw, Clock, DollarSign, Percent, Ban, ExternalLink, XCircle } from 'lucide-react' -import { signTransaction, isConnected, requestAccess } from '@stellar/freighter-api' import { vouchingService } from '../services/vouching.service' import { queryKeys } from '../services/queryKeys' import { useSubmitVouch, useRevokeVouch } from '../hooks/useOptimisticVouch' @@ -14,7 +13,6 @@ import { VouchRequestCard } from '../components/vouch/VouchRequestCard' import { VouchImpactPreview } from '../components/vouch/VouchImpactPreview' import { useWallet } from '../hooks/useWallet' import { useToast } from '../hooks/useToast' -import { STELLAR_NETWORK } from '../constants/config' import type { VouchRequest } from '../types' const REPAYMENT_VARIANTS: Record = { @@ -118,35 +116,19 @@ export function Vouch() { if (!previewRequest) return try { + // A mentor must be connected + authenticated (wallet-based JWT) to approve. if (!walletConnected) { await connectFreighter() } - const connection = await isConnected() - if (!connection.isConnected) { - throw new Error('Freighter not installed. Download at freighter.app') - } - - const access = await requestAccess() - if (access.error) { - throw new Error(access.error.message) - } - - const txXdr = `AAAAAgAAAABz...${Math.random().toString(36).slice(2)}` - const result = await signTransaction(txXdr, { - networkPassphrase: - STELLAR_NETWORK === 'TESTNET' - ? 'Test SDF Network ; September 2015' - : 'Public Global Stellar Network ; September 2015', - }) - - const txHash = 'signedTxXdr' in result ? (result as { signedTxXdr: string }).signedTxXdr : '' - + // Approving a vouch is an authenticated backend operation: POST + // /vouching/approve transitions the vouch record from PENDING to APPROVED + // using the mentor's JWT identity. It is intentionally off-chain today, so + // there is no transaction for the mentor to sign here. If on-chain vouching + // is wired up later, route it through useTransaction + a backend-built XDR + // the way deposits do — never a client-fabricated one. submitMutation.mutate( - { - learnerAddress: previewRequest.learnerAddress, - txHash, - }, + { learnerAddress: previewRequest.learnerAddress }, { onSuccess: () => { setPreviewRequest(null) @@ -159,7 +141,7 @@ export function Vouch() { } ) } catch (err) { - const message = err instanceof Error ? err.message : 'Transaction failed' + const message = err instanceof Error ? err.message : 'Failed to submit vouch.' toast.error(message) } } diff --git a/src/router/index.tsx b/src/router/index.tsx index e3f34e9..4b909b2 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -1,25 +1,32 @@ import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom' +import { lazy, Suspense } from 'react' +import type { ReactNode } from 'react' import { Layout } from '../components/layout/Layout' -import { Home } from '../pages/Home' -import { Docs } from '../pages/Docs' -import { Contracts } from '../pages/Contracts' -import { Dashboard } from '../pages/Dashboard' -import { Vendors } from '../pages/Vendors' -import { VendorDetail } from '../pages/VendorDetail' -import { VendorRegister } from '../pages/VendorRegister' -import { VendorDashboard } from '../pages/VendorDashboard' -import { Sponsors } from '../pages/Sponsors' -import { SponsorOnboarding } from '../pages/SponsorOnboarding' -import { Vouch } from '../pages/Vouch' -import { MentorDashboard } from '../pages/MentorDashboard' -import { LearnerProfile } from '../pages/LearnerProfile' -import { NotFound } from '../pages/NotFound' -import { History } from '../pages/History' -import { RoleSelect } from '../pages/RoleSelect' +import { Spinner } from '../components/ui/Spinner' import { useRoleStore } from '../stores/role.store' import type { UserRole } from '../stores/role.store' import { useWallet } from '../hooks/useWallet' -import type { ReactNode } from 'react' + +// Route pages are code-split so heavy per-page dependencies (e.g. the Stellar +// SDK pulled in by the dashboard/vouch flows) load on demand instead of +// inflating the initial bundle. Pages use named exports, so each is mapped to a +// default export for React.lazy. +const Home = lazy(() => import('../pages/Home').then((m) => ({ default: m.Home }))) +const Docs = lazy(() => import('../pages/Docs').then((m) => ({ default: m.Docs }))) +const Contracts = lazy(() => import('../pages/Contracts').then((m) => ({ default: m.Contracts }))) +const Dashboard = lazy(() => import('../pages/Dashboard').then((m) => ({ default: m.Dashboard }))) +const Vendors = lazy(() => import('../pages/Vendors').then((m) => ({ default: m.Vendors }))) +const VendorDetail = lazy(() => import('../pages/VendorDetail').then((m) => ({ default: m.VendorDetail }))) +const VendorRegister = lazy(() => import('../pages/VendorRegister').then((m) => ({ default: m.VendorRegister }))) +const VendorDashboard = lazy(() => import('../pages/VendorDashboard').then((m) => ({ default: m.VendorDashboard }))) +const Sponsors = lazy(() => import('../pages/Sponsors').then((m) => ({ default: m.Sponsors }))) +const SponsorOnboarding = lazy(() => import('../pages/SponsorOnboarding').then((m) => ({ default: m.SponsorOnboarding }))) +const Vouch = lazy(() => import('../pages/Vouch').then((m) => ({ default: m.Vouch }))) +const MentorDashboard = lazy(() => import('../pages/MentorDashboard').then((m) => ({ default: m.MentorDashboard }))) +const LearnerProfile = lazy(() => import('../pages/LearnerProfile').then((m) => ({ default: m.LearnerProfile }))) +const NotFound = lazy(() => import('../pages/NotFound').then((m) => ({ default: m.NotFound }))) +const History = lazy(() => import('../pages/History').then((m) => ({ default: m.History }))) +const RoleSelect = lazy(() => import('../pages/RoleSelect').then((m) => ({ default: m.RoleSelect }))) function RoleGuard({ allowedRole, @@ -43,70 +50,88 @@ function RoleGuard({ return <>{children} } +// Layout (Navbar + Footer) renders immediately; only the lazily-loaded page +// content suspends, so the navigation chrome never flashes on route changes. +function page(node: ReactNode) { + return ( + + + + + } + > + {node} + + + ) +} + const router = createBrowserRouter([ { path: '/', - element: , + element: page(), }, { path: '/docs', - element: , + element: page(), }, { path: '/contracts', - element: , + element: page(), }, { path: '/role-select', - element: , + element: page(), }, { path: '/dashboard', - element: , + element: page(), }, { path: '/vendors', - element: , + element: page(), }, { path: '/vendors/dashboard', - element: , + element: page(), }, { path: '/vendors/register', - element: , + element: page(), }, { path: '/vendors/:id', - element: , + element: page(), }, { path: '/sponsors', - element: , + element: page(), }, { path: '/sponsors/onboarding', - element: , + element: page(), }, { path: '/mentor', - element: , + element: page(), }, { path: '/vouch', - element: , + element: page(), }, { path: '/learner/:walletAddress', - element: , + element: page(), }, { path: '/history', - element: , + element: page(), }, { path: '*', - element: , + element: page(), }, ]) diff --git a/src/services/soroban.service.ts b/src/services/soroban.service.ts index 62ef63b..0457987 100644 --- a/src/services/soroban.service.ts +++ b/src/services/soroban.service.ts @@ -31,21 +31,18 @@ function createDummyAccount(): Account { async function computeSha256(buffer: Uint8Array | ArrayBuffer): Promise { const bytes = new Uint8Array(buffer) - if (typeof crypto !== 'undefined' && crypto.subtle) { - const hashBuffer = await crypto.subtle.digest('SHA-256', bytes) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + // Web Crypto (crypto.subtle) is present in every secure browser context and in + // Node's global webcrypto, so it is the one portable SHA-256 path. Using it + // exclusively keeps Node's 'crypto' builtin out of the browser bundle (no + // "Module 'crypto' has been externalized for browser compatibility" warning). + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('Web Crypto (crypto.subtle) is unavailable in this environment.') } - try { - const nodeCrypto = await import('crypto') - return nodeCrypto - .createHash('sha256') - .update(bytes) - .digest('hex') - } catch { - throw new Error('No SHA-256 crypto implementation available in this environment.') - } + const hashBuffer = await crypto.subtle.digest('SHA-256', bytes) + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') } async function simulateContractCall( diff --git a/src/services/vouching.service.ts b/src/services/vouching.service.ts index 351c2e1..05a2f69 100644 --- a/src/services/vouching.service.ts +++ b/src/services/vouching.service.ts @@ -83,8 +83,11 @@ export const vouchingService = { return (res.data ?? []).filter((v) => v.status === 'approved').map(mapActiveVouch) }, - // POST /vouching/approve — mentor approves a pending vouch request for a learner. - submitVouch: async (learnerAddress: string, _?: string): Promise => { + // POST /vouching/approve — mentor approves a pending vouch request for a + // learner. Authenticated by the mentor's wallet-bound JWT; intentionally an + // off-chain record transition (PENDING -> APPROVED), so no signed transaction + // is involved. + submitVouch: async (learnerAddress: string): Promise => { const res = await api.post('/vouching/approve', { learnerWallet: learnerAddress, })